[needs careful review] feat(security): screen-lock app gate + auth-bound cache decrypt (#22) #45

Merged
JMR-dev merged 10 commits from feat-screen-unlock into main 2026-07-02 05:19:35 +00:00
JMR-dev commented 2026-07-01 05:25:23 +00:00 (Migrated from github.com)

Closes #22.

Opt-in screen-lock app gate + auth-bound decryption of the encrypted cache.
Off by default — with app-lock off, the DB path is byte-for-byte the old behavior.

What it does

  • App-lock setting ("Require screen lock", Advanced settings): gates the whole UI behind
    BiometricPrompt (strong biometric OR device-credential fallback) on launch and on
    resume-after-timeout.
  • Binds decryption to auth: a new auth-bound Keystore key (DatabaseKeyCipher,
    setUserAuthenticationRequired(true)) seals the SQLCipher passphrase. The unwrapped passphrase
    lives only in memory (PassphraseSession); provideDatabase reads it via await() on a
    background DI thread, so the encrypted cache is opened only after the user authenticates.
  • Enrollment change / lock removal: KeyInvalidationPolicy decides clear-vs-disable; the cache
    is wiped and re-synced (never corrupted — see below).
  • The non-auth master key (KeystoreCrypto, used for OAuth/IMAP credentials) is unchanged, so
    background IDLE push still decrypts credentials without user presence.

⚠ Security surface — please review carefully

  • setUserAuthenticationRequired binding (DatabaseKeyCipher): AES-256-GCM, time-bound validity
    (setUserAuthenticationParameters on API 30+, setUserAuthenticationValidityDurationSeconds on
    29) so no CryptoObject is needed (works with DEVICE_CREDENTIAL on API 29).
    setInvalidatedByBiometricEnrollment(true).
  • BiometricPrompt / device-credential flow (AppLockGateHost): BIOMETRIC_STRONG or DEVICE_CREDENTIAL, no negative button (disallowed with device-credential). MainActivity is now a
    FragmentActivity (required by BiometricPrompt).
  • Key-invalidation handling — the "never corrupt" guarantee: the cache file is only wiped at
    cold start inside provideDatabase, before Room opens it — never from the ViewModel while a Room
    connection may be open. On invalidation the ViewModel persists a flag and restarts the process;
    the next cold start wipes + re-syncs. Unwrap failures are classified: permanently-invalidated or
    deleted key → clear+resync; transient UserNotAuthenticatedException → retry (no wipe).
  • Lock removal: KeyguardManager.isDeviceSecure() gate → clear+disable (cache) or disable-only
    (no cache). Disabling app-lock reseals the passphrase under the master key first, guarded so a
    failed reseal keeps app-lock on rather than orphan the encrypted DB.
  • Invariant the design relies on: nothing injects the Room DB on the main thread before the gate
    unlocks (the lock screen and AppLockViewModel are DB-free; app content composes only when
    Unlocked). The only pre-unlock DB access is the background sync/push collector, which blocks
    harmlessly on a background thread until unlock.

Testing

  • Fast CI gate green: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.
  • New JVM unit tests (20) cover the testable logic: AppLockGate state machine, KeyInvalidationPolicy
    decision table, PassphraseSession.
  • Device-only, NOT exercised here (needs careful human/CI validation on real hardware):
    the auth-bound Keystore key + BiometricPrompt, biometric re-enrollment / lock-removal
    invalidation, and the process-restart clear path. minSdk 29 device-credential behavior in
    particular needs on-device checks.

Known limitations (called out for the reviewer)

  • Background sync/push cannot write to the encrypted cache while the app is locked (no UI to
    authenticate); the worker simply waits/reschedules. Documented, not fixed here.
  • On re-lock/timeout the in-memory passphrase and the already-open Room connection persist for the
    process lifetime; fully evicting the key would require closing/reopening Room (follow-up).

🤖 Generated with Claude Code

Closes #22. Opt-in **screen-lock app gate** + **auth-bound decryption** of the encrypted cache. Off by default — with app-lock off, the DB path is byte-for-byte the old behavior. ## What it does - **App-lock setting** ("Require screen lock", Advanced settings): gates the whole UI behind `BiometricPrompt` (strong biometric OR device-credential fallback) on launch and on resume-after-timeout. - **Binds decryption to auth**: a new auth-bound Keystore key (`DatabaseKeyCipher`, `setUserAuthenticationRequired(true)`) seals the SQLCipher passphrase. The unwrapped passphrase lives only in memory (`PassphraseSession`); `provideDatabase` reads it via `await()` on a background DI thread, so the encrypted cache is opened only after the user authenticates. - **Enrollment change / lock removal**: `KeyInvalidationPolicy` decides clear-vs-disable; the cache is wiped and re-synced (never corrupted — see below). - The **non-auth master key** (`KeystoreCrypto`, used for OAuth/IMAP credentials) is unchanged, so background IDLE push still decrypts credentials without user presence. ## ⚠ Security surface — please review carefully - **`setUserAuthenticationRequired` binding** (`DatabaseKeyCipher`): AES-256-GCM, time-bound validity (`setUserAuthenticationParameters` on API 30+, `setUserAuthenticationValidityDurationSeconds` on 29) so no `CryptoObject` is needed (works with `DEVICE_CREDENTIAL` on API 29). `setInvalidatedByBiometricEnrollment(true)`. - **BiometricPrompt / device-credential flow** (`AppLockGateHost`): `BIOMETRIC_STRONG or DEVICE_CREDENTIAL`, no negative button (disallowed with device-credential). `MainActivity` is now a `FragmentActivity` (required by `BiometricPrompt`). - **Key-invalidation handling — the "never corrupt" guarantee**: the cache file is **only** wiped at cold start inside `provideDatabase`, *before* Room opens it — never from the ViewModel while a Room connection may be open. On invalidation the ViewModel persists a flag and **restarts the process**; the next cold start wipes + re-syncs. Unwrap failures are classified: permanently-invalidated or deleted key → clear+resync; transient `UserNotAuthenticatedException` → retry (no wipe). - **Lock removal**: `KeyguardManager.isDeviceSecure()` gate → clear+disable (cache) or disable-only (no cache). Disabling app-lock reseals the passphrase under the master key first, guarded so a failed reseal keeps app-lock on rather than orphan the encrypted DB. - **Invariant the design relies on**: nothing injects the Room DB on the main thread before the gate unlocks (the lock screen and `AppLockViewModel` are DB-free; app content composes only when `Unlocked`). The only pre-unlock DB access is the background sync/push collector, which blocks harmlessly on a background thread until unlock. ## Testing - Fast CI gate green: `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt`. - New JVM unit tests (20) cover the testable logic: `AppLockGate` state machine, `KeyInvalidationPolicy` decision table, `PassphraseSession`. - **Device-only, NOT exercised here** (needs careful human/CI validation on real hardware): the auth-bound Keystore key + `BiometricPrompt`, biometric re-enrollment / lock-removal invalidation, and the process-restart clear path. minSdk 29 device-credential behavior in particular needs on-device checks. ## Known limitations (called out for the reviewer) - Background sync/push cannot write to the encrypted cache while the app is locked (no UI to authenticate); the worker simply waits/reschedules. Documented, not fixed here. - On re-lock/timeout the in-memory passphrase and the already-open Room connection persist for the process lifetime; fully evicting the key would require closing/reopening Room (follow-up). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.