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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 13:19:58 -04:00
1 parent 3c144f8070
commit 9f54a80ca4
2 files changed
+48 -3

No files matched your search

@@ -1,13 +1,18 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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 * 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. // returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableIntStateOf(0) } 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 val current = settings
if (current == null) { if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR // Not enrolled yet: settings is the only usable screen. The QR
@@ -71,9 +71,21 @@ private const val KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "aiapp-token-key" private const val KEY_ALIAS = "aiapp-token-key"
private const val GCM_TAG_BITS = 128 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) } 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 return it
} }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
@@ -89,6 +101,19 @@ private fun tokenKey(): SecretKey {
return generator.generateKey() 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. */ /** iv:ciphertext, both base64 -- the stored form of the token. */
private fun seal(token: String): String { private fun seal(token: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
@@ -112,7 +137,7 @@ private fun unseal(sealed: String): String? =
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init( cipher.init(
Cipher.DECRYPT_MODE, Cipher.DECRYPT_MODE,
tokenKey(), existingTokenKey() ?: return null,
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
) )
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString() cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()