Behavior-preserving security refactor removing the duplication between the two AES-256-GCM Keystore users and unifying the accepted-authenticator policy that was defined in two places.
1. Shared AES-GCM Keystore base
New AesGcmKeystoreCipher holds everything KeystoreCrypto and DatabaseKeyCipher copy-pasted: the encrypt/decrypt bodies, existingKey/getOrCreateKey under a lock, key deletion, and the 5 identical GCM constants (ANDROID_KEYSTORE, TRANSFORMATION, IV_LENGTH, TAG_BITS, AES_KEY_SIZE_BITS). Each cipher now contributes only its delta:
KeystoreCrypto -> the plain AES-256-GCM keySpec (via keySpecBuilder()).
DatabaseKeyCipher -> the auth-bound keySpec + the self-healing encrypt, isInvalidated(), and fail-fast missing-key message.
A future change to the crypto (StrongBox opt-in, IV handling, error mapping) is now made once.
2. The master / auth-bound missing-key split is preserved DELIBERATELY
The two ciphers intentionally disagree on what a missing key means on decrypt, and they still do — expressed as a generateKeyOnDecrypt policy parameter, documented on the base:
Master key (KeystoreCrypto, generateKeyOnDecrypt = true): silently generates a key on a missing alias — correct for a first-run master key with nothing sealed yet.
Auth-bound cache key (DatabaseKeyCipher, generateKeyOnDecrypt = false): fails fast (error("auth-bound database key is missing")), because a missing auth-bound key means it was invalidated; silently regenerating it would re-arm the lock against a cache that can never be decrypted, defeating the security model.
Per the ticket, the opaque AEADBadTagException the master path throws when it generates a fresh key and then can't decrypt old data is now mapped to a clear GeneralSecurityException. The catch is narrow: KeyPermanentlyInvalidatedException (thrown at Cipher.init) still propagates unwrapped so callers can classify invalidation.
3. One authenticator policy, two vocabularies
The accepted authenticators were encoded twice with no cross-reference (BiometricManager terms in AppLockManager.AUTHENTICATORS, KeyProperties terms in DatabaseKeyCipher). New AuthenticatorPolicy.ACCEPTED is the single source of truth; both flag sets are folded from it through an exhaustive when, so loosening or tightening one is structurally impossible without the other. This closes the device-only drift where a prompt succeeds but the key throws UserNotAuthenticatedException.
Tests
AesGcmKeystoreCipherTest (new, JVM): both generateKeyOnDecrypt modes (auto-generate vs fail-fast), existing-key reuse, the AES-GCM error remap, and that non-AEAD failures propagate unwrapped.
AuthenticatorPolicyTest (new, JVM): pins ACCEPTED and each vocabulary mapping, ties AppLockManager.AUTHENTICATORS to the policy, and documents that the two API bit-sets genuinely differ.
Fast gate all green with JDK 21: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, plus compileDebugAndroidTestKotlin. The real Keystore paths remain device-only (unchanged), covered by the existing instrumentation.
Scope was limited to the two ciphers, the new shared base, the two authenticator-policy definitions, and tests. No app-lock UI, DB provisioning, backup, or restart-path changes.
Closes #102.
Behavior-preserving security refactor removing the duplication between the two AES-256-GCM Keystore users and unifying the accepted-authenticator policy that was defined in two places.
## 1. Shared AES-GCM Keystore base
New `AesGcmKeystoreCipher` holds everything `KeystoreCrypto` and `DatabaseKeyCipher` copy-pasted: the encrypt/decrypt bodies, `existingKey`/`getOrCreateKey` under a lock, key deletion, and the 5 identical GCM constants (`ANDROID_KEYSTORE`, `TRANSFORMATION`, `IV_LENGTH`, `TAG_BITS`, `AES_KEY_SIZE_BITS`). Each cipher now contributes only its delta:
- `KeystoreCrypto` -> the plain AES-256-GCM `keySpec` (via `keySpecBuilder()`).
- `DatabaseKeyCipher` -> the auth-bound `keySpec` + the self-healing encrypt, `isInvalidated()`, and fail-fast missing-key message.
A future change to the crypto (StrongBox opt-in, IV handling, error mapping) is now made once.
## 2. The master / auth-bound missing-key split is preserved DELIBERATELY
The two ciphers intentionally disagree on what a *missing* key means on decrypt, and they still do — expressed as a `generateKeyOnDecrypt` policy parameter, documented on the base:
- **Master key** (`KeystoreCrypto`, `generateKeyOnDecrypt = true`): silently generates a key on a missing alias — correct for a first-run master key with nothing sealed yet.
- **Auth-bound cache key** (`DatabaseKeyCipher`, `generateKeyOnDecrypt = false`): **fails fast** (`error("auth-bound database key is missing")`), because a missing auth-bound key means it was *invalidated*; silently regenerating it would re-arm the lock against a cache that can never be decrypted, defeating the security model.
Per the ticket, the opaque `AEADBadTagException` the master path throws when it generates a fresh key and then can't decrypt old data is now mapped to a clear `GeneralSecurityException`. The catch is narrow: `KeyPermanentlyInvalidatedException` (thrown at `Cipher.init`) still propagates unwrapped so callers can classify invalidation.
## 3. One authenticator policy, two vocabularies
The accepted authenticators were encoded twice with no cross-reference (BiometricManager terms in `AppLockManager.AUTHENTICATORS`, KeyProperties terms in `DatabaseKeyCipher`). New `AuthenticatorPolicy.ACCEPTED` is the single source of truth; both flag sets are folded from it through an exhaustive `when`, so loosening or tightening one is structurally impossible without the other. This closes the device-only drift where a prompt succeeds but the key throws `UserNotAuthenticatedException`.
## Tests
- `AesGcmKeystoreCipherTest` (new, JVM): both `generateKeyOnDecrypt` modes (auto-generate vs fail-fast), existing-key reuse, the AES-GCM error remap, and that non-AEAD failures propagate unwrapped.
- `AuthenticatorPolicyTest` (new, JVM): pins `ACCEPTED` and each vocabulary mapping, ties `AppLockManager.AUTHENTICATORS` to the policy, and documents that the two API bit-sets genuinely differ.
- #100's safety net (`DatabaseKeyStoreTest` seal-exchange, `KeyInvalidationPolicyTest` table, `AppLockViewModelTest`, `SettingsViewModelTest`) stays green, untouched.
## Verification
Fast gate all green with JDK 21: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, plus `compileDebugAndroidTestKotlin`. The real Keystore paths remain device-only (unchanged), covered by the existing instrumentation.
Scope was limited to the two ciphers, the new shared base, the two authenticator-policy definitions, and tests. No app-lock UI, DB provisioning, backup, or restart-path changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #102.
Behavior-preserving security refactor removing the duplication between the two AES-256-GCM Keystore users and unifying the accepted-authenticator policy that was defined in two places.
1. Shared AES-GCM Keystore base
New
AesGcmKeystoreCipherholds everythingKeystoreCryptoandDatabaseKeyCiphercopy-pasted: the encrypt/decrypt bodies,existingKey/getOrCreateKeyunder a lock, key deletion, and the 5 identical GCM constants (ANDROID_KEYSTORE,TRANSFORMATION,IV_LENGTH,TAG_BITS,AES_KEY_SIZE_BITS). Each cipher now contributes only its delta:KeystoreCrypto-> the plain AES-256-GCMkeySpec(viakeySpecBuilder()).DatabaseKeyCipher-> the auth-boundkeySpec+ the self-healing encrypt,isInvalidated(), and fail-fast missing-key message.A future change to the crypto (StrongBox opt-in, IV handling, error mapping) is now made once.
2. The master / auth-bound missing-key split is preserved DELIBERATELY
The two ciphers intentionally disagree on what a missing key means on decrypt, and they still do — expressed as a
generateKeyOnDecryptpolicy parameter, documented on the base:KeystoreCrypto,generateKeyOnDecrypt = true): silently generates a key on a missing alias — correct for a first-run master key with nothing sealed yet.DatabaseKeyCipher,generateKeyOnDecrypt = false): fails fast (error("auth-bound database key is missing")), because a missing auth-bound key means it was invalidated; silently regenerating it would re-arm the lock against a cache that can never be decrypted, defeating the security model.Per the ticket, the opaque
AEADBadTagExceptionthe master path throws when it generates a fresh key and then can't decrypt old data is now mapped to a clearGeneralSecurityException. The catch is narrow:KeyPermanentlyInvalidatedException(thrown atCipher.init) still propagates unwrapped so callers can classify invalidation.3. One authenticator policy, two vocabularies
The accepted authenticators were encoded twice with no cross-reference (BiometricManager terms in
AppLockManager.AUTHENTICATORS, KeyProperties terms inDatabaseKeyCipher). NewAuthenticatorPolicy.ACCEPTEDis the single source of truth; both flag sets are folded from it through an exhaustivewhen, so loosening or tightening one is structurally impossible without the other. This closes the device-only drift where a prompt succeeds but the key throwsUserNotAuthenticatedException.Tests
AesGcmKeystoreCipherTest(new, JVM): bothgenerateKeyOnDecryptmodes (auto-generate vs fail-fast), existing-key reuse, the AES-GCM error remap, and that non-AEAD failures propagate unwrapped.AuthenticatorPolicyTest(new, JVM): pinsACCEPTEDand each vocabulary mapping, tiesAppLockManager.AUTHENTICATORSto the policy, and documents that the two API bit-sets genuinely differ.DatabaseKeyStoreTestseal-exchange,KeyInvalidationPolicyTesttable,AppLockViewModelTest,SettingsViewModelTest) stays green, untouched.Verification
Fast gate all green with JDK 21:
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt, pluscompileDebugAndroidTestKotlin. The real Keystore paths remain device-only (unchanged), covered by the existing instrumentation.Scope was limited to the two ciphers, the new shared base, the two authenticator-policy definitions, and tests. No app-lock UI, DB provisioning, backup, or restart-path changes.
🤖 Generated with Claude Code