diff --git a/docs/build-status.md b/docs/build-status.md index 5517023..529241a 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1002,3 +1002,55 @@ Also fixed: the Settings *Preview what an upload would send* button did nothing. `UiState.history`, which is empty until the History screen has been opened — the same root cause as the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to preview rather than silently ignoring the tap. + +### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01) +Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and +`/admin/selftest` to the internet **with no authentication**. Anyone who could reach +`fmr.echo-lot.app` could mint enrolment tokens. + +The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that +assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`, +added the same day, verifies **encryption** and says nothing about **authentication**. It passed, +and a green light on an adjacent property is worse than no check, because it invites you to stop +looking. + +Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device +enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window +existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens +were purged, since any could have been minted by someone else and they cost nothing to replace, and +63 test devices removed. + +**The admin listener does not become reachable again until it authenticates.** That reorders the UI +work: auth on the listener first, everything else after. + +### Open: encrypted uploads, where the operator cannot read the data +Not built. Recorded because the shape is decided by a few early choices, and the current design +happens to leave the door open. + +The goal: hand someone an account, let them upload, and be unable to read what they uploaded. + +Sketch: a random per-account **master key**, generated on the first device and wrapped under a +key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is +stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally; +the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per +run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side. + +Four consequences that decide whether it is worth it: + +1. **What stays readable determines what the UI can do.** The server builds its index by *parsing* + the document — verdict, finding count, started_at. An opaque payload means the client supplies + that metadata or the index disappears, and with it retention-by-verdict and any "runs with + findings" view. The honest version supplies only run id, timestamp and size, and moves the rest + client-side. +2. **Lose the passphrase, lose the data.** That is the feature working, and also the support + burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset. +3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and + how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell. +4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read. + That is not a conflict so much as a redundancy: the anonymization floor exists to protect the + user from the operator, and encryption does that better. The two should not both be demanded of + one upload. + +What keeps this possible: uploads are already stored byte-for-byte as received, and every index +field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require* +reading content — those would have to be unbuilt later.