feat(security): encrypt persisted reports at rest when cache encryption is on (#369) #424

Merged
JMR-dev merged 1 commits from feat-369-encrypt-reports-at-rest into main 2026-07-07 21:02:04 +00:00
JMR-dev commented 2026-07-07 19:56:00 +00:00 (Migrated from github.com)

Closes #369.

What

When the opt-in cache encryption (encryptCache) is ON, persisted crash and "Report a problem" reports (ReportStore, files/debug_reports) are now encrypted at rest and decrypted on read (the Problem Reports screen). When OFF, reports stay plaintext exactly as before. Reports remain PII-free regardless.

Not in scope (per the ticket): the decryption-FAILURE gate's ephemeral report (#367) is untouched.

How

  • ReportStore gains a ReportEncryption collaborator (default None = plaintext, so all existing call sites compile and behave identically). On write it seals the storage JSON with AES-256-GCM and tags it with a marker prefix (libremail.report.enc.v1:); on read it sniffs the prefix, so plaintext reports written before the setting was turned on and sealed reports written after it coexist transparently.
  • Fail closed: if sealing throws while encryption is ON, the report is dropped rather than written in plaintext — the user's opt-in encryption is never silently defeated by leaving a plaintext report on disk. A dropped crash report still lets the original crash propagate to the system handler.
  • KeystoreReportEncryption reuses the vetted KeystoreCrypto (the non-auth master key, so a crash while the app is locked can still seal and persist its report). enabled() reads a crash-safe in-memory mirror of encryptCache, warmed by observing settings at startup — never a DataStore read on the crashing thread (mirrors the existing DiagnosticsCollector cache pattern).
  • Logging: PII-free AppLog breadcrumbs at the enable/disable transition and both fallback paths (sealing failure, decrypt failure); report contents are never logged.

Tests

  • JVM unit ReportStoreEncryptionTest: seals at rest + reads back, plaintext byte-for-byte when off, crash report persisted encrypted, fail-closed (no plaintext written), reads a mix of plaintext + encrypted files, skips an undecryptable report, markSurfaced re-seals, None pass-through.
  • JVM unit KeystoreReportEncryptionTest: crypto delegation + the setting mirror (on/off, default-off before first value).
  • Instrumented ReportStoreEncryptionInstrumentedTest: proves real Android Keystore ciphertext on disk (no report content in plaintext) + round-trip on device; plaintext when off.

Gate

Local non-emulator gate green: assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification (0.84 floor held), compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt.

The local emulator E2E steps were deferred to CI: a physical Pixel 8 is actively in use by another agent, and local_instrumented.py resets the shared adb server while api37_e2e.py runs the unfiltered full suite — neither could be guaranteed not to disrupt that device. CI runs the full multi-API matrix + e2e-preview on isolated emulators, including the new instrumented test.

Closes #369. ## What When the opt-in cache encryption (`encryptCache`) is **ON**, persisted crash and "Report a problem" reports (`ReportStore`, `files/debug_reports`) are now encrypted **at rest** and decrypted on read (the Problem Reports screen). When **OFF**, reports stay plaintext exactly as before. Reports remain PII-free regardless. Not in scope (per the ticket): the decryption-FAILURE gate's ephemeral report (#367) is untouched. ## How - **`ReportStore`** gains a `ReportEncryption` collaborator (default `None` = plaintext, so all existing call sites compile and behave identically). On write it seals the storage JSON with AES-256-GCM and tags it with a marker prefix (`libremail.report.enc.v1:`); on read it sniffs the prefix, so plaintext reports written before the setting was turned on and sealed reports written after it **coexist** transparently. - **Fail closed:** if sealing throws while encryption is ON, the report is dropped rather than written in plaintext — the user's opt-in encryption is never silently defeated by leaving a plaintext report on disk. A dropped crash report still lets the original crash propagate to the system handler. - **`KeystoreReportEncryption`** reuses the vetted `KeystoreCrypto` (the non-auth **master** key, so a crash while the app is locked can still seal and persist its report). `enabled()` reads a crash-safe in-memory mirror of `encryptCache`, warmed by observing settings at startup — never a DataStore read on the crashing thread (mirrors the existing `DiagnosticsCollector` cache pattern). - **Logging:** PII-free `AppLog` breadcrumbs at the enable/disable transition and both fallback paths (sealing failure, decrypt failure); report contents are never logged. ## Tests - **JVM unit** `ReportStoreEncryptionTest`: seals at rest + reads back, plaintext byte-for-byte when off, crash report persisted encrypted, fail-closed (no plaintext written), reads a mix of plaintext + encrypted files, skips an undecryptable report, `markSurfaced` re-seals, `None` pass-through. - **JVM unit** `KeystoreReportEncryptionTest`: crypto delegation + the setting mirror (on/off, default-off before first value). - **Instrumented** `ReportStoreEncryptionInstrumentedTest`: proves real Android Keystore ciphertext on disk (no report content in plaintext) + round-trip on device; plaintext when off. ## Gate Local non-emulator gate green: `assembleDebug`, `testDebugUnitTest`, `jacocoTestCoverageVerification` (0.84 floor held), `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt`. The local emulator E2E steps were **deferred to CI**: a physical Pixel 8 is actively in use by another agent, and `local_instrumented.py` resets the shared adb server while `api37_e2e.py` runs the unfiltered full suite — neither could be guaranteed not to disrupt that device. CI runs the full multi-API matrix + `e2e-preview` on isolated emulators, including the new instrumented test.
mergify[bot] commented 2026-07-07 20:18:20 +00:00 (Migrated from github.com)

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request
Tick the box to add this pull request to the merge queue (same as `@mergifyio queue`). - [ ] Queue this pull request <!-- mergify:queue-control:queue -->
Sign in to join this conversation.