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:
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()
|
||||||
|
|||||||
Reference in new issue
Block a user