chore(agent): phase roadmap from TODO.md, 5 phases

The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

TODO.md is cleared to its empty title; the items live in .agents/phases/ now.
AGENTS.md gains the five protocol rules and nothing else.

LLM-Generated: true
This commit is contained in:
2026-11-08 23:28:25 -05:00
parent ece0313d02
commit 56aa97d60d
28 changed files with 2033 additions and 0 deletions
@@ -0,0 +1,95 @@
# Phase 02 — Encrypted control channel: HTTPS + `wss://`
**Source:** `TODO.md` L3 — "Some encryption for password/key input. Doesn't need to be
proper x509, but something"
**Story:** `n/a`
**Context:** `PLAN.md` anchors "Crypto/TLS stack", "TLS transport shape", "TLS identity",
"Plaintext rollback"; `src/control_server.{h,cpp}`, `src/config.h`, `src/main.cpp`,
`src/web_page.h`, `tools/relayctl.py`, README "Security model and trust boundaries"
## Objective
Encrypt the control channel end to end: the board serves the page and the WebSocket over
TLS on 443 with a self-signed ECDSA certificate it generated itself and keeps in NVS, the
browser pins its SHA-256 fingerprint once, and the passphrase never crosses the network in
cleartext again. `:80` stops carrying anything except a redirect, so an on-path attacker can
no longer replace the page or read the keystrokes.
**Why not "just encrypt the passphrase".** A challenge–response would hide the passphrase
and nothing else: the keystrokes, the pasted text and the pointer stay readable on a network
the user does not control, and a page served over `http://` can be replaced on-path by
anybody who can see the traffic — at which point the attacker does not need the passphrase,
they need one keystroke from a page they wrote. The user asked for TLS rather than the
lighter option, and the trust-boundary table in the README only closes under TLS.
## Safety position (unchanged, and load-bearing for every task here)
TLS is **transport**, never an arming input. Arming still needs the passphrase, the board
button or the serial console; a TLS session that is authenticated but not armed types
nothing; every fail-safe in `AGENTS.md` keeps working when TLS drops, because a dropped
session is a lost link, which runs the release funnel and disarms. The rollback `-D
KR_TLS=0` restores today's behaviour and must stay buildable.
## Dependencies
- `01_verification_gate` (todo) — the gate must be running the real checks before a change
this central can be trusted; the hardware contract tells this phase how to observe the
board without blocking on it.
## Tasks
1. `01_tls_identity_module.md` — `src/tls.h`: on-board ECDSA secp256r1 keypair + self-signed
cert via mbedTLS, with the pure PEM/fingerprint helpers host-tested in
`tests/test_tls.cpp`.
2. `02_identity_persistence.md` — NVS storage of the PEM pair, generate-once, serial `cert`
command printing the fingerprint and never the key, `StatusSnapshot` visibility.
3. `03_tls_listener.md` — the 443 listener, non-blocking mbedTLS session over the accepted
socket, session lifetime, heap cost measured against the phase 01 baseline.
4. `04_transport_abstraction.md` — `ControlServer` routes through one transport interface so
`serveReadonly()` stays single-sourced; `:80` becomes redirect-only and plaintext `/ws`
is refused rather than downgraded.
5. `05_page_client_fingerprint.md` — the page's `wss://` path and pinning banner,
`relayctl.py` over stdlib `ssl` (`--ca`, `--insecure`), and the cross-checks that keep
page, tool and firmware agreeing.
6. `06_tls_docs_rollback.md` — README security model/wire table/quick start/backlog,
`AGENTS.md` stale statements, and the `-D KR_TLS=0` rollback build proven to compile.
## Testing & Quality
- Unit/integration: `tests/test_tls_pem.cpp` (new suite registered in `tests/run_tests.sh`
`suites=()`) covering the pure half only — PEM armour round-trip, fingerprint formatting,
SAN/subject string building, PEM truncation and CRLF handling, rejecting a key/cert pair
that does not match. The mbedTLS-bound half is exercised by the firmware build plus the
device check (`openssl s_client`, browser, `watch_target.py`).
- `tests/test_proto.cpp`: nothing on the wire changes shape, so its 2 327 checks must stay
green untouched — that is the point: **this phase does not change the protocol.**
- `tests/test_relayctl.py`: TLS connection setup, refusal to fall back to plaintext
silently, `--insecure` opt-in is loud, and the existing frame/table pins unaffected.
- `tests/test_page.js`: the connect path still builds the right URL for both `http:` and
`https:` origins, the fingerprint banner renders only when the board reports TLS, and
every element id still exists in the markup.
- Coverage: **>90%** on new/modified code (the pure module is fully walked; the socket layer
is measured by the device checks and stated, not faked).
- `.agents/validate.sh` runs the suites, the sanitiser run and **both** firmware environments
after every task; `bridge` must still link, and `console` (`KR_HAVE_HID == 0`) too.
## Completion Criteria
- [ ] `bash .agents/validate.sh` green, and the report quotes `bridge` `RAM:`/`Flash:`
against the phase 01 baseline (`RAM 26.7% / Flash 36.1%`) plus the measured heap
cost of one and two live TLS sessions.
- [ ] A fresh board (or `nvs erase`) generates an identity at first boot and prints its
SHA-256 fingerprint **once**, on the serial console; reflashing keeps the same
fingerprint (NVS survives), and nothing about the key ever appears in a log.
- [ ] `curl -sk https://keyrelay.local/ | wc -c` equals the page byte count the gate prints,
and `curl -s http://keyrelay.local/` no longer serves the page (it redirects).
- [ ] The browser page over `https://` reaches `linked`, arms, types, and `watch_target.py`
on the target sees the keystrokes; `relayctl.py --ca <fingerprint's pem>` and
`--insecure` both connect and type; a plaintext `ws://` upgrade on :80 is refused.
- [ ] `grep -rn "\.pem\|\.key" --include=* .` finds no key material in tracked files; the
hygiene gate in the gate script is still enabled.
- [ ] `-D KR_TLS=0` still builds `bridge` (the rollback path), and README says how to use it.
- [ ] README (intro check count, Safety model, Security model table + the paragraph that
currently *admits* the token is visible in cleartext, Wire protocol, Controller page,
Architecture file map, Tests table, Quick start, Troubleshooting, Design decisions,
Backlog item 2) and `AGENTS.md` stale statements updated in this phase's commit.
@@ -0,0 +1,87 @@
# Task 01 — Device identity: keypair + self-signed certificate on the board
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
`kr::tls` can, on the board, produce an ECDSA secp256r1 keypair and a self-signed X.509
certificate for `keyrelay.local`, as PEM strings, plus a SHA-256 fingerprint of the
certificate — with the string-handling half of that testable on a host.
## Work
1. `src/tls_pem.h` — **new, pure** module (no Arduino, no IDF headers; the rule in
`AGENTS.md` exists so this compiles and runs under sanitisers on the host):
- `wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out, size_t cap)`
→ `-----BEGIN <label>-----\n` + base64 in 64-char lines + `\n-----END <label>-----\n`;
returns the length, `0` when `cap` is too small (never truncates silently — invariant 5).
- `fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap)` →
lowercase hex, `out` sized by the caller; refuses a `cap` that cannot hold it.
- `subjectLine()` / `sanList()` builders producing
`CN=keyrelay.local`, `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1` —
the names in `src/config.h` (`kHostname`, `kApGw`), not literals invented here.
- `pemFind(label, haystack, out, cap)` → extracts the DER back out of an armour block
(tolerant of CRLF and of a missing trailing newline), `false` on a malformed block.
2. `src/tls.h` (+ `src/tls.cpp` if it needs a translation unit) — the mbedTLS half:
- `bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap)`: `mbedtls_entropy_init`
+ `mbedtls_ctr_drbg_seed` (personalisation string `"keyrelay"`) →
`mbedtls_pk_setup` + `mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, …)` →
`mbedtls_x509write_crt` (subject == issuer, serial from the DRBG, validity from build
time to +10 years, `MBEDTLS_MD_SHA256`, SAN extension) →
`mbedtls_x509write_crt_der` → `wrapPem("CERTIFICATE")`; key via
`mbedtls_pk_write_key_pem`. Free everything on every exit path, including the error
paths; no early `return` that leaks a context.
- `bool selfCheck(const PemPair &)` — reparse cert+key and prove they match; used at boot
so a half-written NVS record is reported instead of producing a listener that fails
every handshake.
- Header comment must record *how this was verified linkable*: `flags/ld_libs` of
`framework-arduinoespressif32-esp32s3-libs` carries `-lmbedtls_2 -lmbedcrypto
-lmbedx509 -lesp-tls`, and sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`,
`ECP_DP_SECP256R1_ENABLED`, `KEY_EXCHANGE_ECDHE_ECDSA`.
3. `src/config.h` — `kCertCommonName[]`, `kCertValidityYears`, `kPemMax` (sized from the
measured PEM lengths plus headroom, with the numbers in a comment), and
`#ifndef KR_TLS / #define KR_TLS 1` so the rollback flag has one home.
4. `tests/test_tls_pem.cpp` — **new suite**, registered in `tests/run_tests.sh` in
`suites=()`. It includes `src/tls_pem.h` **only** — it must never include `src/tls.h`
or anything pulling in `mbedtls`, or the host build breaks and the pure-module rule is
gone:
PEM round-trip (`wrapPem` → `pemFind` → same bytes) for a DER blob of 0/1/45/46/47/n bytes
around the 64-char line boundary, CRLF and no-trailing-newline tolerance, refusal when
`cap` is one byte short, `fingerprintHex` against the known SHA-256 of `"abc"`, SAN list
containing every name the README tells the operator to expect, and ASan-clean runs of all
of it under `SANITIZE=1`.
5. `platformio.ini` — `-D KR_TLS=1` for `bridge` and `console`, in the existing
`build_flags` blocks, with a comment naming the rollback value.
- ASSUMPTION: **on-board generation rather than a keypair built on the host and flashed.**
The alternatives were rejected on record: committing a PEM would put every board behind
one key *and* trip the pipeline's key-material hygiene gate; a host-generated per-user PEM
makes the first build fail for anybody who has not run a script. Nothing in `src/` may
contain a key.
- ASSUMPTION: the certificate is generated at boot when NVS holds none, and regenerated only
when NVS is erased — see task 02. A changed fingerprint after an erase is expected and
documented, not a fault.
- ASSUMPTION: ECDSA secp256r1 rather than RSA 2048 — this board has no PSRAM, and the EC
pair is a fraction of the RAM and flash cost of RSA while being the stronger key. Browser
support for self-signed EC certs is universal on the platforms the README targets.
## Testing & Quality
- `bash tests/run_tests.sh` → the new suite passes and is listed; `SANITIZE=1` variant clean.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
→ both link (this is the task that proves the mbedTLS symbols resolve in a real image).
- The pure module includes no Arduino/IDF header: `grep -n "Arduino.h\|esp_\|mbedtls"
src/tls_pem.h` finds nothing.
- Coverage **>90%** on `src/tls_pem.h` (walked exhaustively), stated for `src/tls.*`.
## Completion Criteria
- [ ] `tests/test_tls_pem.cpp` runs in the suite and the PEM boundary cases (0/45/46/47 bytes,
one byte short of `cap`) each have a named check.
- [ ] `generateIdentity()` compiles and links into `bridge` **and** `console`; the report
quotes the new `RAM:`/`Flash:` pair against `bridge 26.7% / 36.1%`.
- [ ] `tests/test_proto.cpp` untouched and still at its previous check count — the wire
format did not change in this task.
- [ ] Full test suite green; no key material anywhere in the tree (`git status`, `grep`).
@@ -0,0 +1,73 @@
# Task 02 — Keep the identity across reflashes: NVS, first boot, `cert`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the identity half: an encryption key has to live somewhere that is not
a file in this repo)
**Story:** `n/a`
## Objective
The keypair and certificate are generated once, stored in NVS, reused after a reflash, and
made observable to the operator as a SHA-256 fingerprint — on the serial console and in the
status frame — without the key ever being printed, echoed, or written to a file.
## Work
1. `src/main.cpp` — the NVS half (follow the existing shape exactly: `Preferences prefs;`,
`prefs.begin(kNvsNamespace, true/false)` / `end()`, `constexpr char kKey…[]` next to
`kKeyToken`):
- `kKeyTlsCert[] = "tlscrt"`, `kKeyTlsKey[] = "tlskey"`, `kKeyTlsFp[] = "tlscrtfp"`.
- `loadTlsIdentity()` at boot: load cert+key+fingerprint; if any is missing **or**
`kr::tls::selfCheck()` fails, generate, store, and print the fingerprint **once**
(`[tls] identity generated — pin this certificate:` then the hex, wrapped 2-by-2).
Never `Serial.printf` the key, the cert body, or a length that identifies the text.
- A corrupt/half-written record regenerates rather than booting a listener that cannot
complete a handshake — and says so, once, loudly.
2. `src/main.cpp`, serial console — a `cert` command next to `token` in the help text
(`~main.cpp:893` block) and the dispatch chain (`~main.cpp:1010`): prints the
fingerprint, the certificate's notBefore/notAfter and the SAN list. **No** argument form
that writes a key, **no** subcommand that dumps the PEM. Update the help line and the
`usage` output.
3. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool tls` and
`const char *fp` (the first 8 bytes of the fingerprint as 16 hex chars, sized by a named
constant); `statusJson()` emits `"tls":u,"fp":"…"`; `kStatusJsonCap` grows accordingly
with the arithmetic in the comment (the reason that constant exists is that a truncated
status lies about state).
4. `src/main.cpp` — fill both fields where `StatusSnapshot` is populated (`~main.cpp:522`,
next to `s.defaultToken`).
5. `tests/test_proto.cpp` — extend the status-frame assertions: the new fields render, a
`tls:0` snapshot renders `fp:""`, and the rendered frame still fits
`kStatusJsonCap` for the longest legal SSID (that test exists; make sure the new fields
do not push it over).
6. `README.md` — "First bring-up" / Quick start: the fingerprint appears once at first boot,
`cert` reprints it, an `nvs erase` changes it, and the browser asks you to accept the
certificate once per browser. Security-model wording is task 06's job; do not restate the
whole model here.
- ASSUMPTION: the fingerprint lives in NVS next to the cert rather than being recomputed at
boot, so `cert` costs no hashing and the page can show it from the status frame; it is
public data, so storing it is not a secret-at-rest question.
- ASSUMPTION: `Preferences.putString` is fine for ~1 KB PEM blobs in the existing namespace
(`huge_app.csv` gives NVS 20 KB, already holding SSID/pass/token) — verify with the boot
log and the "nvs: " lines; if it does not fit, the fallback is a dedicated small partition
and that is a stop-and-report, not a silent change of layout.
## Testing & Quality
- `tests/test_proto.cpp`: new status fields pinned, cap arithmetic pinned.
- `bash .agents/validate.sh` green, including `console` (`KR_HAVE_HID == 0`) — the console
environment is what proves the TLS/identity path does not depend on USB HID.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): first boot prints a
fingerprint; reflash prints the same one; `cert` prints it again; `grep` over the serial
transcript for `PRIVATE KEY` finds nothing. Record `ok`/`skipped (no device)`.
## Completion Criteria
- [ ] `statusJson()` output contains `"tls":1` and a 16-hex `fp` on a TLS build; the frame
length is asserted `<= kStatusJsonCap`.
- [ ] `cert` on the serial console prints fingerprint + validity + SANs and no PEM body.
- [ ] Fingerprint survives a reflash (or the report says `skipped (no device)` and the exact
commands to confirm it).
- [ ] No private key material in any file, log, or `Serial.printf` (`git grep -i "private
key" src/` finds only the mbedTLS API call names).
- [ ] Full test suite green.
@@ -0,0 +1,106 @@
# Task 03 — The TLS listener: one session at a time, never blocking the loop
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
A second listener on 443 accepts a socket, drives an mbedTLS **server** handshake from
`tick()` without ever blocking `loop()`, and hands the control server a byte pipe that looks
like the plain socket it already reads. The keyboard path must not wait on the network stack
— that is the reason `tick()` is non-blocking today, and TLS must not break it.
## Work
1. `src/tls_session.h` (+ `.cpp`) — `kr::tls::Session`, one TLS connection over an already
accepted fd:
- `bool open(int fd, const PemPair &id)` — `mbedtls_ssl_init` +
`mbedtls_ssl_config_init` + `mbedtls_ssl_config_defaults(...,
MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)`,
`mbedtls_ssl_conf_endpoint(MBEDTLS_SSL_IS_SERVER)`, `mbedtls_x509_crt_parse` +
`mbedtls_pk_parse_key` + `mbedtls_ssl_conf_own_cert`, and
`mbedtls_ssl_set_bio(&fd, recvCb, sendCb, NULL)` with `recvCb`/`sendCb` wrapping
`::recv`/`::send` on the fd and mapping `EWOULDBLOCK` to
`MBEDTLS_ERR_SSL_WANT_READ`/`WANT_WRITE`. Set the fd non-blocking
(`fcntl(fd, F_SETFL, O_NONBLOCK)`) before the handshake. Client certification is **not**
requested (`MBEDTLS_SSL_VERIFY_NONE`) — the passphrase is still the controller's
credential, not the certificate.
- `Handshake poll()` → `InProgress | Ready | Failed`, calling `mbedtls_ssl_handshake()`
once per `tick()` and returning on `WANT_READ`/`WANT_WRITE`. A session that has not
completed within `kTlsHandshakeMs` is closed — a socket that opens and never finishes a
handshake is the same kind of scan as one that opens and never sends `T<token>`.
- `size_t read(uint8_t *dst, size_t cap)` / `size_t write(const uint8_t *src, size_t len)`
— `mbedtls_ssl_read`/`mbedtls_ssl_write`, returning 0 on `WANT_READ`/`WANT_WRITE` and
never spinning; a short write is returned as a short write (the caller keeps its
framing state, exactly as with `WiFiClient`).
- `bool alive()`, `void close()` — `mbedtls_ssl_session_reset` + `mbedtls_ssl_free` +
`mbedtls_x509_crt_free` + `mbedtls_pk_free` + `::close(fd)` on **every** path,
including the error paths out of `open()`.
- Do **not** use `esp_tls` for this: `esp_tls_server_session_create()` owns the socket
lifetime and its handshake is not driven the way `tick()` needs. Note that in the
header comment along with what was checked in `esp_tls.h`, so the next reader does not
"simplify" it back.
2. `src/control_server.h` / `.cpp` — a second `WiFiServer _tlsServer(kHttpsPort)`;
`acceptNew()` accepts from both listeners; `tick()` gives the TLS path the same shape as
the existing one: `acceptNew → serveHttp/serveWebSocket → timeout checks`. Keep the
controller slot in exactly **one** place: a socket is either HTTP, WebSocket or none, no
matter which listener it came from, so a TLS controller and a plaintext controller can
never both hold the keyboard.
- At most **two** live sessions at once (`kTlsMaxSessions`): the controller's, and one
more — a short-lived read-only request destroyed the moment its response is flushed,
or (phase 03) a pending takeover claim. A third connection is refused **explicitly**
(TLS alert then close, plus a counter), never queued and never half-opened: the
read-only path must fail visibly rather than push the board out of memory. This is a
RAM decision, not a policy one — say so in the comment, and keep the refusal
independent of which listener the socket came from.
3. `src/config.h` — `kHttpsPort = 443`, `kTlsHandshakeMs = 10000`,
`kTlsMaxSessions = 2`, and the RAM note: `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` is 16384 in
this core, so a session's I/O buffers are ~2×16 KB. Try
`mbedtls_ssl_conf_max_frag_len(&ssl, MBEDTLS_SSL_MAX_FRAG_LEN_4096)` **only** if the
measured heap says it is needed, and record the measurement either way.
4. Measure, in the report and in `.agents/HW_VERIFICATION.md`: `ESP.getFreeHeap()` at boot,
with one session open, with a second (viewer) session open, and after both close —
against the phase 01 baseline (`bridge RAM 26.7% / 87 592 B`). Print the numbers in the
boot log behind `KR_DEBUG` if the project has a debug switch, otherwise log once at
startup and once per session close *as numbers only* (invariant 4: nothing about content).
5. `tools/hw_probe.sh` (from phase 01) — extend with a `openssl s_client -connect
<ip>:443 -servername keyrelay.local` probe wrapped in `timeout 5` reporting protocol and
cipher, informational, exit status unchanged.
- ASSUMPTION: raw mbedTLS rather than `esp_tls` (rationale above). If raw mbedTLS turns out
unable to complete a handshake non-blockingly on this core, the fallback is `esp_tls` in
async mode on the same fd — but that is a design change to be recorded in the task report
and the README's Design decisions, not a quiet swap.
- ASSUMPTION: TLS on 443 only, no plaintext WebSocket on 8443, no dual-stack port list. The
rollback is `-D KR_TLS=0` (task 06), not a second open port.
- ASSUMPTION: 2 concurrent sessions is the ceiling. Today's server already holds at most one
controller plus one one-shot viewer, so the policy is unchanged; only the cost per socket
went up.
## Testing & Quality
- Host: the parsing/timeout arithmetic that can be isolated (handshake-deadline comparison,
"a session that never completes is dropped", "read returns 0 rather than spinning") goes
into a pure helper in `src/tls_session.h` guarded the same way `output.h` is, and is
checked in `tests/test_tls_pem.cpp` or a new `tests/test_tls_session.cpp` if the helper is
big enough — no mbedTLS includes in either.
- Firmware: `bridge` and `console` both build; the report quotes the four heap numbers.
- Device (best effort, one attempt): `openssl s_client` completes a handshake and negotiates
`TLSv1.2`/`TLSv1.3` with `ECDHE-ECDSA-*`; a second concurrent connection is accepted while
the first is open; a connection that never completes the handshake is dropped after
`kTlsHandshakeMs` and the loop keeps typing (verify by watching the LED heartbeat / a
`status` poll during the stalled attempt).
- The keystroke path's latency is unchanged while a *second* client connects — that is the
regression this task can introduce, so it gets named in the report even when the answer is
"not measured".
## Completion Criteria
- [ ] A TLS connection to :443 completes a handshake and survives `kTlsHandshakeMs`-bound
teardown when it does not.
- [ ] `loop()` never blocks on the TLS path: the report states how that was shown (code walk
of every `mbedtls_ssl_*` call site plus the device observation), not asserted.
- [ ] Heap numbers recorded for boot / 1 session / 2 sessions / after close, and no leak over
20 connect/disconnect cycles.
- [ ] `bash .agents/validate.sh` green; full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,89 @@
# Task 04 — One routing path, two transports: TLS is not a second server
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the routing half: the routes must not fork into a secure one and an
insecure one that can disagree)
**Story:** `n/a`
## Objective
Put the transport underneath `ControlServer` instead of beside it: every route — page,
`/status`, `/ws`, the read-only viewer — is served through `serveReadonly()`/`serveHttp()`
once, over either a plain socket or a TLS session, and `:80` stops carrying content: it
redirects and otherwise tells the client to use `https://`. Invariant 8 explicitly requires
that both routes keep going through `serveReadonly()` "so they cannot drift apart"; a
half-encrypted server is the shape that drifts.
## Work
1. `src/conn.h` — a small transport view used by the routing code: `read(dst,cap)`,
`write(src,len)` (may be partial — returns what went), `alive()`, `stop()`, and
`writeProgmem(src,len)` for the page. Implemented by `PlainConn` (wraps `WiFiClient`) and
`TlsConn` (wraps `kr::tls::Session` from task 03). No virtual calls in the keystroke path
if a tagged union is cheaper — measure, then choose, and say which in the comment.
2. `src/control_server.cpp`:
- `readSome()`, `writeTextFrame()`, `httpResponse()`, `httpError()`, `sendStatus()`,
`sendError()` go through the conn. **`readSome()` keeps its rule**: only bytes from an
authenticated controller on the WebSocket refresh `_lastInboundMs`. A TLS session must
not change that — a handshake, a `/status` poll or a viewer refresh is not the operator
being alive (that comment in `readSome()` explains a real bug; carry it over).
- `serveReadonly()` and `serveViewer()` keep serving *both* listeners; a read-only
request over TLS is the same code with a different conn.
- The page write becomes **resumable**: `c.write_P(kIndexHtml, kIndexHtmlLen)` works for a
`Print`, not for a socket. Copy PROGMEM into a bounded RAM chunk (name the size) and
carry a per-connection send cursor (`sent`, `total`) so a 35 KB page drains across
several `tick()`s under a per-tick byte budget. If the client stalls, the existing 10 s
stall rule drops it. **Never** block in `loop()` waiting for a slow page consumer, and
never queue the page in RAM beyond the chunk.
- `closeLink()` stays the single funnel: whichever transport it is handed, it releases on
link change (`wasLinked` semantics, bug 10) and destroys the TLS session. A TLS teardown
failure must not skip the release.
3. `:80` behaviour (`KR_TLS == 1`):
- `GET /` and `/index.html` → `301` with `Location: https://<host>/…`.
- A request carrying a `token` query parameter (i.e. `/status?token=…`) → **`426 Upgrade
Required`** with a one-line body telling the operator to use `https://`, and **no
redirect** — a redirect that echoes `?token=` would expose the passphrase twice instead
of once. `AGENTS.md` says the token stays a query parameter on `/status` because `curl`
is the point of that endpoint; this keeps `curl` working (over TLS) without making the
plaintext path leakier.
- A `/ws` upgrade on :80 → `426`, plaintext controller refused rather than silently
downgraded (invariant 5: never quietly accept the weaker thing).
- All of the above is compiled out by `-D KR_TLS=0`, which restores today's `:80` server
exactly.
4. `src/config.h` — `kHttpRedirectPort`/`kHttpsPort` naming, `kPageChunkBytes`, and the
per-tick page budget, each with the measured numbers that chose it.
5. `tests/test_relayctl.py` + `tests/test_page.js` — assert the *refusal* shapes that can be
checked from the client side: the tool's error text distinguishes "board wants TLS" from
"wrong passphrase", and the page's URL builder picks `wss://` from an `https:` origin and
`ws://` only on a `-D KR_TLS=0` build (pin it by asserting the served page contains no
plaintext `ws://` literal when TLS is on).
- ASSUMPTION: 426 (Upgrade Required) for token-carrying and WebSocket requests on the
plaintext port, 301 for everything else. The alternative — redirect everything including
`?token=` — was rejected for the reason above.
- ASSUMPTION: mDNS stays one service on port 80 plus a second `_https._tcp` advertisement;
`keyrelay.local` must keep resolving, which is the whole point of the README's "IP if the
name fails" note.
## Testing & Quality
- Host: no protocol change, so `tests/test_proto.cpp` (2 327 checks) and `test_output.cpp`
must stay green untouched — if either needs editing, the task has changed the wire or the
funnel and must stop and say what it changed and why.
- The `readSome()` rule gets a **source pin** in `tests/test_relayctl.py` alongside the
existing three: the TLS conn's bytes reach `_lastInboundMs` only when
`_phase == WebSocket && _authed`. (Pinning source sentences is how bug 10 stays dead.)
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
gate's page count; `curl -sI http://keyrelay.local/` is a 301; `curl -s
'http://keyrelay.local/status?token=x'` is 426 and its body contains no token; the page
still reaches `linked` over `wss://` while a second tab watches `/status`.
- RAM: the chunk size + one page cursor, quoted in the report.
## Completion Criteria
- [ ] One `serveReadonly()` implementation reachable from both listeners (a `grep` for a
second HTTP response writer in `src/` comes up empty).
- [ ] Page delivered intact over TLS (byte-for-byte, `wc -c` and a `sha256sum` against the
PROGMEM count) and the send is resumable, not blocking.
- [ ] `:80` serves only the redirect and the two 426s; no keystroke-bearing path listens on 80.
- [ ] `bash .agents/validate.sh` green; the new source pin present; full test suite green.
@@ -0,0 +1,86 @@
# Task 05 — The client side of pinning: page and `relayctl.py`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the half the operator actually touches: a browser that must trust the
board, and the terminal controller)
**Story:** `n/a`
## Objective
The page talks `wss://` when it was served over `https://`, shows the board's certificate
fingerprint so the operator can confirm it against the serial console, and refuses to accept
a passphrase when it was loaded over plaintext; `relayctl.py` speaks TLS over stdlib `ssl`
with **fingerprint pinning** as the default posture, `--ca` for a real CA, and `--insecure`
only as a loud, explicit opt-in.
## Work
1. `src/web_page.h`:
- `connect()` already picks `wss://` from `location.protocol === "https:"` — keep it, and
add the guard the other way round: when the page was loaded over `http:` while the board
reports `tls:1`, do **not** open a socket and do **not** accept the passphrase input;
say why ("this page arrived in the clear; reload over https"). Do not silently fall
back to `ws://`.
- A fingerprint line in the Connection card, driven by the status frame: `certificate
SHA-256 aabbccdd… — confirm it against the `cert` command on the board's serial
console`. Rendered only from `s.fp` (task 02), never assembled from anything the page
invents, and the pill says `tls` vs `plaintext` so the state is visible without reading.
- Keep every element id the script reaches for intact (`tests/test_page.js` checks that
coverage) and add the new ids to the markup in the same change.
2. `tools/relayctl.py`:
- `Relay.__init__`: build the socket through `ssl` when TLS is on — default port 443,
`WS_PATH` unchanged.
- Pinning: `--fingerprint <sha256>` (also honoured as `$KEYRELAY_TLS_FP`) connects with
certificate verification off, then takes `getpeercert(binary_form=True)`, hashes it and
**aborts on mismatch** before a single frame is sent. That is real pinning rather than
TOFU, and it needs no CA distribution for a self-signed device certificate.
- `--ca <pem>` uses `ssl.create_default_context()` with hostname checking on.
- `--insecure` sets `CERT_NONE` and prints a warning line to stderr on every run.
If none of the three is given, refuse to connect and say what to pass — the tool must
not be the component that quietly downgrades (`AGENTS.md` invariant 5).
- Keep `--token` / `$KEYRELAY_TOKEN` behaviour, including the pre-flight check that does
not require a reachable board, and never print the token.
- `-D KR_TLS=0` boards: `--plain` (explicit) selects the old `ws://` on port 80, so the
rollback path stays scriptable and remains opt-in.
3. `tests/test_relayctl.py`: URL/port selection per flag combination; the "no TLS option
given → refuse" rule; a fingerprint mismatch aborts **before** `T<token>` is sent (assert
on the recorded frame list, which the suite already has); `--insecure` emits the warning;
`--plain` is the only path to port 80; and all existing frame/table pins unchanged.
4. `tests/test_page.js`: the `https:` origin builds `wss://`, the `http:` origin refuses and
never sends a frame; the fingerprint line renders exactly the `fp` the status frame
carried and renders nothing when `tls:0`; every id the script touches exists in the markup.
5. `README.md` — Quick start: accept the certificate warning once per browser, or pin;
`curl -sk https://keyrelay.local/status?token=…`; `relayctl.py --fingerprint`; the
fingerprint from `cert`. Troubleshooting: `SSL_ERROR_R…`/`certificate verify failed` means
the board regenerated its identity (`nvs erase`) or you are on the wrong network — which
is exactly the attack the fingerprint is for, so say it in those words.
- ASSUMPTION: fingerprint pinning is the default posture rather than shipping a CA. A CA
distributed with the repo would be one shared key for every board (rejected in task 01);
per-board CA installation on every controller device is more ceremony than a keyboard
bridge is worth. `--ca` remains for the operator who wants a real chain.
- ASSUMPTION: the passphrase stays in `sessionStorage` (as today) and stays a `/status` query
parameter (per `AGENTS.md`). Over TLS both are encrypted in transit; the remaining exposure
is the local browser and shell history, which the README already names.
## Testing & Quality
- Both host suites extended as above; `bash tests/run_tests.sh` and the `SANITIZE=1` variant green.
- `uv … run -e bridge -e console` green, and `curl -sk https://keyrelay.local/ | wc -c` still
equals the gate's page count after the markup change (the page is served from flash — an
unflashed board shows the old page, so say which you observed).
- Device (best effort, one attempt): phone or laptop reaches the page over `https://`,
`linked` appears, the fingerprint shown matches `cert` on the console, and `relayctl.py
--fingerprint …` types while `watch_target.py` on the target confirms it.
- Coverage **>90%** for the changed `relayctl.py` paths (the suite drives the connection
object against a fake socket, as it already does for frames).
## Completion Criteria
- [ ] An `http:`-loaded page refuses to connect and to accept a passphrase (shown by
`test_page.js`, and by the served bytes when the board is reachable).
- [ ] `relayctl.py` with no TLS argument refuses; with a wrong fingerprint it aborts before
sending `T<token>`; with the right one it arms and types.
- [ ] The page displays the board's fingerprint from the status frame, and the pill says
`tls`.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,92 @@
# Task 06 — Tell the truth about it: docs, backlog, and the rollback build
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something" (the documentation half
`AGENTS.md` requires in the same change: *"Docs move with the code."*)
**Story:** `n/a`
## Objective
The README stops describing a plaintext control channel — including the paragraph that
currently *apologises* for it — and Backlog item 2 ("TLS with a pinned certificate") is
closed. The `-D KR_TLS=0` rollback is built, documented and named, so the encryption is a
decision an operator can reverse rather than a dependency they are stuck with.
## Work
1. `README.md`, in place, keeping the reasoning and not just the behaviour:
- **Intro bullets + the check count**: update `**3406 checks**` to the new total and add
one bullet for what is now encrypted in transit.
- **"Security model and trust boundaries"**: the trust table — "Anyone on the board's
network" can no longer read keystrokes or the passphrase; add/adjust rows for
"Anyone who can see the traffic" (sees a TLS handshake to an unnamed host and nothing
else) and "Anyone with a copy of this firmware" (gets no key: the identity is generated
on each board). The paragraph beginning **"What the passphrase does and does not do"**
currently states that the page is plain HTTP, the socket is `ws://` and "the token is
visible at first contact"; rewrite it in place, keeping its structure — what TLS now
buys, what it does **not** buy (an unpinned connection is still an on-path risk until you
confirm the fingerprint; the target still sees whatever you type on it; the local
machine is still not trusted), and the **Practical rule** paragraph adjusted to
"the certificate you did not pin is the one thing to check".
- **Safety model**: one sentence locating TLS where it belongs — transport, not an arming
input. A TLS session that is not armed types nothing, and a dropped TLS session is a
lost link, which runs the funnel and disarms.
- **Wire protocol**: the table is unchanged (same frames), but add the line that the
transport is `wss://` and that `T<token>` no longer crosses the network in the clear;
`426` on the plaintext port documented with the error-code list.
- **Quick start**: first boot prints the fingerprint once, `cert` reprints it, the browser
asks once per browser, `curl -sk https://keyrelay.local/status?token=…`, `relayctl.py
--fingerprint`. Note the `-D KR_TLS=0` rollback right where a stuck operator looks for it.
- **Controller page**: the `tls` pill and the fingerprint line; keep the existing element
table current rather than appending a new section.
- **Architecture** file map: `src/tls_pem.h` (pure), `src/tls.h` (identity),
`src/tls_session.h` (session), `src/conn.h` (transport view), and what each owns.
- **Tests**: new `test_tls_pem` row with its real check count; the totals in the intro and
the table made to agree.
- **Measured behaviour**: the TLS numbers that task 03 measured — handshake time, heap
with 1 and 2 sessions, flash growth — as measurements, with the command that produced them.
- **Backlog**: remove item 2 and renumber the rest; if a narrower remainder exists (e.g.
"a real CA so browsers stop warning", or session-resumption/ticket tuning), that becomes
an item — do not leave a closed item in place.
- **Design decisions**: why TLS instead of a challenge–response over the passphrase; why
the identity is generated on the board rather than shipped as a PEM; why raw mbedTLS
rather than `esp_tls`; why a `token`-carrying request on `:80` is refused with 426
instead of redirected.
2. `AGENTS.md` — **fix stale statements only, do not expand**: the page-size check
`curl -s http://keyrelay.local/ | wc -c` becomes `curl -sk https://keyrelay.local/ |
wc -c` (the page is still served from flash, so the "reflash before you believe the
network" warning stays); "Current defaults worth knowing" gains the TLS identity fact in
the same one-line style (generated on board, in NVS, survives reflashing, `cert` prints
the fingerprint); the "Secrets" bullet keeps its rule and, if it does not already cover
it, the concrete instance that the private key lives in NVS and never in a file. Nothing
else in this file: phase narrative belongs in `.agents/phases/`.
3. `src/tls.h`, `src/tls_pem.h`, `src/tls_session.h`, `src/conn.h`, `tools/relayctl.py` —
the docstring at the top of each module describes *why*, in the house style of
`proto.h`/`web_page.h`/`control_server.h`: what it protects, what it does not, and the
decisions a later editor must not reverse by accident.
4. Build the rollback and prove it exists: `uv run --python 3.14 --with platformio python
-m platformio run -e bridge -e bridge --upload-port "" --build-flags "-D KR_TLS=0"` or
the equivalent temporary env (document the exact working command in the README rather
than inventing prose around it), and record the resulting `RAM:`/`Flash:` pair.
5. `.agents/HW_VERIFICATION.md` — fill in the device rows this phase touched with what was
actually observed, or `skipped (no device)` plus the command. Never a claim that was not
made.
## Testing & Quality
- `bash .agents/validate.sh` green, including both firmware environments and the rollback build.
- Every number quoted in the README matches a command in this repository (check count from
the suite, page bytes from the gate, heap/RAM/flash from the build and the boot log).
- A reader who has never seen the code can bring the board up over TLS from the README alone.
## Completion Criteria
- [ ] `grep -n "plain HTTP\|ws://" README.md` returns only statements about the rollback
build, not about the default.
- [ ] Backlog has no TLS item and the numbering is contiguous.
- [ ] The trust-boundary table reflects the encrypted channel, and the "does and does not"
paragraph names what TLS still does not protect.
- [ ] `-D KR_TLS=0` builds, and the README states the exact command and the trade.
- [ ] README check count and Tests table agree with the suite's own output; `AGENTS.md`
carries no new prose, only corrected facts.
- [ ] Full test suite green.