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
109 lines
6.5 KiB
Markdown
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.
|