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 unitReportStoreEncryptionTest: 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 unitKeystoreReportEncryptionTest: crypto delegation + the setting mirror (on/off, default-off before first value).
InstrumentedReportStoreEncryptionInstrumentedTest: proves real Android Keystore ciphertext on disk (no report content in plaintext) + round-trip on device; plaintext when off.
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.
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 -->
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.
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
ReportStoregains aReportEncryptioncollaborator (defaultNone= 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.KeystoreReportEncryptionreuses the vettedKeystoreCrypto(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 ofencryptCache, warmed by observing settings at startup — never a DataStore read on the crashing thread (mirrors the existingDiagnosticsCollectorcache pattern).AppLogbreadcrumbs at the enable/disable transition and both fallback paths (sealing failure, decrypt failure); report contents are never logged.Tests
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,markSurfacedre-seals,Nonepass-through.KeystoreReportEncryptionTest: crypto delegation + the setting mirror (on/off, default-off before first value).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.pyresets the shared adb server whileapi37_e2e.pyruns the unfiltered full suite — neither could be guaranteed not to disrupt that device. CI runs the full multi-API matrix +e2e-previewon isolated emulators, including the new instrumented test.Tick the box to add this pull request to the merge queue (same as
@mergifyio queue).