Files
LibreMail/docs/debug-report-privacy.md
T
JMR-dev 700ce071ae feat(reporting): anonymize + encrypt debug reports before opt-in upload (#34)
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
2026-07-08 10:24:52 -05:00

109 lines
6.5 KiB
Markdown

<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# 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 to
`Other`).
- 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 residual `user@host` / `host:port` token is redacted.
- The recent in-app log buffer (`RingLogBuffer`), which is governed by the `AppLog` **"no PII" contract**
(a detekt guard forbids raw `android.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_ENDPOINT` and `DEBUG_REPORT_PUBLIC_KEY` (below) must be set. With either
empty, `ReportSubmitter.isEnabled` is 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:port` tokens, bare IPv4 addresses, JWT-shaped tokens, `Bearer` authorization values, and
`key=value` credentials (`password=…`, `access_token=…`, …). The stack trace is re-run through
`StackTraceScrubber`.
- 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.** `userEmail` is 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):**
1. Generate a fresh random **AES-256-GCM** content key + 96-bit IV; encrypt the report payload with it.
2. Wrap the content key with the recipient's RSA public key using **RSA-OAEP (SHA-256, MGF1-SHA-256)**.
3. Emit a self-describing JSON envelope — the exact wire body of the POST:
```json
{ "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, `ReportUploadWorker` returns a
failure and sends nothing — it never transmits an unencrypted report. This is distinct from the
on-device at-rest `ReportEncryption` (#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.