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()