From 9f54a80ca4a529d5bc8a7e088206e3322288d7ec Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 13:19:58 -0400 Subject: [PATCH] Tell the difference between a blocked app and a missing server Two findings from dev-updater's session reading this codebase, both checked against the code here before acting on them, and both real. **A denied local-network permission was invisible.** The manifest requests ACCESS_LOCAL_NETWORK and MainActivity asks for it, but nothing ever checked whether it was granted -- and on Android 17 a denial is indistinguishable from an unreachable server at the socket, because the OS simply drops the traffic. So every screen would have shown "is ai-server running, and is this device able to reach that address (WireGuard up)?", blaming two things that were both fine. Stated once at the root as a standing condition rather than appended to each failure it might have caused: it is not a property of any one request, and repeating it per error is how a message ends up saying the same thing twice, which this app has already done once today. **The Keystore read path was creating keys.** `unseal` called the get-or-create key function, so a sealed token whose key had been lost -- a device reset, or the app's data restored onto a device the key cannot travel to -- generated a fresh key, then failed to decrypt with it, leaving a key nothing had ever sealed with. The behaviour was already right by accident (it fails soft to "not enrolled"), but the read side now asks for the key without making one, which is what it meant all along. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .../main/kotlin/com/example/aiapp/AppRoot.kt | 20 ++++++++++++ .../kotlin/com/example/aiapp/ServerConfig.kt | 31 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index e91cef7..6dbe0b1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -1,13 +1,18 @@ package com.example.aiapp import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp /** * One `when` rather than a navigation library: four screens, with the list as the root and the back @@ -42,6 +47,21 @@ fun AppRoot(settingsVersion: Int) { // returning to it refetches instead of showing a stale list. var reloadToken by remember { mutableIntStateOf(0) } + // A standing condition rather than a per-request failure, so it is + // stated once here instead of appended to every error that might be + // caused by it. Without this the app is simply unreachable and every + // screen blames the server or the tunnel for it. + if (!localNetworkAllowed(context)) { + Text( + "This app is not allowed to reach local network addresses, so it cannot " + + "connect to the backend at all. Grant \"local network\" in Android's app " + + "settings; until then every screen here will look like the server is down.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(16.dp), + ) + } + val current = settings if (current == null) { // Not enrolled yet: settings is the only usable screen. The QR 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 abb5a22..554bc80 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -71,9 +71,21 @@ private const val KEYSTORE = "AndroidKeyStore" private const val KEY_ALIAS = "aiapp-token-key" private const val GCM_TAG_BITS = 128 -private fun tokenKey(): SecretKey { +/** + * The key if there is one, without making one. + * + * 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". + */ +private fun existingTokenKey(): SecretKey? { val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } - (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { + return keyStore.getKey(KEY_ALIAS, null) as? SecretKey +} + +private fun tokenKey(): SecretKey { + existingTokenKey()?.let { return it } val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) @@ -89,6 +101,19 @@ private fun tokenKey(): SecretKey { return generator.generateKey() } +/** + * 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 + /** iv:ciphertext, both base64 -- the stored form of the token. */ private fun seal(token: String): String { val cipher = Cipher.getInstance("AES/GCM/NoPadding") @@ -112,7 +137,7 @@ private fun unseal(sealed: String): String? = val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init( Cipher.DECRYPT_MODE, - tokenKey(), + existingTokenKey() ?: return null, GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), ) cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()