fix(security): derive key-invalidation and cache-guard decisions from seal state, not the encryptCache setting #504

Merged
JMR-dev merged 1 commits from fix-479-seal-state-desync into main 2026-07-10 20:40:23 +00:00
JMR-dev commented 2026-07-10 20:10:19 +00:00 (Migrated from github.com)

The bug (#479, P0 — verified by the 2026-07-09 whole-repo review)

SettingsViewModel.setEncryptCache only writes the DataStore setting; the on-disk conversion (decrypt-to-plaintext) is deferred to the next cold start (DatabaseProvisioner). That creates a transitional window — encryptCache == false, DB still SQLCipher-encrypted, SEALED_AUTH still present — in which three components answered from the setting instead of the actual seal/on-disk state:

  1. AppLockViewModel.onForeground → KeyInvalidationPolicy (critical): removing the device PIN inside the window produced decide(appLock=true, encryptCache=false, deviceSecure=false) → DISABLE_APP_LOCK: app-lock silently off, no wipe, SEALED_AUTH orphaned with its auth-bound key permanently invalidated. The next cold start hits DatabaseKeyStore.resolvePassphrase → SealState.AUTH → session.await() that nothing can ever complete (app-lock is off, so no auth flow runs again) — the app hangs forever behind the CacheEncryptionGate until the user clears app data.
  2. EncryptedCacheGuard.isCacheLocked() (high): derived "locked" from appLock && encryptCache, wrong in both transitional states — workers parked forever inside provideDatabase when the setting was off but the DB still auth-sealed (case A), and sync/push/send stalled needlessly while the seal was still MASTER (case B).
  3. The DISABLE_APP_LOCK arm did only setAppLock(false) — no setClearPending, no reseal — which is what orphaned SEALED_AUTH.

The fix — gate on the seal, not the setting

  • AppLockViewModel.onForeground now derives the policy input as settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() — the same gate-on-the-seal guard SettingsViewModel.setAppLock already carries. Lock removal in the window now routes to CLEAR_AND_DISABLE (wipe scheduled + restart); key invalidation routes to CLEAR_AND_REQUIRE_AUTH. DISABLE_APP_LOCK is only reachable seal-free, so it can no longer orphan a seal. The KeyInvalidationPolicy.decide parameter is renamed to encryptedCacheProtected to make the contract explicit; every existing row of the 16-row policy table is unchanged.
  • EncryptedCacheGuard now mirrors resolvePassphrase's blocking branches: keyed off DatabaseKeyStore.sealState() (MASTER → never locked; NONE → locked only while both settings ask for a not-yet-armed cache; AUTH → locked while the session is), plus — for the AUTH-seal-with-setting-off window — a raw 16-byte header read of the cache file, so a lingering orphaned seal over an already-plaintext cache does not stall background work. Still never touches Room.
  • DatabaseProvisioner closes the window at its source: after the decrypt-on-disable conversion it releases the orphaned auth seal (best-effort reseal under the master key, dropping SEALED_AUTH and its Keystore key), so the passphrase stays recoverable and a later re-enable reuses it.
  • All new decision/fallback paths breadcrumb through AppLog (PII-free enums/booleans only).

Tests

  • Unit: KeyInvalidationPolicyTest (renamed column + explicit transitional-window row; 16-row exhaustive table preserved), new AppLockViewModelSealStateTest driving the REAL policy through the ViewModel for the seal-present/setting-off matrix (clear+disable / disable-only / clear+require-auth / require-auth), EncryptedCacheGuardTest rewritten for the seal-based truth table incl. both transitional states against real fixture files, DatabaseProvisionerTest reseal-ordering + non-fatal-failure cases.
  • Instrumented: WorkerCacheLockDeferralInstrumentedTest — PruneWorker defers (Result.retry(), Lazy never resolved) during the case-A window with the real guard; MASTER-seal and orphan-seal-over-plaintext contrast cases. DatabaseProvisionerInstrumentedTest — real-Keystore end-to-end: transitional-window cold start decrypts AND releases the auth seal (seal becomes MASTER, passphrase round-trips). FetchGateReceiverInstrumentedTest updated for the new guard wiring.

Validation (all green locally)

  • assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt — BUILD SUCCESSFUL
  • Local emulator E2E (local_instrumented.py, API 36): 17/17 pass across the three changed instrumented classes
  • API 37 preview E2E (api37_e2e.py): full suite 309/309 pass

Closes #479

## The bug (#479, P0 — verified by the 2026-07-09 whole-repo review) `SettingsViewModel.setEncryptCache` only writes the DataStore setting; the on-disk conversion (decrypt-to-plaintext) is deferred to the next cold start (`DatabaseProvisioner`). That creates a **transitional window** — `encryptCache == false`, DB still SQLCipher-encrypted, `SEALED_AUTH` still present — in which three components answered from the *setting* instead of the *actual seal/on-disk state*: 1. **`AppLockViewModel.onForeground` → `KeyInvalidationPolicy` (critical):** removing the device PIN inside the window produced `decide(appLock=true, encryptCache=false, deviceSecure=false)` → `DISABLE_APP_LOCK`: app-lock silently off, **no wipe**, `SEALED_AUTH` orphaned with its auth-bound key permanently invalidated. The next cold start hits `DatabaseKeyStore.resolvePassphrase` → `SealState.AUTH` → `session.await()` that nothing can ever complete (app-lock is off, so no auth flow runs again) — the app hangs forever behind the `CacheEncryptionGate` until the user clears app data. 2. **`EncryptedCacheGuard.isCacheLocked()` (high):** derived "locked" from `appLock && encryptCache`, wrong in both transitional states — workers parked forever inside `provideDatabase` when the setting was off but the DB still auth-sealed (case A), and sync/push/send stalled needlessly while the seal was still `MASTER` (case B). 3. **The `DISABLE_APP_LOCK` arm** did only `setAppLock(false)` — no `setClearPending`, no reseal — which is what orphaned `SEALED_AUTH`. ## The fix — gate on the seal, not the setting * **`AppLockViewModel.onForeground`** now derives the policy input as `settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase()` — the same gate-on-the-seal guard `SettingsViewModel.setAppLock` already carries. Lock removal in the window now routes to `CLEAR_AND_DISABLE` (wipe scheduled + restart); key invalidation routes to `CLEAR_AND_REQUIRE_AUTH`. `DISABLE_APP_LOCK` is only reachable seal-free, so it can no longer orphan a seal. The `KeyInvalidationPolicy.decide` parameter is renamed to `encryptedCacheProtected` to make the contract explicit; **every existing row of the 16-row policy table is unchanged**. * **`EncryptedCacheGuard`** now mirrors `resolvePassphrase`'s blocking branches: keyed off `DatabaseKeyStore.sealState()` (`MASTER` → never locked; `NONE` → locked only while both settings ask for a not-yet-armed cache; `AUTH` → locked while the session is), plus — for the `AUTH`-seal-with-setting-off window — a raw 16-byte header read of the cache file, so a lingering orphaned seal over an already-plaintext cache does not stall background work. Still never touches Room. * **`DatabaseProvisioner`** closes the window at its source: after the decrypt-on-disable conversion it releases the orphaned auth seal (best-effort reseal under the master key, dropping `SEALED_AUTH` and its Keystore key), so the passphrase stays recoverable and a later re-enable reuses it. * All new decision/fallback paths breadcrumb through `AppLog` (PII-free enums/booleans only). ## Tests * **Unit:** `KeyInvalidationPolicyTest` (renamed column + explicit transitional-window row; 16-row exhaustive table preserved), new `AppLockViewModelSealStateTest` driving the REAL policy through the ViewModel for the seal-present/setting-off matrix (clear+disable / disable-only / clear+require-auth / require-auth), `EncryptedCacheGuardTest` rewritten for the seal-based truth table incl. both transitional states against real fixture files, `DatabaseProvisionerTest` reseal-ordering + non-fatal-failure cases. * **Instrumented:** `WorkerCacheLockDeferralInstrumentedTest` — PruneWorker defers (`Result.retry()`, `Lazy` never resolved) during the case-A window with the real guard; MASTER-seal and orphan-seal-over-plaintext contrast cases. `DatabaseProvisionerInstrumentedTest` — real-Keystore end-to-end: transitional-window cold start decrypts AND releases the auth seal (seal becomes `MASTER`, passphrase round-trips). `FetchGateReceiverInstrumentedTest` updated for the new guard wiring. ## Validation (all green locally) * `assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — BUILD SUCCESSFUL * Local emulator E2E (`local_instrumented.py`, API 36): 17/17 pass across the three changed instrumented classes * API 37 preview E2E (`api37_e2e.py`): full suite 309/309 pass Closes #479
mergify[bot] commented 2026-07-10 20:34:45 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-10 20:34 UTC · Rule: default · triggered by merge protections
  • ✅ Checks skipped · PR is already up-to-date
  • ✅ Merged — 2026-07-10 20:40 UTC · at 46ca019aa878e364b44480fd6e915639eaa9c690 · merge

This pull request spent 5 minutes 43 seconds in the queue, including 2 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-10T20:34:40.801907+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-10 20:34 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks skipped** · PR is already up-to-date - ✅ **Merged** — `2026-07-10 20:40 UTC` · at `46ca019aa878e364b44480fd6e915639eaa9c690` · merge This pull request spent **5 minutes 43 seconds** in the queue, including **2 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #504 - `-draft` - [X] #504 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #504 - `label != broken` - [X] #504 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.