fix(security): key-invalidation policy keys off encryptCache setting, not seal state — can permanently brick the encrypted cache #479

Closed
opened 2026-07-10 19:13:55 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:13:55 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.

app/src/main/kotlin/org/libremail/data/security/KeyInvalidationPolicy.kt:48 — critical

DISABLE_APP_LOCK is chosen from the encryptCache SETTING, not the actual seal/on-disk state, so removing the device lock during the documented transitional window (encryptCache just toggled off, DB still encrypted, SEALED_AUTH present) silently disables app-lock and leaves the cache permanently unopenable — every subsequent launch hangs forever on a blank screen.

Failure scenario: State: appLock ON, user toggles encryptCache OFF in Settings (setting written, but the on-disk DB stays encrypted and SEALED_AUTH persists until the next cold start's decrypt-to-plaintext — the 'transitional case' unlockOrArm's KDoc describes). Before that cold start the user removes the device screen lock, which permanently invalidates the auth-bound key. Next foreground: decide(appLock=true, encryptCache=false, deviceSecure=false) -> DISABLE_APP_LOCK -> AppLockViewModel sets appLock=false and proceeds; no wipe is scheduled and SEALED_AUTH remains. Next cold start: app-lock gate passes instantly (appLock off, so no auth and no invalidation check ever runs again), then DatabaseProvisioner.resolveOpenMode hits 'DatabaseEncryption.isEncrypted(dbFile) -> resolvePassphrase(false)', which sees sealState()==AUTH and does session.current() ?: session.await() — suspending forever, since app-lock is off so nothing ever unlocks the session (and the key is invalidated anyway). CacheEncryptionGate stays at Checking (opaque cover) forever; Settings is unreachable behind the gate, so the app is bricked until the user clears app data. The correct action for this state is CLEAR_AND_DISABLE, which the policy cannot choose because it is never told an auth seal exists.

Verifier justification (CONFIRMED): Every link verified in source. (1) The transitional window exists: SettingsViewModel.setEncryptCache only writes the setting; the on-disk decrypt is deferred to the next cold start (DatabaseProvisioner.resolveOpenMode isEncrypted branch) and SEALED_AUTH persists — the codebase documents exactly this state in unlockOrArm's KDoc and in SettingsViewModel.setAppLock's comment ("a separate store that can already be off while the on-disk DB is still auth-sealed"), and guards it on the app-lock-toggle path but NOT on the policy path. (2) AppLockViewModel.kt:147 feeds decide() the raw setting (encryptCacheEnabled = settings.encryptCache), so with the device lock removed in the window decide(true,false,false,*) hits line 48 and returns DISABLE_APP_LOCK; its handler (AppLockViewModel.kt:157-160) only does setAppLock(false) — no setClearPending, no reseal to master. (3) Next cold start: app-lock is off so onForeground short-circuits to Unlocked without authenticating; CacheEncryptionGateViewModel.probe → prepareCache → resolveOpenMode → resolvePassphrase → sealState()==AUTH → session.current() ?: session.await() (DatabaseKeyStore.kt:69) suspends forever, because the only session.unlock callers (unlockWithAuth/sealWithAuth) are reachable solely through the app-lock auth flow which never runs again. The gate stays at Checking (blank cover, "never the app"), Settings is unreachable, and no path ever wipes the cache — bricked until the user clears app data. CLEAR_AND_DISABLE (the correct action) is unreachable because the policy is never told an auth seal exists. Not covered by any known-intentional behavior.

Defective line: !deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK

Fix hint: In AppLockViewModel.onForeground, derive the policy input from the actual seal/on-disk state, not the setting alone — e.g. encryptCacheEnabled = settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() (mirroring SettingsViewModel.setAppLock's "gate on the seal, not the setting" fix at lines 120-127), so lock removal during the transitional window yields CLEAR_AND_DISABLE and schedules the wipe; alternatively add a seal-exists parameter to KeyInvalidationPolicy.decide and cover the new row in KeyInvalidationPolicyTest.

app/src/main/kotlin/org/libremail/ui/lock/AppLockViewModel.kt:147 — critical

KeyInvalidationPolicy is fed the encryptCache SETTING instead of the actual seal/on-disk encryption state, so removing the device lock while the cache is still auth-sealed but the setting is already off yields DISABLE_APP_LOCK (no wipe), permanently stranding an unreadable encrypted cache and hanging every subsequent launch.

Failure scenario: App-lock ON + encrypted cache ON and armed (SEALED_AUTH, DB encrypted on disk). User toggles encryptCache OFF in Settings — only the setting is written; DatabaseProvisioner converts the file to plaintext only at the NEXT cold start, so the DB stays auth-sealed on disk. Before that cold start the user removes their device PIN, which permanently invalidates the auth-bound key. On the next foreground pass, decide(appLockEnabled=true, encryptCacheEnabled=false, deviceSecure=false) returns DISABLE_APP_LOCK: setAppLock(false) runs with NO setClearPending and NO wipe. At the next cold start, DatabaseProvisioner.resolveOpenMode hits the isEncrypted(dbFile) branch and calls resolvePassphrase(appLock=false) -> SealState.AUTH -> session.current() ?: session.await() — which suspends forever: app-lock is now off so no BiometricPrompt will ever run, and the key is invalidated so the passphrase is unrecoverable anyway. The provisioner mutex is held, every DB open parks, and the app hangs on a blank mailbox on every launch until the user clears app data. The DISABLE_APP_LOCK doc ('nothing encrypted to protect') is violated because the decision reads the setting, not hasAuthSealedPassphrase()/DatabaseEncryption.isEncrypted — exactly the desync DatabaseKeyStore.resolvePassphrase's own KDoc warns against.

Verifier justification (CONFIRMED): The chain is verifiable end-to-end in code. (1) SettingsViewModel.setEncryptCache only writes the DataStore setting; the on-disk conversion happens at the next cold start (DatabaseProvisioner KDoc: "toggling the setting therefore takes effect on the next app start"), so SEALED_AUTH + encrypted file persist while the setting reads false. (2) AppLockViewModel.kt:147 feeds that setting into KeyInvalidationPolicy.decide; with deviceSecure=false and encryptCacheEnabled=false the table returns DISABLE_APP_LOCK (KeyInvalidationPolicy.kt:48), and that arm (AppLockViewModel.kt:157-160) does only setAppLock(false) — no setClearPending, no wipe, no reseal. (3) The desync is documented as real in the codebase itself: SettingsViewModel.setAppLock deliberately gates its reseal on databaseKeyStore.hasAuthSealedPassphrase(), with the comment "gate on the seal, not the encryptCache setting (a separate store that can already be off while the on-disk DB is still auth-sealed)" — the foreground path lacks that guard. (4) Next cold start: isClearPending()=false, resolveOpenMode hits the isEncrypted(dbFile) branch, resolvePassphrase(appLock=false) sees SealState.AUTH and calls session.current() ?: session.await(); PassphraseSession.await() is passphrase.filterNotNull().first() with no timeout, and with app-lock now off onForeground returns Unlocked without ever prompting, so unlock() never fires (the invalidated key makes recovery impossible regardless). The provisioner mutex stays held and BOTH DatabaseModule and AccountDatabaseModule block in runBlocking { provisioner.prepareCache() }, so every DB open on every launch parks — blank app until the user clears app data. Trigger inputs are concrete and ordinary Android behavior: app-lock ON + encryptCache armed, toggle encryptCache off, remove device PIN before the next cold start (PIN removal both flips isDeviceSecure() false and permanently invalidates auth-bound keys). No test covers the seal-present/setting-off foreground case.

Defective line: encryptCacheEnabled = settings.encryptCache,

Fix hint: In AppLockViewModel.onForeground, derive the policy input from actual protection state rather than the setting — e.g. encryptCacheEnabled = settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() (mirroring SettingsViewModel.setAppLock's seal-based gating) — so a still-auth-sealed cache routes to CLEAR_AND_DISABLE/CLEAR_AND_REQUIRE_AUTH and gets wiped instead of stranded; add a unit test for the seal-present/setting-off/device-insecure case.

app/src/main/kotlin/org/libremail/data/security/EncryptedCacheGuard.kt:29 — high

EncryptedCacheGuard.isCacheLocked() derives 'locked' from the appLock/encryptCache settings instead of which seal actually exists, so it answers wrongly in both transitional states: workers park forever inside provideDatabase when encryptCache is off but the DB is still auth-sealed, and sync/push/send stall needlessly when the seal is still MASTER.

Failure scenario: Case A (guard says unlocked, worker wedges): app-lock ON, cache auth-sealed and encrypted on disk; user toggles encryptCache OFF (conversion deferred to next cold start). Process is killed overnight; a periodic SyncWorker (or SendWorker with a queued outgoing mail, or IdleService) fires before the user opens the app. isCacheLocked() = appLock(true) && encryptCache(FALSE) && ... = false, so the worker proceeds to a DAO -> DatabaseProvisioner.resolveOpenMode isEncrypted branch -> resolvePassphrase -> SealState.AUTH -> session.await(), parking the worker indefinitely while holding the provisioner mutex — instead of the intended Result.retry(). Outgoing mail sits stuck in the outbox and push/sync are dead until the user opens the app and authenticates. Case B (guard says locked, DB actually openable): user enables app-lock (or enables encryptCache) mid-session while the passphrase is still MASTER-sealed — sealWithAuth only runs on the NEXT foreground auth. Until then isCacheLocked() returns true (session never unlocked on the master path), so SyncWorker/SendWorker/PruneWorker retry and IdleService defers even though resolvePassphrase would open the DB with no auth — background sync, push and queued sends silently stall for the rest of the session. The guard should consult DatabaseKeyStore.sealState()/hasAuthSealedPassphrase (both DataStore-only, so its 'never touch Room' constraint still holds).

Verifier justification (CONFIRMED): EncryptedCacheGuard.kt:29 derives lockedness purely from the appLock && encryptCache settings, while DatabaseProvisioner.resolvePassphrase blocks based on which seal actually exists (DatabaseKeyStore.sealState()). The repo itself documents the divergence: SettingsViewModel.kt:120-122 ("gate on the seal, not the encryptCache setting (a separate store that can already be off while the on-disk DB is still auth-sealed)") and AppLockViewModel.kt:228-230 (the encryptCache-off-but-still-encrypted transitional case). Case A verified: appLock ON + encryptCache toggled OFF + auth seal + still-encrypted file (decrypt deferred to next start per DatabaseProvisioner) → guard returns false, worker proceeds, resolveOpenMode's isEncrypted branch calls resolvePassphrase(true) → SealState.AUTH → session.await() suspends indefinitely inside the provisioner mutex in a fresh process instead of Result.retry(). Case B verified: SettingsViewModel.setAppLock(true) only writes the setting; sealWithAuth runs only on the next foreground auth (AppLockViewModel.unlockOrArm), and PassphraseSession.unlock is never called on the MASTER path — so isCacheLocked()=true while resolvePassphrase would open with the master seal auth-free; SyncWorker/SendWorker/PruneWorker retry and IdleService defers (even across restarts, since the MASTER seal persists) until the user next authenticates, stranding queued outgoing mail that was sendable. No guard elsewhere prevents either state; EncryptedCacheGuardTest pins the flawed truth table.

Defective line: return settings.appLock && settings.encryptCache && !session.isUnlocked()

Fix hint: In EncryptedCacheGuard.isCacheLocked(), consult DatabaseKeyStore instead of the settings pair: locked iff the passphrase resolution would actually block, i.e. (sealState() == AUTH || (sealState() == NONE && appLock && encryptCache)) && !session.isUnlocked() — mirroring resolvePassphrase's blocking branches. Both sealState()/hasAuthSealedPassphrase are DataStore-only, so the guard's "never touch Room" constraint holds; update EncryptedCacheGuardTest and the worker deferral tests for both transitional states.

app/src/main/kotlin/org/libremail/ui/lock/AppLockViewModel.kt:157 — high

DISABLE_APP_LOCK silently turns app-lock off while an auth-sealed cache passphrase still exists (policy keys off the encryptCache setting, not the seal), stranding an orphaned SEALED_AUTH whose invalidated key can never be unwrapped; DatabaseKeyStore.resolvePassphrase then suspends forever on session.await(), bricking the app behind a permanent blank gate.

Failure scenario: User enables app-lock + encrypted cache (SEALED_AUTH created), later toggles encryptCache OFF — setEncryptCache only writes the setting and neither the decrypt conversion in DatabaseProvisioner.resolveOpenMode nor anything else removes SEALED_AUTH, so 'appLock=on, encryptCache=off, SEALED_AUTH present' persists across restarts. User then removes the device screen lock (permanently invalidating the auth-bound key). Next foreground pass: KeyInvalidationPolicy.decide(deviceSecure=false, encryptCacheEnabled=false) returns DISABLE_APP_LOCK, which just calls setAppLock(false) — no sealWithMaster/reset/wipe. If the DB was still encrypted (lock removed before the next cold start's decrypt conversion), resolveOpenMode hits the isEncrypted branch and resolvePassphrase(appLockEnabled=false) sees SealState.AUTH -> session.await(): with app-lock off nothing ever unlocks the session, so prepareCache never returns, CacheEncryptionGate shows a blank cover forever on every launch (both databases gate on prepareCache), and the cached mail is cryptographically unrecoverable; the correct action was CLEAR_AND_DISABLE. If the DB was already plaintext, the same permanent hang fires the moment the user later re-enables encryptCache. Only clearing app data recovers.

Verifier justification (CONFIRMED): Every link in the chain is in the code. (1) setEncryptCache only writes the setting, and the decrypt conversion in DatabaseProvisioner.resolveOpenMode (isEncrypted branch) never removes SEALED_AUTH — the only removers are sealWithMaster (settings setAppLock(false) path only) and resetSealedPassphrase (clear-pending wipe only) — so 'appLock=on, encryptCache=off, SEALED_AUTH present' persists indefinitely; SettingsViewModel's own comment admits this state exists. (2) KeyInvalidationPolicy.decide line 48 keys the !deviceSecure branch off encryptCacheEnabled, not the seal, returning DISABLE_APP_LOCK. (3) AppLockViewModel line 157-160 handles DISABLE_APP_LOCK with only setAppLock(false) + Unlocked — no seal cleanup or wipe. (4) DatabaseKeyStore.resolvePassphrase keys off the seal (SealState.AUTH -> session.current() ?: session.await()), and with app-lock now false onForeground short-circuits to Unlocked so unlockWithAuth is never called; PassphraseSession.await() (filterNotNull().first()) never resolves, so DatabaseProvisioner.prepareCache never returns, CacheEncryptionGateViewModel stays in Checking, and CacheEncryptionGate renders a blank GateCover forever — both LibreMailDatabase and AccountDatabase gate on prepareCache. The auth-bound key is invalidated by screen-lock removal, so the encrypted cache is also cryptographically lost; the policy already defines the correct action (CLEAR_AND_DISABLE) for this device state. Trigger sequence is concrete and user-reachable: enable app-lock+encryptCache, toggle encryptCache off, remove device screen lock, foreground once (setting flips off), then cold start (DB still encrypted) hangs on every launch; if the DB was already decrypted, the same hang fires on a later encryptCache re-enable. Only clearing app data recovers. Not covered by any known-intentional decision.

Defective line: LockAction.DISABLE_APP_LOCK -> { settingsRepository.setAppLock(false) _uiState.value = AppLockUiState.Unlocked } // KeyInvalidationPolicy.kt:48 !deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK // DatabaseKeyStore.resolvePassphrase SealState.AUTH -> session.current() ?: session.await()

Fix hint: In AppLockViewModel's DISABLE_APP_LOCK branch, check databaseKeyStore.hasAuthSealedPassphrase() (off the main thread) and, if a seal exists, route to clearCacheAndRestart(disableAppLock = true) — i.e. treat it as CLEAR_AND_DISABLE, since the invalidated auth key makes the seal unrecoverable. Longer term, feed the actual SealState (not the encryptCache setting) into KeyInvalidationPolicy.decide so the decision table matches DatabaseKeyStore's seal-is-source-of-truth model; optionally also drop the orphaned SEALED_AUTH (reseal with master) after the decrypt-on-disable conversion in DatabaseProvisioner.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.** ## `app/src/main/kotlin/org/libremail/data/security/KeyInvalidationPolicy.kt:48` — critical DISABLE_APP_LOCK is chosen from the encryptCache SETTING, not the actual seal/on-disk state, so removing the device lock during the documented transitional window (encryptCache just toggled off, DB still encrypted, SEALED_AUTH present) silently disables app-lock and leaves the cache permanently unopenable — every subsequent launch hangs forever on a blank screen. **Failure scenario:** State: appLock ON, user toggles encryptCache OFF in Settings (setting written, but the on-disk DB stays encrypted and SEALED_AUTH persists until the next cold start's decrypt-to-plaintext — the 'transitional case' unlockOrArm's KDoc describes). Before that cold start the user removes the device screen lock, which permanently invalidates the auth-bound key. Next foreground: decide(appLock=true, encryptCache=false, deviceSecure=false) -> DISABLE_APP_LOCK -> AppLockViewModel sets appLock=false and proceeds; no wipe is scheduled and SEALED_AUTH remains. Next cold start: app-lock gate passes instantly (appLock off, so no auth and no invalidation check ever runs again), then DatabaseProvisioner.resolveOpenMode hits 'DatabaseEncryption.isEncrypted(dbFile) -> resolvePassphrase(false)', which sees sealState()==AUTH and does session.current() ?: session.await() — suspending forever, since app-lock is off so nothing ever unlocks the session (and the key is invalidated anyway). CacheEncryptionGate stays at Checking (opaque cover) forever; Settings is unreachable behind the gate, so the app is bricked until the user clears app data. The correct action for this state is CLEAR_AND_DISABLE, which the policy cannot choose because it is never told an auth seal exists. **Verifier justification (CONFIRMED):** Every link verified in source. (1) The transitional window exists: SettingsViewModel.setEncryptCache only writes the setting; the on-disk decrypt is deferred to the next cold start (DatabaseProvisioner.resolveOpenMode isEncrypted branch) and SEALED_AUTH persists — the codebase documents exactly this state in unlockOrArm's KDoc and in SettingsViewModel.setAppLock's comment ("a separate store that can already be off while the on-disk DB is still auth-sealed"), and guards it on the app-lock-toggle path but NOT on the policy path. (2) AppLockViewModel.kt:147 feeds decide() the raw setting (encryptCacheEnabled = settings.encryptCache), so with the device lock removed in the window decide(true,false,false,*) hits line 48 and returns DISABLE_APP_LOCK; its handler (AppLockViewModel.kt:157-160) only does setAppLock(false) — no setClearPending, no reseal to master. (3) Next cold start: app-lock is off so onForeground short-circuits to Unlocked without authenticating; CacheEncryptionGateViewModel.probe → prepareCache → resolveOpenMode → resolvePassphrase → sealState()==AUTH → session.current() ?: session.await() (DatabaseKeyStore.kt:69) suspends forever, because the only session.unlock callers (unlockWithAuth/sealWithAuth) are reachable solely through the app-lock auth flow which never runs again. The gate stays at Checking (blank cover, "never the app"), Settings is unreachable, and no path ever wipes the cache — bricked until the user clears app data. CLEAR_AND_DISABLE (the correct action) is unreachable because the policy is never told an auth seal exists. Not covered by any known-intentional behavior. **Defective line:** `!deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK` **Fix hint:** In AppLockViewModel.onForeground, derive the policy input from the actual seal/on-disk state, not the setting alone — e.g. encryptCacheEnabled = settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() (mirroring SettingsViewModel.setAppLock's "gate on the seal, not the setting" fix at lines 120-127), so lock removal during the transitional window yields CLEAR_AND_DISABLE and schedules the wipe; alternatively add a seal-exists parameter to KeyInvalidationPolicy.decide and cover the new row in KeyInvalidationPolicyTest. ## `app/src/main/kotlin/org/libremail/ui/lock/AppLockViewModel.kt:147` — critical KeyInvalidationPolicy is fed the encryptCache SETTING instead of the actual seal/on-disk encryption state, so removing the device lock while the cache is still auth-sealed but the setting is already off yields DISABLE_APP_LOCK (no wipe), permanently stranding an unreadable encrypted cache and hanging every subsequent launch. **Failure scenario:** App-lock ON + encrypted cache ON and armed (SEALED_AUTH, DB encrypted on disk). User toggles encryptCache OFF in Settings — only the setting is written; DatabaseProvisioner converts the file to plaintext only at the NEXT cold start, so the DB stays auth-sealed on disk. Before that cold start the user removes their device PIN, which permanently invalidates the auth-bound key. On the next foreground pass, decide(appLockEnabled=true, encryptCacheEnabled=false, deviceSecure=false) returns DISABLE_APP_LOCK: setAppLock(false) runs with NO setClearPending and NO wipe. At the next cold start, DatabaseProvisioner.resolveOpenMode hits the isEncrypted(dbFile) branch and calls resolvePassphrase(appLock=false) -> SealState.AUTH -> session.current() ?: session.await() — which suspends forever: app-lock is now off so no BiometricPrompt will ever run, and the key is invalidated so the passphrase is unrecoverable anyway. The provisioner mutex is held, every DB open parks, and the app hangs on a blank mailbox on every launch until the user clears app data. The DISABLE_APP_LOCK doc ('nothing encrypted to protect') is violated because the decision reads the setting, not hasAuthSealedPassphrase()/DatabaseEncryption.isEncrypted — exactly the desync DatabaseKeyStore.resolvePassphrase's own KDoc warns against. **Verifier justification (CONFIRMED):** The chain is verifiable end-to-end in code. (1) SettingsViewModel.setEncryptCache only writes the DataStore setting; the on-disk conversion happens at the next cold start (DatabaseProvisioner KDoc: "toggling the setting therefore takes effect on the next app start"), so SEALED_AUTH + encrypted file persist while the setting reads false. (2) AppLockViewModel.kt:147 feeds that setting into KeyInvalidationPolicy.decide; with deviceSecure=false and encryptCacheEnabled=false the table returns DISABLE_APP_LOCK (KeyInvalidationPolicy.kt:48), and that arm (AppLockViewModel.kt:157-160) does only setAppLock(false) — no setClearPending, no wipe, no reseal. (3) The desync is documented as real in the codebase itself: SettingsViewModel.setAppLock deliberately gates its reseal on databaseKeyStore.hasAuthSealedPassphrase(), with the comment "gate on the seal, not the encryptCache setting (a separate store that can already be off while the on-disk DB is still auth-sealed)" — the foreground path lacks that guard. (4) Next cold start: isClearPending()=false, resolveOpenMode hits the isEncrypted(dbFile) branch, resolvePassphrase(appLock=false) sees SealState.AUTH and calls session.current() ?: session.await(); PassphraseSession.await() is passphrase.filterNotNull().first() with no timeout, and with app-lock now off onForeground returns Unlocked without ever prompting, so unlock() never fires (the invalidated key makes recovery impossible regardless). The provisioner mutex stays held and BOTH DatabaseModule and AccountDatabaseModule block in runBlocking { provisioner.prepareCache() }, so every DB open on every launch parks — blank app until the user clears app data. Trigger inputs are concrete and ordinary Android behavior: app-lock ON + encryptCache armed, toggle encryptCache off, remove device PIN before the next cold start (PIN removal both flips isDeviceSecure() false and permanently invalidates auth-bound keys). No test covers the seal-present/setting-off foreground case. **Defective line:** `encryptCacheEnabled = settings.encryptCache,` **Fix hint:** In AppLockViewModel.onForeground, derive the policy input from actual protection state rather than the setting — e.g. encryptCacheEnabled = settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() (mirroring SettingsViewModel.setAppLock's seal-based gating) — so a still-auth-sealed cache routes to CLEAR_AND_DISABLE/CLEAR_AND_REQUIRE_AUTH and gets wiped instead of stranded; add a unit test for the seal-present/setting-off/device-insecure case. ## `app/src/main/kotlin/org/libremail/data/security/EncryptedCacheGuard.kt:29` — high EncryptedCacheGuard.isCacheLocked() derives 'locked' from the appLock/encryptCache settings instead of which seal actually exists, so it answers wrongly in both transitional states: workers park forever inside provideDatabase when encryptCache is off but the DB is still auth-sealed, and sync/push/send stall needlessly when the seal is still MASTER. **Failure scenario:** Case A (guard says unlocked, worker wedges): app-lock ON, cache auth-sealed and encrypted on disk; user toggles encryptCache OFF (conversion deferred to next cold start). Process is killed overnight; a periodic SyncWorker (or SendWorker with a queued outgoing mail, or IdleService) fires before the user opens the app. isCacheLocked() = appLock(true) && encryptCache(FALSE) && ... = false, so the worker proceeds to a DAO -> DatabaseProvisioner.resolveOpenMode isEncrypted branch -> resolvePassphrase -> SealState.AUTH -> session.await(), parking the worker indefinitely while holding the provisioner mutex — instead of the intended Result.retry(). Outgoing mail sits stuck in the outbox and push/sync are dead until the user opens the app and authenticates. Case B (guard says locked, DB actually openable): user enables app-lock (or enables encryptCache) mid-session while the passphrase is still MASTER-sealed — sealWithAuth only runs on the NEXT foreground auth. Until then isCacheLocked() returns true (session never unlocked on the master path), so SyncWorker/SendWorker/PruneWorker retry and IdleService defers even though resolvePassphrase would open the DB with no auth — background sync, push and queued sends silently stall for the rest of the session. The guard should consult DatabaseKeyStore.sealState()/hasAuthSealedPassphrase (both DataStore-only, so its 'never touch Room' constraint still holds). **Verifier justification (CONFIRMED):** EncryptedCacheGuard.kt:29 derives lockedness purely from the appLock && encryptCache settings, while DatabaseProvisioner.resolvePassphrase blocks based on which seal actually exists (DatabaseKeyStore.sealState()). The repo itself documents the divergence: SettingsViewModel.kt:120-122 ("gate on the seal, not the encryptCache setting (a separate store that can already be off while the on-disk DB is still auth-sealed)") and AppLockViewModel.kt:228-230 (the encryptCache-off-but-still-encrypted transitional case). Case A verified: appLock ON + encryptCache toggled OFF + auth seal + still-encrypted file (decrypt deferred to next start per DatabaseProvisioner) → guard returns false, worker proceeds, resolveOpenMode's isEncrypted branch calls resolvePassphrase(true) → SealState.AUTH → session.await() suspends indefinitely inside the provisioner mutex in a fresh process instead of Result.retry(). Case B verified: SettingsViewModel.setAppLock(true) only writes the setting; sealWithAuth runs only on the next foreground auth (AppLockViewModel.unlockOrArm), and PassphraseSession.unlock is never called on the MASTER path — so isCacheLocked()=true while resolvePassphrase would open with the master seal auth-free; SyncWorker/SendWorker/PruneWorker retry and IdleService defers (even across restarts, since the MASTER seal persists) until the user next authenticates, stranding queued outgoing mail that was sendable. No guard elsewhere prevents either state; EncryptedCacheGuardTest pins the flawed truth table. **Defective line:** `return settings.appLock && settings.encryptCache && !session.isUnlocked()` **Fix hint:** In EncryptedCacheGuard.isCacheLocked(), consult DatabaseKeyStore instead of the settings pair: locked iff the passphrase resolution would actually block, i.e. (sealState() == AUTH || (sealState() == NONE && appLock && encryptCache)) && !session.isUnlocked() — mirroring resolvePassphrase's blocking branches. Both sealState()/hasAuthSealedPassphrase are DataStore-only, so the guard's "never touch Room" constraint holds; update EncryptedCacheGuardTest and the worker deferral tests for both transitional states. ## `app/src/main/kotlin/org/libremail/ui/lock/AppLockViewModel.kt:157` — high DISABLE_APP_LOCK silently turns app-lock off while an auth-sealed cache passphrase still exists (policy keys off the encryptCache setting, not the seal), stranding an orphaned SEALED_AUTH whose invalidated key can never be unwrapped; DatabaseKeyStore.resolvePassphrase then suspends forever on session.await(), bricking the app behind a permanent blank gate. **Failure scenario:** User enables app-lock + encrypted cache (SEALED_AUTH created), later toggles encryptCache OFF — setEncryptCache only writes the setting and neither the decrypt conversion in DatabaseProvisioner.resolveOpenMode nor anything else removes SEALED_AUTH, so 'appLock=on, encryptCache=off, SEALED_AUTH present' persists across restarts. User then removes the device screen lock (permanently invalidating the auth-bound key). Next foreground pass: KeyInvalidationPolicy.decide(deviceSecure=false, encryptCacheEnabled=false) returns DISABLE_APP_LOCK, which just calls setAppLock(false) — no sealWithMaster/reset/wipe. If the DB was still encrypted (lock removed before the next cold start's decrypt conversion), resolveOpenMode hits the isEncrypted branch and resolvePassphrase(appLockEnabled=false) sees SealState.AUTH -> session.await(): with app-lock off nothing ever unlocks the session, so prepareCache never returns, CacheEncryptionGate shows a blank cover forever on every launch (both databases gate on prepareCache), and the cached mail is cryptographically unrecoverable; the correct action was CLEAR_AND_DISABLE. If the DB was already plaintext, the same permanent hang fires the moment the user later re-enables encryptCache. Only clearing app data recovers. **Verifier justification (CONFIRMED):** Every link in the chain is in the code. (1) setEncryptCache only writes the setting, and the decrypt conversion in DatabaseProvisioner.resolveOpenMode (isEncrypted branch) never removes SEALED_AUTH — the only removers are sealWithMaster (settings setAppLock(false) path only) and resetSealedPassphrase (clear-pending wipe only) — so 'appLock=on, encryptCache=off, SEALED_AUTH present' persists indefinitely; SettingsViewModel's own comment admits this state exists. (2) KeyInvalidationPolicy.decide line 48 keys the !deviceSecure branch off encryptCacheEnabled, not the seal, returning DISABLE_APP_LOCK. (3) AppLockViewModel line 157-160 handles DISABLE_APP_LOCK with only setAppLock(false) + Unlocked — no seal cleanup or wipe. (4) DatabaseKeyStore.resolvePassphrase keys off the seal (SealState.AUTH -> session.current() ?: session.await()), and with app-lock now false onForeground short-circuits to Unlocked so unlockWithAuth is never called; PassphraseSession.await() (filterNotNull().first()) never resolves, so DatabaseProvisioner.prepareCache never returns, CacheEncryptionGateViewModel stays in Checking, and CacheEncryptionGate renders a blank GateCover forever — both LibreMailDatabase and AccountDatabase gate on prepareCache. The auth-bound key is invalidated by screen-lock removal, so the encrypted cache is also cryptographically lost; the policy already defines the correct action (CLEAR_AND_DISABLE) for this device state. Trigger sequence is concrete and user-reachable: enable app-lock+encryptCache, toggle encryptCache off, remove device screen lock, foreground once (setting flips off), then cold start (DB still encrypted) hangs on every launch; if the DB was already decrypted, the same hang fires on a later encryptCache re-enable. Only clearing app data recovers. Not covered by any known-intentional decision. **Defective line:** `LockAction.DISABLE_APP_LOCK -> { settingsRepository.setAppLock(false) _uiState.value = AppLockUiState.Unlocked } // KeyInvalidationPolicy.kt:48 !deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK // DatabaseKeyStore.resolvePassphrase SealState.AUTH -> session.current() ?: session.await()` **Fix hint:** In AppLockViewModel's DISABLE_APP_LOCK branch, check databaseKeyStore.hasAuthSealedPassphrase() (off the main thread) and, if a seal exists, route to clearCacheAndRestart(disableAppLock = true) — i.e. treat it as CLEAR_AND_DISABLE, since the invalidated auth key makes the seal unrecoverable. Longer term, feed the actual SealState (not the encryptCache setting) into KeyInvalidationPolicy.decide so the decision table matches DatabaseKeyStore's seal-is-source-of-truth model; optionally also drop the orphaned SEALED_AUTH (reseal with master) after the decrypt-on-disable conversion in DatabaseProvisioner.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#479