diff --git a/docs/build-status.md b/docs/build-status.md index 01f15b3..e4f0d4f 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -660,3 +660,37 @@ One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`, default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this is an API, not a page), and the client now *parses* the error field instead of pattern-matching it, so it survives whatever a future encoder decides to escape. + +### Enrollment: the server mints the bootstrap link (server-v0.5.3 … v0.5.4, 2026-08-01) +Until now a device was configured by hand-typing a control URL, a base64 SPKI pin and a +credential. That is the step that goes wrong, and it goes wrong quietly: a pin off by one +character does not fail loudly, it just never matches, and surfaces days later as an inscrutable +TLS error. + +`POST /admin/enroll-tokens` now returns the whole §2.1 bootstrap link alongside the token, because +the server is the only party holding all three parts at once. The app takes it from a paste or an +`echolot://enroll` deep link (so a QR scan configures a server in one action) and writes URL, pin +and credential **together or not at all** — a half-applied server fails later, somewhere else, +with an error pointing at the wrong thing. + +The control URL comes from `ECHOLOT_PUBLIC_URL` (set on fmr to `https://fmr-1.echo-lot.app:8443`), +falling back to the first control listen address; a wildcard bind warns rather than emitting a +link to `0.0.0.0`. + +**The encoding trap, which is the whole reason this is tested across both languages.** The pin is +base64, so it contains `+`, `/` and `=` — each of which means something else in a query string. An +unencoded `+` decodes to a space, leaving the pin wrong by exactly one character. Base64 has no +spaces, so the parser restores them; that cannot damage a correctly-encoded pin and it rescues +every hand-assembled link. `LiveEnrollmentTest` redeems a link the *server* produced, which is the +only way to catch a disagreement between the Go assembler and the Kotlin parser — a unit test on +either side alone cannot see it. It also asserts the token is refused the second time. + +Also fixed a spec divergence found while reading §2.1: the spec names the field +`device_credential`, the first implementation shipped `credential`. The server now sends both and +the client prefers the spec's; the alias goes once nothing reads it. + +Two process notes from this round: +- An edit to the admin handler silently failed to apply and the endpoint kept returning just the + token. Caught by deploying and *looking at the response*, not by trusting a green build. +- The live suite is now six tests (`LiveServerTest`, `LiveMeasurement`, `LiveGranted`, + `LiveUpload`, `LiveCompat`, `LiveEnrollment`), all green against fmr from the PC with no device. diff --git a/docs/probe-protocol.md b/docs/probe-protocol.md index 7d1c4fc..4e4bf38 100644 --- a/docs/probe-protocol.md +++ b/docs/probe-protocol.md @@ -24,11 +24,34 @@ echolot://enroll?v=1&u=&p=pin-sha256:&t= ``` POST /v1/enroll Authorization: Bearer -→ 200 { "device_credential": "", - "device_id": "uuid", - "profile": { ... §2.2 ... } } +→ 201 { "device_credential": "", + "device_id": "uuid" } ``` +The **server assembles the bootstrap link**, because it is the only party holding all three parts +at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail +loudly, it just never matches, and surfaces later as an inscrutable TLS error: + +``` +POST /admin/enroll-tokens +→ { "token": "…", "expires_in_s": 86400, + "enroll_uri": "echolot://enroll?v=1&u=…&p=…&t=…" } +``` + +The control URL in the link comes from `ECHOLOT_PUBLIC_URL`, falling back to the first control +listen address. A wildcard bind has no single right answer, so it warns rather than guessing. + +Encoding notes that matter in practice: +- `u`, `p` and `t` are **percent-encoded**. The pin is base64, so it contains `+`, `/` and `=`, + every one of which means something else in a query string. +- A `+` that was *not* encoded decodes to a space. Base64 contains no spaces, so a parser SHOULD + restore them — the alternative is a pin wrong by one character and a failure that points nowhere + near the cause. +- The control URL MUST be `https://`. The pin only protects a TLS connection; a cleartext URL + would hand the token to anyone on the path. +- **The link is a secret** while it is live: it carries a bearer token, so anyone who sees it + before the device does can enroll instead. + Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI. ### 2.2 Profile