Reworks the original approach to #359. A SQLCipher UnsatisfiedLinkError (native library fails to load) while the opt-in encrypted cache is enabled must no longer crash-loop the app. The first attempt degraded to a plaintext cache — a silent fail-open that defeats the encryption feature — which was rejected. This PR fails closed and hardens the key material.
Note: the investigation confirmed the SQLCipher .sois 16 KB-aligned, so "16 KB" is not the cause and there is no dependency bump. This PR is purely the failure-handling policy plus the StrongBox hardening.
(A) Fail closed + notify
DatabaseProvisioner now raises a distinct CacheEncryptionUnavailableException on a LinkageError from the SQLCipher-backed open/convert path (e.g. DatabaseEncryption.ensureNativeLibraryLoaded()) while encryptCache is on. It does NOT open plaintext, does NOT wipe the on-disk ciphertext, and does NOT write the encryptCache setting. The throw is not memoized, so the next launch re-attempts and recovers automatically if the library later loads.
A new CacheEncryptionGate wraps the app inside AppLockGateHost (so the auth-bound passphrase is already unlocked), proactively probes prepareCache() before any DB-backed screen composes, and on failure shows CacheEncryptionErrorScreen — never the mailbox, never a crash — with the exact message:
Error - decryption could not proceed. Native decryption library load failure.
The error screen offers "Report a problem", which generates an ephemeral, PII-free diagnostic report reusing the existing DiagnosticsCollector — held in memory only (never written to ReportStore, because encryption is unavailable in that exact moment), for on-screen review + explicit Copy/Save. The copy states it is PII-free and not saved unless the user saves/exports it.
The plaintext AccountDatabase (which never uses SQLCipher) tolerates CacheEncryptionUnavailableException so accounts/credentials stay readable — needed to render the gate and assemble the report. The wipe+migrate steps run before the encryption gate that throws, so the migrate-before-open ordering still holds.
PII-free AppLog.w breadcrumbs on the fail-closed path.
(B) StrongBox-backed keys, TEE fallback
AesGcmKeystoreCipher.keySpecBuilder() now requests setIsStrongBoxBacked(true) (API 28+; minSdk 29) in the single shared place, so both the master (KeystoreCrypto) and auth-bound (DatabaseKeyCipher) keys are bound to the hardware secure element.
Devices without StrongBox throw StrongBoxUnavailableException at generation; a generate-with-fallback path catches it and regenerates a TEE-backed key so key creation still succeeds everywhere.
Framing (Base64(iv||ct)), seal/unseal, and the missing-key policies are unchanged. The passphrase remains never plaintext at rest (sealed by the non-exportable Keystore key, materialized only transiently in PassphraseSession) and never logged.
(C) encryptCache never flips programmatically
Removed the degrade path's setEncryptCache(false) write. Audit result: after this change the only production writer of the encryptCache setting is SettingsViewModel.setEncryptCache, wired exclusively to the Settings screen toggle (onCheckedChange). DatabaseProvisioner/AccountDatabaseModule never mutate it. A guard test asserts the provisioning/failure path performs zero writes to the setting.
Tests
Unit (DatabaseProvisionerTest): a simulated native-load failure fails closed — raises CacheEncryptionUnavailableException, does not open plaintext, does not wipe ciphertext, does not write the setting, and is not memoized (retries).
Unit (AesGcmKeystoreCipherTest): StrongBoxUnavailableException on generation falls back to a TEE key via the existing seams.
Unit (CacheEncryptionGateViewModelTest): resolves Ready vs Unavailable without crashing; builds the ephemeral report from DiagnosticsCollector with no ReportStore dependency (ephemeral by construction).
Instrumented: CacheEncryptionErrorScreenTest (verbatim message + report action); AccountDatabaseModuleInstrumentedTest (account store still opens when the cache-encryption library fails); DatabaseProvisionerInstrumentedTest unchanged, so CI's API-37 job still exercises the real keyed open.
Fast gate green locally: assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt. Emulator E2E left to CI (incl. the API-37 google_apis_ps16k job).
Maintainer decisions to confirm
Error-gate UX: implemented a sound default (full-screen notice with the verbatim message + reassurance that nothing was lost + "Report a problem" → ephemeral review with Copy/Save). No retry/exit button — the gate re-probes automatically on the next launch. Flagging in case a different UX is preferred.
Crash reports remain persisted (separate concern). This PR only makes the fail-closed gate's own report ephemeral. Auto-captured crash reports (CrashReporter) still persist plaintext to files/debug_reports as today. That leaves a plaintext diagnostic on disk while the cache is encrypted — flagged as a separate maintainer decision (the "encrypt persisted reports at rest" follow-up), deliberately not changed here.
## Summary
Reworks the original approach to #359. A SQLCipher `UnsatisfiedLinkError` (native library fails to load) while the opt-in **encrypted cache** is enabled must no longer crash-loop the app. The first attempt degraded to a plaintext cache — a silent **fail-open** that defeats the encryption feature — which was rejected. This PR **fails closed** and hardens the key material.
Note: the investigation confirmed the SQLCipher `.so` **is** 16 KB-aligned, so "16 KB" is not the cause and there is **no dependency bump**. This PR is purely the failure-handling policy plus the StrongBox hardening.
## (A) Fail closed + notify
- **`DatabaseProvisioner`** now raises a distinct **`CacheEncryptionUnavailableException`** on a `LinkageError` from the SQLCipher-backed open/convert path (e.g. `DatabaseEncryption.ensureNativeLibraryLoaded()`) while `encryptCache` is on. It does **NOT** open plaintext, does **NOT** wipe the on-disk ciphertext, and does **NOT** write the `encryptCache` setting. The throw is **not memoized**, so the next launch re-attempts and recovers automatically if the library later loads.
- A new **`CacheEncryptionGate`** wraps the app **inside `AppLockGateHost`** (so the auth-bound passphrase is already unlocked), proactively probes `prepareCache()` before any DB-backed screen composes, and on failure shows **`CacheEncryptionErrorScreen`** — never the mailbox, never a crash — with the exact message:
> **Error - decryption could not proceed. Native decryption library load failure.**
- The error screen offers **"Report a problem"**, which generates an **ephemeral, PII-free** diagnostic report reusing the existing `DiagnosticsCollector` — held **in memory only** (never written to `ReportStore`, because encryption is unavailable in that exact moment), for on-screen review + explicit **Copy/Save**. The copy states it is PII-free and not saved unless the user saves/exports it.
- The plaintext **`AccountDatabase`** (which never uses SQLCipher) tolerates `CacheEncryptionUnavailableException` so accounts/credentials stay readable — needed to render the gate and assemble the report. The wipe+migrate steps run before the encryption gate that throws, so the migrate-before-open ordering still holds.
- PII-free `AppLog.w` breadcrumbs on the fail-closed path.
## (B) StrongBox-backed keys, TEE fallback
- `AesGcmKeystoreCipher.keySpecBuilder()` now requests **`setIsStrongBoxBacked(true)`** (API 28+; minSdk 29) in the single shared place, so **both** the master (`KeystoreCrypto`) and auth-bound (`DatabaseKeyCipher`) keys are bound to the hardware secure element.
- Devices without StrongBox throw `StrongBoxUnavailableException` at generation; a generate-with-fallback path catches it and regenerates a **TEE-backed** key so key creation still succeeds everywhere.
- Framing (`Base64(iv||ct)`), seal/unseal, and the missing-key policies are unchanged. The passphrase remains **never plaintext at rest** (sealed by the non-exportable Keystore key, materialized only transiently in `PassphraseSession`) and **never logged**.
## (C) `encryptCache` never flips programmatically
Removed the degrade path's `setEncryptCache(false)` write. **Audit result:** after this change the only production writer of the `encryptCache` setting is `SettingsViewModel.setEncryptCache`, wired exclusively to the Settings screen toggle (`onCheckedChange`). `DatabaseProvisioner`/`AccountDatabaseModule` never mutate it. A guard test asserts the provisioning/failure path performs zero writes to the setting.
## Tests
- **Unit** (`DatabaseProvisionerTest`): a simulated native-load failure fails closed — raises `CacheEncryptionUnavailableException`, does not open plaintext, does not wipe ciphertext, does not write the setting, and is not memoized (retries).
- **Unit** (`AesGcmKeystoreCipherTest`): `StrongBoxUnavailableException` on generation falls back to a TEE key via the existing seams.
- **Unit** (`CacheEncryptionGateViewModelTest`): resolves `Ready` vs `Unavailable` without crashing; builds the ephemeral report from `DiagnosticsCollector` with no `ReportStore` dependency (ephemeral by construction).
- **Instrumented**: `CacheEncryptionErrorScreenTest` (verbatim message + report action); `AccountDatabaseModuleInstrumentedTest` (account store still opens when the cache-encryption library fails); `DatabaseProvisionerInstrumentedTest` unchanged, so CI's API-37 job still exercises the real keyed open.
Fast gate green locally: `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`. Emulator E2E left to CI (incl. the API-37 `google_apis_ps16k` job).
## Maintainer decisions to confirm
- **Error-gate UX**: implemented a sound default (full-screen notice with the verbatim message + reassurance that nothing was lost + "Report a problem" → ephemeral review with Copy/Save). No retry/exit button — the gate re-probes automatically on the next launch. Flagging in case a different UX is preferred.
- **Crash reports remain persisted (separate concern).** This PR only makes the fail-closed gate's *own* report ephemeral. Auto-captured crash reports (`CrashReporter`) still persist plaintext to `files/debug_reports` as today. That leaves a plaintext diagnostic on disk while the cache is encrypted — flagged as a **separate maintainer decision** (the "encrypt persisted reports at rest" follow-up), deliberately **not** changed here.
🤖 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.
Summary
Reworks the original approach to #359. A SQLCipher
UnsatisfiedLinkError(native library fails to load) while the opt-in encrypted cache is enabled must no longer crash-loop the app. The first attempt degraded to a plaintext cache — a silent fail-open that defeats the encryption feature — which was rejected. This PR fails closed and hardens the key material.Note: the investigation confirmed the SQLCipher
.sois 16 KB-aligned, so "16 KB" is not the cause and there is no dependency bump. This PR is purely the failure-handling policy plus the StrongBox hardening.(A) Fail closed + notify
DatabaseProvisionernow raises a distinctCacheEncryptionUnavailableExceptionon aLinkageErrorfrom the SQLCipher-backed open/convert path (e.g.DatabaseEncryption.ensureNativeLibraryLoaded()) whileencryptCacheis on. It does NOT open plaintext, does NOT wipe the on-disk ciphertext, and does NOT write theencryptCachesetting. The throw is not memoized, so the next launch re-attempts and recovers automatically if the library later loads.A new
CacheEncryptionGatewraps the app insideAppLockGateHost(so the auth-bound passphrase is already unlocked), proactively probesprepareCache()before any DB-backed screen composes, and on failure showsCacheEncryptionErrorScreen— never the mailbox, never a crash — with the exact message:The error screen offers "Report a problem", which generates an ephemeral, PII-free diagnostic report reusing the existing
DiagnosticsCollector— held in memory only (never written toReportStore, because encryption is unavailable in that exact moment), for on-screen review + explicit Copy/Save. The copy states it is PII-free and not saved unless the user saves/exports it.The plaintext
AccountDatabase(which never uses SQLCipher) toleratesCacheEncryptionUnavailableExceptionso accounts/credentials stay readable — needed to render the gate and assemble the report. The wipe+migrate steps run before the encryption gate that throws, so the migrate-before-open ordering still holds.PII-free
AppLog.wbreadcrumbs on the fail-closed path.(B) StrongBox-backed keys, TEE fallback
AesGcmKeystoreCipher.keySpecBuilder()now requestssetIsStrongBoxBacked(true)(API 28+; minSdk 29) in the single shared place, so both the master (KeystoreCrypto) and auth-bound (DatabaseKeyCipher) keys are bound to the hardware secure element.StrongBoxUnavailableExceptionat generation; a generate-with-fallback path catches it and regenerates a TEE-backed key so key creation still succeeds everywhere.Base64(iv||ct)), seal/unseal, and the missing-key policies are unchanged. The passphrase remains never plaintext at rest (sealed by the non-exportable Keystore key, materialized only transiently inPassphraseSession) and never logged.(C)
encryptCachenever flips programmaticallyRemoved the degrade path's
setEncryptCache(false)write. Audit result: after this change the only production writer of theencryptCachesetting isSettingsViewModel.setEncryptCache, wired exclusively to the Settings screen toggle (onCheckedChange).DatabaseProvisioner/AccountDatabaseModulenever mutate it. A guard test asserts the provisioning/failure path performs zero writes to the setting.Tests
DatabaseProvisionerTest): a simulated native-load failure fails closed — raisesCacheEncryptionUnavailableException, does not open plaintext, does not wipe ciphertext, does not write the setting, and is not memoized (retries).AesGcmKeystoreCipherTest):StrongBoxUnavailableExceptionon generation falls back to a TEE key via the existing seams.CacheEncryptionGateViewModelTest): resolvesReadyvsUnavailablewithout crashing; builds the ephemeral report fromDiagnosticsCollectorwith noReportStoredependency (ephemeral by construction).CacheEncryptionErrorScreenTest(verbatim message + report action);AccountDatabaseModuleInstrumentedTest(account store still opens when the cache-encryption library fails);DatabaseProvisionerInstrumentedTestunchanged, so CI's API-37 job still exercises the real keyed open.Fast gate green locally:
assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin+lintDebug+ktlintCheck+detekt. Emulator E2E left to CI (incl. the API-37google_apis_ps16kjob).Maintainer decisions to confirm
CrashReporter) still persist plaintext tofiles/debug_reportsas today. That leaves a plaintext diagnostic on disk while the cache is encrypted — flagged as a separate maintainer decision (the "encrypt persisted reports at rest" follow-up), deliberately not changed here.🤖 Generated with Claude Code