refactor(security): de-duplicate AES-GCM Keystore plumbing and unify the authenticator policy #145

Merged
JMR-dev merged 1 commits from refactor-keystore-crypto-base into main 2026-07-02 17:33:28 +00:00
JMR-dev commented 2026-07-02 17:21:44 +00:00 (Migrated from github.com)

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

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)
Sign in to join this conversation.