Advances the client (app-repo) slice of the debug-report ingest pipeline: the existing opt-in ReportUploadWorker now runs a best-effort PII anonymization pass and seals each report with end-to-end (envelope) encryption to a maintainer public key BEFORE it leaves the device, and fails closed if it cannot. - ReportAnonymizer: pre-upload redaction of the free-text comment + log lines (emails, host:port, IPv4, JWTs, Bearer/Basic, key=value secrets). Re-scrubs the stack trace. Deliberately retains the user-supplied reply-to email (#159). - ReportPayloadEncryptor / HybridReportPayloadEncryptor: AES-256-GCM content key wrapped with RSA-OAEP-SHA256 to a public key; JSON envelope. JCA only, no new dependency, no GMS -> F-Droid-safe. Public key from BuildConfig DEBUG_REPORT_PUBLIC_KEY (empty default); private key stays with the maintainer. - ReportUploadWorker fails closed: uploads only when an endpoint AND a usable encryption key are configured; never transmits plaintext. PII-free AppLog at the anonymize/encrypt/upload lifecycle points. - Does NOT put R2/S3 credentials or SigV4 signing in the app (would violate the F-Droid + secrets-never-in-app constraints); the app POSTs the encrypted envelope to the ingest Worker, which holds the R2 secrets server-side (#11/#34). - Unit tests for anonymization, encryption round-trip, and the worker paths (success/retry/failure, fail-closed). docs/debug-report-privacy.md documents the data flow, anonymization, encryption scheme, and key custody (for #16/#20). Closes #34
6.5 KiB
Debug report privacy posture
Data flow and privacy design for LibreMail's opt-in debug reporting, covering the client (app) side that lives in this repository. It is referenced by the F-Droid audit (#16) and the README (#20), and is the client half of the ingest pipeline epic (#11); the ingest server (#34) and the weekly publish job (#35) are separate infrastructure, not part of this repo.
TL;DR: nothing is ever sent unless the user taps Submit on a report they have read in full, and even then the payload is anonymized and encrypted to the maintainer's public key on the device before it leaves. A stock build (and every F-Droid build) is configured to send nothing at all.
1. What a report contains
A DebugReport is assembled by DiagnosticsCollector from coarse, non-identifying fields only:
- App version name/code; Android release + SDK int; device manufacturer + model.
- A small settings summary (feature toggles and enum names — e.g.
dynamicColor=true,fetchPolicy=ALL). - A bucketed account summary: one
"<provider> (<authType>)"entry per account (e.g.Gmail (PASSWORD_IMAP)) — never an email address, username, or server hostname (custom hosts collapse toOther). - For crashes, a stack trace scrubbed at capture time by
StackTraceScrubber: exception class names and stack frames are kept; the free-text exception message (the only place a host or username appears) is dropped, and any residualuser@host/host:porttoken is redacted. - The recent in-app log buffer (
RingLogBuffer), which is governed by theAppLog"no PII" contract (a detekt guard forbids rawandroid.util.Log; throwables are auto-scrubbed). - Two user-supplied fields: a free-text comment, and an optional reply-to email the user typed when submitting (issue #159).
Message bodies, headers, attachments, credentials, and tokens are never collected.
2. Opt-in gating (strictly user-initiated)
Reporting is off by default and cannot phone home without deliberate action:
- A report is only transmitted when the user taps Submit on the review screen after reading the full payload verbatim (issue #33). There is no background or automatic upload.
- The transmit path additionally requires build configuration that a stock/F-Droid build does not
ship: both
DEBUG_REPORT_ENDPOINTandDEBUG_REPORT_PUBLIC_KEY(below) must be set. With either empty,ReportSubmitter.isEnabledis false / the worker fails closed, and the UI steers the user to Copy or Save to file instead.
3. Anonymization pass (ReportAnonymizer)
Immediately before upload, a best-effort redaction pass runs as defence-in-depth over the two surfaces that can still carry PII the earlier stages never saw:
- User comment (free text) and log lines are scanned and redacted for: email addresses,
host:porttokens, bare IPv4 addresses, JWT-shaped tokens,Bearerauthorization values, andkey=valuecredentials (password=…,access_token=…, …). The stack trace is re-run throughStackTraceScrubber. - Redaction is intentionally conservative-but-lossy (the ticket's "best-effort" bar): it prefers to over-redact a false positive rather than leak a real secret. If any PII shape survives, the worker logs a PII-free warning (it never blocks the user's submission).
- Exception — the reply-to email is retained by design.
userEmailis consented, purposeful data the user chose to give so the maintainer can follow up (issue #159); it is the one identifier that intentionally leaves the device, and only when the user fills it in. The ingest server (#34) is where reply-to is separated from the report body if desired.
4. Encryption scheme and key custody
The report is end-to-end encrypted on the device before upload (ReportPayloadEncryptor), so the
ingest Worker and the R2 bucket only ever see opaque ciphertext — "not readable without the documented
key" holds against the transport and storage tiers, not just at rest.
Scheme — hybrid (envelope) encryption, JCA only (no third-party or Google/GMS crypto):
-
Generate a fresh random AES-256-GCM content key + 96-bit IV; encrypt the report payload with it.
-
Wrap the content key with the recipient's RSA public key using RSA-OAEP (SHA-256, MGF1-SHA-256).
-
Emit a self-describing JSON envelope — the exact wire body of the POST:
{ "v": 1, "alg": "RSA-OAEP-SHA256+A256GCM", "ek": "<base64 RSA-OAEP-wrapped AES key>", "iv": "<base64 12-byte GCM IV>", "ct": "<base64 AES-GCM ciphertext||tag>" }
Key custody:
- The public key ships in the build via
BuildConfig.DEBUG_REPORT_PUBLIC_KEY(a single-line Base64 X.509/SPKI RSA key). A public key is not a secret, so nothing sensitive is embedded in the APK — this is F-Droid-safe. - The matching private key is held by the maintainer, off-device (e.g. Cloudflare Secret Manager per #11), and never appears in this repository or the app. Only that private key can decrypt a report.
- Fail-closed: if no public key is configured, or if sealing throws,
ReportUploadWorkerreturns a failure and sends nothing — it never transmits an unencrypted report. This is distinct from the on-device at-restReportEncryption(#369), whose symmetric Android-Keystore key can never leave the phone and so could never decrypt a report a remote reviewer must read; that is why the upload path is asymmetric.
5. Why R2 credentials are NOT in the app
Uploading to Cloudflare R2 (S3-compatible) requires write credentials. Embedding those in a FOSS, publicly-built APK would expose them to every user and violate both F-Droid's rules and #11/#34's "secrets stored in Cloudflare, never in the app". So the app does not hold R2/S3 credentials or sign requests: it performs a single authenticated-by-nothing HTTPS POST of the encrypted envelope to the ingest Worker's endpoint, and the Worker (server-side infra, #34) holds the R2 credentials and writes the object. The client's only configuration is a URL and a public key.
6. Configuration summary
Build config (secrets.properties, git-ignored) |
Default | Effect when empty |
|---|---|---|
DEBUG_REPORT_ENDPOINT |
empty | No submit path; UI offers Copy/Save only. |
DEBUG_REPORT_PUBLIC_KEY |
empty | Worker fails closed; nothing is ever uploaded. |
See secrets.properties.example for how to generate the key pair and populate these.