diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index dc49ee2..f90733c 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -128,6 +128,10 @@ androidComponents { } dependencies { + // The link both this app and Dev Updater's need in order to reach a + // machine they were enrolled against: the pinned CA, the enrollment + // store, and the QR capture activity. See wg-app-link's README. + implementation(project(":link")) // Not a library this code calls: it is what `isCoreLibraryDesugaring // Enabled` above rewrites java.time against, so API 24 and 25 have it. coreLibraryDesugaring(libs.desugar.jdk.libs) diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index a0aa264..202e1e8 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -63,7 +63,7 @@ asked for anyway. Scoped to this activity, so a genuine pin elsewhere would still be reported. --> () - .first() - SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) }.socketFactory -} +// The pinning itself lives in wg-app-link, since dev-updater needs exactly +// the same thing. What stays here is the one product-specific fact -- which +// certificate this app pins. +private val pinned = PinnedTls(PINNED_CA_PEM) /** Every request this app makes goes through this -- there is no unpinned path. */ -fun HttpURLConnection.applyPinnedTls() { - if (this is HttpsURLConnection) { - sslSocketFactory = pinnedSslSocketFactory - } -} +fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt index 554bc80..ddb30a3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -2,145 +2,27 @@ package com.example.aiapp import android.content.Context import android.net.Uri -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 -import androidx.core.content.edit -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec +import com.example.wgapplink.ServerStore /** * Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by * scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity) * or by typing the fields into the settings screen. */ -data class ServerSettings(val host: String, val port: Int, val token: String) { - val baseUrl: String - get() = "https://$host:$port" -} - -private const val PREFS_NAME = "server" -private const val KEY_HOST = "host" -private const val KEY_PORT = "port" -private const val KEY_TOKEN = "token" - -fun loadServerSettings(context: Context): ServerSettings? { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val host = prefs.getString(KEY_HOST, null) ?: return null - val port = prefs.getInt(KEY_PORT, 0) - val sealed = prefs.getString(KEY_TOKEN, null) ?: return null - val token = unseal(sealed) ?: return null - if (port == 0 || token.isEmpty()) return null - return ServerSettings(host, port, token) -} - -fun saveServerSettings(context: Context, settings: ServerSettings) { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { - putString(KEY_HOST, settings.host) - putInt(KEY_PORT, settings.port) - putString(KEY_TOKEN, seal(settings.token)) - } -} +typealias ServerSettings = com.example.wgapplink.ServerSettings /** - * Parses the enrollment URI the server's QR carries: - * `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is missing -- a malformed - * scan shouldn't clobber a working enrollment. - */ -fun parseEnrollmentUri(uri: Uri): ServerSettings? { - if (uri.scheme != "aiapp" || uri.host != "enroll") return null - val host = uri.getQueryParameter("host") ?: return null - val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null - val token = uri.getQueryParameter("token") ?: return null - if (host.isEmpty() || token.isEmpty()) return null - return ServerSettings(host, port, token) -} - -// --------------------------------------------------------------------------- -// Token sealing. The token is the credential for remote code execution on -// the backend, so it is stored AES-GCM-encrypted under an Android Keystore -// key (hardware-backed where the device has it) rather than in plain -// preferences. Hand-rolled (~40 lines) instead of Jetpack's -// EncryptedSharedPreferences because that library is deprecated with no -// drop-in successor -- Google's own guidance is now "use Keystore directly". - -private const val KEYSTORE = "AndroidKeyStore" -private const val KEY_ALIAS = "aiapp-token-key" -private const val GCM_TAG_BITS = 128 - -/** - * The key if there is one, without making one. + * This app's enrollment, which is the whole of what is product-specific about it. * - * The read side must never create: a sealed token with no key behind it means the key was lost (a - * device reset, or the app's data restored onto another device, where the key does not travel), and - * generating a fresh one there would leave a key nothing had ever sealed with and still fail to - * decrypt. Absent is the honest answer, and the caller reads it as "not enrolled". + * Both values are load-bearing and neither may be changed casually. The scheme is what routes a + * scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the + * token is already sealed under on every enrolled phone -- changing it would leave those phones + * reading as not enrolled, with no error to explain why. */ -private fun existingTokenKey(): SecretKey? { - val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } - return keyStore.getKey(KEY_ALIAS, null) as? SecretKey -} +private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key") -private fun tokenKey(): SecretKey { - existingTokenKey()?.let { - return it - } - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) - generator.init( - KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) - return generator.generateKey() -} +fun loadServerSettings(context: Context): ServerSettings? = store.load(context) -/** - * Whether this app may reach a local network address at all. - * - * Android 17 made `ACCESS_LOCAL_NETWORK` mandatory for it, and a denial is invisible at the socket: - * the OS drops the traffic, so a blocked app and an unreachable server produce the same connect - * timeout. Without asking explicitly there is no way to tell those apart, and the failure message - * would blame the server or the tunnel for something neither is doing. - */ -fun localNetworkAllowed(context: Context): Boolean = - android.os.Build.VERSION.SDK_INT < 37 || - context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") == - android.content.pm.PackageManager.PERMISSION_GRANTED +fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings) -/** iv:ciphertext, both base64 -- the stored form of the token. */ -private fun seal(token: String): String { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, tokenKey()) - val ciphertext = cipher.doFinal(token.encodeToByteArray()) - return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + - ":" + - Base64.encodeToString(ciphertext, Base64.NO_WRAP) -} - -/** - * Null on any failure -- e.g. the Keystore key was lost to a device reset or the app's data was - * restored onto another device, where the key never travels. The caller treats that as "not - * enrolled"; re-scanning the QR (or `--rotate-token`) is the recovery, so failing soft here is - * right. - */ -private fun unseal(sealed: String): String? = - try { - val (ivB64, dataB64) = - sealed.split(":", limit = 2).let { if (it.size != 2) return null else it[0] to it[1] } - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init( - Cipher.DECRYPT_MODE, - existingTokenKey() ?: return null, - GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), - ) - cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString() - } catch (_: Exception) { - null - } +fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index 5140b05..b733ca1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import com.example.wgapplink.EnrollmentScanActivity import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanIntentResult diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 4c56cfb..b8dd14f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -286,7 +286,11 @@ fun SpawnScreen( // The id, not the label: labels are // editable and the server resolves by // id. - setup = setup?.id.orEmpty(), + // Non-null here: `chosen` came from + // `setup`'s own provider list, so + // reaching this point proves there was + // a setup to take it from. + setup = setup.id, provider = chosen.name, title = title.trim(), model = diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d49e685..a1a0efc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false } diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 148d283..5c0d68f 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -40,6 +40,9 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } +# For the :link subproject (wg-app-link/app), which resolves its plugins +# from the build including it rather than from its own catalog. +androidLibrary = { id = "com.android.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" } diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts index 2ead5d4..18ed8d1 100644 --- a/app/settings.gradle.kts +++ b/app/settings.gradle.kts @@ -16,3 +16,10 @@ dependencyResolutionManagement { } include(":androidApp") + +// The app half of wg-app-link, resolved by path through the submodule so +// this checkout and the crate it consumes move together -- the same +// arrangement `server/` uses for the Rust half. See that repo's README. +include(":link") + +project(":link").projectDir = file("../wg-app-link/app") diff --git a/wg-app-link b/wg-app-link index 73a32cb..4de8bff 160000 --- a/wg-app-link +++ b/wg-app-link @@ -1 +1 @@ -Subproject commit 73a32cb89795b79913e65cb92362612cd109b22c +Subproject commit 4de8bff5f27640a161e5548affb41214ddb2455a