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).
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)
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 #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
BiometricPrompt(strong biometric OR device-credential fallback) on launch and onresume-after-timeout.
DatabaseKeyCipher,setUserAuthenticationRequired(true)) seals the SQLCipher passphrase. The unwrapped passphraselives only in memory (
PassphraseSession);provideDatabasereads it viaawait()on abackground DI thread, so the encrypted cache is opened only after the user authenticates.
KeyInvalidationPolicydecides clear-vs-disable; the cacheis wiped and re-synced (never corrupted — see below).
KeystoreCrypto, used for OAuth/IMAP credentials) is unchanged, sobackground IDLE push still decrypts credentials without user presence.
⚠ Security surface — please review carefully
setUserAuthenticationRequiredbinding (DatabaseKeyCipher): AES-256-GCM, time-bound validity(
setUserAuthenticationParameterson API 30+,setUserAuthenticationValidityDurationSecondson29) so no
CryptoObjectis needed (works withDEVICE_CREDENTIALon API 29).setInvalidatedByBiometricEnrollment(true).AppLockGateHost):BIOMETRIC_STRONG or DEVICE_CREDENTIAL, no negative button (disallowed with device-credential).MainActivityis now aFragmentActivity(required byBiometricPrompt).cold start inside
provideDatabase, before Room opens it — never from the ViewModel while a Roomconnection 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).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.
unlocks (the lock screen and
AppLockViewModelare DB-free; app content composes only whenUnlocked). The only pre-unlock DB access is the background sync/push collector, which blocksharmlessly on a background thread until unlock.
Testing
assembleDebug+testDebugUnitTest+lintDebug+ktlintCheck+detekt.AppLockGatestate machine,KeyInvalidationPolicydecision table,
PassphraseSession.the auth-bound Keystore key +
BiometricPrompt, biometric re-enrollment / lock-removalinvalidation, and the process-restart clear path. minSdk 29 device-credential behavior in
particular needs on-device checks.
Known limitations (called out for the reviewer)
authenticate); the worker simply waits/reschedules. Documented, not fixed here.
process lifetime; fully evicting the key would require closing/reopening Room (follow-up).
🤖 Generated with Claude Code