From bb56d800dff1b8f5c91e25ff9c6a9b866c9a9ccd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 6 May 2026 23:45:11 +0100 Subject: [PATCH 1/2] fix(android): drop setUnlockedDeviceRequired from rootkey wrapper key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setUnlockedDeviceRequired(true)` requires a configured secure lock screen at key generation and permanently invalidates the key if the user later disables their lock screen — even briefly, with no recovery path. For CoMapeo the rootkey IS the user's identity in every project they participate in, so either failure mode is identity loss; the marginal at-rest gain over baseline AndroidKeyStore hardware-backed AES-GCM doesn't justify it. Matches the trade-off expo-secure-store makes for the same reason. Existing wrapper keys keep working unchanged; this only affects fresh generations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/comapeo/core/RootKeyStore.kt | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/comapeo/core/RootKeyStore.kt b/android/src/main/java/com/comapeo/core/RootKeyStore.kt index 6591ed0..e76c947 100644 --- a/android/src/main/java/com/comapeo/core/RootKeyStore.kt +++ b/android/src/main/java/com/comapeo/core/RootKeyStore.kt @@ -237,9 +237,39 @@ class RootKeyStore(private val context: Context) { // attacked by another app on the same device". .setUserAuthenticationRequired(false) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - builder.setUnlockedDeviceRequired(true) - } + // Notably absent: setUnlockedDeviceRequired(true). + // + // On API 28+ this flag would prevent the wrapper key from + // being usable while the device is locked, which sounds like + // a free at-rest hardening. In practice it has two + // unacceptable consequences for the rootkey: + // + // 1. It requires a configured secure lock screen at key + // generation. On a device without one (the user has + // not set up a PIN/pattern/password) the keystore + // refuses to generate the key — the FGS bricks at + // startup with a `rootkey` error, which is identity + // loss for that user. + // + // 2. The key is permanently and irreversibly invalidated + // if the user later disables their secure lock screen, + // even briefly. Re-adding the lock screen does NOT + // recover it. The CoMapeo rootkey IS the user's + // identity in every project they participate in; + // losing it means losing access to their data. + // + // `expo-secure-store` (the de-facto standard secret store + // on Expo/RN Android) makes the same trade and omits this + // flag — see expo/expo + // packages/expo-secure-store/.../AESEncryptor.kt. + // + // The wrapper key remains hardware-backed (StrongBox if + // available, TEE otherwise), AES-256 GCM, scoped to this + // app's signature, and non-extractable. The only attack the + // unlock-required gate would have prevented — code + // execution as our app while the device sits in the + // post-boot pre-unlock state — has no practical path on + // current Android even without the gate. val generator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, From 07f82e248f14018538c820e104a90cc903fdbda0 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 7 May 2026 09:24:16 +0100 Subject: [PATCH 2/2] docs(android): tighten RootKeyStore comment on dropped unlock-required gate Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/comapeo/core/RootKeyStore.kt | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/android/src/main/java/com/comapeo/core/RootKeyStore.kt b/android/src/main/java/com/comapeo/core/RootKeyStore.kt index e76c947..8a3e2d3 100644 --- a/android/src/main/java/com/comapeo/core/RootKeyStore.kt +++ b/android/src/main/java/com/comapeo/core/RootKeyStore.kt @@ -237,39 +237,9 @@ class RootKeyStore(private val context: Context) { // attacked by another app on the same device". .setUserAuthenticationRequired(false) - // Notably absent: setUnlockedDeviceRequired(true). - // - // On API 28+ this flag would prevent the wrapper key from - // being usable while the device is locked, which sounds like - // a free at-rest hardening. In practice it has two - // unacceptable consequences for the rootkey: - // - // 1. It requires a configured secure lock screen at key - // generation. On a device without one (the user has - // not set up a PIN/pattern/password) the keystore - // refuses to generate the key — the FGS bricks at - // startup with a `rootkey` error, which is identity - // loss for that user. - // - // 2. The key is permanently and irreversibly invalidated - // if the user later disables their secure lock screen, - // even briefly. Re-adding the lock screen does NOT - // recover it. The CoMapeo rootkey IS the user's - // identity in every project they participate in; - // losing it means losing access to their data. - // - // `expo-secure-store` (the de-facto standard secret store - // on Expo/RN Android) makes the same trade and omits this - // flag — see expo/expo - // packages/expo-secure-store/.../AESEncryptor.kt. - // - // The wrapper key remains hardware-backed (StrongBox if - // available, TEE otherwise), AES-256 GCM, scoped to this - // app's signature, and non-extractable. The only attack the - // unlock-required gate would have prevented — code - // execution as our app while the device sits in the - // post-boot pre-unlock state — has no practical path on - // current Android even without the gate. + // Not using setUnlockedDeviceRequired(true): generation fails + // on no-lock devices and disabling the lock later permanently + // invalidates the key — both are identity loss. See PR #57. val generator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES,