fix(security): fail closed on cache-encryption load failure + StrongBox-back keys (#359) #367

Merged
JMR-dev merged 10 commits from fix-359-sqlcipher-16kb into main 2026-07-07 00:41:35 +00:00
JMR-dev commented 2026-07-06 00:26:36 +00:00 (Migrated from github.com)

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

## 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)
Sign in to join this conversation.