From e0780f58e09c60b5330c3e0e5d86e10b80b59a34 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 9 Nov 2026 05:51:57 -0500 Subject: [PATCH] phase: 02_tls_control_channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Verified / implemented (phase 02 final pass)** - All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label). - Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no). - Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page. **Tests / lint (100 % green)** - `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`. - Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2). - `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free. **Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓. **Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`. **Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit. **Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`. --- .agents/HW_VERIFICATION.md | 34 +- .../02_tls_control_channel/00_phase.md | 0 .../01_tls_identity_module.md | 0 .../02_identity_persistence.md | 0 .../02_tls_control_channel/03_tls_listener.md | 0 .../04_transport_abstraction.md | 0 .../05_page_client_fingerprint.md | 0 .../06_tls_docs_rollback.md | 0 .../02_tls_control_channel__00_phase.a1.err | 0 .../02_tls_control_channel__00_phase.a1.md | 17 + ...tls_control_channel__00_phase.a1.md.status | 1 + ..._tls_control_channel__00_phase.a1.validate | 205 ++++ ...rol_channel__01_tls_identity_module.a1.err | 0 ...trol_channel__01_tls_identity_module.a1.md | 17 + ...annel__01_tls_identity_module.a1.md.status | 1 + ...hannel__01_tls_identity_module.a1.validate | 153 +++ ...ol_channel__02_identity_persistence.a1.err | 0 ...rol_channel__02_identity_persistence.a1.md | 16 + ...nnel__02_identity_persistence.a1.md.status | 1 + ...annel__02_identity_persistence.a1.validate | 153 +++ ...ls_control_channel__03_tls_listener.a1.err | 0 ...tls_control_channel__03_tls_listener.a1.md | 12 + ...trol_channel__03_tls_listener.a1.md.status | 1 + ...ntrol_channel__03_tls_listener.a1.validate | 156 +++ ...l_channel__04_transport_abstraction.a1.err | 0 ...ol_channel__04_transport_abstraction.a1.md | 11 + ...nel__04_transport_abstraction.a1.md.status | 1 + ...nnel__04_transport_abstraction.a1.validate | 162 +++ ...channel__05_page_client_fingerprint.a1.err | 0 ..._channel__05_page_client_fingerprint.a1.md | 10 + ...l__05_page_client_fingerprint.a1.md.status | 1 + ...el__05_page_client_fingerprint.a1.validate | 166 +++ ...ntrol_channel__06_tls_docs_rollback.a1.err | 0 ...ontrol_channel__06_tls_docs_rollback.a1.md | 14 + ...channel__06_tls_docs_rollback.a1.md.status | 1 + ..._channel__06_tls_docs_rollback.a1.validate | 205 ++++ AGENTS.md | 73 +- README.md | 550 ++++++++-- platformio.ini | 34 + src/config.h | 140 ++- src/conn.h | 663 ++++++++++++ src/control_server.cpp | 719 ++++++++++--- src/control_server.h | 176 +++- src/main.cpp | 302 +++++- src/status.h | 143 +++ src/tls.cpp | 455 ++++++++ src/tls.h | 174 +++ src/tls_pem.h | 876 +++++++++++++++ src/tls_session.cpp | 301 ++++++ src/tls_session.h | 360 +++++++ src/web_page.h | 90 +- src/ws_codec.h | 20 +- tests/run_tests.sh | 13 +- tests/test_conn.cpp | 367 +++++++ tests/test_page.js | 164 ++- tests/test_proto.cpp | 122 +++ tests/test_relayctl.py | 537 ++++++++++ tests/test_tls_pem.cpp | 993 ++++++++++++++++++ tests/test_tls_session.cpp | 286 +++++ tools/hw_probe.sh | 28 +- tools/relayctl.py | 295 +++++- 61 files changed, 8860 insertions(+), 359 deletions(-) rename .agents/phases/{todo => complete}/02_tls_control_channel/00_phase.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/01_tls_identity_module.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/02_identity_persistence.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/03_tls_listener.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/04_transport_abstraction.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/05_page_client_fingerprint.md (100%) rename .agents/phases/{todo => complete}/02_tls_control_channel/06_tls_docs_rollback.md (100%) create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.validate create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.err create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md.status create mode 100644 .agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.validate create mode 100644 src/conn.h create mode 100644 src/status.h create mode 100644 src/tls.cpp create mode 100644 src/tls.h create mode 100644 src/tls_pem.h create mode 100644 src/tls_session.cpp create mode 100644 src/tls_session.h create mode 100644 tests/test_conn.cpp create mode 100644 tests/test_tls_pem.cpp create mode 100644 tests/test_tls_session.cpp diff --git a/.agents/HW_VERIFICATION.md b/.agents/HW_VERIFICATION.md index 7fe53cd..528e31f 100644 --- a/.agents/HW_VERIFICATION.md +++ b/.agents/HW_VERIFICATION.md @@ -48,23 +48,37 @@ may have a board attached that nobody is watching, and a second machine for | claim | command | expected observation | outcome | |---|---|---|---| -| keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | | +| keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | skipped (no passphrase) — task 04, 2026-11-09: the board is reachable on `:443` and the TLS/`wss://` transport rows below are measured, but this board's control passphrase is not the default (`status` says `dflt:0`) and a verification run will not change a credential on a device it cannot put back. To close it: `console.py token ` on a board you are willing to re-provision, then drive `wss://` | | a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | | -| served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check | -| silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | | +| served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — re-measured 2026-11-09 **over TLS after the phase-02 flash**: `curl -sk https://10.2.0.22/` = 48598 and `curl -s http://keyrelay.local/` = 48598, both = the gate's count. **Task 04** re-ran it over TLS three times in a row: 48 598 bytes each time with `sha256` equal to the raw string in `src/web_page.h` (`f7aae9e5…c6f7`) — the page now leaves through `kr::Conn` at 1460 B per write and 5840 B per tick (34 chunks over ~9 `loop()` iterations) instead of one `WiFiClient::print`, so byte-equality across a multi-tick send is the transport claim. Original — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check | +| silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | skipped (no passphrase), same reason as the row above. What task 04 *did* pin is the property this row depends on and that the transport rewrite could silently have moved: `_lastInboundMs` is stamped in exactly one place, gated on `_phase == WebSocket && _authed`, and neither the TLS handshake, a `/status` poll nor a viewer refresh reaches it — asserted structurally in `test_relayctl` (`the controller clock is stamped in exactly one place`, `no HTTP path can refresh the controller clock`) | | nothing outlives the link | hold a key and a mouse button, then drop the link | every key up, every button up; mouse summary shows no `still held` | | ### Phase 02 — HTTPS + `wss://` +Every row below was produced by one capped command on the board at `10.2.0.22` / +`keyrelay.local`, joined to the LAN, flashed with `bridge`. Two rows are honestly `skipped`: +the passphrase-dependent ones (arming/typing over a pinned session need a credential the +operator owns) and the rollback's device leg; one row is `failed` and fixed in the tree with +its device leg still open (the certificate's SAN, found at the final pass) — see the rows +themselves for what would close them and why it was not run. + | claim | command | expected observation | outcome | |---|---|---|---| -| TLS handshake completes | `curl -sk https://keyrelay.local/status` or `openssl s_client -connect keyrelay.local:443 /dev/null \| openssl x509 -noout -fingerprint -sha256` | same hex as the serial console printed; mismatch = two identities, investigate before pinning | | -| `:80` is redirect-only | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/` | 3xx to `https://`, and **no page bytes** on plaintext | | -| plaintext `/ws` refused, not downgraded | a `ws://` upgrade attempt on `:80` (e.g. `curl -i -N` with upgrade headers, or `relayctl.py` pointed at plaintext) | refused loudly (per README error style), never a silent plaintext session | | -| TLS session heap cost | build RAM delta from the gate output + live free-heap readout the phase's `status`/serial path exposes, at 0 / 1 / 2 live sessions | quoted as a delta against the baseline table above; two sessions must fit without allocation failure | | -| `-D KR_TLS=0` rollback behaves like today | build the rollback env, flash once, `curl -s http://keyrelay.local/ \| wc -c` + page connects over `ws://` | page and `ws://` work exactly as pre-phase-02; README documents the rollback | | +| TLS handshake completes | `curl -sk https://keyrelay.local/status` or `openssl s_client -connect keyrelay.local:443 /dev/null \| openssl x509 -noout -fingerprint -sha256` | same hex as the serial console printed; mismatch = two identities, investigate before pinning | ok — measured 2026-11-09: console `cert` `61:54:19:30:ed:df:e5:52:d1:ee:18:6f:cc:f9:57:c5:41:47:d4:15:61:26:1e:11:aa:49:4f:07:79:b1:af:f9` == the `x509 -fingerprint -sha256` of the live handshake, and the pair still matched after two further `reboot` cycles. **Corrected at the phase-02 final pass, same day:** the earlier mismatch recorded as `C8:76:F1:69:…` was **not a rotation** — it is what `openssl x509 -noout -fingerprint -sha256` prints when `-ext ` is passed in the same invocation, on OpenSSL 4.0.3, for *this same* certificate. Reproduced byte for byte: the peer certificate captured with `s_client`, written with `-outform der`, hashes to `61541930…b1aff9`; the same file run through `-fingerprint -sha256 -ext subjectAltName` prints `C8:76F1…E6D2` *and* “No extensions in certificate”. So: take the fingerprint with `-fingerprint -sha256` alone (or `sha256sum` of `-outform der`) and never with `-ext` in the same command, or a stable identity looks like a rotation. Re-measured at the final pass (`console.py cert`, one capped attempt, and `s_client` + `openssl x509 -outform der | sha256sum`): console `61:54:19:30:…:b1:af:f9` == `61541930eddfe552…79b1aff9`, one identity, one board, no rotation since the instrumented boot. | +| `:80` is redirect-only | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/` | 3xx to `https://`, and **no page bytes** on plaintext | ok — measured 2026-11-09 on the task-04 flash, one capped `curl` each: `/` → **301** `Location: https://10.2.0.22/`; `/index.html` → **301** to the same path over `https://`; `/nope` → **301** to `/` (an unknown target is a 404 on 443, and an arbitrary request target is never echoed into a `Location`); `/status?token=…` → **426** with `Upgrade: TLS/1.2, HTTP/1.1`, **no `Location`**, and the body verified free of the token (`grep -c` of the value → 0). No plaintext path returns page bytes any more | +| plaintext `/ws` refused, not downgraded | a `ws://` upgrade attempt on `:80` (e.g. `curl -i -N` with upgrade headers, or `relayctl.py` pointed at plaintext) | refused loudly (per README error style), never a silent plaintext session | ok — measured 2026-11-09: `curl -i -N` with `Upgrade: websocket` on `:80` → **426 Upgrade Required**, `Connection: Upgrade`, `Upgrade: websocket, TLS/1.2, HTTP/1.1`, body `plaintext websocket refused; reconnect over wss:// (https://)` — no `101`, no session; the board's own counter moved (`link controller=no rejected=1`). `relayctl`'s plaintext path now raises the same sentence via `handshake_refusal()` rather than the old "board refused the websocket upgrade". **Re-measured at the phase-02 final pass** (the bodies were reworded after that flash): `/ws` upgrade → **426**, `Upgrade: TLS/1.2, HTTP/1.1`, `Connection: close`, `Content-Length: 96`, body `keyrelay: this controller channel is TLS-only -- reconnect over wss://, the page is on https://`; `/status?token=` → **426**, `Content-Length: 140`, **no `Location:`** (`grep -c location: → 0`) and the value not echoed (`grep -c → 0`); `/` → **301** to `https://keyrelay.local/` with a 77-byte redirect body and **no page bytes**; over `:443`: `/status` without the token → **401**, `/nope` → **404**, `/` → 200 / 51 626 B / `sha256 dd16e195…f04e9` == the locally assembled page | +| TLS session heap cost | build RAM delta from the gate output + live free-heap readout the phase's `status`/serial path exposes, at 0 / 1 / 2 live sessions | quoted as a delta against the baseline table above; two sessions must fit without allocation failure | ok — measured 2026-11-09 from the serial `[tls] sessions=N heap=…` lines: boot `[tls] https :443 listening heap=187936`; **1 session ≈ 145.5 k, 2 sessions ≈ 107.9 k** (second session ≈ −37.6 k: the mbed TLS record buffers), close returns heap to 168–185 k. **Task 04** (transport view on both ports: 2 KB `:80` redirect ring + 1460 B page chunk + 64 B `tRing` per slot; gate RAM 97 240 → 99 616) measured in one run `sessions=1` `140 092` → `sessions=2` `109 228` → close one `144 836` → close all `182 256`: second session ≈ −31 k, and the memory comes back inside the band above. 20 further connect/disconnect cycles on `:443` ran in the same pass. Original 20 connect/disconnect cycles: the `sessions=1` readings held at 145.5–149.8 k with **no downward drift** — no leak. Slot cap enforced live: with two sessions held, a third got a **fatal TLS alert (handshake_failure, 40) in 0.0 s** while both listeners kept serving. Stall check: a connection that completed the handshake and sent nothing did **not** block `loop()` — the full page still served over a second connection during the stall, and the stalled socket was closed by the ~10 s deadline | +| `-D KR_TLS=0` rollback behaves like today | build the rollback env, flash once, `curl -s http://keyrelay.local/ \| wc -c` + page connects over `ws://` | page and `ws://` work exactly as pre-phase-02; README documents the rollback | **build leg ok, device leg skipped (not flashed)** — task 06, 2026-11-09. The rollback is now a first-class environment, `[env:rollback]`, built by `.agents/validate.sh` on every run alongside `bridge` and `console`: RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) against the TLS `bridge` 30.4 % (99 616 B) / 39.6 % (1 245 353 B), so TLS costs +6 160 B RAM and +99 216 B flash. *(Final pass, same day: after the SAN fix `bridge` is 99 616 B / 1 246 145 B — RAM unmoved, +792 B of flash — and the rollback image is **byte-for-byte unchanged** at 93 456 B / 1 146 137 B, because the fix lives entirely inside `#if KR_TLS`; TLS now costs +100 008 B of flash.)* That the flag *took* is measured, not assumed: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** (115 in `bridge`), `… \| grep -c mbedtls_x509write` → **0** (15), `strings .pio/build/rollback/firmware.elf \| grep -c "pin this certificate"` → **0** (1). The device half was deliberately not run: flashing the rollback and back is two write cycles on a board that is a live HID keyboard into the machine running this pipeline, `to_bootloader.py` strands it in ROM download mode if anything goes wrong after that point, and no criterion for task 06 needed it. To close it, in order: `uv run --python 3.14 --with platformio python -m platformio run -e rollback -t upload` → `timeout 10 curl -s http://keyrelay.local/ \| wc -c` (expect the same 51 626 the gate prints, on plaintext, with `var KR_TLS_BUILD=0;` from `curl -s http://keyrelay.local/ \| grep -o 'var KR_TLS_BUILD=[01];'`) → `timeout 10 curl -s -o /dev/null -w '%{http_code}' https://keyrelay.local/` (expect 000/refused: no `:443` exists any more) → boot log line `[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel` → `python3 tools/relayctl.py --plain --host keyrelay.local --token … status` → then reflash the TLS build (`run -e bridge -t upload`) and confirm `curl -sk` serves 51 626 again | +| TLS handshake latency and page fetch over TLS | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` (and `…/status`), one capped run each | a handshake that completes in a time a browser operator will not mistake for a dead board, and a page that arrives whole over the chunked TLS send | ok — measured 2026-11-09 (task 06, read-only, board on the LAN): seven warm connections, **handshake 0.349–0.423 s**; page 51 626 B in **0.528–0.618 s** total; `/status` 0.382–0.434 s total. The first connection after ~10 min of quiet took **3.94 s** of handshake (one observation, recorded because it looks like a timeout from a browser and is a cold start). Both numbers are in README “Measured behaviour” with the command | +| served page carries the build marker and the pinning UI | `curl -sk https://keyrelay.local/ \| wc -c` + `grep -o 'var KR_TLS_BUILD=[01];'` + `sha256sum` against the locally **assembled** page (chunks + KR_TLS=1 branch) | 51 626 bytes, marker `=1`, `certFp`/`pTls`/`clearWarn` present, digest equal to the assembled TLS page | ok — measured 2026-11-09 after the task-05 flash: 51 626 bytes, `var KR_TLS_BUILD=1;`, all three new ids (6 matches), `sha256 dd16e195…f04e9` **byte-equal** to the locally assembled page — the multi-tick chunked send still delivers the changed page intact, and `curl -sI http://keyrelay.local/` is still a 301 | +| `relayctl.py` pinning, end to end | `--fingerprint 00…00` / the real fingerprint / `--ca ` / `--insecure` / no argument / `--plain`, each `timeout 15`, dummy token | wrong pin aborts **before sending**; correct pin passes the pin and stops only at authentication; `--ca` verifies the self-signed cert with hostname checking on; `--insecure` warns on stderr every run; no argument → refusal at exit 2 without a socket; `--plain` reads the 426 as a TLS refusal, never as a passphrase problem | ok — measured 2026-11-09: wrong pin → `certificate fingerprint mismatch … Nothing was sent` (and the presented digest `61:54:19:30:ed:df:…:b1:af:f9` equals the serial `cert` value recorded above — the pin and the console agree on one identity across reflashes); correct pin, `--insecure` and `--ca /tmp/board.pem` all reached authentication and were refused there by `E1` (the board's passphrase is the operator's — I did not guess it, so **arming/typing over a pinned session is not device-confirmed in this row**, three `authfails` total, well under the 5-try lockout); no argument → the four-option refusal, exit 2, no connection attempted; `--plain` → `board refuses plaintext … wss:// on :443`, not one word about the passphrase | + +| the certificate carries the name list `cert` prints | `openssl s_client -connect keyrelay.local:443 `: the OID was written from the **ASCII text** `"2.5.29.17"` where the DER content bytes `55 1d 11` belong, because `mbedtls_x509write_crt_set_extension()` takes the OID as DER content bytes. So the served certificate has the right SAN *value* under a label no client reads, i.e. **no SAN**: encryption and pinning work, hostname validation cannot (browsers match SAN, not CN). Fixed in `src/tls_pem.h` (`kSanOidTlv`/`sanOidDer`, `sanExtensionFullDer` pinned to the 43 bytes OpenSSL writes for these three names, `derCarriesSan` requiring OID *and* value), used by `src/tls.cpp`, 33 new host checks in `test_tls_pem.cpp`; the regression fixture is the bytes above, and `derCarriesSan` answers no to it while `derContains` says yes. README bug 11. **To close the device leg** (deliberately not run here: `nvs erase` also forgets the passphrase and the Wi-Fi credentials, which the verification run cannot put back): reflash `bridge`, `nvs erase`, reboot, then `openssl s_client … \| openssl x509 -noout -text` must show `X509v3 Subject Alternative Name: DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, the new fingerprint must appear exactly once in the boot log, and `console.py cert` must print that same value with **no** `!! the stored certificate does NOT carry that name list` warning. Until then `cert` prints that warning for this board — which it now does, because the check looks at the OID. | ### Phase 03 — session takeover diff --git a/.agents/phases/todo/02_tls_control_channel/00_phase.md b/.agents/phases/complete/02_tls_control_channel/00_phase.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/00_phase.md rename to .agents/phases/complete/02_tls_control_channel/00_phase.md diff --git a/.agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md b/.agents/phases/complete/02_tls_control_channel/01_tls_identity_module.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md rename to .agents/phases/complete/02_tls_control_channel/01_tls_identity_module.md diff --git a/.agents/phases/todo/02_tls_control_channel/02_identity_persistence.md b/.agents/phases/complete/02_tls_control_channel/02_identity_persistence.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/02_identity_persistence.md rename to .agents/phases/complete/02_tls_control_channel/02_identity_persistence.md diff --git a/.agents/phases/todo/02_tls_control_channel/03_tls_listener.md b/.agents/phases/complete/02_tls_control_channel/03_tls_listener.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/03_tls_listener.md rename to .agents/phases/complete/02_tls_control_channel/03_tls_listener.md diff --git a/.agents/phases/todo/02_tls_control_channel/04_transport_abstraction.md b/.agents/phases/complete/02_tls_control_channel/04_transport_abstraction.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/04_transport_abstraction.md rename to .agents/phases/complete/02_tls_control_channel/04_transport_abstraction.md diff --git a/.agents/phases/todo/02_tls_control_channel/05_page_client_fingerprint.md b/.agents/phases/complete/02_tls_control_channel/05_page_client_fingerprint.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/05_page_client_fingerprint.md rename to .agents/phases/complete/02_tls_control_channel/05_page_client_fingerprint.md diff --git a/.agents/phases/todo/02_tls_control_channel/06_tls_docs_rollback.md b/.agents/phases/complete/02_tls_control_channel/06_tls_docs_rollback.md similarity index 100% rename from .agents/phases/todo/02_tls_control_channel/06_tls_docs_rollback.md rename to .agents/phases/complete/02_tls_control_channel/06_tls_docs_rollback.md diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md new file mode 100644 index 0000000..92ca6af --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md @@ -0,0 +1,17 @@ +**Verified / implemented (phase 02 final pass)** +- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label). +- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no). +- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page. + +**Tests / lint (100 % green)** +- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`. +- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2). +- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free. + +**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓. + +**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`. + +**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit. + +**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.validate new file mode 100644 index 0000000..a445f27 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.validate @@ -0,0 +1,205 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 862 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 862 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console -e rollback == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes) +Flash: [==== ] 39.6% (used 1246145 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes) +Flash: [==== ] 37.1% (used 1166397 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/rollback/partitions.bin +Retrieving maximum program size .pio/build/rollback/firmware.elf +Checking size .pio/build/rollback/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes) +Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.871 +console SUCCESS 00:00:00.860 +rollback SUCCESS 00:00:00.857 +========================= 3 succeeded in 00:00:02.588 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds) + +== README/AGENTS claims vs what this run measured == +README/AGENTS claims: OK (4 826 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md new file mode 100644 index 0000000..9534593 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md @@ -0,0 +1,17 @@ +**Implemented** +- `src/tls_pem.h` (new, pure — no Arduino/IDF/mbed includes): `wrapPem`/`pemFind` PEM armour both ways, `fingerprintHex`, the single `sanEntries()` name table + `subjectLine()`/`sanList()`, hand-built SAN GeneralNames DER, strict `ipv4Bytes`, and `__DATE__`/`addDays`/`addYears`/`utcTime` validity arithmetic. +- `src/tls.h` + `src/tls.cpp` (mbed TLS half): `generateIdentity()` (secp256r1 → self-signed SHA-256 cert, DRBG personalisation `"keyrelay"`, SAN extension, ±validity window, SHA-256 fingerprint), `selfCheck()` (reparse + `pk_check_pair`), `certificateFingerprint()`, `lastError()`; RAII context wrappers free everything on every error path; `#if KR_TLS == 0` stubs keep the rollback linkable. Header records the ld_libs/sdkconfig/nm evidence. +- `src/config.h`: `KR_TLS` (default 1), `kCertCommonName`, `kCertValidityYears`, `kPemMax=1024` with measured numbers; `platformio.ini`: `-DKR_TLS=1` in both envs with the rollback named. +- SAN is encoded by tested code (OID 2.5.29.17 via `set_extension`) rather than the library's version-varying SAN struct; the 34 DER bytes were verified byte-identical to OpenSSL's for the same names. + +**Verification** (all green) +- `bash .agents/validate.sh` → `validation OK`; suites + `SANITIZE=1` both `RESULT: all suites passed`, **4170 checks** (3162 C++ + **764** `test_tls_pem` + 160 relayctl + 84 page), `test_proto` untouched at 2327, page bytes 48 598, hygiene OK, no key-material shapes. +- gcov: **src/tls_pem.h 100.00 % of 305 lines** (requirement >90 %); `src/tls.*` stated, not faked. +- Firmware: `bridge` 26.7 % RAM / 36.1 % Flash and `console` 16.8 % / 33.6 % — **unchanged from baseline** because nothing calls the identity yet (`--gc-sections` drops it). Proved that is not a false pass: an injected undefined symbol in `tls.cpp` linked anyway, so I re-linked a scratch copy with `-Wl,-u,` forcing the three functions in → both envs link all mbed TLS symbols; cost then `bridge` **88 304 B RAM (+712) / 1 170 853 B Flash (+35 560)**, `console` 55 904 / 1 091 181. `-D KR_TLS=0` builds both envs at exactly the pre-phase footprint. +- Found and fixed a real defect via the cross-check: `wrapPem` omitted the newline after a padded final group (1/2 bytes mod 48) — round-trips fine, but not what any other tool writes. + +**Docs**: README (check count ×2, architecture map, pure-module count, Tests table row, "gaps" paragraph — explicit that the identity is not on the wire yet). AGENTS.md stale facts only (check count, pure-module list now including `tls_pem.h` + the no-crypto-library rule, a name-table row, the "fingerprint yes, key never" clause). + +**Decisions**: no device changes — nothing is reachable over TLS yet, so no `HW_VERIFICATION.md` row was run or recorded; `.agents/HW_VERIFICATION.md`'s 3406 baseline left as the phase-01 delta anchor. + +**Next pending task**: `.agents/phases/todo/02_tls_control_channel/02_identity_persistence.md` (NVS storage, generate-once, serial `cert`, `StatusSnapshot` visibility). diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.validate new file mode 100644 index 0000000..dfc0e39 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__01_tls_identity_module.a1.validate @@ -0,0 +1,153 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2327 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 764 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2327 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 764 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes) +Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.84 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes) +Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.85 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.845 +console SUCCESS 00:00:00.853 +========================= 2 succeeded in 00:00:01.698 ========================= + +== page size: src/web_page.h raw string == +page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ unreachable (curl rc 7, code 000) — expected until phase 02 (TLS) lands +hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md new file mode 100644 index 0000000..bab4dcc --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md @@ -0,0 +1,16 @@ +**Implementation** (`02_tls_control_channel/02_identity_persistence`) + +- `src/main.cpp`: identity now lives in NVS (`tlscrt`/`tlskey`/`tlscrtfp`); `loadTlsIdentity()` runs at boot before `control.begin()` — loads and `selfCheck()`s the stored pair, else generates, stores and prints the fingerprint **once**; a corrupt record is regenerated with two loud `!!` lines before the new value ("any fingerprint you pinned is now wrong"). Store does a read-back (lengths compared, contents never) and prints `IDENTITY NOT STORED … do not pin this fingerprint` if NVS refuses/truncates. All PEM helpers are inside `#if KR_TLS`. +- Serial `cert` (no argument form, in `help`): SHA-256 fingerprint (two 47-char lines, not one 95-char line — 128-byte CDC ring, README bug 1), validity window from the stored cert, and the names from `sanEntries()`, with a `!!` warning when `derContains()` shows the stored cert does not carry that table. No command writes or dumps a key/cert; `git grep -i "private key" src/` returns only prose comments and the `kPemKeyLabel`/mbedTLS API names. +- New pure `src/status.h`: `StatusSnapshot` + `statusJson()` moved out of `control_server.{h,cpp}` so the frame is host-testable, plus `tls` and `fp` (first 8 bytes of the fingerprint in hex, `fp:""` whenever `tls:0`; a non-hex `fp` is cut so a stored value cannot forge a JSON field). `src/tls_pem.h` gained `fingerprintPairs`, `fingerprintPrefix`, `derContains`, `dateTimeText`, `sanEntries()`; `src/tls.{h,cpp}` gained `certificateDetails()` (+ `KR_TLS==0` stub) and the `tls.cpp` docstring. +- Tests: `test_tls_pem.cpp` +97 checks (pinned name table incl. `CN == kHostname + ".local"`, both printed fingerprint shapes rejoining to the digest and refusing at `cap`, `derContains` breaking on one changed byte either side, `dateTimeText`); `test_proto.cpp` + status-frame suite (fields, sanitising, longest legal frame **322 B ≤ `kStatusJsonCap` 384**, renders as nothing when 1 B short). + +**Results:** `bash .agents/validate.sh` → `validation OK` (exit 0). `tests/run_tests.sh`: **4267 checks, 0 failures** (proto 2359, tls_pem 829, text 459, relayctl 160, output 153, ws_codec 135, page 84, mouse 48, keystate 40); same under `SANITIZE=1`. Coverage: `src/tls_pem.h` 100.00 % of 367, `src/status.h` 100.00 % of 28. `pio run -e bridge -e console` SUCCESS — bridge RAM 27.6 % (90 432 B) / Flash 37.5 %, console 17.7 %/34.9 %. Rollback `-DKR_TLS=0` (out-of-tree copy) builds both envs: RAM 26.8 %, Flash 36.1 %, no new warnings. + +**Docs:** README — check count 3406→4267, Quick-start first-boot fingerprint block + survive-reflashing/re-pin rule, `cert` in the serial-console table, `tls`/`fp` in the Wire-protocol status row, architecture map (`status.h`, `tls_pem.h`, `tls.{h,cpp}`, `test_tls_pem.cpp`), Tests-table rows, NVS design decision, 3 troubleshooting rows, note that the measured transcript predates these fields. AGENTS.md — check count and the pure-module list (`status.h`). + +**Deviations:** (1) fingerprint printed as wrapped colon-pair lines, not one 64-hex line; (2) status `fp` is a 16-char label, not the full digest — the cap and the page can't carry 64 hex usefully; (3) `StatusSnapshot`/`statusJson` moved modules (needed for the host-measured cap); (4) added `certificateDetails()` and the four `tls_pem.h` helpers as the tested seams `cert`/status need; (5) boot-time store read-back added to honour "if it does not fit, say so" — the API otherwise fails silently. + +**Device pass (next, not run here):** `console.py cert` → `reboot` → same fingerprint + `identity loaded from NVS`; `nvs erase` + reboot → `identity generated`, pin it, reboot again → unchanged; `console.py status` shows `"tls":1,"fp":"<16 hex>"` matching `cert`'s first 8 bytes. + +Next task: `.agents/phases/todo/02_tls_control_channel/03_tls_listener.md`. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.validate new file mode 100644 index 0000000..c271a56 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__02_identity_persistence.a1.validate @@ -0,0 +1,153 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 27.6% (used 90432 bytes from 327680 bytes) +Flash: [==== ] 37.5% (used 1178749 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 17.7% (used 58040 bytes from 327680 bytes) +Flash: [=== ] 34.9% (used 1099237 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.863 +console SUCCESS 00:00:00.865 +========================= 2 succeeded in 00:00:01.727 ========================= + +== page size: src/web_page.h raw string == +page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local did not resolve within 4 s (no responder? glibc does not cache mDNS — a retry may still work; the IP is the fallback) +hw-probe https https://keyrelay.local/ unreachable (curl rc 28, code 000) — expected until phase 02 (TLS) lands +hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md new file mode 100644 index 0000000..acefc33 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md @@ -0,0 +1,12 @@ +**Task complete: `02_tls_control_channel/03_tls_listener.md`** + +**Implemented** — TLS control-channel listener on `:443`, raw mbedTLS driven from `tick()`: +- `src/tls_session.{h,cpp}` — one non-blocking server session per accepted fd; handshake = one `mbedtls_ssl_handshake()` per tick with a `kTlsHandshakeMs` deadline; strict same-buffer write-resume contract; slot-cap refusal renders the 7-byte RFC 5246 fatal alert without building a context. Pure policy half (deadline, admission, return-code table, alert, write queue, frame ring) host-tested; library half behind `KR_TLS && ESP_PLATFORM`. +- `src/control_server.{h,cpp}` — `:443` listener + plaintext `:80` feed **one** controller slot via one `admitConnection()` rule (cap 2, no transport parameter); per-slot response pumps (page leaves 4 KB/tick); WS frames whole-or-dropped via `TxRing`; `KR_TLS=0` rollback intact. +- Device logs only sessions + heap; `hw_probe.sh` gained an informational TLS probe. + +**Tests** — `tests/test_tls_session.cpp` (135 checks; 100 % coverage of the pure half, ASan/UBSan clean); suite now **4402 checks**, green normal + sanitised; `validate.sh` **validation OK** (both firmware builds, page bytes, hygiene). + +**Device pass** (board flashed and verified live): TLSv1.2/**ECDHE-ECDSA-AES256-GCM-SHA384**; page over `https` = 48598 B = gate count; `/status` 401 without token over TLS; console `cert` == handshake fingerprint, stable across 2 reflashes + 3 reboots; heap 187.9 k boot → 145.5 k (1 session) → 107.9 k (2), 20 cycles no drift; 3rd connection refused with alert 40 in 0.0 s; stalled handshake never blocked `loop()` and died on the ~10 s deadline; `:80` untouched (task 04's territory). Results recorded in `.agents/HW_VERIFICATION.md`. + +**Found & fixed during verification**: one silent identity rotation happened on the first boot under the new firmware with nothing observable about it — `loadTlsIdentity()`/`storeTlsIdentity()` now name the failing gate with lengths only. README/AGENTS.md updated (check counts, security model, new pinned-table row). No phase files touched; nothing staged; board left running the new firmware, not in download mode. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.validate new file mode 100644 index 0000000..f121299 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__03_tls_listener.a1.validate @@ -0,0 +1,156 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 135 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 135 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 160 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +element ids +page 84 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 29.6% (used 97136 bytes from 327680 bytes) +Flash: [==== ] 39.4% (used 1238321 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 19.8% (used 64744 bytes from 327680 bytes) +Flash: [==== ] 36.8% (used 1158621 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.863 +console SUCCESS 00:00:00.858 +========================= 2 succeeded in 00:00:01.721 ========================= + +== page size: src/web_page.h raw string == +page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md new file mode 100644 index 0000000..d62d028 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md @@ -0,0 +1,11 @@ +**Task 04 (`02_tls_control_channel/04_transport_abstraction.md`) — complete.** + +- `src/conn.h`: one `kr::Conn` transport view (tagged union, no virtual — rebinding per slot, inlinable pump) over `WiFiClient`/`tls::Session`; whole-frame-or-nothing `push`, resumable `pump` capped per write (4 KB) and per tick, flash body streamed through one 1460 B RAM chunk. +- `:80` is now redirect-only via pure `plaintextRoute()`: `301 https://…`, `426 Upgrade Required` for a plaintext `/ws` upgrade **and** for any `?token=` request (a `Location` would leak the passphrase twice); full `301`/`426` body pair built by the one `renderHttpResponseHead()`. `serveReadonly()` is the single HTTP answer for both slots. +- Safety invariant kept and pinned: `_lastInboundMs` is stamped only for `WebSocket && _authed` — handshake, `/status` and viewer frames cannot keep a dead controller armed. +- Tests: new `tests/test_conn.cpp` (264 checks, 100 % of the pure half), `test_tls_session` 135→118 (`WriteQueue` deleted; `TxRing` reseat/unseated pinned), `test_relayctl` 160→189 (structural pins: one formatter, one route decision, one clock stamp, page bytes == `kIndexHtmlLen`), `test_page` 84→93 (`https:`→`wss://`, no credential in the connect URL). +- Two real bugs found by the **sanitised** run and fixed, each pinned by the case that triggered it: `memcmp` past a short query, and `octets[4]` overflow on `"192.168.1.1.1"`. +- Gate: `.agents/validate.sh` exits 0 — 4687 checks in both the normal and `SANITIZE=1` runs, `bridge` RAM 30.4 % (99 616)/Flash 39.5 % (1 242 329), `console` 20.5 %/37.0 %, page 48 598 B, hygiene OK. +- Device (flashed, observed): `https://…/` = 48 598 B ×3 with `sha256` == `web_page.h`; `/`→301, `/index.html`→301, `/nope`→301 to `/`; `/status?token=`→426 with no token in the body; `/ws`→426 (`rejected=1` on the board); `wss://` upgrade→101, wrong passphrase→`E1`+close; viewer 200/401 concurrently; heap 140 k/109 k/182 k over 0–2 sessions. +- Docs moved: README (intro count, quick start `https://`, security-model rewrite, measured-behaviour rows, architecture map +`conn.h`, tests table, gaps), AGENTS.md (`curl -sk`, 4687, new defaults), `.agents/HW_VERIFICATION.md` rows. +- Not run, recorded honestly: the keystroke/silence rows need an *authenticated* controller and this board's passphrase is not the default (`dflt:0`) — I did not change a credential I could not restore. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.validate new file mode 100644 index 0000000..7720d94 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__04_transport_abstraction.a1.validate @@ -0,0 +1,162 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 264 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 189 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +element ids +page 93 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 264 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 189 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +element ids +page 93 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes) +Flash: [==== ] 39.5% (used 1242329 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.88 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes) +Flash: [==== ] 37.0% (used 1162585 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.882 +console SUCCESS 00:00:00.866 +========================= 2 succeeded in 00:00:01.748 ========================= + +== page size: src/web_page.h raw string == +page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md new file mode 100644 index 0000000..068e860 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md @@ -0,0 +1,10 @@ +**Task 05 (`02_tls_control_channel/05_page_client_fingerprint.md`) — complete.** + +- **Page** (`src/web_page.h`): one 19-byte `#if KR_TLS` build marker joins the raw-string chunks (branches equal length → page size stays one number); TLS page on an `http:` origin opens **no socket, sends no frame**, disables the passphrase box + connect button behind a banner ("this page arrived in the clear; reload over https") — never a silent `ws://` fallback; rollback build keeps `ws://`; new `tls`/`plaintext` pill and a certificate line rendered from the status frame's `fp` only (nothing when `tls:0` or no `fp`). +- **`relayctl.py`**: TLS by default on :443 via stdlib `ssl`; `--fingerprint`/`$KEYRELAY_TLS_FP` hashes the peer DER and **aborts before any frame** on mismatch; `--ca` = `create_default_context` + hostname checking; `--insecure` warns on every run; none of them → refuses (exit 2) without a socket; `--plain` is the only, explicit path to :80; 426/301 messages updated (tool now has a TLS client). Token pre-flight, `paste --yes` gate, no token ever printed — kept. +- **Tests**: `test_relayctl` 189→271 (trust-decision matrix, port table, fingerprint normalisation/refusals, fake-board byte recording proving mismatch ⇒ zero bytes sent, `--ca`/`--insecure`/`--plain` shapes, main-level refusals, page assembled from *both* marker branches); `test_page` 93→113 (http: refusal incl. `boot()` disabling inputs, rollback marker keeps `ws://`, pill + exact-`fp` rendering); `test_conn` count 264→268 + `kServedPageBytes` 51 310→**51 626**; `test_proto.cpp` untouched. +- **Gate**: `bash .agents/validate.sh` → **validation OK** — 4793 checks in both normal and `SANITIZE=1` runs; bridge RAM 30.4 % (99 616) / Flash 39.6 % (1 245 353), console 20.5 %/37.1 %; page bytes 51 626; hygiene OK. Coverage of changed/added `relayctl.py` functions: **156/158 = 98.7 %** (stdlib `trace`). `ruff --select F,E9` clean on `tools/`+`tests/`; both marker branches compile (`KR_TLS=0/1` syntax-checked). +- **Device** (flashed `bridge`, board healthy, not in download mode): served page **51 626 B**, sha256 `dd16e195…f04e9` byte-equal to the locally assembled TLS page, marker `=1`, new ids present; `:80` still 301. `--fingerprint 00…00` → `certificate fingerprint mismatch … Nothing was sent`; real pin / `--insecure` / `--ca ` all passed their trust step and stopped at authentication (`E1` — the passphrase is the operator's, 3 authfails total, not guessed); no argument → exit 2, no socket; `--plain` → the 426 sentence, no passphrase blame. Recorded in `.agents/HW_VERIFICATION.md`; typing over a pinned session stays the operator's confirmation. +- **Docs**: README (4793 counts, Quick start pinning block with `cert`/`curl -sk …/status?token=`/`$KEYRELAY_TLS_FP`, Controller-page byte count + 3 new element rows, architecture map, end-to-end examples, 2 new Troubleshooting rows incl. the wrong-network/identity-rotation wording in full, tests table), AGENTS.md (check count, page-size and raw-string statements now describe the marker, new cross-check table row), `.agents/validate.sh` page-size check now assembles chunks+marker (would otherwise fail on the new layout). +- **Notable**: the page must differ per build (a runtime probe would *guess* whether the board does TLS — invariant 5), hence the equal-length build marker; the pin hash deliberately runs post-handshake on the presented DER so self-signed pinning needs no CA ceremony, while `--ca` keeps OpenSSL's full verification. +- **Next pending task**: `02_tls_control_channel/06_tls_docs_rollback.md`. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.validate new file mode 100644 index 0000000..9a2bc17 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__05_page_client_fingerprint.a1.validate @@ -0,0 +1,166 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes) +Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes) +Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.85 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.862 +console SUCCESS 00:00:00.854 +========================= 2 succeeded in 00:00:01.716 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene) diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.err b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md new file mode 100644 index 0000000..eff0074 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md @@ -0,0 +1,14 @@ +**Implemented** +- `platformio.ini`: new `[env:rollback]` (`extends env:bridge`, unflags `-DKR_TLS=1`, sets `-DKR_TLS=0`) — `pio run -e rollback` is now the documented, reproducible rollback; `pio run` has no `--build-flags`, so an env is the only unambiguous spelling. +- `.agents/validate.sh`: builds all three envs, plus new check 5 that pins the docs to measurements — check count in README+AGENTS, **every Tests-table row against that suite's own line**, the page byte count, Backlog numbered 1..N, and every `ws://` line must name the rollback on the same line; fixed its stale `curl -s http://` comment. +- README: TLS intro bullet; Safety model "TLS is transport, not an arming input"; trust table gains "who can see the traffic" (with the unpinned window stated) and "anyone with a copy of this firmware"; WiFi-owner row corrected; new "What TLS does not buy" + "the certificate you did not check is the one thing to check"; Wire-protocol transport paragraph + the non-`E` refusals (401/503/301/**426**); new **"The rollback — building it, and what it costs"**; Measured-behaviour rows (footprint vs baseline, TLS cost, handshake time, session heap); Troubleshooting rollback row; 5 Design decisions (challenge–response, on-board identity, raw mbedTLS vs `esp_tls`, 426 vs 301, rollback-as-build); Backlog TLS item removed, renumbered 1–9, narrower "a certificate a browser already trusts" added. +- Docstrings: "what it does NOT protect / must not reverse" added to `tls.h`, `tls_pem.h`, `tls_session.h`, `conn.h`, `relayctl.py`. AGENTS.md: corrected facts only (3-env gate, `conn.h` pure half, gate checks doc numbers, TLS identity defaults, "Three tables"→"Tables", "last row"→"evdev row"). +- Fixed earlier-task defects: stale `test_page | 93`→113; two Tests rows missing a closing pipe + a blank line splitting that table; HW_VERIFICATION duplicate blank `/ws` row and a heap row wrapped over 6 lines (broke its table). + +**Tests/lint**: `bash .agents/validate.sh` → **exit 0, validation OK**; 4793 checks / 0 failures in both the plain and `SANITIZE=1` runs; page bytes 51 626; RAM/Flash — bridge 30.4 %/99 616 · 39.6 %/1 245 353, console 20.5 %/67 224 · 37.1 %/1 165 609, **rollback 28.5 %/93 456 · 36.4 %/1 146 137**; `ruff --select F,E9 tools/ tests/` clean; `bash -n` clean. No new executable code, so coverage is unchanged (pure modules already 100 %). + +**Docs**: README.md and AGENTS.md updated as above. + +**Deviations**: rollback proven by build + binary evidence, not by reflashing — `nm` gives 0 `mbedtls_ssl_*`, 0 `mbedtls_x509write`, 0 identity strings in the rollback image vs 115/15/1 in `bridge`; flashing the rollback and back would put a live HID keyboard into download mode on the host running this pipeline, so the device leg is recorded `skipped (not flashed)` in `.agents/HW_VERIFICATION.md` with the exact commands. Handshake time measured this task (0.349–0.423 s warm ×7, 3.94 s cold, page whole in 0.53–0.62 s); heap quoted from the recorded task 03/04 serial readouts, not re-measured (opening the CDC port resets the board). + +**Next pending task**: this was phase 02's last task; after the harness moves it, next is `03_session_takeover/01_pending_claim_socket.md`. diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md.status b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.validate b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.validate new file mode 100644 index 0000000..492f6b8 --- /dev/null +++ b/.agents/reports/02_tls_control_channel/02_tls_control_channel__06_tls_docs_rollback.a1.validate @@ -0,0 +1,205 @@ +== host suites: bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== host suites: SANITIZE=1 bash tests/run_tests.sh == +compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work) +sanitizers: on +keystate 40 checks, 0 failures +proto 2359 checks, 0 failures +text 459 checks, 0 failures +mouse 48 checks, 0 failures +output 153 checks, 0 failures +ws_codec 135 checks, 0 failures +test_tls_pem 829 checks, 0 failures +test_tls_session 118 checks, 0 failures +test_conn 268 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 271 checks, 0 failures +the log stays readable +characters +key names in the log +sending text +refusing what it cannot type +stopping +the mouse +the connect path +the channel pill and the certificate line +element ids +page 113 checks, 0 failures +RESULT: all suites passed + +== firmware: platformio run -e bridge -e console -e rollback == +Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/bridge/partitions.bin +Retrieving maximum program size .pio/build/bridge/firmware.elf +Checking size .pio/build/bridge/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes) +Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/console/partitions.bin +Retrieving maximum program size .pio/build/console/firmware.elf +Checking size .pio/build/console/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes) +Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino) +-------------------------------------------------------------------------------- +Verbose mode can be enabled via `-v, --verbose` option +CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html +PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM) +HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash +DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa) +PACKAGES: + - framework-arduinoespressif32 @ 4.30312.261024 (3.3.12) + - framework-arduinoespressif32-esp32s3-libs @ 3.3.12 + - tool-esptool @ 2.50400.0 (5.4.0) + - tool-riscv32-esp-elf-gdb @ 12.1.0+20221002 + - tool-xtensa-esp-elf-gdb @ 12.1.0+20221002 + - toolchain-riscv32-esp @ 14.2.0+20260121 + - toolchain-xtensa-esp-elf @ 14.2.0+20260121 +LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf +LDF Modes: Finder ~ chain, Compatibility ~ soft +Found 45 compatible libraries +Scanning dependencies... +Dependency Graph +|-- Adafruit NeoPixel @ 1.15.5 +|-- ESPmDNS @ 3.3.12 +|-- USB @ 3.3.12 +|-- Preferences @ 3.3.12 +|-- WiFi @ 3.3.12 +Building in release mode +Generating partitions .pio/build/rollback/partitions.bin +Retrieving maximum program size .pio/build/rollback/firmware.elf +Checking size .pio/build/rollback/firmware.elf +Advanced Memory Usage is available via "PlatformIO Home > Project Inspect" +RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes) +Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.862 +console SUCCESS 00:00:00.860 +rollback SUCCESS 00:00:00.867 +========================= 3 succeeded in 00:00:02.589 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds) + +== README/AGENTS claims vs what this run measured == +README/AGENTS claims: OK (4 793 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/AGENTS.md b/AGENTS.md index a324d4d..b9dd964 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea This file is the operating manual: what must not break, how to build and verify, and where the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy drifts, and two paths that can disagree about arming is the exact failure this project keeps -having to fix (see README, "Ten bugs"). +having to fix (see README, "Eleven bugs"). ## Phased execution @@ -34,7 +34,7 @@ This repository runs work through `.agents/phases/`: button, or the serial console — nothing else may gain the ability. The mouse is captured, never grabbed: Pointer Lock, so `Esc` (the browser's own, never forwarded) always frees it. `mouse off` over serial removes the pointer interface at the descriptor level, which - needs a reboot and is the rollback. + needs a reboot and is the pointer's rollback (the transport rollback is `-D KR_TLS=0`). 3. **Every fail-safe cancels work in flight; nothing "pauses"** — and **no fail-safe may be gated on the absence of the thing it detects.** `ControlServer::linked()` is false when a controller is gone, so gating the silence-disarm on it made the backstop for a vanished @@ -61,7 +61,7 @@ This repository runs work through `.agents/phases/`: read-only HTTP is served even while a controller holds the bridge. Keep both routes going through `serveReadonly()` so they cannot drift apart. -## Three tables that must agree +## Tables that must agree | table | where | names | pinned by | |---|---|---|---| @@ -70,8 +70,11 @@ This repository runs work through `.agents/phases/`: | `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes | | mouse button bits | `src/mouse.h`, `src/web_page.h` (`MOUSE_BTN`), `tools/relayctl.py` (`MOUSE_BUTTONS`) | HID bit → DOM `MouseEvent.button` **and** name | `test_proto.cpp` (page↔config), `test_relayctl.py` (tool↔page↔config) | | evdev axis codes | `tools/watch_target.py` (`REL_*`) | kernel code → label, for the *verifier* | `test_relayctl.py` — pinned to ``, **not** to the report's byte order | +| certificate names | `src/tls_pem.h` (`sanEntries`, `kSanOidTlv`, `sanExtensionFullDer`) | the CN + SAN the certificate carries, **and the OID that labels the SAN**, and the same list rendered for the operator | `test_tls_pem.cpp` — the CN must be `kHostname` + `.local`, the 34 DER bytes were cross-checked against what OpenSSL writes for those three names, and so were the 43 bytes of the whole extension: the writer takes the OID as **DER content bytes** (`55 1D 11`), and handing it the dotted text produces a valid certificate with no SAN (README bug 11) | +| mbed TLS return codes + the refusal alert | `src/tls_session.h` (`kMbed*`, `mapIo`, `renderFatalAlert`) | "would block" vs "peer gone" vs "fatal" for every session call; the 7-byte RFC 5246 alert record a slot-cap refusal sends | `test_tls_session.cpp` walks the tables; `static_assert`s in `tls_session.cpp` weld the three copied error-code literals to the shipped library's macros — the code values differ between mbed TLS 2.x and 3.x, which is exactly how a confident transplant goes wrong | +| page build marker | `src/web_page.h` (`#if KR_TLS` between the raw-string chunks) | which build served the page — the TLS page refuses an `http:` origin outright; only the rollback page may speak plaintext | `test_page.js`, `test_relayctl.py` and `validate.sh` each assemble the served bytes from the chunks, and the two marker branches are asserted **equal length**, so one page byte count covers both builds | -The last row exists because it was guessed from the HID report's field order and was wrong +The evdev row exists because it was guessed from the HID report's field order and was wrong (`REL_WHEEL` is 8, not 2): a working wheel looked dead and the summary said no scroll happened. Codes the verifier has no name for are now counted and printed rather than dropped — the same fix as giving F13–F24 names. **When you add a table that decodes @@ -87,11 +90,13 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi ## Build, test, flash ```bash -tests/run_tests.sh # 3406 checks, no hardware +tests/run_tests.sh # 4826 checks, no hardware SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers # PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must # be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload: -uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console +uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console -e rollback +# `rollback` is [env:bridge] with -DKR_TLS=0: the plaintext way back. The gate builds it on +# every run, because a way back that stopped compiling is not a way back. python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode uv run --python 3.14 --with platformio python -m platformio run -e bridge -t upload ``` @@ -113,8 +118,13 @@ Environment facts that will otherwise cost you a cycle: thing that checks the no-HID `#else` branches. - **The page is served from flash.** A change to `src/web_page.h` is invisible on the network until the board is reflashed — and a tab that is already open keeps the old page in memory - until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size - of the raw string in `src/web_page.h`, and grep the served bytes for the element you added. + until it is reloaded. Confirm with `curl -sk https://keyrelay.local/ | wc -c` against the + `page bytes:` line the gate prints (the raw-string chunks plus the 19-byte build marker; + equal-length branches make it one number for both builds), and grep the served bytes for + the element you added. Plain `http://` returns a 301 and no page bytes at all (task 04), so a `curl -s + http://…` that prints a redirect body means the transport is working, not that the page is + stale — fetch over `https://`, and `curl -s -L` is not a substitute: it follows to the same + bytes whether or not the flash changed. - **This directory is a git repository** with a short history that predates most of the current tree. Use it: `git diff` before and after any sweeping edit, and remember that `git checkout ` reverts far more than you asked for — restore a *region*, not a @@ -148,12 +158,23 @@ wired now — but ask, of every green check, *what would still pass if this were ## Editing rules -- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `text.h`, `ws_codec.h`, - `sha1.h`) include no Arduino or ESP-IDF headers. That is what lets the identical code run - on a host under sanitisers. Keep it. -- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run - `tests/test_page.js` after touching it (it executes the page against a DOM shim and checks - that every element id the script reaches for exists in the markup). +- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `status.h`, `text.h`, + `ws_codec.h`, `sha1.h`, `tls_pem.h`) include no Arduino or ESP-IDF headers. That is what lets the + identical code run on a host under sanitisers. Keep it — and keep the *crypto library* + out too: `tls_pem.h` handles the identity's strings, `tls.h`/`tls.cpp` handle the + library, and the host suite includes the first and never the second. `tls_session.h` + follows the same split inside one file: its policy half (deadline, admission, + library-return table, alert record, queues) is pure and host-tested, and its + `kr::tls::Session` class hides behind `KR_TLS && ESP_PLATFORM` — with the mbed TLS + includes **outside** the namespace, because inside it they declare `::FILE` as + `kr::tls::FILE` and every later stdio user in the translation unit dies. `conn.h` splits the + same way and for the same reason: `plaintextRoute()`, the HTTP response head, the redirect + builder and the page send cursor are pure and walked by `test_conn.cpp`; only `kr::Conn` + itself needs `WiFiClient`. +- **`web_page.h`** is C++ raw string (plural since task 05: chunks joined by one + `#if KR_TLS` build marker of equal-length branches): no compiler looks at the HTML or JS. + Run `tests/test_page.js` after touching it (it executes the assembled page against a DOM + shim and checks that every element id the script reaches for exists in the markup). - **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list. Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`. @@ -161,12 +182,19 @@ wired now — but ask, of every green check, *what would still pass if this were Safety model, Security model trust-boundary table, Controller page, Wire protocol, Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module you changed. This README documents *why* decisions were made — keep the reasoning, don't just - restate the behaviour. + restate the behaviour. `.agents/validate.sh` checks the mechanical half so nobody has to + remember it: the check count and the page byte count the docs quote must equal what the run + measured, the Backlog must be numbered 1..N with nothing closed left inside it, and every line + that mentions a plaintext `ws://` session must name the rollback build on the same line (wrap + the sentence so it does — the point is that no sentence can read as if plaintext were default). - Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is - the point of that endpoint — do not add the token to any other URL. + the point of that endpoint — do not add the token to any other URL. The TLS private key is + the same rule with more teeth: it is generated on the board and lives only in NVS, so + nothing may print it, hash-and-print it in a way that reads as the key, or be added to the + tree as a `.pem`. Fingerprint yes, key never. - `TODO.md` holds **open items only**; clear it when they are done. The durable record is the - README: a fixed bug belongs in "Ten bugs this verification caught" with its root cause and + README: a fixed bug belongs in "Eleven bugs this verification caught" with its root cause and how it was proven, a new capability in the section that describes it, and a rule worth repeating here. A TODO that has accumulated resolved items tells the next reader nothing about what is left to do. @@ -174,6 +202,15 @@ wired now — but ask, of every green check, *what would still pass if this were ## Current defaults worth knowing Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP -`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace +`keyrelay-ctl` / `keyrelay-ctl-pass`; **`https://keyrelay.local`** or `https://192.168.4.1` +(port 443 — `:80` answers a 301 to it, a 426 to a plaintext `/ws` upgrade or to any request +carrying `?token=`, and nothing else; plaintext only in the `-D KR_TLS=0` rollback, which is +`pio run -e rollback`); the TLS identity is an ECDSA secp256r1 keypair and self-signed +certificate generated on the board's first boot and stored in NVS (`tlscrt`, `tlskey`, +`tlscrtfp`) so it survives reflashing — rotated only by `nvs erase`, printed once at that first +boot and again on demand by the serial `cert` command, which prints the SHA-256 fingerprint, the +validity window and the names, and has no argument form that yields the key; at most 2 live TLS +sessions (`kTlsMaxSessions`, RAM-sized, a third is refused with an alert) and a 10 s handshake +deadline (`kTlsHandshakeMs`); paste pace 12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the station credentials and passphrase and survives reflashing. diff --git a/README.md b/README.md index 147925e..950a989 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ genuine USB HID input — including Enter and Ctrl+Alt+Del. Capture the pointer mouse moves the other machine's cursor. The board enumerates on the target as a boot-protocol keyboard with a relative mouse on the same HID interface, so the target needs no software, no driver and no network. The controller is a web page served by the -board itself. +board itself, over TLS, with a certificate the board made on its own first boot. ``` your laptop target machine ┌──────────────────┐ ┌──────────────────┐ │ browser tab │ WiFi │ │ - │ keydown/keyup ├────WebSocket──►│ AtomS3 Lite │ + │ keydown/keyup ├──wss://(TLS)──►│ AtomS3 Lite │ │ mousemove │ (token) │ keyrelay │ │ (Pointer Lock) │ │ │ └──────────────────┘ └────────┬─────────┘ @@ -37,6 +37,15 @@ that one is the browser's own guarantee, not something this firmware has to hono buttons up"** — to the target, as reports, not just to the board's own bookkeeping. * **Arming needs a passphrase** (or a thumb on the board's button). The LED is readable from across the room, so the state is not a secret between you and the screen. +* **Nothing that matters crosses the network in the clear.** The board serves the page, the + WebSocket and `/status` over TLS on `:443` with an ECDSA keypair and self-signed + certificate it generated on its first boot and keeps in NVS, so the passphrase, the + keystrokes, the pasted text and the pointer deltas are encrypted or they are not sent. + Port `:80` answers nothing but the way to `:443` — a `301`, and a `426 Upgrade Required` + to a plaintext `/ws` upgrade or to a request carrying `?token=`. The certificate's SHA-256 + fingerprint is printed **once** at first boot and reprinted by the serial `cert` command; + checking it against the board's own console is the one link in this chain that a human + has to close, because encryption you have not identified is encryption you have assumed. * **The mouse is captured, not grabbed.** The page asks the browser for Pointer Lock — the same thing a web game asks for — so the cursor cannot escape the tab while it is being forwarded, and `Esc` always returns it. Losing the lock, the tab or the link @@ -47,10 +56,16 @@ that one is the browser's own guarantee, not something this firmware has to hono script, nothing it can type later, no gesture it can replay. Pasted text is typed from RAM and forgotten; the serial console has no command that types or clicks anything at all. It is a keyboard and a mouse, not a payload dropper. -* **3406 checks** cover the key-state machine, the mouse state, the report queues and the +* **4826 checks** cover the key-state machine, the mouse state, the report queues and the release funnel, the wire protocol, the text→keystroke map, SHA-1/base64, the WebSocket - codec, the controller page's own script, the CLI's frames and the target-side verifier - itself — all before anything is flashed. + codec, the TLS identity's PEM armour, name table and fingerprint renderings, the TLS + session's policy layer (handshake deadline, slot admission, library-return table, + refusal record and frame ring), the transport view's response head, `:80` answer table, + redirect builder and page send cursor, the status frame's fields and the buffer they have + to fit in, the controller page's own script (including its refusal to type a passphrase + from a page that arrived in the clear), the CLI's frames and its TLS trust decisions + (no trust argument → refuse; wrong fingerprint → nothing sent), and the target-side + verifier itself — all before anything is flashed. --- @@ -153,8 +168,8 @@ connector it already uses. ```bash cd keyrelay -tests/run_tests.sh # 3406 checks, no hardware needed -pio run -e bridge # ~1.1 MB image, ~36% of the 3 MB app slot +tests/run_tests.sh # 4826 checks, no hardware needed +pio run -e bridge # ~1.2 MB image, ~40% of the 3 MB app slot (TLS on) python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode pio run -e bridge -t upload # flash ``` @@ -173,22 +188,107 @@ python3 tools/console.py token "a-long-passphrase-you-pick" # do this one, python3 tools/console.py status ``` +A board with nothing in NVS makes its TLS identity on that first boot — an ECDSA secp256r1 +keypair and a self-signed certificate, kept in NVS — and prints the certificate's SHA-256 +fingerprint **once**, wrapped over two lines so the serial console cannot drop half of it: + +``` +[tls] identity generated -- pin this certificate: + ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23 + b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad +``` + +(Two 47-character lines, never one 95-character line. The bytes above are the SHA-256 of +`abc`, so nobody reads them as their own board's.) + +That is the only time the board prints it. `python3 tools/console.py cert` prints it again, +on demand, with the certificate's validity window and the names it is issued for — and +never the key, in either direction: there is no command that writes an identity and none +that dumps one. The fingerprint **survives reflashing** (NVS does) and changes only when NVS +is erased (`nvs erase`, or the factory reset): the board then generates a new identity and +says so loudly, because a fingerprint that changed quietly is a pinned value that stopped +meaning anything. The first `https://` connection will ask the browser to accept the +certificate once per browser — that acceptance is per device and per browser profile, and a +*second* prompt after a pinning is the same signal as a changed fingerprint. `https://` is +how the page is reached: port 80 answers a `301` to the encrypted port and a `426 Upgrade +Required` to anything it must not answer in plaintext (a `/ws` upgrade, a request carrying +`?token=`), and serves no content of its own. + Now, on the **laptop**: | the board is on | open in a browser | because | |---|---|---| -| your WiFi network | `http://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP | -| its own access point | `http://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed | +| your WiFi network | `https://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP | +| its own access point | `https://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed | Enter the passphrase, press **connect**, press **arm** — the LED goes solid red — and type in that tab. Enter goes. Ctrl+Alt+Del is the button (twice), because your laptop's own OS swallows the real combination before a browser can see it. +The Connection card then shows the board's certificate fingerprint — `certificate +SHA-256 aabbccdd… — confirm it against the cert command on the board's serial console` +— and a header pill reads `tls`. Confirm those digits against +`python3 tools/console.py cert`; a mismatch means the board regenerated its identity or +somebody else is answering `keyrelay.local`, which is exactly the thing the fingerprint is +for. The browser's own certificate warning is accepted **once per browser**, or you skip +it for the terminal and pin instead: + +```bash +python3 tools/console.py cert # the fingerprint, printed on demand +export KEYRELAY_TLS_FP='' # 2:2 colon form works too +curl -sk 'https://keyrelay.local/status?token=a-long-passphrase-you-pick' +python3 tools/relayctl.py --host keyrelay.local status # pins via $KEYRELAY_TLS_FP +``` + +`relayctl.py` requires exactly one trust argument — `--fingerprint` (also honoured as +`$KEYRELAY_TLS_FP`), `--ca `, or `--insecure` (which warns on stderr every single +run) — and refuses to connect without one, because a tool that quietly downgrades is the +component that makes all of this pointless. A pinned run hashes the peer certificate +**before sending its first frame** and aborts on mismatch, so a wrong pin never even leaks +the passphrase attempt. `--plain` selects the plaintext `ws://` on :80 — the `-D KR_TLS=0` +rollback board is the only thing that answers it, explicit opt-in, scriptable like everything +else. + To send text instead of pressing keys, paste it into **Send text** and press its button: it says how many characters it will type, the board types them as keystrokes at a pace the board keeps, and **stop typing** abandons the rest. From a terminal it is `wl-paste | relayctl.py paste --yes` (or `xclip -o -selection clipboard`, `pbpaste`). +### The rollback — building it, and what it costs + +If TLS itself is the thing that broke — a provisioning change you cannot undo from the +network, a client that will not do TLS, a board you have to reach *now* — the way back is one +reflash, and it is a **build** rather than a setting on purpose. A board that has lost its way +over TLS should not also have to be trusted with the switch that would let it be reached in +the clear; the same reasoning makes `mouse off` a reboot. Turning TLS out saves about 6 kB of +RAM and 99 kB of flash, and the image fits the 3 MB app slot either way, so the way back is +never a question of space: + +```bash +pio run -e rollback # env:bridge with -DKR_TLS=0, i.e. the pre-phase-02 build +python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode +pio run -e rollback -t upload # if pio here is a broken launcher (ModuleNotFoundError), + # use: python3 -m platformio run -e rollback -t upload +``` + +`[env:rollback]` in `platformio.ini` is `env:bridge` with the default `-DKR_TLS=1` unflagged +and `-DKR_TLS=0` in its place. Unflagged, not overridden: two `-D` flags for one macro is a +redefinition whose winner depends on flag order, and this PlatformIO's `run` has no +`--build-flags` option to argue with, so the environment *is* the documented command. +`.agents/validate.sh` builds all three environments on every run for the same reason — a way +back that stopped compiling is not a way back, and the phase that added the encryption is not +the phase that gets to decide it cannot be reversed. + +What you get is the pre-phase-02 device: page and socket on `:80` in plaintext, no `:443`, no +identity generated and no NVS records for one, a boot line that says `[tls] TLS compiled out +(KR_TLS=0) -- plaintext control channel` so nobody has to guess which board is answering, and +a page carrying `var KR_TLS_BUILD=0;` so it connects over the plaintext socket instead of +refusing an `http:` origin it was legitimately served. What you give up is the whole +[Security model](#security-model-and-trust-boundaries): the passphrase and every keystroke are +readable on the network again. Nothing about arming, the release funnel or the LED moves with +the flag — it changes which port carries bytes, never who may type them. The rollback's `ws://` +is the only plaintext socket this README calls a normal way to reach a board, one reflash away. + To put the sibling project back on the board: `cd ../jiggler && python3 .../to_bootloader.py && pio run -t upload`. @@ -244,7 +344,7 @@ Enforced in this order, each independent of the others: indefinitely with the last key still pressed (bug 10). A board armed by its own button with no controller at all is deliberately left running: it has no controller to go quiet. This backstop is not gated on the link still existing, and must never be — see - [Ten bugs](#ten-bugs-this-verification-caught-all-fixed). + [Eleven bugs](#eleven-bugs-this-verification-caught-all-fixed). 5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys up" and disarms (unless you tick *stay armed if this tab loses focus*, which still releases the keys — you cannot keep a held Ctrl alive by tabbing away). @@ -274,6 +374,14 @@ Enforced in this order, each independent of the others: keyboard only — a held mouse button is never something to keep across a tab switch. The page stops capturing rather than queueing movement to send later. +**TLS is transport, not an arming input.** Every rule above reads the same on `:443` as it +read on `:80`, because nothing in the arming path consults the transport: a TLS session that +is authenticated and not armed types nothing, and a TLS session that drops is a lost link, +which runs `releaseEverything()` and disarms exactly as a dropped plaintext socket did. The +`-D KR_TLS=0` rollback changes which port carries the bytes, never who may type them — which +is why it is a way back rather than a second security model (see +[Design decisions](#design-decisions)). + The physical kill switch is the button: press it and the keys come up and the buttons come up, no network, no passphrase, no software required. `Esc` is a second one for the pointer specifically, and it is the browser's, not ours. @@ -283,7 +391,7 @@ specifically, and it is the browser's, not ours. the target rather than only clearing the board's own idea of what is down. That is the fixed ninth bug: a held key stayed held on the target after silence-disarm, target loss or a reload, because the report that would have released it was never queued (see -[Measured behaviour](#ten-bugs-this-verification-caught-all-fixed)). +[Measured behaviour](#eleven-bugs-this-verification-caught-all-fixed)). --- @@ -292,29 +400,69 @@ a reload, because the report that would have released it was never queued (see | actor | can | cannot | |---|---|---| | Anyone on the target (its own OS) | see a Logitech G815 keyboard and a pointer in its device list; read the CDC console; type on itself | arm or drive the bridge remotely, detect that a second controller exists | -| Anyone on the board's network | fetch the page; open a socket; learn that a keyrelay exists | arm, send a key, send a chord, type a run of text, move the pointer, click, read `/status` — all need the passphrase, and all but `/status` need the bridge armed | +| Anyone on the board's network | fetch the page over `https://`; open a socket; learn that a keyrelay exists and read the public names on its certificate | arm, send a key, send a chord, type a run of text, move the pointer, click, read `/status` — all need the passphrase, and all but `/status` need the bridge armed. They no longer get to *hear* any of it: the passphrase and the keystrokes are inside a TLS record, and `:80` hands out a redirect and two `426`s and nothing else | +| Anyone who can see the traffic — a café access point, a mirror port, a hostile neighbour | learn that this address answers TLS, when, and how many bytes; read the certificate's own public names (`keyrelay.local`, `keyrelay`, `192.168.4.1`) | read a passphrase, a keystroke, one pasted character or one pointer delta; get a plaintext page or a plaintext socket out of this board. **One honest exception:** an attacker who can see the traffic *and* is first to answer `keyrelay.local` is a certificate authority of one for any connection nobody pinned — the browser warning, or the fingerprint you compared against `cert`, is what closes that window, and nothing in the firmware can close it for you | +| Anyone with a copy of this firmware (or of this repository) | build their own keyrelay; read every byte of the protocol and of this README | impersonate *your* board or decrypt a session to it. The keypair is generated on your device, lives only in its NVS, and is not in the image, the tree, a log or a URL; every board's fingerprint is different, which is the only reason pinning means anything | | Whoever holds the laptop's mouse | the browser tab's pointer, only after a click on **Capture pointer**, and only while armed | move the target's pointer without the LED being red, or keep moving it after `Esc` — Pointer Lock is revoked by the browser and cannot be retaken without another click | | Someone guessing the passphrase | 5 attempts before a 30 s lockout during which the token is not evaluated at all | learn anything from the failure: same error, same close, no timing oracle (constant-time compare) | -| The owner of the WiFi | join the network, then everything above | — the network *is* the perimeter; see below | +| The owner of the WiFi | join the network, learn that a keyrelay is there, watch the size and timing of a session | read or forge what a controller sends, or arm anything. The perimeter used to be the network alone; now it is the network *and* whether the certificate was checked — see below | | You, holding the board | arm/disarm (button), reconfigure or reflash (console), stop everything (unplug) | — | | The target's user | notice a red LED next to their monitor, and pull it out | — | -**What the passphrase does and does not do.** It authenticates the *control* channel; it -is not confidentiality for keystrokes. The page is served over plain HTTP and the -WebSocket is `ws://`, so on a network you do not control, keystrokes are readable by -anyone who can see the traffic and the token is visible at first contact. This is a -deliberate v1 trade: WSS would need either a trusted certificate (no way to get one for -`keyrelay.local`) or certificate-pinning ceremony in a browser (which the target may not -even have). Documented in [Backlog](#backlog) with the two honest fixes. +**What the passphrase does and does not do.** It authenticates the *control* channel; +confidentiality is a separate layer, and that layer now carries everything: the board serves +the page, the WebSocket and `/status` over TLS on `:443` (`https://`, `wss://`) with the +identity it generated on first boot (`src/tls.h`, `src/tls_session.h`), and `:80` answers +**nothing** but the way to `:443` — a `301` for a page request, and a `426 Upgrade Required` +for a plaintext `/ws` upgrade or for a request carrying `?token=` (`src/conn.h`, +`plaintextRoute()`). So the passphrase, the keystrokes, the pasted text and the pointer now +cross the network encrypted or not at all, and a page loaded over `http://` — which an +on-path attacker could rewrite, at which point it is their page asking for your keystrokes — +is not a page this board serves. A plaintext `/ws` upgrade is *refused* rather than accepted: +the weaker option is never taken quietly, and the refusal says `wss://` in the body so the +operator knows what to reconnect as. The controller slot stays shared between the two +listeners, so a controller on either transport holds the keyboard — but never two at once, +encrypted or not. The only build that carries keystrokes in plaintext is the `-D KR_TLS=0` +rollback, which is a reflash and a decision, not a mode a running board can fall into; see +[The rollback](#the-rollback--building-it-and-what-it-costs). -Practical rule: **use it on a network you trust**, or on the board's own access point -whose WPA2 passphrase is a separate secret (`kr::kApPass`, change it in `src/config.h`). -Both are better than either alone: the AP means you never have to put the board on -someone else's LAN. +**What TLS does not buy.** Encryption is not identity until you look at the certificate. It +is self-signed, so the first connection to a board you have never pinned is a +decision-you-have-not-made-yet window, and closing it costs one comparison: 64 hex digits +from `cert`, read on the board's *own* serial console, where nobody on a network can follow +you. Ticking "accept the risk" in a browser is the same decision made without looking at +anything, once per browser profile — and a *second* prompt for a board you already accepted is +the same alarm as a fingerprint that moved. Encryption buys nothing for +the things outside this channel, and they are worth naming in the same breath: **the target** +sees every keystroke you send it and can log all of it, and that machine is not in this +threat model at all; **the laptop** keeps the passphrase in `sessionStorage` and keeps +`--token` in your shell history if you use it, so a controller someone else has touched is a +controller you must re-passphrase; **whatever you type while armed** goes to the target +whether or not you meant it to, which is what the LED and the focus-loss rule are for; and +**the board you walked away from** is stopped by disarming and unplugging, not by crypto. + +Why `?token=` gets 426 instead of a redirect: a `Location:` that echoes the query exposes +the passphrase **twice** on the wire the redirect is escaping, and it trains operators to +type the secret into a `http://` bar. The body of that answer names the fix without +repeating the value — verified on the device, where the 426 body contains no token. + +Practical rule: **the certificate you did not check is the one thing to check.** Everything +else on this device is automated, reviewable and testable; comparing 64 hex digits against +`cert` on the serial console is not, and it is the one step an on-path attacker can defeat +merely by being there first. Next to it, still worth doing for a different reason: use it on a +network you trust, or on the board's own access point whose WPA2 passphrase is a separate +secret (`kr::kApPass`, change it in `src/config.h`) — the AP means you never have to put the +board on someone else's LAN, which is a smaller blast radius even when the traffic is +encrypted. **Single controller, by design.** A second WebSocket upgrade is refused with `503` / `E3` rather than queued, swapped or shared. Two controllers typing into one machine is precisely the ambiguity a keyboard must not have, and "who owns this bridge" should never be a tie. +The refusal is one slot wide: the board holds at most `kTlsMaxSessions` (2) live +connections — the controller's, and one read-only spare — and a third connection is +refused outright (a TLS alert record on `:443`, a close on `:80`, both counted), never +queued and never half-opened, because a TLS session's I/O buffers are ~32 KB each and +"accept first, find no memory later" is not an option on a board with no PSRAM. What is exclusive is the **keyboard**, not the board: `GET /` and `/status?token=` are served even while a controller holds the bridge, so a page can be reloaded, a second window @@ -442,18 +590,24 @@ tool's validator and the board's map accept exactly the same 128 codes. ## Controller page -Served by the board from flash (`src/web_page.h`, 48 598 bytes — the exact count +Served by the board from flash (`src/web_page.h`, 51 626 bytes — the exact count `.agents/validate.sh` prints on every run, which is what a reflashed device must serve; no CDN, no fonts, no framework, nothing external — a keyboard bridge must work on an airplane and on a network that is down). Its script is executed by `tests/test_page.js`, because a C++ raw string is invisible to every compiler and a typo there fails in a browser, with a -target live. +target live. The page carries exactly one byte-of-build-identity: a 19-character +`#if KR_TLS` marker (`var KR_TLS_BUILD=1;` / `…=0;`, equal lengths, so the served page is +the same size in either build) that lets the TLS build's page refuse an `http://` origin +before any socket exists — refusing to type a passphrase into a page that arrived in the +clear, while the rollback build keeps its `ws://`. | element | why it is there | |---|---| | big ARM/DISARM bar | the state must be unmissable; it only changes when the board confirms | | red page border + red LED while armed | two independent indicators of the same fact | -| status pills: network / target / link / armed / caps-num / **mouse** | "will my keystrokes land, and where", and whether the pointer is yours right now | +| status pills: network / target / link / armed / caps-num / **mouse** / **tls** | "will my keystrokes land, and where", whether the pointer is yours right now, and whether the channel is encrypted (`tls` vs `plaintext`, from the status frame — not from what the page hopes) | +| certificate line in the Connection card | the board's own `fp` from the status frame, verbatim, plus where to confirm it (`cert` on the serial console). Rendered only when the frame carries TLS and a fingerprint; the page invents nothing | +| *"This page arrived in the clear"* banner | a TLS build's page on an `http://` origin — bytes no TLS board ever served on :80 — disables the passphrase box and the connect button outright and says why, rather than collecting a secret it cannot protect | | **Ctrl+Alt+Del** with two-press confirm | the OS eats the real one; on Linux it reboots | | VT1–VT7, Alt+Tab, Win, PrtSc, Menu | the chords the laptop's compositor steals | | **⇦ snap left / snap right ⇨ / ⛶ fullscreen** | Super+Left, Super+Right, Super+PageUp — KDE window management, sent by the board so your laptop does not answer it with its own windows | @@ -461,7 +615,7 @@ target live. | *stay armed if this tab loses focus* | off by default; for the one workflow that needs it | | **Send text** box, its button and its *stop typing* | paste something and have it typed as keystrokes; the button says how many characters, refuses to be armed-into accidentally, and is the only path text takes | | *drop characters I cannot type* | off by default: a paste that quietly loses a character is worse than one that refuses | -| **capture the pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag | +| **capture the pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag | | **Release buttons** under the pad | panic for the pointer, separate from the keyboard's because the two fail independently — held-button-then-lost-link is the mouse's stuck-key, and it is the same one release funnel | | held-keys readout and the mouse line under it, keys/chords/text/chords/mouse counters | so "it is typing" and "it is moving" are facts you can see; the mouse line names the buttons held and the button byte, because "left is down" and "the target has 0x01" are different claims and only the second one is on the wire | | log, *log each key sent*, **clear** | the board's errors verbatim plus one line per key pressed — never one per release, never a blank line, and a repeating line is counted rather than stacked | @@ -502,7 +656,22 @@ and impossible for one malformed field to desynchronise what follows it. | c → d | `K` | keepalive | | d → c | `OK` | accepted | | d → c | `E:` | error (codes below) | -| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type | +| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex | + +**The transport is `wss://` on `:443`, and that is the only thing TLS changed about this +table.** Every frame above is the same bytes in the same order — the phase that encrypted the +channel deliberately did not touch the protocol, so a controller written before it still +parses and every host test still means what it meant. What is different is that `T` +no longer crosses the network in the clear: on a TLS board the first thing anybody else can +read about a controller is that a session exists. A plaintext socket is refused rather than +accepted: the rollback build is the only thing that will ever take a `ws://` upgrade on `:80`, +and here that upgrade is answered `426 Upgrade Required` before a frame is read. So a session +that is typing has never been plaintext by accident, in either direction. + +The status frame is rendered by `src/status.h` — a pure module, so the whole frame including +the `kStatusJsonCap` arithmetic is measured on a host (`test_proto`) instead of discovered on +a device: a frame that would not fit renders as **nothing** rather than as half a status, and +`fp` is cut at the first non-hex byte so a stored value cannot forge a field. `` is the modifier byte as two hex digits, `` a HID usage id as two hex digits. Both are strict: exactly two hex digits, no spaces, no trailing comma, usage `00` is not @@ -550,6 +719,17 @@ no mouse report can be sent safely. `E` + `code % 10` and would have rendered both of them as `E0:` — a refusal that names nothing. Codes now render in full, every single-digit code byte-identical to before. +**The refusals that are not `E`** live at the HTTP layer, because they happen before a +WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, if the socket +got as far as an upgrade) when another controller already holds the bridge; `404` on an +unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location` +pointing at `https://` the same address and nothing else — plus **`426 Upgrade Required`** +for a `/ws` upgrade or for **any** request carrying `?token=`, which is refused rather than +redirected precisely so the passphrase is not echoed a second time on the wire the redirect +is escaping. All of it is `src/conn.h`'s `plaintextRoute()`, one table, consulted in one +place, and compiled away by the rollback. See [Security model]( +#security-model-and-trust-boundaries). + The same protocol is what a future OS-wide capture client would speak — see [Backlog](#backlog) — which is why it lives in `src/proto.h` instead of inside the server. @@ -572,6 +752,7 @@ python3 tools/console.py /dev/ttyACM0 status # one command, print the reply | `wifi clear` | forget it; the access point becomes the only way back | | `ap auto\|force\|off` | AP policy: when the station is down / always / never | | `token ` | control passphrase, 6–32 chars, never echoed back | +| `cert` | the TLS certificate: SHA-256 fingerprint, validity window, and the names it carries — and a warning when the stored certificate does not actually contain that extension, so the list is this build's table rather than the certificate's. **Never the key, never the PEM body** — there is no argument form that writes an identity and no subcommand that reads one out | | `arm` / `disarm` | same as the button | | `mouse on\|off` | whether the bridge enumerates a pointer interface at all. Stored, and it needs a **reboot**: the composite descriptor is fixed when USB starts, so this cannot change under a live target. Off means the pointer cannot be moved by anything, ever, until it is turned back on | | `reboot`, `help` | | @@ -603,17 +784,65 @@ src/ output.h pure the one place keystrokes, pasted runs, chords and mouse reports become two ordered queues, and the one place anything is released proto.h pure the wire protocol: parse/render commands, constant-time compare + status.h pure the status frame: StatusSnapshot and its renderer, the tls/fp + identity fields, and the SSID's JSON-boundary sanitising -- host + measured against kStatusJsonCap, which is why it is not in the server sha1.h pure SHA-1 + base64 for the WebSocket handshake ws_codec.h pure handshake request parsing + RFC 6455 frame codec - web_page.h the controller page, in PROGMEM (~48 KB now, with Pointer Lock) - control_server.{h,cpp} sockets: HTTP routes, WebSocket lifecycle, auth, heartbeat - main.cpp glue: USB identity/HID composite, WiFi, chords, text run, - LED, button, watchdogs, serial console + tls_pem.h pure the TLS identity as strings: PEM armour in both directions, the + fingerprint's hex and the two shapes the console and the status frame + need (colon pairs, short label), the ONE table of names the certificate + is issued for, that table's SAN extension in DER *and the OID that + labels it* (sanOidDer -- DER content bytes, because that is what the + writer takes; the dotted string writes a well-formed certificate with + no SAN), the whole Extension SEQUENCE as it goes on the wire + (sanExtensionFullDer), derContains() and derCarriesSan() for checking a + claim against the certificate itself, and the validity-window arithmetic + over __DATE__ with its human-readable rendering + tls.{h,cpp} the mbed TLS half of the identity: secp256r1 keypair, self-signed + certificate, reparse-and-match check, fingerprint and validity of a + stored certificate, all behind KR_TLS (config.h; 1 is the default, 0 + builds the plaintext rollback). main.cpp generates it on first boot, + keeps it in NVS and prints the fingerprint once + tls_session.{h,cpp} the TLS session: a raw-mbedTLS server session over an accepted fd, + handshake driven one call per tick with a kTlsHandshakeMs deadline, + non-blocking read/write with a strict same-buffer resume contract, + and an explicit slot-cap refusal (alert record, no context built). + Its POLICY half (deadline test, accept table, library-return table, + alert record, write queue, frame ring) is pure and host-tested; the + library half is compiled only where mbed TLS exists (KR_TLS && + ESP_PLATFORM), which is what keeps the suite compiling without it + conn.h pure* the transport view the routing sits on: one byte pipe with two + implementations (a WiFiClient, or a tls::Session) so serveHttp() and + serveViewer() do not know which they are talking to. Queued outbound + bytes (whole frame or nothing), a resumable pump capped per write and + per tick, and a flash body streamed through a bounded RAM chunk -- + which is what lets a ~50 KB page leave across ticks on either + transport instead of in one call that can wait (the plaintext write + helper retries a blocking select ten times at one second each). Its + pure half also holds the HTTP response head, the `:80` answer table + (serve / 301 / 426), the redirect builder that turns a client `Host:` + into a `Location:` only when it is a name this board is reached at, + and the page send cursor (sent/total). `kr::Conn` itself is + ESP-only, like tls::Session; the tables are host-tested + control_server.{h,cpp} sockets: two listeners (:80, and :443 when an identity exists) + feeding ONE controller slot, ONE routing table and ONE transport view; + HTTP routes, WebSocket lifecycle, auth, heartbeat; `serveReadonly()` + is the only HTTP answer for either listener and either port, so the + encrypted and plaintext paths cannot disagree about what is served -- + they differ only in that :80's answer is a redirect or a 426 + web_page.h the controller page, in PROGMEM (~51 KB now, with Pointer Lock + and the pinning banner; one #if KR_TLS build marker, equal-length + branches, assembled and size-pinned by three separate test readers) + main.cpp glue: USB identity/HID composite, WiFi, chords, text run, the TLS + identity's NVS records, LED, button, watchdogs, serial console tools/ to_bootloader.py 1200-baud touch into ROM download mode (stdlib termios) console.py serial console, one command or interactive (stdlib) relayctl.py controller from a terminal: status/arm/disarm/release/cad/key/keys/ - chord/listen/paste/mouse + chord/listen/paste/mouse; pinned TLS by default (--fingerprint / + --ca / --insecure, refused without one), --plain for -D KR_TLS=0 + rollback boards watch_target.py on the target: decode the board's evdev events (keyboard *and* mouse), prove delivery hw_probe.sh what device context exists right now (serial nodes by stat only, mDNS, @@ -621,24 +850,26 @@ tools/ tests/ run_tests.sh builds and runs everything (host g++ or a local container) test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp - test_ws_codec.cpp + test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.cpp test_relayctl.py test_page.js (node, skipped when absent) .agents/ PLAN.md the roadmap and the decisions a phase may not move - validate.sh the gate the pipeline stops on: both suite runs, both firmware builds, - the page byte count, repo hygiene — no device, no upload, no network + validate.sh the gate the pipeline stops on: both suite runs, all three firmware + builds (bridge, console, the KR_TLS=0 rollback), the page byte count, the + numbers README/AGENTS quote, repo hygiene — no device, no upload, no network HW_VERIFICATION.md the device half: every claim only hardware can settle, the command that settles it, and the outcome someone actually observed -AGENTS.md operating manual for whoever edits this next: the invariants, the three - tables that must agree, the build traps, the verification standard +AGENTS.md operating manual for whoever edits this next: the invariants, the tables + that must agree, the build traps, the verification standard TODO.md open items, kept with their root cause rather than deleted ``` If you are an agent or a new contributor, read `AGENTS.md` before changing anything: several rules here are invisible to a compiler and to the test suite at the same time. -The seven modules marked *pure* have no Arduino and no ESP-IDF include, so the identical -code compiles and runs on a host under `-fsanitize=address,undefined`. That is not +The nine modules marked *pure* — plus `conn.h`, marked `pure*` because only its tables are — +have no Arduino and no ESP-IDF include, so the identical code compiles and runs on a host +under `-fsanitize=address,undefined`. That is not convenience for its own sake: the sanitiser caught five real defects in the frame decoder before the first flash (a `reset()` that left the reader latched on the previous frame, an unsequenced `_buf[_got++] = b ^ _mask[_got & 3]`, a missing FIN check that silently @@ -693,10 +924,13 @@ $ for i in /sys/bus/usb/devices/*:1.*/; do echo "$(basename $i) class=$(cat $i/b 7-1:1.2 class=0a sub=00 proto=00 <- CDC-ACM data ``` -Two build environments: `bridge` (the real thing, USB-OTG + TinyUSB) and `console` -(`ARDUINO_USB_MODE=1`: no HID, so it cannot type, but WiFi/page/WebSocket/LED/button all -live — for separating "the network side is broken" from "the HID side is broken" without -rebuilding. Arming refuses with `E5` there, on purpose). +Three build environments, one flag apart. `bridge` is the real thing (USB-OTG + TinyUSB, TLS +on). `console` is `ARDUINO_USB_MODE=1`: no HID, so it cannot type, but WiFi/page/WebSocket/ +LED/button all live — for separating "the network side is broken" from "the HID side is +broken" without rebuilding; arming refuses with `E5` there, on purpose. `rollback` is +`bridge` with `-DKR_TLS=0`: the plaintext pre-phase-02 control channel, kept buildable by the +gate because it is the documented way back from a TLS problem and not a mode a live board can +choose (`src/config.h`, README [The rollback](#the-rollback--building-it-and-what-it-costs)). --- @@ -705,8 +939,9 @@ rebuilding. Arming refuses with `E5` there, on purpose). ```bash tests/run_tests.sh # uses g++ if present, else a local container image SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 bugs) -bash .agents/validate.sh # the gate: both runs above + `bridge` and `console` - # firmware builds + the page's byte count + repo hygiene, +bash .agents/validate.sh # the gate: both runs above + all three firmware builds + # (bridge, console, the KR_TLS=0 rollback) + the page's byte + # count + the numbers README/AGENTS quote + repo hygiene, # and never a device, an upload or a network fetch ``` @@ -715,11 +950,14 @@ bash .agents/validate.sh # the gate: both runs above + `bridge` and `cons | `test_keystate` | 40 | idempotence, transition preservation (a fast tap is two reports), 6-key limit, ErrorRollOver and recovery from it, dedupe, `clear()`, the exact Ctrl+Alt+Del bytes `05 00 4c 00 00 00 00 00` | | `test_mouse` | 48 | the 5-byte report layout, every button bit where the HID spec says it is, idle frames producing **no** report, a button change with no movement still producing one, the button byte being restated rather than evented, `releaseAll` being idempotent, and that no frame can put a value outside one signed byte on the wire | | `test_output` | 153 | **the release funnel.** Queues hold their order and collapse duplicates; releasing with keys held queues an all-keys-up report *when there is a target to receive it*; releasing drops a press that was queued but never sent; releasing with a mouse button held queues all-buttons-up; boot protocol or `mouse off` means no mouse report is queued **but the key release still is**; a pasted run and an in-flight chord are both cancelled; and overflow is detected on both queues. Regression-proven: reinstating the old `releaseEverything()` fails 23 of these | -| `test_proto` | 2327 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, and every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims | +| `test_proto` | 2359 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (322 bytes) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs | | `test_text` | 459 | the whole printable-ASCII map walked (nothing may be unmapped, nothing may claim a key it has no glyph for), shifted glyphs and the digit row, what is refused rather than guessed at, all-or-nothing frame acceptance, pace arithmetic and clamping, the exact press/release report sequence for two characters, overlay on the operator's held keys, ErrorRollOver propagation, cancel, buffer-full, `P=` render/parse round-trip including the largest legal frame, and one end-to-end case that takes the exact frame the page sends and asserts the HID report sequence the target ends up seeing | | `test_ws_codec` | 135 | SHA-1 against published vectors including the million-`a` case, base64 against RFC 4648, the RFC 6455 handshake example, frame encode/decode at the 125/126 and 64-bit boundaries, byte-at-a-time delivery, and rejection of unmasked, fragmented, reserved-bit, binary, over-long and oversized frames | -| `test_relayctl` | 160 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h` | -| `test_page` | 84 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup** | +| `test_tls_pem` | 862 | the identity's strings, walked exhaustively (100 % of `src/tls_pem.h`): PEM armour against an RFC 4648 vector and line-by-line at 0/1/45/46/47/48/49 bytes and every 64-character boundary up to 704, the padded final group terminating its own line, a refusal (writing nothing) one byte short of `cap`, the armour read back through CRLF / missing-newline / stray-space shapes and torn or re-labelled blocks rejected rather than decoded, the fingerprint against the published SHA-256 of `abc` and refused at one byte short, **the two renderings of it the board actually prints** (the status frame's 16-character label, and the console's colon pairs -- rejoining the two printed lines reproduces the digest exactly, each line is short enough for the 128-byte CDC transmit ring, and a refusal writes nothing rather than half a fingerprint), **`derContains`** deciding whether a stored certificate really carries the SAN bytes this build writes (one changed byte anywhere, in the needle or in the certificate, stops matching), the name table pinned to `kHostname`/`kApGw`/`kCertCommonName` with the CN asserted to be `kHostname + ".local"`, the SAN extension's 34 DER bytes (cross-checked byte-for-byte against what OpenSSL writes for the same three names), **the OID that turns those bytes into a `subjectAltName`** (`06 03 55 1D 11`, pinned as DER content bytes with an explicit check that no byte of the dotted text survives into what the writer is handed, and the whole 43-byte `SEQUENCE { OID, OCTET STRING }` pinned against the certificate OpenSSL issues for the same three names), and **`derCarriesSan`** answering no to the exact extension a live board produced before that was fixed -- the right value bytes under a mislabelled OID, which `derContains` still calls a match and no client does -- plus one mutated OID byte, value-without-OID, OID-without-value, and a haystack cut one byte short of carrying it, strict dotted-quad parsing, `__DATE__` with its padded day, the leap-day + 10 years clamp, `utcTime` refusing every component UTCTime cannot carry, and `dateTimeText` rendering a validity bound for `cert` -- same component checks, deliberately without UTCTime's 1950..2049 window because reading a date is not the permission to write one | +| `test_tls_session` | 118 | the TLS session's policy layer, walked without mbed TLS (100 % of the pure half of `src/tls_session.h`): the handshake/stall deadline in its wrap-safe `int32_t(now - start) > limit` form including the 49.7-day `millis()` rollover and the exact-boundary case, the slot-admission table (controller, spare, refused) with the cap checked before the free slot and *no transport parameter to consult* -- the third connection is refused the same way whichever listener it knocked on -- `kTlsMaxSessions` pinned to the 2 the RAM arithmetic in `config.h` is written against, the library-return table that decides Bytes / Want / Closed / Error (a WANT misread as fatal drops working controllers on slow WiFi; a fatal misread as WANT spins the loop; the three error-code literals it branches on are pinned against the real mbed TLS by `static_assert` inside `tls_session.cpp`, so neither half can drift silently), the 7-byte fatal-alert record byte-for-byte against RFC 5246 -- including the description byte drafted once as `0x40 because forty`, which is the entire reason a wire format gets its own test, never confidence -- and the frame ring's whole-frame-or-nothing push, wrap-span arithmetic and stable head (which is what makes re-handing identical bytes after a refused write possible at all), its default-constructed / unseated state -- cap coerced to 1, nothing accepted, no modulo by zero on the first tick before any socket exists -- and `reseat()`, because `kr::Conn` binds to a different transport between sockets and a byte left behind would be delivered to the new occupant. Task 04 deleted its `WriteQueue` (and with it the queue checks): the transport view queues head and body through the ring plus `kr::FlashSend` instead, so a second streaming cursor here would be exactly the second path this phase exists to remove | +| `test_conn` | 268 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 51 626 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 36 chunks / 9 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them | +| `test_relayctl` | 271 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested | +| `test_page` | 113 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing** | The verifier is verified too, because twice now it was the verifier that was wrong: node classification (EV_REL means a pointing device — not the device's name, not the order the @@ -727,6 +965,18 @@ glob found them in), movement coalescing one report into one line, net displacem arithmetic, hi-res wheel events not counted as extra notches, and a stuck button being called out in those words. +A second pinned section, **`one routing path, two transports`**, does for the transport +split what the fail-safe pins do for the release funnel: it asserts sentences no host test +can execute and no compiler can see -- that `readSome()` stamps the controller-silence clock +only for `_phase == WebSocket && _authed` (so a TLS handshake, a `/status` poll and a viewer +refresh cannot hold a dead controller's armed state open), that the read-only slot never +goes through `readSome()` at all, that exactly one formatter in `src/` writes an HTTP status +line, that `serveReadonly()` is reached from both slots and defined once, that the `:80` +table is consulted in exactly one place, and that the page's `wss://`-or-rollback-`ws://` +choice exists exactly once. It also pins the served page's byte count against the constant `test_conn.cpp` drains +through the chunk arithmetic -- the same *test the table, not your confidence in it* rule as +the mouse bits and the evdev codes. + Five checks in `test_relayctl`'s `the fail-safe wiring` section are **source pins**, and the suite says so out loud: they assert that `loop()`'s silence disarm runs off `controllerSeen()` rather than `linked()` (and that nothing else gates it on the link still @@ -739,9 +989,36 @@ stamping in `acceptNew()` fails 1, reinstating the bug as it was fails 3. Gaps, stated plainly: nothing here exercises real sockets, real WiFi associations, or real USB transactions — those are the [end-to-end procedure](#end-to-end-verification) below. -The HTTP routing and auth-lifecycle code in `control_server.cpp` is the least-tested part -of the project; it is deliberately the thinnest layer, but it is not covered by a unit -test. +`control_server.cpp` is still the least-executed part of the project, deliberately the +thinnest layer, and task 04 narrowed what that means: the transport decision, the `:80` +answer table, the redirect construction, the response head and the page send cursor used to +be lines in front of `WiFi.h` that only a device could prove, and they now live in `conn.h` +where `test_conn` walks them — what remains untestable there is the socket plumbing itself +and the auth *lifecycle* (the ordering of accept → handshake → upgrade → `T=` → close), +which is why the sentences around it are pinned as source assertions. What it *used* to +render is: the status frame moved to `src/status.h` so that +`test_proto` can build one and measure it, which it could not do while the code sat behind +`#include ` — the frame's field list, its sanitising and its buffer arithmetic were +previously verifiable only on a device. `src/tls.cpp` and the session half of +`src/tls_session.cpp` are the same kind of gap on purpose: they are sequences of mbed TLS +calls whose failure mode is an error code, so they are proven by the two firmware builds +(the symbols resolve, both environments link) and by the device checks in +`.agents/HW_VERIFICATION.md`, not by a host suite that would have to fake the library to +run -- which is also exactly why every decision those calls make (deadlines, admission, +return-code meaning, buffer-resume bookkeeping) was pushed out of them into pure, +host-tested helpers in `tls_pem.h` and `tls_session.h`. The one thing a host build cannot establish — that a section nothing yet calls still +has every mbed TLS reference it needs — was settled by a throwaway link with `-Wl,-u,` +forcing those three functions to survive garbage collection. + +The gate also checks two things in the other direction, because a build that succeeds proves +nothing about a build that is supposed to *disappear*: `-e rollback` has to link (`-D KR_TLS=0` +is the documented way back, so it is built on every run), and the image it produces carries no +`mbedtls_ssl_*` symbol at all — 0 against 115 in `bridge`, by `nm`, quoted under +[Measured behaviour](#measured-behaviour). And since this README is the source of truth for +behaviour, the gate compares the numbers it quotes against the commands that produce them: the +check count in this table, the page's byte count, a Backlog numbered 1..N with nothing closed +left inside it, and the rule that any line naming a plaintext `ws://` must name the rollback +build on that very line, so no sentence can be read as if plaintext were the default. --- @@ -756,6 +1033,7 @@ python3 tools/watch_target.py --seconds 60 # 2. on the laptop: drive it from the terminal export KEYRELAY_TOKEN='the-passphrase-you-set' +export KEYRELAY_TLS_FP='' # the pin; --fingerprint works too python3 tools/relayctl.py --host keyrelay.local status python3 tools/relayctl.py --host keyrelay.local arm python3 tools/relayctl.py --host keyrelay.local key Enter @@ -825,7 +1103,11 @@ the stream, and give `--seconds` enough room for the pace (`2048` characters at _On real hardware: AtomS3 Lite → this laptop (Linux, US layout), board on the same WiFi, firmware 1.0.0, `bridge` environment._ Fill in / re-run after any change to the keystroke -path; the numbers below are the shape a correct run has. +path; the numbers below are the shape a correct run has. (Those frames predate the TLS +identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and `cert` +exists and has been confirmed against the live handshake — the pinning rows below — but +the status fields themselves and the keystroke path *over a pinned session* need an +authenticated controller, and the board's passphrase is the operator's, not this repo's.) ``` $ python3 tools/console.py status @@ -853,12 +1135,18 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0. | what | result | |---|---| -| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified | -| footprint | RAM 26.7 % (87 592 / 327 680); Flash 36.1 % (1 135 293 / 3 145 728) of the 3 MB app slot — the pair `.agents/validate.sh` prints on every run, re-measured 2026-11-09 | +| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the phase-02 image is 1 246 145 B (see the footprint row below), so the same math applies to a body ~10 % larger | +| footprint | `bridge` with TLS on: **RAM 30.4 % (99 616 / 327 680); Flash 39.6 % (1 246 145 / 3 145 728)** of the 3 MB app slot, `console` 20.5 % (67 224 B) / 37.1 % (1 166 397 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-02 final pass on 2026-11-09. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phase 02 — identity, listener, transport view, the pinning page, the SAN fix — cost **+12 024 B of RAM and +110 852 B of flash**, 3.5 % more of the app slot. The RAM figure has not moved since task 06 even though the final pass added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions | +| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) — unchanged by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 160 B of RAM and +100 008 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 | +| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page (36 chunks over 9 `loop()` ticks) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start | +| **TLS session heap** | the board's own `[tls] sessions=N heap=…` serial lines: boot `[tls] https :443 listening heap=187936` → **1 session ≈ 145.5 k** → **2 sessions ≈ 107.9 k**; the task-04 run with the transport view on both ports read 140 092 → 109 228 → close one 144 836 → close all 182 256. A second session is ~31–38 kB (two 16 kB mbed TLS record buffers plus the context), the memory comes back, and 20 connect/disconnect cycles showed no downward drift. This is why `kTlsMaxSessions` is 2 and why a third connection is refused outright rather than queued | | enumeration | `class=03 sub=01 proto=01` — boot-protocol keyboard, the Windows secure-attention precondition | | reflashing | 1200-baud touch into ROM download mode works with this firmware running, so no button and no GPIO0 | -| page delivery | `GET /` → 200 and **48 598 bytes** — byte-for-byte the count the gate prints, re-measured 2026-11-09 with one capped `curl`; zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS (the first cold probe can miss — glibc does not cache mDNS, so retry) | -| arming from the browser | the page armed the board over `ws://` on first try (observed as `controller=yes armed=1`) | +| page delivery | `GET /` over TLS → 200 and **51 626 bytes**, `sha256` equal to the locally **assembled** page from `src/web_page.h` (raw-string chunks + the 19-byte KR_TLS=1 marker; `dd16e195…f04e9`) — re-measured 2026-11-09 after the pinning-banner page change — so the resumable, chunked flash send (1460 B per write, 5840 B per tick) delivers the page intact across many `loop()` iterations; the served bytes carry `var KR_TLS_BUILD=1;` and the new `certFp`/`pTls`/`clearWarn` elements; zero external references; 404 on unknown paths; 401 on `/status` without the passphrase | +| **pinning from the terminal** | `relayctl.py --fingerprint 00…00` aborts with `certificate fingerprint mismatch … Nothing was sent` — the presented digest (`61:54:19:30:ed:df:…`, same identity the serial `cert` printed, across reflashes) and *zero* wire bytes; the real fingerprint passes the pin and stops only at authentication (`E1`, operator's passphrase — typing over a pinned session is the operator's confirmation to make); `--ca ` verifies with hostname checking on; `--insecure` prints its warning on every run; no trust argument → the four-option refusal, exit 2, no socket; `--plain` reads the board's 426 as "board refuses plaintext", never as a passphrase problem — measured 2026-11-09, one capped run each | +| `:80` is redirect-only | `GET /` → `301` with `Location: https:///`; `/index.html` → `301` to the same path over `https://`; `/nope` → `301` to `/` (an unknown target is a 404 on the encrypted port, and the redirect does not echo an arbitrary request target); `GET /status?token=…` → **426** with `Upgrade: TLS/1.2, HTTP/1.1` and **no `Location`**, body free of the token (`grep -c` of the value in the body: 0); `/ws` upgrade → **426**, refused rather than downgraded — measured 2026-11-09, one capped `curl` each | +| controller over `wss://` | upgrade → `101` with the correct `Sec-WebSocket-Accept`, TLSv1.2 / `ECDHE-ECDSA-AES256-GCM-SHA384`; a wrong passphrase over that session → `E1:wrong passphrase` then close, never a silent pass; while that slot was open a second TLS connection fetched the page (200) and `/status` (401) — measured 2026-11-09 | +| arming from the browser | the page armed the board on first try (observed as `controller=yes armed=1`); pre-TLS runs used `ws://`, phase 02+ runs use `wss://` | | keystroke delivery | 6/6 inert keys arrived on the target with matched press **and** release: F13, F15, F17, ScrollLock, CapsLock ×2 | | chord path | `chords=1` counted and delivered (Shift+F15) | | LED read-back | `LED CapsLock on` then `off`, coming from the *target's* HID output reports | @@ -874,7 +1162,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0. | lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop | | queues | `drop=0 roll=0` throughout; `queue 0 keys pending, 0 held, ok` at rest | -### Ten bugs this verification caught (all fixed) +### Eleven bugs this verification caught (all fixed) Worth listing, because each one is a class of bug that HID-over-network tools ship with: @@ -963,6 +1251,42 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh source — five checks — and reinstating the `linked()` gate fails 2 of them, the accept-time stamp fails 1, the bug as it stood fails 3. +11. **The certificate carried its names under the wrong OID, so no client could read them.** + `mbedtls_x509write_crt_set_extension()` takes an extension OID as the **content bytes of + the DER OBJECT IDENTIFIER**, and this tree handed it the dotted string `"2.5.29.17"`. The + library said nothing, because nine bytes of ASCII *are* a well-formed OID — + `1.10.46.53.46.50.57.46.49.55` — so the board issued a certificate with the correct + 34-byte GeneralNames under a label nobody recognises. That is not a broken certificate: the + key is fine, the handshake completes, the page is served, the fingerprint pins. It is a + certificate with **no `subjectAltName`**, and browsers stopped matching hostnames on the CN + years ago, so the name check fails with a message about the *name* while every part of the + board insists the names are correct. + + It was found by reading the certificate the live board was serving instead of the code that + writes it: `openssl x509 -text` on the peer certificate printed + `1.10.46.53.46.50.57.46.49.55` where `X509v3 Subject Alternative Name` belongs, `-ext + subjectAltName` answered *no extensions*, and a hex dump showed `06 09 32 2e 35 2e 32 39 + 2e 31 37` — the literal text — where `06 03 55 1d 11` should have been. The host suite + could not have caught it as written, because every check in it walked the **value** and the + name table, never the label. Same shape as the verifier's LED table (bug 4) and its + `REL_WHEEL` axis code: a table cross-checked against itself, green the whole time — which + is why AGENTS.md says to test the table, not your confidence in it. + + Fixed where the other DER lives, so it is host-tested: `kSanOidTlv` / `sanOidDer()` are the + bytes the writer takes, `sanExtensionFullDer()` is the whole `SEQUENCE { OID, OCTET STRING }` + pinned to the 43 bytes OpenSSL writes for the same three names, and `derCarriesSan()` asks + the certificate the question a client asks — so `cert` now refuses to claim names a stored + certificate does not carry. The regression test embeds the pre-fix extension byte for byte: + `derContains` says the value is there and `derCarriesSan` says the extension is not, which + is the difference between those two functions and the reason both exist. + + A board reflashed with the fix **keeps its old certificate** — NVS survives reflashing and + rotating an identity is the operator's decision, not the build's — so `cert` prints a + warning and the remedy is `nvs erase`, which reissues it, prints the new fingerprint once, + and also forgets the passphrase and the network. Two rules out of it: an API that accepts a + wrong-but-parseable argument is undocumented until it has bitten you, and a wire format has + to be checked against **somebody else's** implementation, not against your own encoder. + ### The hazard nobody warns you about **When the target is the machine you are working on, your keystrokes land on your own @@ -984,9 +1308,12 @@ way. So: | symptom | cause, and what to do | |---|---| -| the page does not have a feature the README describes | **the page is served from the board's flash**, so the image you built is not the image on the device. `curl -s http://keyrelay.local/ \| wc -c` and compare with the page in `src/web_page.h`; if they differ, reflash (`to_bootloader.py` then upload) and *reload the tab* — one already open still holds the old page in memory, passphrase notwithstanding | +| the page does not have a feature the README describes | **the page is served from the board's flash**, so the image you built is not the image on the device. `curl -sk https://keyrelay.local/ \| wc -c` and compare with the page in `src/web_page.h`; if they differ, reflash (`to_bootloader.py` then upload) and *reload the tab* — one already open still holds the old page in memory, passphrase notwithstanding | | `pio run -t upload` sits at `Connecting…` | the app owns the USB pads. `python3 tools/to_bootloader.py`, or hold the button 3 s while powering up (LED solid red) | | page will not connect | wrong address: `console.py status` for the real `ip=`; `net:"ap"` means you must join `keyrelay-ctl` | +| the browser says `SSL_ERROR_…`, `curl` says `certificate verify failed`, or `relayctl.py` says `certificate fingerprint mismatch` | the board regenerated its identity (`nvs erase` — the boot log prints the new fingerprint once, loudly) **or you are on the wrong network and a different device is answering**. That is exactly the attack the fingerprint is for, in those words: re-read `cert` on the board's *own serial console* — where nobody on a network can fake it — before typing the passphrase anywhere again. A browser that shows a *second* acceptance prompt after you already trusted it is the same signal | +| TLS itself is the thing that is broken and you need the board **now** | the way back is a reflash, not a repair: `pio run -e rollback` and upload it (that is `[env:bridge]` with `-DKR_TLS=0`), which is the pre-phase-02 build — page and socket on `:80` in plaintext, no `:443`, booting with `[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel` so there is no doubt which board is answering. `relayctl.py` needs `--plain` to talk to one. Everything in [Security model](#security-model-and-trust-boundaries) is off while you run it; nothing about arming changes. Details and the measured cost: [The rollback](#the-rollback--building-it-and-what-it-costs) | +| the page says *"this page arrived in the clear; reload over https"* and will not take a passphrase | you loaded `http://…`. A TLS board answers :80 with a redirect and never serves the page there, so those bytes came from a cache, a downgrade attempt, or somebody else's server. Open `https://keyrelay.local` (or `https://`) and confirm the fingerprint the Connection card shows | | `keyrelay.local` does not resolve | mDNS is queried per-process with no cache on glibc (`mdns4_minimal [NOTFOUND=return]`), so a cold probe that misses returns "not known" immediately — **just retry**, or use the IP. Across VLANs/VPN it is often blocked outright | | `arm` does nothing, LED blinks red | no USB host attached (`usb:0`); the board is not plugged into the target, or the target suspended the bus | | typing does nothing, `armed` says yes | the tab lost focus: click into the page. Or the target's focus moved — that is the target's answer, not a bug | @@ -999,6 +1326,9 @@ way. So: | a paste stops before the end | something cancelled it, on purpose: the tab lost focus, the bridge disarmed, the target suspended the bus, `E7`/`E8`, or 8 s with nothing inbound from the controller. The page's log says which, and how far it got | | a chord button does something other than its label | the bytes on the button are wrong — the page carries the wire form itself, so nothing browser-side can catch it. `curl` the page and read `data-chord`; this was the case for Alt+Tab (`05:04`, Ctrl+Alt+A) until the label-vs-bytes test was written | | a key is stuck on the target | `R` / **Release keys** / the button. Then check whether the tab lost focus without the page noticing (a hidden tab still gets blur on most browsers) | +| the boot log printed `!! THE STORED TLS IDENTITY IS UNUSABLE`, or `cert` says there is none | NVS holds a record that does not check out: a write lost to a power cut, an `nvs erase`, a key from one generation beside a certificate from another. The board regenerates and says so, once, before printing the new fingerprint -- **that is the moment to re-pin**. Nothing about arming changed either way: keystrokes are gated on arming, never on TLS | +| `cert` warns that the stored certificate does not carry the name list it prints | the certificate in NVS was issued by a different build than the one running now: the names come from the one table in `src/tls_pem.h` (`sanEntries`), and `cert` searches the certificate's own bytes for that extension, OID included. The usual reason is **an identity generated before bug 11 was fixed** — that certificate has the names in it under an OID no client reads, so encryption and pinning work and the hostname check cannot, and a browser complains about the *name* rather than about the extension. It is not dangerous and it is not a rotation: the fingerprint above that line still identifies the board. `nvs erase` reissues it (new fingerprint — re-pin — and it also forgets the token and the Wi-Fi credentials), which is the operator's call, not the board's | +| the fingerprint changes on every boot | NVS is refusing or losing the identity records. The boot log says `IDENTITY NOT STORED` when it happened; check the partition table (`huge_app.csv`: NVS is 20 KB and already holds the SSID, the passphrase and the token) | | Ctrl+Alt+Del does nothing on the target | on Linux this is normal on a graphical session and *fatal* on a VT; on Windows the interface must be boot protocol (confirmed above) | | target keeps suspending the bus | the target suspends, which disarms by design; disable suspend or plug into a port that stays powered | @@ -1040,15 +1370,75 @@ A library buys fragmentation, extensions, TLS plumbing and many-client handling surface and heap, none of it wanted. The hand-rolled codec is ~200 lines of pure logic with 135 host checks behind it, and every deviation from the subset is a hard close. -**Why HTTP/WS without TLS.** See [Security model](#security-model-and-trust-boundaries). -The honest options in the backlog are a user-installed CA (works, annoying) or a -client-side proxy that terminates TLS with a pinned key. Pretending confidentiality here -would be worse than documenting its absence. +**Why TLS rather than a challenge–response over the passphrase.** The original ask was "doesn't +need to be proper x509, but something", and the cheapest something is a nonce and an HMAC: the +passphrase never appears on the wire, and there is no certificate, no warning screen, no +fingerprint, no NVS record. It was rejected because hiding the secret is not the same as +protecting the session. Under a challenge–response the keystrokes, the pasted text and the +pointer deltas stay readable, and the page that collects them is still served in the clear — +which anybody on the path can rewrite, at which point the attacker does not need the passphrase +at all, only one keystroke from a page they wrote in a browser that already believes this is the +keyboard bridge. Encrypting the *channel* is the option that closes that, and the only one that +makes the trust-boundary table in [Security model](#security-model-and-trust-boundaries) true +rather than aspirational. The cost is stated rather than hidden: about 99 kB of flash and 6 kB +of RAM, a handshake measured at ~0.35 s per new connection, and a certificate every browser +complains about until you look at it once. + +**Why the identity is generated on the board rather than shipped as a PEM.** Three reasons, in +order of how much they matter. A key in this repository is one key for every board ever built, +so reading the repo would buy a position between every deployment — and a per-user key generated +by a build script makes the first `pio run` fail for anyone who has not run the script. Second, +a private key is where this project's "never in a file, a log or a URL" rule gets teeth, and +`.agents/validate.sh` refuses tracked `*.pem`, `*.key` and `*.crt` for exactly that reason; a +key that exists only in NVS cannot be committed by accident, printed by a diagnostic, or shipped +in a release. Third, per-board keys are what make pinning mean something: with a shared key any +other board answers for yours and the fingerprint you checked identifies a *model*, not the +device in your hand. So the board makes one on first boot, stores it in NVS (`tlscrt`, `tlskey`, +`tlscrtfp`), keeps it across reflashing because NVS does, and regenerates it only when NVS is +erased — printing the new fingerprint, loudly, because a rotated identity that stays quiet is a +pinned value that stopped meaning anything. Where it lives is a separate decision, below. + +**Why raw mbed TLS rather than `esp_tls`.** `esp_tls.h` was read before choosing, and its server +entry point `esp_tls_server_session_create()` is documented to create the context **and perform +the handshake** inside the call; the asynchronous pair (`esp_tls_server_session_init` + +`_continue_async`) still takes ownership of the session and its teardown, layers its own +error-code translation over mbed TLS's, and keeps its own fd bookkeeping. `loop()` needs the +opposite on all three counts: one library call per tick, an fd whose lifetime is exactly the +`Session` object's so it is closed on every path including `open()`'s failures, and `WANT_READ` +distinguished from a real failure by the same table the read and write paths use (misread a +`WANT` as fatal and slow WiFi drops working controllers; misread a fatal as `WANT` and the loop +spins). `esp_tls` remains the right tool for a *client*; for a server that must not block a +keyboard it is a wrapper whose convenience is the obstacle. The reasoning is in +`src/tls_session.h`, not in a diff, so nobody "simplifies" it back. + +**Why a `token`-carrying request on `:80` is refused with 426 instead of redirected.** Because +the obvious redirect *is* the leak: a `301` whose `Location:` echoes `?token=…` puts the +passphrase on the plaintext wire twice — once in the request the attacker already sees, once in +the header written for their benefit — and it trains everyone who reads a `curl -v` to type the +secret into an `http://` address bar. So a plaintext `/ws` upgrade and any request carrying +`?token=` get `426 Upgrade Required` with a one-line body that names `https://` and contains no +value (checked on the device: `grep -c` of the token in that body is 0), and everything else on +that port gets the `301` it can safely have. `AGENTS.md` keeps the token a query parameter on +`/status` because `curl` is the point of that endpoint — this is what makes that acceptable +without the plaintext path becoming leakier than the encrypted one. + +**Why the rollback is a build, not a setting.** A runtime switch for "accept keystrokes in +plaintext" is a value the board could be talked into, and one that a future operator would leave +on because turning it off costs a reboot. So the way back is `-D KR_TLS=0` — `pio run -e +rollback`, a reflash, same standing as `mouse off`, and built by the gate on every run so the +reversal cannot rot. Nothing about arming moves with the flag: it decides which port carries +bytes, never who may type them. **Why NVS for configuration.** So the passphrase and network live in the board rather than in a header you recompile, and survive reflashing — and so a passphrase never has to enter a shell history, a git diff or a build log. The `token` command deliberately does not echo -it back. +it back. The TLS identity lives in the same namespace (`tlscrt`, `tlskey`, `tlscrtfp`) for +the same reason: reflashing must not change which certificate the operator pinned. Storing +the *fingerprint* alongside it, rather than rehashing the certificate at every boot, is a +trivial cost on a device with no PSRAM and no clock, and the value is public — the thing that +is secret is the key, and the only outputs that touch it are `mbedtls_pk_write_key_pem` into +NVS and the listener that will read it back. Nothing in `src/` prints it, and the serial +console has no command that could ask it to. **Why the sibling project's USB identity.** Two firmwares on one board that are indistinguishable from the host side, and stable `/dev/input/by-id` names for the tools. @@ -1067,33 +1457,43 @@ Ordered by what would make the biggest difference first. "transparently drive another machine". Firmware unchanged — the protocol was designed for it. Needs root or `input` group + a `udev` rule; documented as opt-in because it swallows the laptop's keyboard while running. -2. **TLS with a pinned certificate** (WSS): either an on-board CA the laptop trusts once, - or a `localhost`-terminating proxy. Until then: trusted network or the board's AP. -3. **A second, independent key path for the panic release**: a GPIO or button +2. **A second, independent key path for the panic release**: a GPIO or button double-click that force-releases without waiting for the loop's disarm path, so the panic release does not depend on the code that just failed. -4. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or +3. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or hostile client cannot pump the queue into the overflow-disarm path as a nuisance attack (currently it disarms: safe, but noisy), and a cap on page fetches now that the - page is served even while a controller is live (each is ~48 KB, and `no-store` means a + page is served even while a controller is live (each is ~50 KB, and `no-store` means a determined client will ask for it again and again). -5. **mDNS also on the AP interface**, verified on each chipset — some builds answer only +4. **mDNS also on the AP interface**, verified on each chipset — some builds answer only on the station interface, which is why the docs say "IP if the name fails". -6. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot, +5. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot, instead of one shared constant in `config.h`. -7. **Report-rate telemetry**: measure the achieved report interval against the host's +6. **Report-rate telemetry**: measure the achieved report interval against the host's poll rate, so the queue-depth choice is measured rather than reasoned. -8. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that +7. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that wants to send more has to split the job itself. The flow-control hook already exists — `typing` in the status frame is the board's own remaining count — so what is missing is only a client policy that refills as the board drains, plus the `E8` retry that goes with it. Deliberately not done yet: unbounded buffering on a keyboard bridge buys nothing and enlarges the window in which text sits in RAM waiting to be typed. -9. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table +8. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table for characters; positions live in `web_page.h`/`relayctl.py`. A second layout means a second character table *and* a second `CODE_TO_USAGE`, with the existing refusal semantics intact (no entry → `E9`, never a guess). Until then the answer is "set the target to US", which is one line on the target and zero bytes on the board. +9. **A certificate a browser already trusts.** The channel is encrypted, the fingerprint is + pinnable and `relayctl.py` proves the pin; what is still manual is the trust itself — one + warning accepted per browser profile, or 64 hex digits compared against the board's serial + `cert`. The candidates are a **per-board CA** whose root the operator installs once (real + chain validation, and a file to carry onto every controller that will ever connect) or + **ACME against a real hostname** when the board has one (no warning at all, and it needs + DNS this project does not control). Deliberately absent: a shared CA shipped in this + repository — one key for every board ever built, which is the thing phase 02 moved + generation onto the device to avoid. So are session tickets and resumption, on the blunter + grounds that a board with no PSRAM and one controller is not paying a handshake cost worth + optimising (measured: ~0.35 s per new connection, see + [Measured behaviour](#measured-behaviour)). --- diff --git a/platformio.ini b/platformio.ini index e124770..1a8282c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -50,6 +50,12 @@ lib_deps = build_flags = ${usb.fs_include} -DARDUINO_USB_MODE=0 + ; TLS on: the page and the WebSocket are served over HTTPS/wss with an identity the + ; board generated itself (src/tls.h). The rollback value is KR_TLS=0 -- plain http:// + ; and ws:// only, exactly the pre-phase-02 behaviour, same standing as `mouse off`. + ; It is a compile-time switch because a device that cannot be reached over TLS after a + ; bad provisioning change needs a way back that is a reflash, not a repair. + -DKR_TLS=1 ; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1, ; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the ; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every @@ -79,3 +85,31 @@ build_flags = ${usb.fs_include} -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 + ; Same TLS default as bridge: the control plane is what this environment exists to + ; diagnose, so it must behave identically. Roll back with -DKR_TLS=0 (see env:rollback). + -DKR_TLS=1 + +; --------------------------------------------------------------------------- +; Rollback: the bridge with TLS compiled out, i.e. the pre-phase-02 build -- plain +; http:// page and ws:// socket on :80, no :443, no identity in NVS. PLAN.md locks this +; in as the way back from a provisioning change that left a board unreachable over TLS: +; the fix is a reflash, not a repair, and a way back only counts if it still builds. +; That is why it is an environment and not a remembered command line: `pio run` has no +; --build-flags option in this PlatformIO, and a second -DKR_TLS on the command line +; beside the -DKR_TLS=1 above is a macro redefinition whose winner depends on flag order. +; Unflagging the default and setting 0 is the only spelling with no ambiguity -- and +; .agents/validate.sh builds this env on every run, so the rollback cannot quietly rot. +; +; Nothing about arming moves here: KR_TLS changes which port carries bytes, never who may +; type (src/config.h, README "The rollback"). The page this image serves is the KR_TLS=0 +; branch of the marker in src/web_page.h; it is the only build where a plaintext ws:// +; session is the normal case, and the only one the README permits it in. +; --------------------------------------------------------------------------- +[env:rollback] +extends = env:bridge +build_flags = + ${env:bridge.build_flags} + -DKR_TLS=0 +build_unflags = + ${env:bridge.build_unflags} + -DKR_TLS=1 diff --git a/src/config.h b/src/config.h index d5441f0..8500bec 100644 --- a/src/config.h +++ b/src/config.h @@ -7,7 +7,11 @@ * * Nothing secret belongs in this file. The default passphrase is deliberately * weak and loudly announced as such on the serial console and in the web page, - * because a keyboard that anyone on the LAN can arm is an open door. + * because a keyboard that anyone on the LAN can arm is an open door. The TLS + * private key is the extreme case of the same rule: it is not here, not in a + * PEM in the tree, not in a build flag -- it is generated on the board and kept + * in NVS (src/tls.h). Only the *public* facts about the identity, the names it + * is issued for and how long it lasts, are below. */ #pragma once @@ -24,7 +28,16 @@ constexpr const char kFirmwareVersion[] = "1.0.0"; // --------------------------------------------------------------------------- // Control plane only: the keystrokes travel WiFi -> board -> USB. The target // machine provides nothing but 5 V and a USB port, and never sees a network. +// +// One number with two names, because in a KR_TLS build the socket on it has one job left: +// telling people where to go. `kHttpPort` is the port (80, what a browser dials when the +// operator types a bare name); `kHttpRedirectPort` is what this board does with it -- 301 +// to https://, and 426 to anything that must not be answered in plaintext (a `/ws` upgrade, +// a request carrying `?token=`). They are the same socket by design: two listeners on 80 +// would be two answers that can disagree. In the `-D KR_TLS=0` rollback the port carries +// content again and the second name is only the honest description of what it used to be. constexpr uint16_t kHttpPort = 80; +constexpr uint16_t kHttpRedirectPort = kHttpPort; constexpr char kHostname[] = "keyrelay"; // http://keyrelay.local // Fallback access point, started whenever the station interface is not up, so @@ -46,6 +59,106 @@ constexpr char kApGw[] = "192.168.4.1"; constexpr uint32_t kStaTimeoutMs = 8000; // give the association attempt this long at boot constexpr uint32_t kStaRetryMs = 20000; // ... then retry in the background at this rate +// --------------------------------------------------------------------------- +// TLS: the encrypted control channel +// --------------------------------------------------------------------------- +// The board serves the page and the WebSocket over TLS, with an identity it generated +// itself at first boot (src/tls.h). One switch, one home: +// +// KR_TLS=1 the default. HTTPS on 443, wss://, and :80 as a redirect only. +// KR_TLS=0 the rollback. The pre-TLS build: plain http:// and ws:// only, exactly the +// behaviour the README described before phase 02. Same standing as `mouse +// off`: a way back that is a reflash, not a repair. +// +// Nothing about arming changes with this flag. It moves bytes, not permissions. +#ifndef KR_TLS +#define KR_TLS 1 +#endif + +// The name the certificate is issued to, and the name every client connects to. It is +// kHostname plus the mDNS domain -- tests/test_tls_pem.cpp asserts that, because a +// certificate for a name nobody types is a warning screen, and a CN that drifted from +// the hostname the page tells you to use is the same failure wearing a different hat. +constexpr char kCertCommonName[] = "keyrelay.local"; + +// How long the self-signed certificate stays valid. Ten years outlives the firmware, +// the browser support and probably the project; re-generating an identity means the +// operator re-pins a fingerprint, so doing it on a timer would be a support burden with +// no security gain -- the key never leaves the board and there is no revocation path to +// use. An NVS erase regenerates it, and that is the documented rotation. +constexpr int kCertValidityYears = 10; + +// The encrypted listener's port. Until task 04 makes :80 redirect-only, both serve the +// page; the controller slot is shared, so a TLS controller and a plaintext controller can +// never both hold the keyboard no matter which port they arrived on. +constexpr uint16_t kHttpsPort = 443; + +// A socket that opens on 443 and never finishes its handshake is the same kind of scan as +// one that opens on :80 and never sends `T` -- it gets the same treatment: closed, +// not waited on. Ten seconds is generous for an ECDHE-ECDSA handshake on this core; the +// deadline is checked once per tick inside Session::poll(), so a stalled handshake cannot +// cost more than one library call that already returned WANT_READ. +constexpr uint32_t kTlsHandshakeMs = 10000; + +// Live sessions at most: the controller's, and one spare -- 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 (a TLS alert record, then close, plus a +// counter); never queued, never half-opened. +// +// This is a RAM decision, not a policy one, and the arithmetic is the core's: +// CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN is 16384 in this core's sdkconfig (verified with grep +// against `framework-arduinoespressif32-esp32s3-libs/*/include/sdkconfig.h`), so one +// session's mbed TLS I/O buffers are ~2x16 KB once it has read and written -- roughly +// 64 KB of heap at the full two sessions. mbedtls_ssl_conf_max_frag_len(4096) was NOT +// applied: the measured free heap with two live sessions (see .agents/HW_VERIFICATION.md, +// phase 02 task 03) fits with margin, so the smaller-fragments saving is not worth the +// smaller record ceiling on the 48 KB page. Re-measure before changing that answer. +constexpr size_t kTlsMaxSessions = 2; + +// Bytes staged between the server's writes and a transport that may not accept one this +// tick. A frame is pushed whole or dropped whole -- the ring is what keeps +// `mbedtls_ssl_write`'s "call again with the same data" contract satisfiable while the +// producer keeps running, and it is the same ring on the plaintext side now, where it fixes +// a worse bug: NetworkClient::write() can return a *partial* write, and the code that +// ignored the count used to truncate a WebSocket frame in half and desynchronise the stream. +// +// Sized for the largest single thing the controller slot writes: a status frame plus its +// WebSocket header (kStatusJsonCap + 16 = 400), with room for two of them plus an HTTP head +// so a heartbeat and a response can be queued in the same tick. Measured, not guessed: the +// page never enters this ring (it is a flash body, see kPageChunkBytes below). +constexpr size_t kConnTxCap = 2048; + +// One transport write is one chunk: for TLS it is one record, below the 16 KB content limit +// of this core, so the library never fragments and a call either accepts the whole chunk or +// returns WANT_* with nothing consumed and the retry hands back byte-identical arguments. +// The plaintext side takes the same cap because it is the stricter of the two rules and one +// number is one fewer way for the transports to drift. +constexpr size_t kConnWriteChunk = 4096; + +// How much of a flash-resident body (the controller page) is copied into RAM per transport +// write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one +// segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The +// cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase +// report against the phase 01 baseline. The served page is 51 626 B (the count the gate +// prints), so a page is 36 chunks. +constexpr size_t kPageChunkBytes = 1460; + +// The per-tick budget for draining a slot's queue: 4 chunks, so the 51 626-byte page leaves +// in 9 ticks of pump() and no single loop() iteration can spend more than four transport +// writes on one socket -- whatever that socket's peer is doing. loop() runs continuously +// (nothing in it delays), so the page is out in milliseconds; the budget is not about +// throughput, it is the ceiling on how long one iteration can be busy sending. +constexpr size_t kPageBytesPerTick = 4 * kPageChunkBytes; + +// Room for one PEM block. Measured on a P-256 identity carrying exactly the names in +// sanEntries(): the certificate 603 bytes armoured (about 430 of DER inside it) and the +// SEC1 private key 227 -- so nothing this board can be asked to armour is over 700 +// bytes, and 1024 is that with room to spare. One number in one place because a PEM +// buffer exactly the size of today's certificate is an outage the day a name is added: +// wrapPem() and the key exporter both refuse rather than truncate, so too small fails +// loudly at boot instead of shipping a half-written certificate. +constexpr size_t kPemMax = 1024; + // --------------------------------------------------------------------------- // Control passphrase // --------------------------------------------------------------------------- @@ -114,8 +227,33 @@ constexpr uint32_t kStatusIntervalMs = 1000; // The status JSON, sized for every field in StatusSnapshot plus headroom. Sized in // one place because three call sites build it and a truncated status lies about state, // which is worse than no status. +// +// The arithmetic, with every field at its widest legal value -- a 32-character SSID +// (staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each of the five +// counters, UINT16_MAX in `typing`, and the whole 16-hex `fp`: 322 bytes, measured by the +// longest-legal-snapshot check in tests/test_proto.cpp and asserted below this cap, not +// believed. `tls` and `fp` together cost 32 of those 322 -- `,"tls":1` is 8 bytes and +// `,"fp":"0123456789abcdef"` is 24 -- which is why the cap did not have to move when they +// were added, and why the measurement exists: the next field added without re-measuring is +// how this constant stops meaning anything. The 62 bytes left over are one more field's +// worth of headroom, asserted in the test, not slack to be spent by accident. constexpr size_t kStatusJsonCap = 384; +// The widest HTTP response head the board can build, in bytes: one formatter +// (kr::renderHttpResponseHead, conn.h) for every route, port and transport, and this is +// the sizing its answer is tested against -- with a full-length authority and the longest +// path it carries over, so "it fits" is a measurement and not a hope. A head that would +// not fit is refused (length 0), never truncated: half a header block desynchronises a +// stream while looking like an answer. +constexpr size_t kHttpResponseHeadCap = 320; + +// The read-only slot only ever sends ONE response and then dies, so its queue is sized for +// exactly that: the widest response head plus a whole status JSON, queued together (head and +// body are accepted as a pair or refused together, so the ring has to hold the pair). +// Smaller than the controller slot's ring on purpose -- this is static RAM on a board with +// no PSRAM, and a spare slot must never be the reason the controller runs out. +constexpr size_t kConnViewTxCap = kHttpResponseHeadCap + kStatusJsonCap; + constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger than this constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast diff --git a/src/conn.h b/src/conn.h new file mode 100644 index 0000000..ec00129 --- /dev/null +++ b/src/conn.h @@ -0,0 +1,663 @@ +/** + * keyrelay -- the transport view: one byte pipe, two implementations, and the tables that + * decide what the *plaintext* port is allowed to say. + * + * Why this file exists + * -------------------- + * `ControlServer` used to carry two HTTP paths: one that wrote straight to a `WiFiClient` + * and one that staged bytes for an mbed TLS session and pumped them across ticks. Two + * paths that can disagree about a route is the failure mode this project keeps having to + * fix (AGENTS.md, invariant 8: both read-only routes go through `serveReadonly()` "so they + * cannot drift apart"), and a half-encrypted server is the shape that drifts. So the + * transport stops being a routing question: the routing code asks a `Conn` to read, write + * and drain, and does not know -- and must not care -- which of the two it is holding. + * + * What is pure, and where each half is proven + * ------------------------------------------- + * The PURE half is everything that decides rather than I/Os: the HTTP response head, the + * `:80` answer table (`plaintextRoute`), the redirect target builder (which is the one + * place a client-supplied `Host:` and request path become bytes a browser will navigate + * to), and the flash-body send cursor. It includes no Arduino, no IDF and no socket + * header, so tests/test_conn.cpp walks it under sanitisers exactly like tls_pem.h. + * + * The LIBRARY half is `Conn` itself, compiled only where `WiFiClient` exists. It is proven + * the way tls_session.h is: both firmware environments link, and .agents/HW_VERIFICATION.md + * records what a real client did with it (page byte-exact over TLS, `:80` redirecting, a + * plaintext `/ws` upgrade refused with 426). + * + * Tagged union, not virtual -- measured, then chosen + * -------------------------------------------------- + * `Conn` is a kind tag plus two pointers, not a base class with two derived classes. + * Measured on the target toolchain (`xtensa-esp32s3-elf-g++ -Os`, two probe translation + * units performing the same dispatch): the virtual form emits `l32i` + `l32i` + `callx8` + * -- two dependent loads and an indirect call -- and came out *smaller* at the call site, + * 82 B against the tag switch's 98 B. So this choice is not about the cost of one dispatch, + * and pretending it were would be a comment rationalising a decision instead of recording + * it. It is about three other things: + * + * - a Conn is RE-BOUND between transports as slots come and go (`bindPlain` on an accept + * at :80, `bindTls` on an accept at :443, the same slot object either way). A vtable + * expresses that only by placement-new over a live object, or by holding both derived + * objects and a pointer to the live one -- which is this union, plus an indirection and + * a lifetime question nobody has to answer; + * - `callx8` is not inlinable. `writeTextFrame()` -> `push()` -> `pump()` -> `writeRaw()` + * is the road a keystroke frame leaves on, and every hop of a virtual chain there is a + * call, while a branch on a value that never changes between a bind and a reset is one + * the predictor sees coming and the inliner can see through; + * - no vptr per object and no vtable to place: static RAM is the scarce resource on this + * board (no PSRAM) and `Conn` lives inside an object that is already ~11.7 kB of it. + * + * The write contract + * ------------------ + * `pump()` is resumable on every transport and never blocks `loop()`: + * - plaintext asks lwIP whether the socket will take bytes *this instant* (zero-timeout + * select) and returns "nothing written" rather than entering the Arduino write helper, + * which retries a blocking select ten times with a one-second timeout each; + * - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which + * is that library's documented resume rule and is enforced in tls::Session; + * - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and + * a body that lives in flash is copied through a bounded RAM chunk, so a 48 KB page + * leaves in bounded pieces across ticks instead of in one call that can wait. + * Nothing here ever waits. A peer that stops reading is dropped by the caller's stall + * deadline, which is the same rule that already applied to a stalled *request*. + * + * What this file must never become + * -------------------------------- + * A Conn is a byte pipe plus one opinion: what the plaintext port is allowed to say. It + * protects nothing else, and it must not grow. No route, no credential check, no arming + * decision and no "but this one is over TLS" may land here -- the transport is not an authority + * (README, Safety model), which is the same sentence that keeps a `wss://` controller from being + * privileged and a `:80` socket from being trusted. Two specific decisions are load-bearing and + * have tests: a token-carrying request and a `/ws` upgrade on the plaintext port are answered + * 426 rather than 301, because a `Location:` that echoes `?token=` leaks the passphrase twice on + * the wire the redirect is escaping; and exactly one formatter in `src/` writes an HTTP status + * line, so the encrypted answer and the plaintext answer cannot disagree about anything except + * which of them they are (AGENTS.md invariant 8). + */ +#pragma once + +#include +#include +#include // snprintf, for the one response-head formatter +#include + +#include "config.h" +#include "tls_session.h" // tls::TxRing, tls::Handshake (+ Session behind KR_TLS && ESP_PLATFORM) + +// WiFiClient is a typedef of NetworkClient in this core, so it cannot be forward-declared; +// including it is the only way to name it. Guarded, exactly like the mbed TLS includes in +// tls_session.h, so a host translation unit (tests/test_conn.cpp) can include THIS header +// and walk the pure half with no Arduino installed. +#if defined(ESP_PLATFORM) +#include +#include +#include +#endif + +namespace kr { + +// --------------------------------------------------------------------------- +// Pure: the HTTP response head +// --------------------------------------------------------------------------- + +// The two content paths, named once. The redirect builder below and the route table in +// control_server.cpp both need them, and a second spelling of "/index.html" is a second +// answer to "what does this board serve" -- which is the drift this phase exists to remove. +constexpr char kPathIndex[] = "/"; +constexpr char kPathIndexHtml[] = "/index.html"; + +/** + * One response formatter for every route, on every transport, in every build. It lives + * here (pure) rather than in the server because a header block is a wire format whose + * failure mode is silent: `snprintf` truncating it used to mean `headLen = 0`, which meant + * the client got a closed socket and no explanation. The cap it is given, + * `kHttpResponseHeadCap` (config.h, with the arithmetic), is asserted in + * tests/test_conn.cpp against the *widest legal* response -- a 63-character authority and + * the longest path a redirect carries over -- not believed. + * + * Returns the length written, or 0 when `cap` cannot hold the whole block (never a partial + * head: half an HTTP response is a desynchronised stream). + */ +inline size_t renderHttpResponseHead(char *out, size_t cap, const char *status, + const char *contentType, size_t bodyLen, + const char *extra = nullptr) { + if (!out || cap < kHttpResponseHeadCap) return 0; + const int n = snprintf(out, cap, + "HTTP/1.1 %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %u\r\n" + "Cache-Control: no-store\r\n" + "X-Content-Type-Options: nosniff\r\n" + "%s" + "Connection: close\r\n\r\n", + status, contentType, (unsigned)bodyLen, extra ? extra : ""); + return (n > 0 && static_cast(n) < cap) ? static_cast(n) : 0; +} + +// --------------------------------------------------------------------------- +// Pure: what :80 is allowed to answer when this build serves TLS +// --------------------------------------------------------------------------- + +/** + * `Serve` is the pre-TLS (and `-D KR_TLS=0`) behaviour: answer the request here. + * `Redirect` is a 301 to the same path over `https://`. + * `UpgradeRequired` is 426: the client is asking for something this port refuses to do at + * all, and the truthful answer is "use TLS", not a redirect. + * + * Why 426 and not a redirect for two of the shapes (the assumption this task was written + * against, and the reason is a leak): a `Location:` that echoes `?token=` + * exposes the passphrase TWICE -- once in the plaintext request and once in the plaintext + * redirect the browser dutifully re-issues -- and would also train operators to paste the + * passphrase into a `http://` bar. A plaintext `/ws` upgrade gets the same code because + * silently accepting the weaker thing is never the default (invariant 5): the browser must + * fail, read the answer, and reconnect to `https://`. + */ +enum class PlainRoute : uint8_t { Serve, Redirect, UpgradeRequired }; + +/** + * `token=` present as a *key* in the query string. The value is never copied -- this + * answers "is the passphrase in this URL", and copying it here would put the thing the + * whole response exists to protect into a buffer for no reason. Same boundary rule as + * ws::tokenFromQuery(): at the start of the query or straight after a '&', so a path like + * `/status?mytoken=x` is not a token-carrying request. + */ +inline bool queryCarriesToken(const char *query) { + static const char kKey[] = "token="; + const size_t kl = sizeof(kKey) - 1; + if (!query) return false; + // Bounded by the length, not by the NUL: memcmp() of a 6-byte key against the last two + // bytes of "a=1" reads four bytes past the buffer. The sanitiser found exactly that on + // the first run of this file, which is why the boundary is written as `i + kl <= len` + // (the same shape ws::tokenFromQuery uses) and why tests/test_conn.cpp feeds it every + // short prefix of the key. + const size_t len = strlen(query); + for (size_t i = 0; i + kl <= len; i++) { + if (i != 0 && query[i - 1] != '&') continue; + if (memcmp(query + i, kKey, kl) == 0) return true; + } + return false; +} + +inline PlainRoute plaintextRoute(bool upgradeWebsocket, const char *query, bool tlsBuild) { + if (!tlsBuild) return PlainRoute::Serve; // the rollback build IS the plaintext server + if (upgradeWebsocket) return PlainRoute::UpgradeRequired; + if (queryCarriesToken(query)) return PlainRoute::UpgradeRequired; + return PlainRoute::Redirect; +} + +// --------------------------------------------------------------------------- +// Pure: the redirect target +// --------------------------------------------------------------------------- + +/** + * The authority for a `Location:` header, from the client's own `Host:`. + * + * Not a cosmetic decision: `Location` is bytes a browser navigates to. `Host` is attacker + * text on an attacker-watched port, and `http://keyrelay.local@evil.com/` puts `evil.com` + * in it -- which would make this board an open redirect onto a phishing page, on the one + * port whose whole purpose is to say "go here instead". So the Host is accepted only when + * it is a name this board can actually be reached at: the certificate's name, the bare + * hostname, or a private-use dotted quad (the README's "IP if the name fails" path, which + * is why a bare IP has to keep working). Anything else falls back to `kCertCommonName`, + * which is always right about this board. + * + * A `:port` suffix is stripped (a redirect keeps the scheme's default port). Returns false + * only when even the fallback would not fit `cap`, which cannot happen with the names in + * config.h -- and is still checked, because that is how a name change stays honest. + */ +inline bool dottedQuadPrivate(const char *h, size_t n) { + // Every index into octets[] is guarded BEFORE the write, not at the end: the first + // version validated the octet count when a dot arrived, so "192.168.1.1.1" wrote + // octets[4] -- a stack overflow on the way to answering "is this a private address?". + // ASan found it on the first run; tests/test_conn.cpp keeps it found. + unsigned octets[4] = {0, 0, 0, 0}; + size_t oi = 0, digits = 0; + for (size_t i = 0; i <= n; i++) { + if (i == n || h[i] == '.') { + if (digits == 0 || oi >= 4) return false; + oi++; + digits = 0; + continue; + } + if (h[i] < '0' || h[i] > '9' || oi >= 4) return false; + octets[oi] = octets[oi] * 10u + static_cast(h[i] - '0'); + if (octets[oi] > 255u) return false; // bound it where it is built, not later + digits++; + } + if (oi != 4) return false; + // 10/8, 172.16/12, 192.168/16, link-local, loopback. Nothing else is a name this board + // is plausibly reached at by number, and an attacker-supplied public IP never is. + return octets[0] == 10 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] == 192 && octets[1] == 168) || octets[0] == 169 || octets[0] == 127; +} + +inline bool ciEqN(const char *a, const char *b, size_t n) { + for (size_t i = 0; i < n; i++) { + const char ca = (a[i] >= 'A' && a[i] <= 'Z') ? static_cast(a[i] + 32) : a[i]; + const char cb = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast(b[i] + 32) : b[i]; + if (ca != cb || ca == '\0') return false; + } + return true; +} + +inline size_t strLenN(const char *s, size_t max) { + size_t n = 0; + while (n < max && s[n]) n++; + return n; +} + +inline bool redirectAuthority(const char *headerHost, char *out, size_t cap) { + const char *fallback = kCertCommonName; + char cand[80]; + size_t n = headerHost ? strLenN(headerHost, sizeof(cand) - 1) : 0; + if (headerHost && n) { + memcpy(cand, headerHost, n); + cand[n] = '\0'; + // strip :port -- but a second colon means an IPv6 literal or garbage, not a name + for (size_t i = 0; i < n; i++) { + if (cand[i] != ':') continue; + bool digits = i + 1 < n; + for (size_t j = i + 1; j < n; j++) + if (cand[j] < '0' || cand[j] > '9') digits = false; + if (!digits) { + n = 0; + break; + } + cand[i] = '\0'; + n = i; + break; + } + bool ok = n > 0 && n < sizeof(cand); + for (size_t i = 0; ok && i < n; i++) { + const char c = cand[i]; + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '.' || c == '-'; + } + ok = ok && cand[0] != '.' && cand[0] != '-' && cand[n - 1] != '.' && cand[n - 1] != '-'; + const size_t flen = strlen(kCertCommonName); + const size_t hlen = strlen(kHostname); + const bool isCertName = n == flen && ciEqN(cand, kCertCommonName, n); + const bool isHostname = n == hlen && ciEqN(cand, kHostname, n); + ok = ok && (isCertName || isHostname || dottedQuadPrivate(cand, n)); + // Accepted: emit the canonical spelling, not the caller's. A DNS name is + // case-insensitive, so `KEYRELAY.LOCAL` matched -- and echoing a client's mixed case + // into a header this board is authoring buys nothing worth the ambiguity. + if (isCertName) fallback = kCertCommonName; + else if (isHostname) fallback = kHostname; + else if (ok) fallback = cand; + else n = 0; + } + const size_t len = strlen(fallback); + if (!out || cap < len + 1) return false; + memcpy(out, fallback, len + 1); + return true; +} + +/** + * `https://` -- the whole `Location` value. + * + * Only the two paths this board actually has are carried over (`/` and `/index.html`); + * anything else redirects to `/`. That is both the honest answer -- the board has two + * content paths and everything else is a 404 on the encrypted port too -- and the safe + * one: the request target is client text, and a bare CR or LF in it would inject headers + * into the response this board is building. The character check below stays in as defence + * in depth for whoever reuses this function with a longer path. The query is NEVER carried + * over: the only query this board answers is `/status?token=`, and that request gets 426 + * precisely so its query does not travel twice. + * + * Returns the length written (the caller puts it in a body or a log), 0 if it would not fit. + */ +inline size_t renderRedirectTarget(const char *headerHost, const char *path, char *out, + size_t cap) { + if (!out || cap == 0) return 0; + char authority[80]; + if (!redirectAuthority(headerHost, authority, sizeof(authority))) return 0; + const char *safe = "/"; + if (path && path[0] == '/' && path[1]) { + bool ok = true; + for (size_t i = 1; path[i]; i++) { + const char c = path[i]; + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '.' || c == '-' || c == '_' || c == '~' || c == '/' || c == '%' || + c == '+' || c == '='; + if (!ok) break; + } + // ... and one of the two paths this board serves. Both halves are load-bearing: the + // character check stops injection, this one bounds the head and refuses to echo an + // arbitrary target back as a promise. + if (ok && (strcmp(path, kPathIndexHtml) == 0 || strcmp(path, kPathIndex) == 0)) + safe = path; + } + const int n = snprintf(out, cap, "https://%s%s", authority, safe); + return (n > 0 && static_cast(n) < cap) ? static_cast(n) : 0; +} + +// --------------------------------------------------------------------------- +// Pure: a body that lives in flash, drained in bounded pieces +// --------------------------------------------------------------------------- + +/** + * The controller page is a PROGMEM raw string of several tens of kilobytes and no socket + * call here may be handed all of it at once. This is the cursor that decides what leaves + * next: at most `kPageChunkBytes` per transport write (the copy that bounds one call), and + * at most `kPageBytesPerTick` per tick (the budget that bounds one loop() iteration). + * + * The bytes are COPIED into a bounded RAM buffer rather than passed through, even though on + * ESP32 flash is memory-mapped and readable in place: the same code serves a transport that + * reads the pointer directly (plaintext lwIP) and one that encrypts it (mbed TLS), and a + * copy of one MSS is cheaper than a rule about what a transport may dereference. It also + * keeps the resume arguments -- the bytes a partial write re-hands -- stable and small. + * + * `sent`/`total` are the per-connection send cursor the routing code keeps, and they stay + * readable after the send completes so a caller can report "the page left intact". + */ +class FlashSend { + public: + void start(const char *src, size_t len) { + _src = (src && len) ? src : nullptr; + _total = _src ? len : 0; + _sent = 0; + } + void stop() { + _src = nullptr; + _total = 0; + _sent = 0; + } + + bool armed() const { return _src != nullptr; } // more bytes to copy + bool done() const { return _sent >= _total; } // everything has been handed over + size_t sent() const { return _sent; } + size_t total() const { return _total; } + size_t remaining() const { return _total - _sent; } + + // How many bytes to copy for the next write: 0 when finished, when no budget is left, or + // when the chunk would be empty. Never more than one chunk, never more than the budget. + size_t next(size_t chunkCap, size_t budgetLeft) const { + if (!armed() || chunkCap == 0 || budgetLeft == 0) return 0; + size_t n = remaining(); + if (n > chunkCap) n = chunkCap; + if (n > budgetLeft) n = budgetLeft; + return n; + } + + // Copy `n` bytes at the cursor. On ESP32 `_src` is in flash and a plain memcpy reads it + // (there is no Harvard split to mind, unlike AVR's pgm_read_*); on the host it is memory. + void copyTo(char *dst, size_t n) const { + if (!armed() || !dst) return; + if (n > remaining()) n = remaining(); + memcpy(dst, _src + _sent, n); + } + + // Called with what the transport actually accepted. Completing the send clears the source + // (so armed() goes false and the caller can close the link) but keeps sent()/total(). + void advance(size_t n) { + if (!armed()) return; + const size_t left = remaining(); + _sent += (n < left) ? n : left; + if (_sent >= _total) _src = nullptr; + } + + private: + const char *_src = nullptr; + size_t _total = 0; + size_t _sent = 0; +}; + +// --------------------------------------------------------------------------- +// The transport itself (ESP only -- this is the half that touches sockets) +// --------------------------------------------------------------------------- +#if defined(ESP_PLATFORM) + +/** + * One control link, either transport, seen as a byte pipe with a resumable outbound queue. + * + * Not a base class (see the header comment). Not copyable: it is a *view* of a socket that + * somebody else owns -- `ControlServer` owns the `WiFiClient` and the `tls::Session` + * objects, and a copy of the view would be a second owner of the same close(). + * + * Outbound bytes are queued, never written from the caller's stack frame: `push()` takes a + * whole frame or nothing (a half-queued WebSocket frame corrupts the stream; a dropped + * status frame is one second of staleness), and `pump()` drains what the transport will + * take this tick. Inbound is a straight `read()` -- the caller's buffer IS the framing + * state, which is why requests need no queue. + */ +class Conn { + public: + Conn() = default; + Conn(const Conn &) = delete; + Conn &operator=(const Conn &) = delete; + + void bindPlain(WiFiClient *client, uint8_t *ringBuf, size_t ringCap) { + resetOut(); + _kind = Kind::Plain; + _plain = client; + _ring.reseat(ringBuf, ringCap); + } +#if KR_TLS + void bindTls(tls::Session *session, uint8_t *ringBuf, size_t ringCap) { + resetOut(); + _kind = Kind::Tls; + _tls = session; + _ring.reseat(ringBuf, ringCap); + } +#endif + // Forget the transport without closing it: the slot is being handed to a different socket + // object, and whatever was queued for the old one is not coming. + void unbind() { + resetOut(); + _kind = Kind::None; + _plain = nullptr; +#if KR_TLS + _tls = nullptr; +#endif + } + + bool isTls() const { return _kind == Kind::Tls; } + bool bound() const { return _kind != Kind::None; } + + /** A socket is held, handshake pending or not. */ + bool attached() const { + if (_kind == Kind::Plain) return _plain && static_cast(*_plain); +#if KR_TLS + if (_kind == Kind::Tls) return _tls && _tls->alive(); +#endif + return false; + } + + /** ... and the peer is still there. This is the question a controller link asks. */ + bool alive() const { + if (_kind == Kind::Plain) return _plain && static_cast(*_plain) && _plain->connected(); +#if KR_TLS + if (_kind == Kind::Tls) return _tls && _tls->alive(); +#endif + return false; + } + + /** Application bytes may flow. Plaintext is ready the moment it is attached; TLS not + * until the handshake completes. */ + bool ready() const { + if (_kind == Kind::Plain) return attached(); +#if KR_TLS + if (_kind == Kind::Tls) return _tls && _tls->ready(); +#endif + return false; + } + + /** One non-blocking step of the handshake. Plaintext has none, and says so. */ + tls::Handshake poll() { + if (_kind == Kind::Plain) return tls::Handshake::Ready; +#if KR_TLS + if (_kind == Kind::Tls && _tls) return _tls->poll(); +#endif + return tls::Handshake::Failed; + } + + /** Application bytes in. 0 means "nothing right now" (or "gone"), never "spin". */ + size_t read(uint8_t *dst, size_t cap) { + if (!dst || cap == 0) return 0; + if (_kind == Kind::Plain) { + if (!_plain || !static_cast(*_plain) || !_plain->available()) return 0; + const int n = _plain->read(dst, cap); + return n > 0 ? static_cast(n) : 0; + } +#if KR_TLS + if (_kind == Kind::Tls && _tls) return _tls->read(dst, cap); +#endif + return 0; + } + + /** Close the transport. Never waits; the queue goes with it. */ + void stop() { + if (_kind == Kind::Plain && _plain && static_cast(*_plain)) _plain->stop(); +#if KR_TLS + else if (_kind == Kind::Tls && _tls) _tls->close(); +#endif + resetOut(); + } + + /** + * Last-chance flush before a close, plaintext only: without it the FIN that follows a + * WebSocket close frame often arrives as an RST and the peer never sees the goodbye. + * Over TLS there is nothing to flush -- the close_notify rides the same queue, and + * tls::Session::close() sends what it can and moves on. + */ + void flushBeforeClose() { + if (_kind == Kind::Plain && _plain && static_cast(*_plain)) _plain->flush(); + } + + // ---- outbound ------------------------------------------------------------- + + /** Whole frame or nothing: false leaves the queue untouched. */ + bool push(const uint8_t *src, size_t len) { + if (!src || len == 0) return len == 0; + return _ring.push(src, len); + } + + /** + * Two segments queued atomically -- an HTTP head followed by a body that lives in RAM + * (a status JSON, a one-line error). Either both fit or neither is queued, because a + * head without its body is a response that lies about Content-Length. + */ + bool pushPair(const uint8_t *a, size_t alen, const uint8_t *b, size_t blen) { + if (alen + blen > _ring.capacity() - _ring.count()) return false; + if (alen && !_ring.push(a, alen)) return false; + if (blen && !_ring.push(b, blen)) return false; // cannot happen: sized above + return true; + } + + /** + * A head in RAM followed by a body in flash: the page, and nothing else is this big. + * The head is queued, the body is armed here and copied through the chunk buffer as the + * pump gets to it. + */ + bool pushFlashBody(const uint8_t *head, size_t headLen, const char *body, size_t bodyLen) { + if (!body || bodyLen == 0) return push(head, headLen) || headLen == 0; + if (headLen && headLen > _ring.capacity() - _ring.count()) return false; + if (headLen && !_ring.push(head, headLen)) return false; + _flash.start(body, bodyLen); + return true; + } + + bool busyOut() const { + return _ring.count() != 0 || _pendLen != 0 || _flash.armed(); + } + size_t queuedOut() const { return _ring.count() + _pendLen + _flash.remaining(); } + const FlashSend &flashSend() const { return _flash; } + + /** + * Hand the transport as much as it will take, up to `budgetBytes` this call, one write of + * at most `kConnWriteChunk` at a time. True means the queue is empty; false means "not + * yet" -- including "the peer is not reading", which the caller owns as a stall deadline. + * Never waits, never spins, and a partial write resumes from the identical bytes. + */ + bool pump(size_t budgetBytes) { + while (budgetBytes > 0) { + if (_pendLen == 0) { + size_t n = 0; + const size_t cap = budgetBytes < kConnWriteChunk ? budgetBytes : kConnWriteChunk; + const uint8_t *p = _ring.head(cap, &n); + if (p && n) { + _pendPtr = p; + _pendIsChunk = false; + _pendLen = n; + } else if (_flash.armed()) { + // The ring first, always: the head precedes the body it describes. + const size_t c = _flash.next(kPageChunkBytes, budgetBytes); + if (c == 0) return false; // budget spent; the rest goes next tick + _flash.copyTo(_chunk, c); + _pendPtr = reinterpret_cast(_chunk); + _pendIsChunk = true; + _pendLen = c; + } else { + return true; + } + } + const size_t acc = writeRaw(_pendPtr, _pendLen); + if (acc == 0) return false; + if (_pendIsChunk) _flash.advance(acc); + else _ring.advance(acc); + _pendPtr += acc; + _pendLen -= acc; + budgetBytes -= (acc < budgetBytes) ? acc : budgetBytes; + } + return !busyOut(); + } + + /** Drop everything queued. Used when the link is going away, never mid-response. */ + void resetOut() { + _ring.reset(); + _pendPtr = nullptr; + _pendIsChunk = false; + _pendLen = 0; + _flash.stop(); + } + + private: + /** What the transport accepted this call: whole, partial or nothing. Never waits. */ + size_t writeRaw(const uint8_t *p, size_t len) { + if (!p || len == 0) return 0; + if (_kind == Kind::Plain) { + if (!_plain || !static_cast(*_plain)) return 0; + const int fd = _plain->fd(); + if (fd < 0) return 0; + // Never enter NetworkClient::write() unless the socket will take bytes THIS instant. + // That helper retries a blocking select() up to WIFI_CLIENT_MAX_WRITE_RETRY (10) times + // with WIFI_CLIENT_SELECT_TIMEOUT_US (1 s) each, which is how one laptop that stopped + // reading -- locked tab, asleep phone, closed lid -- could hold loop(), and therefore + // the keystroke path, for ten seconds inside a single page write. A zero-timeout + // select turns "not now" into a 0 return and a retry on the next tick, which is what + // pump() already handles for TLS. + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(fd, &wfds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 0; + if (select(fd + 1, nullptr, &wfds, nullptr, &tv) <= 0) return 0; + if (!FD_ISSET(fd, &wfds)) return 0; + return _plain->write(p, len); // may be partial; the caller keeps the cursor + } +#if KR_TLS + if (_kind == Kind::Tls && _tls) return _tls->write(p, len); +#endif + return 0; + } + + enum class Kind : uint8_t { None, Plain, Tls }; + + Kind _kind = Kind::None; + WiFiClient *_plain = nullptr; // owned by ControlServer, outlives every use here +#if KR_TLS + tls::Session *_tls = nullptr; // likewise +#endif + tls::TxRing _ring{nullptr, 1}; + const uint8_t *_pendPtr = nullptr; + bool _pendIsChunk = false; + size_t _pendLen = 0; + FlashSend _flash; + char _chunk[kPageChunkBytes]; // the bounded RAM copy of the flash body +}; + +#endif // ESP_PLATFORM + +} // namespace kr diff --git a/src/control_server.cpp b/src/control_server.cpp index 2180f33..d2129b8 100644 --- a/src/control_server.cpp +++ b/src/control_server.cpp @@ -1,10 +1,28 @@ /** * keyrelay -- control server implementation. See control_server.h for the shape - * and the reasoning; this file is the socket mechanics. + * and the reasoning; this file is the routing, framing and lifecycle on top of the + * transport view in conn.h. + * + * The rule this file lives or dies by: one controller slot, ONE routing table, ONE + * read-only answer, and a transport the routing code barely notices. acceptNew() asks + * tls::admitConnection() the same question for both listeners; serveHttp() and + * serveViewer() read a request through a kr::Conn and route it without looking at which + * transport answered; every response is built by buildResponse()/serveReadonly() and + * queued by respondAndClose()/respondViewer(). The transport is visible in exactly three + * places: which Conn a slot was bound to, the `plaintext` argument that selects the :80 + * policy in serveReadonly(), and the handshake question Conn::ready()/poll() answers. + * A phase that adds a third transport adds one bind, not a second state machine. */ #include "control_server.h" #include +#include + +#if KR_TLS +#include +#include +#include +#endif #include "web_page.h" @@ -12,64 +30,43 @@ namespace kr { namespace { -// HTTP responses are built by hand: one page, one JSON endpoint, one upgrade. A web -// framework would add a request object, a route table and a buffer pool to serve -// four URLs. -void httpResponse(WiFiClient &c, const char *status, const char *contentType, - const char *body, size_t len) { - char head[192]; - const int n = snprintf(head, sizeof(head), - "HTTP/1.1 %s\r\n" - "Content-Type: %s\r\n" - "Content-Length: %u\r\n" - "Cache-Control: no-store\r\n" - "X-Content-Type-Options: nosniff\r\n" - "Connection: close\r\n\r\n", - status, contentType, (unsigned)len); - if (n > 0) c.write((const uint8_t *)head, (size_t)n); - if (body && len) c.write((const uint8_t *)body, len); -} +// The status frame itself -- StatusSnapshot, jsonSafe() and statusJson() -- is in +// status.h, so that the host suite can render it. Only the sockets live here. -void httpError(WiFiClient &c, const char *status, const char *text) { - httpResponse(c, status, "text/plain; charset=utf-8", text, strlen(text)); -} - -// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather -// than trusting whatever a neighbouring access point is called. -void jsonSafe(char *dst, size_t cap, const char *src) { - size_t o = 0; - for (; src && *src && o + 1 < cap; src++) { - const char ch = *src; - if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue; - dst[o++] = ch; - } - dst[o] = '\0'; -} - -constexpr char kPathIndex[] = "/"; -constexpr char kPathIndexHtml[] = "/index.html"; +// kPathIndex and kPathIndexHtml come from conn.h, where the redirect builder needs them +// too; these two are routing-only. constexpr char kPathStatus[] = "/status"; constexpr char kPathWs[] = "/ws"; -} // namespace +// A socket that opened, sent a partial request and went silent gets this long before +// it is dropped -- the pre-TLS value, applied unchanged to every request path (plain, +// TLS controller, TLS spare) so "stalled" means one number everywhere. A TLS *handshake* +// stall has its own deadline (kTlsHandshakeMs, enforced inside the session) because a +// stalled handshake never reaches "request". And since the page became a resumable send, +// the same clock covers a peer that stopped *reading*: a response half-delivered is a +// client that will render garbage, and a link that is not taking bytes is a link gone. +constexpr uint32_t kRequestStallMs = 10000; -size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) { - char ssid[40]; - jsonSafe(ssid, sizeof(ssid), s.ssid); - const int n = snprintf( - out, cap, - "{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\"," - "\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu," - "\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u," - "\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu}", - s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u, - s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent, - (unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState, - s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u, - (unsigned long)s.typedChars, (unsigned)s.typing, - s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames); - return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n; -} +// The :80 answers, in a KR_TLS build. Bodies are flash literals with no client text in +// them -- the one piece of client-supplied data in either response is the Location header, +// and that goes through kr::renderRedirectTarget(), which refuses anything that is not a +// name or path this board can actually be reached at. +// +// 426 says "the thing you asked for needs a protocol this port does not speak", and RFC +// 5246/4266 want an Upgrade field with it. We cannot upgrade in place -- there is no +// in-place TLS upgrade for HTTP/1.1 in this stack, and pretending otherwise would be a +// second protocol -- so the connection closes and the field names what to come back as. +constexpr char kUpgradeHeader[] = "Upgrade: TLS/1.2, HTTP/1.1\r\n"; +constexpr char kBodyPlaintextWs[] = + "keyrelay: this controller channel is TLS-only -- reconnect over wss://, the page is" + " on https://\n"; +constexpr char kBodyPlaintextToken[] = + "keyrelay: 426 -- the passphrase must not cross this port in plaintext. Use" + " https:// on this host (curl -k https:///status?token=...)\n"; +constexpr char kBodyRedirect[] = + "keyrelay: this port redirects. Follow Location, or use https:// on this host\n"; + +} // namespace void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange, FillStatus fillStatus, GetToken getToken, void *ctx) { @@ -81,11 +78,15 @@ void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange, _server.begin(kHttpPort); _server.setNoDelay(true); MDNS.addService("http", "tcp", kHttpPort); +#if KR_TLS + startTlsListener(); + if (_tlsListen >= 0) MDNS.addService("https", "tcp", kHttpsPort); +#endif } void ControlServer::tick(uint32_t nowMs) { acceptNew(nowMs); - if (_viewer) serveViewer(nowMs); + if (_viewOpen) serveViewer(nowMs); switch (_phase) { case Phase::Http: serveHttp(nowMs); break; @@ -93,6 +94,11 @@ void ControlServer::tick(uint32_t nowMs) { default: break; } + // One drain per tick, after everything that could have produced bytes. pump() stops at + // the first transport that will not take more, so an unreadable peer costs this loop one + // syscall (plaintext: one zero-timeout select), never a wait. + pumpSlots(nowMs); + // A socket that opens and never speaks is a scan, not a controller. if (_phase == Phase::WebSocket && !_authed && nowMs - _connectedAtMs > kAuthTimeoutMs) { @@ -110,17 +116,27 @@ void ControlServer::tick(uint32_t nowMs) { _lastPingMs = nowMs; sendPing(); } + // The heartbeat and the ping produced bytes this tick; they leave in the drain below, + // which is now the only place any slot's queue is pushed at the transport. + pumpSlots(nowMs); } - if (_client && !_client.connected() && _phase != Phase::None) closeLink("socket closed"); + // The link died -- peer reset, peer gone, or (TLS) a fatal record that already + // released the session. Conn::alive() is the same question for both transports, and this + // check is deliberately the LAST thing tick() does with the slot: everything above got + // its chance to notice and clean up first. + if (_phase != Phase::None && !_ctl.alive()) closeLink("socket closed"); } void ControlServer::acceptNew(uint32_t nowMs) { +#if KR_TLS + if (_tlsListen >= 0) admitTls(nowMs); +#endif if (!_server.hasClient()) return; WiFiClient fresh = _server.accept(); if (!fresh) return; - if (_phase != Phase::None && _client && !_client.connected()) { + if (_phase != Phase::None && !_ctl.alive()) { // The incumbent controller is gone and its socket says so. Taking over without // settling it would inherit its armed state and whatever keys it left down: the // release funnel runs on a link *change*, and a silent handover to an unrelated @@ -128,13 +144,30 @@ void ControlServer::acceptNew(uint32_t nowMs) { closeLink("controller socket closed"); } - if (_phase != Phase::None && _client && _client.connected()) { + const bool ctlBusy = _phase != Phase::None; + const bool spare = spareBusy(); + const tls::Admit admit = tls::admitConnection( + ctlBusy, (ctlBusy ? 1u : 0u) + (spare ? 1u : 0u), kTlsMaxSessions); + + if (admit == tls::Admit::Refused) { + // Two slots full. The answer this socket gets depends on its transport only in + // HOW it is refused (TLS: alert record then close; plaintext: close), never in + // WHETHER -- the decision above did not ask which port it came in on. + _slotRefused++; + fresh.stop(); + return; + } + + if (admit == tls::Admit::Spare) { // The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket - // is still allowed to look at the board. Only a second /ws upgrade -- a controller - // claim -- is refused, and serveViewer() is what decides that. - if (_viewer) stopViewer(); + // is still allowed to look at the board. Only a /ws upgrade -- a controller claim -- + // is refused, and serveViewer() is what decides that. + if (_viewOpen) stopViewer(); // the spare is dead-but-flagged; settle it first _viewer = fresh; _viewer.setNoDelay(true); + _view.bindPlain(&_viewer, _viewTx, sizeof(_viewTx)); + _viewOpen = true; + _viewAfter = AfterDrain::Nothing; _viewerLen = 0; _viewerHttp[0] = '\0'; _viewerAtMs = nowMs; @@ -142,6 +175,8 @@ void ControlServer::acceptNew(uint32_t nowMs) { } fresh.setNoDelay(true); _client = fresh; + _ctl.bindPlain(&_client, _ctlTx, sizeof(_ctlTx)); + _ctlAfter = AfterDrain::Nothing; _phase = Phase::Http; _httpLen = 0; _authed = false; @@ -155,33 +190,170 @@ void ControlServer::acceptNew(uint32_t nowMs) { } size_t ControlServer::readSome(uint8_t *dst, size_t cap) { - if (!_client || !_client.available()) return 0; - const int n = _client.read(dst, cap); - if (n > 0) { + // The controller slot only, and only its Conn: the spare slot reads through _view + // directly so that a read-only viewer can never be mistaken for the operator. + const size_t got = _ctl.read(dst, cap); + if (got) { // "The controller is alive" means the *authenticated controller on the WebSocket* is - // sending. Bytes from a half-read HTTP request, an unauthenticated socket or a - // read-only /status poll must not count, or the fail-safe that is supposed to fire 8 s - // after a controller vanishes can be held open by anything else on the network. + // sending. Bytes from a half-read HTTP request, an unauthenticated socket, a TLS + // handshake or a read-only /status poll must not count, or the fail-safe that is + // supposed to fire 8 s after a controller vanishes can be held open by anything else + // on the network. That is why this is the only place a read stamps the clock, and why + // the condition is the phase and the authentication and not merely "bytes arrived": + // over TLS a handshake and over :443 a /status poll look exactly like a controller to + // a transport that does not know the difference. tests/test_relayctl.py pins it. if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis(); - return (size_t)n; } - return 0; + return got; } +// --------------------------------------------------------------------------- +// Responses: built once, queued on whichever transport the slot holds. +// --------------------------------------------------------------------------- + +void ControlServer::buildResponse(BuiltResponse &rsp, const char *status, + const char *contentType, BuiltResponse::Body body, + const char *text, size_t textLen, const char *extra, + bool textInFlash) { + rsp.body = body; + rsp.text = nullptr; + rsp.textLen = 0; + rsp.textInFlash = false; + if (body == BuiltResponse::Body::Text) { + rsp.text = text; + rsp.textLen = textLen; + rsp.textInFlash = textInFlash; + } + // A "JSON" body that failed to render (statusJson returns 0 when the frame would not + // fit -- it never sends a truncated status) degrades to no body, and the content + // length says so. Half a status frame is a lie; an empty one is an instrument panel + // that admits it is dark. + if (body == BuiltResponse::Body::Json && rsp.jsonLen == 0) rsp.body = BuiltResponse::Body::None; + const size_t len = rsp.body == BuiltResponse::Body::Json + ? rsp.jsonLen + : (rsp.body == BuiltResponse::Body::Text ? rsp.textLen : 0); + // One formatter, in conn.h, host-tested for the widest legal block (including a + // Location). It returns 0 rather than a truncated head: half a header block desynchronises + // the stream, and a response with no head is a closed socket the client can read as + // "no answer" instead of "a corrupt answer". + rsp.headLen = renderHttpResponseHead(rsp.head, sizeof(rsp.head), status, contentType, len, + extra); +} + +bool ControlServer::queueResponse(Conn &conn, AfterDrain &after, AfterDrain done, + const BuiltResponse &rsp) { + if (!rsp.headLen) return false; // nothing safe to send; the caller closes the link + bool ok = false; + if (rsp.body == BuiltResponse::Body::Text && rsp.textInFlash && rsp.textLen) { + ok = conn.pushFlashBody(reinterpret_cast(rsp.head), rsp.headLen, + rsp.text, rsp.textLen); + } else { + const uint8_t *body = nullptr; + size_t bodyLen = 0; + if (rsp.body == BuiltResponse::Body::Text && rsp.textLen) { + body = reinterpret_cast(rsp.text); + bodyLen = rsp.textLen; + } else if (rsp.body == BuiltResponse::Body::Json && rsp.jsonLen) { + body = reinterpret_cast(rsp.json); + bodyLen = rsp.jsonLen; + } + ok = conn.pushPair(reinterpret_cast(rsp.head), rsp.headLen, body, + bodyLen); + } + if (!ok) return false; + after = done; // the link (or the spare) goes when pumpSlots() says it has all left + return true; +} + +void ControlServer::respondAndClose(const BuiltResponse &rsp) { + if (!queueResponse(_ctl, _ctlAfter, AfterDrain::CloseLink, rsp)) + closeLink("response would not fit"); +} + +void ControlServer::respondViewer(const BuiltResponse &rsp) { + if (!queueResponse(_view, _viewAfter, AfterDrain::StopViewer, rsp)) stopViewer(); +} + +void ControlServer::pumpSlots(uint32_t nowMs) { + if (_phase != Phase::None && (_ctl.busyOut() || _ctlAfter != AfterDrain::Nothing)) { + if (_ctl.pump(kPageBytesPerTick)) { + const AfterDrain after = _ctlAfter; + _ctlAfter = AfterDrain::Nothing; + if (after == AfterDrain::CloseLink) { + closeLink("served"); + return; + } + } else if (_phase == Phase::Http && + tls::deadlinePassed(nowMs, _connectedAtMs, kRequestStallMs)) { + // Same stall rule that always applied to a stalled *request*, now covering a stalled + // *send*: a peer that will not take the page is not waiting for it. Bounded by + // kRequestStallMs, never by however long the peer takes. + closeLink("http stalled"); + return; + } + } + + if (_viewOpen && (_view.busyOut() || _viewAfter != AfterDrain::Nothing)) { + if (_view.pump(kPageBytesPerTick)) { + const AfterDrain after = _viewAfter; + _viewAfter = AfterDrain::Nothing; + if (after == AfterDrain::StopViewer) { + stopViewer(); + return; + } + } else if (tls::deadlinePassed(nowMs, _viewerAtMs, kRequestStallMs)) { + stopViewer(); // stalled; drop it, no reply owed (the rule the plain viewer always had) + } + } +} + +// --------------------------------------------------------------------------- +// HTTP on the controller slot: routes, then one queueing path. +// --------------------------------------------------------------------------- + void ControlServer::serveHttp(uint32_t nowMs) { - // Read until the header block is complete, then route. A request line larger - // than the buffer is refused instead of buffered, so the board cannot be pushed - // into allocating around an attacker's header size. + if (!_ctl.bound()) { closeLink("no transport"); return; } + + if (!_ctl.ready()) { + // Not carrying application bytes yet, which for a TLS slot means the handshake is + // still in flight: ONE library call per tick (see Session::poll), bounded by + // kTlsHandshakeMs inside the session, and Failed means the socket is already + // released. A plaintext slot is ready the moment it is attached, so this is where a + // plaintext socket that vanished between accept() and here gets noticed. + const tls::Handshake h = _ctl.poll(); + if (h == tls::Handshake::Failed) { + closeLink("tls handshake failed"); + return; + } + if (h == tls::Handshake::Ready) { + // The HTTP stall clock starts here, not at accept: a handshake that legitimately + // took most of kTlsHandshakeMs should not arrive already in timeout on its first + // request, and the handshake itself was already bounded by its own deadline. + _connectedAtMs = nowMs; + _httpLen = 0; + } + return; + } + + if (_ctlAfter != AfterDrain::Nothing) return; // a response is draining; pumpSlots owns it + + // Read until the header block is complete, then route. A request line larger than the + // buffer is refused instead of buffered, so the board cannot be pushed into allocating + // around an attacker's header size. for (;;) { const size_t space = sizeof(_http) - _httpLen - 1; if (space == 0) { - httpError(_client, "431 Request Header Fields Too Large", "header block too large\n"); - closeLink("header too large"); + BuiltResponse rsp; + buildResponse(rsp, "431 Request Header Fields Too Large", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "header block too large\n", + strlen("header block too large\n")); + respondAndClose(rsp); return; } const size_t got = readSome((uint8_t *)_http + _httpLen, space); if (got == 0) { - if (nowMs - _connectedAtMs > 10000) closeLink("http stalled"); + if (tls::deadlinePassed(nowMs, _connectedAtMs, kRequestStallMs)) + closeLink("http stalled"); return; } _httpLen += got; @@ -191,17 +363,48 @@ void ControlServer::serveHttp(uint32_t nowMs) { const int r = ws::parseRequest(_http, _httpLen, req); if (r == 0) continue; if (r < 0) { - httpError(_client, "400 Bad Request", "malformed request\n"); - closeLink("malformed request"); + BuiltResponse rsp; + buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "malformed request\n", + strlen("malformed request\n")); + respondAndClose(rsp); return; } // ---- routes --------------------------------------------------------------- - if (req.upgradeWebsocket && strcmp(req.path, kPathWs) == 0 && req.wsKey[0]) { + if (req.upgradeWebsocket) { +#if KR_TLS + if (!_ctl.isTls()) { + // A controller claim on the plaintext port. Refused, not downgraded (invariant 5: + // never quietly accept the weaker thing), and not redirected either: a 301 would + // answer a WebSocket question with a page navigation. Counted with the other + // refused claims, because that is exactly what it is. + _rejected++; + BuiltResponse rsp; + buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, kBodyPlaintextWs, + strlen(kBodyPlaintextWs), kUpgradeHeader); + respondAndClose(rsp); + return; + } +#endif + if (strcmp(req.path, kPathWs) != 0 || !req.wsKey[0]) { + // An upgrade for anything other than a well-formed /ws is not a controller and + // not a page either; there is nothing to route it to. + BuiltResponse rsp; + buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "malformed websocket request\n", + strlen("malformed websocket request\n")); + respondAndClose(rsp); + return; + } char accept[64]; if (!ws::acceptFor(req.wsKey, strlen(req.wsKey), accept, sizeof(accept))) { - httpError(_client, "400 Bad Request", "bad websocket key\n"); - closeLink("bad ws key"); + BuiltResponse rsp; + buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "bad websocket key\n", + strlen("bad websocket key\n")); + respondAndClose(rsp); return; } char head[224]; @@ -211,8 +414,10 @@ void ControlServer::serveHttp(uint32_t nowMs) { "Connection: Upgrade\r\n" "Sec-WebSocket-Accept: %s\r\n\r\n", accept); - if (n <= 0) { closeLink("handshake failed"); return; } - _client.write((const uint8_t *)head, (size_t)n); + if (n <= 0 || !_ctl.push(reinterpret_cast(head), (size_t)n)) { + closeLink("handshake failed"); + return; + } _phase = Phase::WebSocket; _reader.reset(); _connectedAtMs = nowMs; @@ -221,41 +426,69 @@ void ControlServer::serveHttp(uint32_t nowMs) { return; } - if (req.upgradeWebsocket) { - // An upgrade for anything other than a well-formed /ws is not a controller and - // not a page either; there is nothing to route it to. - httpError(_client, "400 Bad Request", "malformed websocket request\n"); - closeLink("bad ws request"); - return; - } - - serveReadonly(_client, req); - closeLink("served"); + BuiltResponse rsp; + serveReadonly(req, rsp, !_ctl.isTls()); + respondAndClose(rsp); return; } } -// The read-only routes: the controller page, the JSON status, favicon, 404. Shared by -// the ordinary path above and the viewer path below on purpose -- "who may drive the -// keyboard" and "who may see this board" are different questions, and the answer to the -// second one must not be able to drift from the first. -void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) { +// The read-only routes: the controller page, the JSON status, favicon, 404 -- and, in a +// KR_TLS build on the plaintext port, the redirect and the two 426s. Shared by the +// ordinary path above and the viewer path below on purpose: "who may drive the keyboard" +// and "who may see this board" are different questions, and the answer to the second one +// must not be able to drift from the first. Nor may the two ports drift: this is the only +// place either port answers, so "what does :80 serve" has exactly one answer, and it is +// "nothing but the way to :443". It BUILDS the response rather than writing it, because +// the write is the one part that differs per transport; the decision lives here, once. +void ControlServer::serveReadonly(const ws::Request &req, BuiltResponse &rsp, + bool plaintext) { +#if KR_TLS + if (plaintext) { + // The table is pure (conn.h) so the whole policy is host-tested; `true` is this build's + // answer to "do I serve TLS?", and in the -D KR_TLS=0 rollback this block does not + // exist at all -- which is what makes that rollback today's server exactly, rather than + // a reimplementation of it. + const PlainRoute route = plaintextRoute(false, req.query, true); + if (route == PlainRoute::UpgradeRequired) { + // Only one shape reaches here from serveReadonly(): a query carrying `token=`. The + // body names the fix without repeating the value, and there is no Location header -- + // a redirect would echo the query and leak the passphrase a second time. + buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, kBodyPlaintextToken, + strlen(kBodyPlaintextToken), kUpgradeHeader); + return; + } + if (route == PlainRoute::Redirect) { + char loc[128]; + const size_t locLen = renderRedirectTarget(req.host, req.path, loc, sizeof(loc)); + char extra[160]; + if (locLen == 0 || + snprintf(extra, sizeof(extra), "Location: %s\r\n", loc) < 0) { + buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, kBodyPlaintextToken, + strlen(kBodyPlaintextToken), kUpgradeHeader); + return; + } + buildResponse(rsp, "301 Moved Permanently", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, kBodyRedirect, strlen(kBodyRedirect), extra); + return; + } + } +#endif + if (strcmp(req.method, "GET") != 0) { - httpError(c, "405 Method Not Allowed", "GET only\n"); + buildResponse(rsp, "405 Method Not Allowed", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "GET only\n", strlen("GET only\n")); return; } if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) { - char head[192]; - const int n = snprintf(head, sizeof(head), - "HTTP/1.1 200 OK\r\n" - "Content-Type: text/html; charset=utf-8\r\n" - "Content-Length: %u\r\n" - "Cache-Control: no-store\r\n" - "Connection: close\r\n\r\n", - (unsigned)kIndexHtmlLen); - if (n > 0) c.write((const uint8_t *)head, (size_t)n); - c.write_P(kIndexHtml, kIndexHtmlLen); + // The page is the one body too big to queue: it is streamed out of flash through a + // bounded chunk (config.h kPageChunkBytes / kPageBytesPerTick), which is what lets a + // 48 KB response leave across ticks on either transport without one blocking write. + buildResponse(rsp, "200 OK", "text/html; charset=utf-8", BuiltResponse::Body::Text, + kIndexHtml, kIndexHtmlLen, nullptr, /*textInFlash=*/true); return; } @@ -266,80 +499,123 @@ void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) { const char *want = _getToken ? _getToken(_ctx) : ""; if (!ws::tokenFromQuery(req.query, strlen(req.query), token, sizeof(token)) || !proto::constTimeEq(token, strlen(token), want, strlen(want))) { - httpError(c, "401 Unauthorized", "token required\n"); + buildResponse(rsp, "401 Unauthorized", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "token required\n", + strlen("token required\n")); return; } StatusSnapshot s; if (_fillStatus) _fillStatus(_ctx, s); - char json[kStatusJsonCap]; - const size_t len = statusJson(s, json, sizeof(json)); - httpResponse(c, "200 OK", "application/json", json, len); + rsp.jsonLen = statusJson(s, rsp.json, sizeof(rsp.json)); + buildResponse(rsp, "200 OK", "application/json", BuiltResponse::Body::Json, + nullptr, 0); return; } if (strcmp(req.path, "/favicon.ico") == 0) { - httpResponse(c, "204 No Content", "text/plain", nullptr, 0); + buildResponse(rsp, "204 No Content", "text/plain", BuiltResponse::Body::None, + nullptr, 0); return; } - httpError(c, "404 Not Found", "keyrelay: / (page), /ws (websocket), " - "/status?token=... (json)\n"); + const char *missing = + "keyrelay: / (page), /ws (websocket), /status?token=... (json)\n"; + buildResponse(rsp, "404 Not Found", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, missing, strlen(missing)); } // A connection that arrived while the controller slot was occupied: serve it read-only // and close. This exists because the first version refused everything, which turned the // most ordinary gesture -- pressing reload on the page that already holds the bridge -- // into a wall of text, and left a running controller with no way to be inspected. -// Still refused here: a second /ws upgrade, because that is a controller claim. +// Still refused here: a /ws upgrade, because that is a controller claim -- and on the +// plaintext port it is refused as a transport question (426) before it is refused as a +// slot question (503), so a client never learns "busy" when the answer is "TLS only". void ControlServer::serveViewer(uint32_t nowMs) { - if (!_viewer) { - stopViewer(); + if (!_view.attached()) { stopViewer(); return; } + + if (!_view.ready()) { + // The spare slot's handshake is paced exactly like the controller's: one call per + // tick, its own deadline inside the session, Failed means already-released. + if (_view.poll() == tls::Handshake::Failed) stopViewer(); return; } - while (_viewer.available() && _viewerLen + 1 < sizeof(_viewerHttp)) { - const int b = _viewer.read(); - if (b <= 0) break; - _viewerHttp[_viewerLen++] = (char)b; - _viewerHttp[_viewerLen] = '\0'; - } + if (_viewAfter != AfterDrain::Nothing) return; // draining; pumpSlots owns it - ws::Request req; - const int r = ws::parseRequest(_viewerHttp, _viewerLen, req); - if (r == 0) { - if (_viewerLen + 1 >= sizeof(_viewerHttp)) { - httpError(_viewer, "431 Request Header Fields Too Large", "header block too large\n"); - stopViewer(); + for (;;) { + const size_t space = sizeof(_viewerHttp) - _viewerLen - 1; + if (space == 0) { + BuiltResponse rsp; + buildResponse(rsp, "431 Request Header Fields Too Large", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "header block too large\n", + strlen("header block too large\n")); + respondViewer(rsp); return; } - if (nowMs - _viewerAtMs > 10000) stopViewer(); // stalled; drop it, no reply owed - return; - } - if (r < 0) { - httpError(_viewer, "400 Bad Request", "malformed request\n"); - stopViewer(); - return; - } + // Directly on the Conn, never readSome(): whoever is watching is not whoever is + // typing, and the controller-silence fail-safe must not be held open by a viewer + // refreshing /status. + const size_t got = _view.read(reinterpret_cast(_viewerHttp) + _viewerLen, + space); + if (got == 0) { + if (tls::deadlinePassed(nowMs, _viewerAtMs, kRequestStallMs)) stopViewer(); + return; + } + _viewerLen += got; + _viewerHttp[_viewerLen] = '\0'; - if (req.upgradeWebsocket) { - // One controller. The incumbent keeps the bridge; the newcomer is told why. - httpError(_viewer, "503 Service Unavailable", - "keyrelay: another controller already holds this bridge.\n"); - _rejected++; - stopViewer(); + ws::Request req; + const int r = ws::parseRequest(_viewerHttp, _viewerLen, req); + if (r == 0) continue; + if (r < 0) { + BuiltResponse rsp; + buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, "malformed request\n", + strlen("malformed request\n")); + respondViewer(rsp); + return; + } + + if (req.upgradeWebsocket) { + _rejected++; +#if KR_TLS + if (!_view.isTls()) { + BuiltResponse rsp; + buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, kBodyPlaintextWs, + strlen(kBodyPlaintextWs), kUpgradeHeader); + respondViewer(rsp); + return; + } +#endif + // One controller. The incumbent keeps the bridge; the newcomer is told why -- over + // either transport with the same 503, because the refusal to hand over the keyboard + // is not a transport question either. + BuiltResponse rsp; + const char *busy = "keyrelay: another controller already holds this bridge.\n"; + buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, busy, strlen(busy)); + respondViewer(rsp); + return; + } + + BuiltResponse rsp; + serveReadonly(req, rsp, !_view.isTls()); + respondViewer(rsp); return; } - - serveReadonly(_viewer, req); - stopViewer(); } void ControlServer::stopViewer() { - if (_viewer) { - _viewer.flush(); - _viewer.stop(); + if (_view.bound()) { + _view.flushBeforeClose(); + _view.stop(); } - _viewer = WiFiClient(); + _view.unbind(); + _viewAfter = AfterDrain::Nothing; + _viewer = WiFiClient(); // the shared handle closes the fd when this last copy dies + _viewOpen = false; _viewerLen = 0; } @@ -376,7 +652,7 @@ bool ControlServer::handleWsFrame(uint32_t nowMs) { memcpy(payload, _reader.payload(), plen); _reader.reset(); const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame)); - if (n && _client && _client.connected()) _client.write(frame, n); + if (n && _ctl.alive() && !_ctl.push(frame, n)) _txDropped++; return true; } if (opcode == ws::kOpPong) { _reader.reset(); return true; } @@ -430,18 +706,23 @@ bool ControlServer::handleWsFrame(uint32_t nowMs) { } void ControlServer::sendPing() { - if (!_client || !_client.connected()) return; + if (!_ctl.alive()) return; uint8_t frame[8]; const size_t n = ws::encodeFrame(ws::kOpPing, nullptr, 0, frame, sizeof(frame)); - if (n) _client.write(frame, n); + if (n && !_ctl.push(frame, n)) _txDropped++; } void ControlServer::writeTextFrame(const char *text, size_t len) { uint8_t frame[kWsMessageCap + 8]; const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame)); if (n == 0) return; - if (!_client || !_client.connected()) return; - _client.write(frame, n); + if (!_ctl.alive()) return; + // Whole frame or nothing (Conn::push): a dropped periodic status frame is one second of + // staleness, a half-sent frame is a corrupted stream, and the plaintext transport could + // previously deliver exactly the second one -- NetworkClient::write() returns the number + // of bytes it managed, and the count used to be thrown away. The counter keeps drops + // countable rather than silent. + if (!_ctl.push(frame, n)) _txDropped++; } void ControlServer::sendStatus() { @@ -474,15 +755,25 @@ void ControlServer::closeLink(const char *why) { // and linked() is exactly what stops being true when a controller goes away. // disarm() is a no-op when already disarmed, so this cannot over-release. const bool wasLinked = _phase == Phase::WebSocket; - if (_client && _client.connected()) { - uint8_t frame[16]; - const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame)); - if (n && _phase == Phase::WebSocket) { - _client.write(frame, n); - _client.flush(); // without this the close frame is often RST'd instead + if (_ctl.alive()) { + if (_phase == Phase::WebSocket) { + uint8_t frame[16]; + const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame)); + if (n && _ctl.push(frame, n)) { + // One bounded shove, then goodbye. Over TLS Session::close() adds the + // close_notify on its way out. Neither is waited on -- a peer that is not reading + // gets a torn-down connection, which is a truthful goodbye. + _ctl.pump(kPageChunkBytes); + _ctl.flushBeforeClose(); + } } - _client.stop(); } + // The release runs whether or not the goodbye got through, and whether or not the + // transport thought it was alive: this is the funnel (AGENTS.md invariant 3), and a + // teardown failure must never skip it. Both transports' close paths are idempotent. + if (_ctl.bound()) _ctl.stop(); + _ctl.unbind(); + _ctlAfter = AfterDrain::Nothing; _phase = Phase::None; _authed = false; _httpLen = 0; @@ -499,4 +790,106 @@ void ControlServer::closeLink(const char *why) { if (wasLinked && _onLinkChange) _onLinkChange(_ctx, false); } +#if KR_TLS +// --------------------------------------------------------------------------- +// The encrypted listener. Everything after accept() is Conn's job: the handshake, the +// queue, the pumps and the close are the same code the plaintext slot runs, which is the +// entire point of task 04. +// --------------------------------------------------------------------------- + +void ControlServer::startTlsListener() { + // No identity, no listener. A :443 that fails every handshake is worse than a + // refused connection: it advertises encryption it cannot do. main.cpp generates + // the identity at first boot and only reaches begin() after that attempt. + if (!_identity || !_identity->cert[0] || !_identity->key[0]) return; + + // Raw LWIP listen socket rather than WiFiServer: NetworkClient hands the accepted + // fd to a shared handle that closes it when the last copy dies, and tls::Session + // must own the fd for exactly its own lifetime (closed on every path, here). See + // control_server.h; this is the ownership decision, restated where it is executed. + _tlsListen = ::socket(AF_INET, SOCK_STREAM, 0); + if (_tlsListen < 0) return; + const int on = 1; + setsockopt(_tlsListen, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(kHttpsPort); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (::bind(_tlsListen, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + ::listen(_tlsListen, static_cast(kTlsMaxSessions) + 2) != 0) { + ::close(_tlsListen); + _tlsListen = -1; + return; + } + // Non-blocking accept: "nothing waiting" must be an instant answer, because this + // runs once per loop() beside the keystroke path. + fcntl(_tlsListen, F_SETFL, O_NONBLOCK); +} + +void ControlServer::admitTls(uint32_t nowMs) { + // At most one accept per tick, the same pace rule the plaintext listener lives by: + // a backlog is picked up on following ticks, and a flood costs the loop one syscall + // per tick -- it cannot starve the keystroke path. + const int fd = ::accept(_tlsListen, nullptr, nullptr); + if (fd < 0) return; // EWOULDBLOCK -- nothing waiting -- or a transient error + + if (_phase != Phase::None && !_ctl.alive()) closeLink("controller socket closed"); + + const bool ctlBusy = _phase != Phase::None; + const bool spare = spareBusy(); + const tls::Admit admit = tls::admitConnection( + ctlBusy, (ctlBusy ? 1u : 0u) + (spare ? 1u : 0u), kTlsMaxSessions); + + if (admit == tls::Admit::Refused) { + // The third connection. Refused explicitly -- a TLS alert record, then close -- + // never queued and never half-opened: opening an mbed TLS context here would + // spend ~32 KB of heap to say "there is no heap", which is the entire thing this + // refusal protects. RAM, not policy; same answer a plaintext socket gets above, + // delivered the way TLS speaks. + _slotRefused++; + tls::refuseTls(fd); + return; + } + + if (!_identity) { // defensive: the listener only exists with an identity + tls::refuseTls(fd); + return; + } + + if (admit == tls::Admit::Spare) { + // The spare slot is one slot: whatever the last occupant left (a pump mid-response + // to a peer that died) must be gone before this socket inherits the shared + // _viewerHttp/_viewerLen state, or the previous viewer's half-written response + // would be pumped to the new one. + stopViewer(); + if (!_tlsView.open(fd, *_identity)) { // open consumed the fd either way + _slotRefused++; + return; + } + _view.bindTls(&_tlsView, _viewTx, sizeof(_viewTx)); + _viewOpen = true; + _viewerLen = 0; + _viewerHttp[0] = '\0'; + _viewerAtMs = nowMs; + return; + } + + if (!_tlsCtl.open(fd, *_identity)) { // open consumed the fd either way + _slotRefused++; + return; + } + _ctl.bindTls(&_tlsCtl, _ctlTx, sizeof(_ctlTx)); + _ctlAfter = AfterDrain::Nothing; + _authed = false; + _httpLen = 0; + _reader.reset(); + _phase = Phase::Http; + _connectedAtMs = nowMs; + // Again deliberately NOT _lastInboundMs: a TLS handshake in flight is exactly the + // "someone connected is not someone being alive" case readSome's comment is about. +} + +#endif // KR_TLS + } // namespace kr diff --git a/src/control_server.h b/src/control_server.h index ea121cb..e6606a3 100644 --- a/src/control_server.h +++ b/src/control_server.h @@ -1,5 +1,6 @@ /** - * keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes. + * keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes, + * over a plaintext socket or a TLS session -- into one controller slot. * * Shape of it: * - one controller at a time. A second WebSocket upgrade is refused outright rather @@ -8,14 +9,28 @@ * Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board. * Refusing everything meant pressing reload on your own tab failed, and a live * controller could not be inspected by anything else (serveViewer). + * - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both + * feed the same accept decision -- tls::admitConnection -- the same serveHttp() / + * serveWebSocket() routing and the same serveReadonly(); the only thing a socket's + * origin changes is which Conn it got bound to, and the only place the difference is + * visible at all is the plaintext policy in serveReadonly() (:80 answers a redirect or + * 426 instead of content, compiled out entirely by -D KR_TLS=0). A socket is HTTP, + * WebSocket or none regardless of port, so a TLS controller and a plaintext controller + * can never both hold the keyboard. * - non-blocking throughout: tick() runs once per loop() and consumes only what is - * already buffered. The keystroke path must not wait on the network stack. + * already buffered. The keystroke path must not wait on the network stack -- under + * TLS that includes the handshake (Conn::poll(), one library call per tick) and every + * response write (queued, pumped under kPageBytesPerTick, never waited on); in + * plaintext it includes the write that used to be able to sit in a one-second select + * ten times over (see Conn::writeRaw). * - nothing is trusted before the passphrase. The socket opens, must present T * inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the - * evaluation out for kTokenLockMs. + * evaluation out for kTokenLockMs. TLS is transport, never an arming input: a + * TLS session that authenticates but does not arm types nothing. * * Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse - * (host-tested). What is left here is routing, framing and lifecycle -- the part that + * (host-tested); the transport and the plaintext-port tables live in conn.h (its pure + * half host-tested). What is left here is routing, framing and lifecycle -- the part that * genuinely needs a socket to be worth testing. */ #pragma once @@ -23,40 +38,18 @@ #include #include "config.h" +#include "conn.h" #include "proto.h" +#include "status.h" +#include "tls_session.h" #include "ws_codec.h" namespace kr { -// What the board knows about itself, as shown to the controller. The JSON field -// names in StatusJson below are the contract the page in web_page.h reads. -struct StatusSnapshot { - const char *version = "0"; - uint32_t uptimeS = 0; - bool usbHost = false; // TinyUSB is attached to a target - bool armed = false; - bool netUp = false; - const char *net = "down"; // "sta" | "ap" | "down" - const char *ssid = ""; - const char *ip = ""; - uint32_t keysSent = 0; - uint32_t chordsSent = 0; - uint32_t dropped = 0; - uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text) - uint16_t typing = 0; // characters still queued in the paste in progress - bool rollover = false; - // The mouse: whether it can report at all, which buttons the board believes are - // down, and how many reports have gone out. `mouse` is false when the interface is - // switched off on the board or when the target has forced boot protocol -- in both - // cases the keyboard is untouched and M= frames are refused, not ignored. - bool mouse = false; - uint8_t mouseButtons = 0; - uint32_t mouseFrames = 0; - uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports - bool defaultToken = false; - bool apForced = false; -}; - +// StatusSnapshot and statusJson() live in status.h, which includes nothing but stdio and +// config.h. That is not tidiness: it is what lets tests/test_proto.cpp render the real +// frame and measure it against kStatusJsonCap instead of taking that on trust from a +// device. Include status.h rather than re-declaring anything here. // Error codes, named. The wire form is `E:`; see proto.h for the list. enum ErrorCode : uint8_t { kErrNotAuthed = 1, @@ -72,12 +65,6 @@ enum ErrorCode : uint8_t { kErrMouseBootProtocol = 11, }; -/** - * Renders the status frame. Split out of the class so it is callable from the serial - * console too, and so the field names stay in one place. - */ -size_t statusJson(const StatusSnapshot &s, char *out, size_t cap); - class ControlServer { public: // ctx-first function pointers rather than std::function: no allocation, and the @@ -109,12 +96,77 @@ class ControlServer { uint32_t rejected() const { return _rejected; } uint32_t authFails() const { return _authFails; } + // Frames the transport could not take (its queue was full) -- a periodic status frame + // loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either + // transport: the queue is the transport view's, so the count means one thing everywhere. + uint32_t txDropped() const { return _txDropped; } + + // Connections refused because the slot table was full -- either listener, either + // transport. A number, about a refusal; the refusal itself is visible to the peer + // (TLS alert then close on :443, immediate close on :80). + uint32_t slotRefused() const { return _slotRefused; } + +#if KR_TLS + // The identity the TLS listener serves. Must be set before begin(), and to nullptr + // (or left unset) when there is no usable identity: begin() then does not open :443 + // at all, rather than open a listener that fails every handshake. The PemPair lives + // in main.cpp next to the NVS code that loaded it; the server only reads it, at + // session-open time -- copying 2 kB into every server object would be silly. + void setIdentity(const tls::PemPair *identity) { _identity = identity; } + + // Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this + // changes. A number, about numbers; nothing about content (invariant 4). + uint32_t tlsLiveSessions() const { + return (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u); + } +#endif + private: + // No separate "handshake in progress" phase: the handshake is what Conn::ready() and + // Conn::poll() are for, and a slot in Phase::Http that is not ready yet is a slot still + // handshaking. A phase for it would be a second place to remember the same fact, which + // is how the plaintext and TLS paths used to drift. enum class Phase : uint8_t { None, Http, WebSocket }; + // What the slot does when its outbound queue finally drains: the response is finished, + // so the link goes. Nothing else closes a link on a *write* boundary -- and a response + // that is still draining must never be cut short, or the page arrives truncated and + // parses as garbage. + enum class AfterDrain : uint8_t { Nothing, CloseLink, StopViewer }; + + // A response built but not yet written: the status line and headers, plus a body that + // lives somewhere else -- a string literal in flash, the rendered status JSON, or the + // 48 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of + // the response (literals and kIndexHtml live forever; the JSON lives in this struct, + // which the caller keeps until the response is queued), which is what makes "build once, + // queue on either transport" safe. + struct BuiltResponse { + enum class Body : uint8_t { None, Text, Json }; + char head[kHttpResponseHeadCap]; + size_t headLen = 0; + Body body = Body::None; + const char *text = nullptr; // Body::Text: a literal, or kIndexHtml in PROGMEM + size_t textLen = 0; + bool textInFlash = false; // true for the page: streamed through a bounded chunk + char json[kStatusJsonCap]; // Body::Json: rendered BEFORE buildResponse is called + size_t jsonLen = 0; + }; + + // One response formatter for every route, every transport and both ports; the header + // block itself is rendered by the pure kr::renderHttpResponseHead() in conn.h, which is + // what makes its sizing host-testable. + static void buildResponse(BuiltResponse &rsp, const char *status, + const char *contentType, BuiltResponse::Body body, + const char *text, size_t textLen, + const char *extra = nullptr, bool textInFlash = false); + void acceptNew(uint32_t nowMs); void serveHttp(uint32_t nowMs); - void serveReadonly(WiFiClient &c, const ws::Request &req); + // The read-only routes, built rather than written: the routing answer must have exactly + // one source (invariant 8) while the transport answer differs only in which Conn the + // built response is queued on. `plaintext` is what selects the :80 policy (redirect / + // 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback. + void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext); void serveViewer(uint32_t nowMs); void stopViewer(); void serveWebSocket(uint32_t nowMs); @@ -124,15 +176,43 @@ class ControlServer { void writeTextFrame(const char *text, size_t len); size_t readSome(uint8_t *dst, size_t cap); + // Queue a built response on a slot's transport. One response writer for both slots and + // both transports -- the grep for a second HTTP response writer in src/ is part of this + // task's completion criteria -- so `done` says what that slot does when the bytes have + // all left, and the two callers differ only in that answer. + bool queueResponse(Conn &conn, AfterDrain &after, AfterDrain done, + const BuiltResponse &rsp); + void respondAndClose(const BuiltResponse &rsp); // controller slot + void respondViewer(const BuiltResponse &rsp); // read-only slot + // Drain both slots' queues under the per-tick budget, and run the AfterDrain decisions. + void pumpSlots(uint32_t nowMs); + bool spareBusy() const { return _viewOpen; } // the read-only slot, either transport + +#if KR_TLS + void startTlsListener(); + void admitTls(uint32_t nowMs); // accept at most one TLS socket per tick +#endif + WiFiServer _server; + // The controller slot: a Conn over one of these two, plus the slot's request buffer. + // The WiFiClient and tls::Session objects are the owners; Conn is a view of one of them. WiFiClient _client; + Conn _ctl; + uint8_t _ctlTx[kConnTxCap]; + AfterDrain _ctlAfter = AfterDrain::Nothing; Phase _phase = Phase::None; char _http[kHttpHeaderCap]; size_t _httpLen = 0; - // A second socket, read-only, for when the controller slot is taken. + // A second socket, read-only, for when the controller slot is taken. Same shape as the + // controller slot -- a Conn over whichever transport answered -- because the spare slot + // is one slot and a second set of route handling would be a second answer to give. WiFiClient _viewer; + Conn _view; + uint8_t _viewTx[kConnViewTxCap]; + AfterDrain _viewAfter = AfterDrain::Nothing; + bool _viewOpen = false; char _viewerHttp[kHttpHeaderCap]; size_t _viewerLen = 0; uint32_t _viewerAtMs = 0; @@ -149,6 +229,20 @@ class ControlServer { uint32_t _lockUntilMs = 0; uint32_t _authFails = 0; uint32_t _rejected = 0; + uint32_t _txDropped = 0; + uint32_t _slotRefused = 0; + +#if KR_TLS + // The encrypted listener: a raw LWIP socket rather than WiFiServer, because the + // session must own the accepted fd -- NetworkClient closes its fd through a shared + // handle the moment the last copy dies, which is the double-close tls::Session is + // written to make impossible. bind/listen happen in startTlsListener(). + int _tlsListen = -1; + const tls::PemPair *_identity = nullptr; + + tls::Session _tlsCtl; // the controller slot, when _ctl.isTls() + tls::Session _tlsView; // the spare slot, when _view.isTls() +#endif OnMessage _onMessage = nullptr; OnLinkChange _onLinkChange = nullptr; diff --git a/src/main.cpp b/src/main.cpp index 6eb3dfb..5618b1b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -97,7 +97,9 @@ #include "mouse.h" #include "output.h" #include "proto.h" +#include "status.h" #include "text.h" +#include "tls.h" #if KR_HAVE_HID // TinyUSB's per-instance readiness check is a plain C function in the linked stack. @@ -171,6 +173,9 @@ constexpr char kKeyStaPass[] = "sta_pass"; constexpr char kKeyToken[] = "token"; constexpr char kKeyApMode[] = "ap_mode"; // 0 auto, 1 force on, 2 off constexpr char kKeyMouse[] = "mouse"; // 1 = advertise the mouse collection +constexpr char kKeyTlsCert[] = "tlscrt"; // the certificate, PEM +constexpr char kKeyTlsKey[] = "tlskey"; // the private key, PEM. Never printed. +constexpr char kKeyTlsFp[] = "tlscrtfp"; // its SHA-256, hex: public, and printed freely char staSsid[33] = ""; char staPass[65] = ""; @@ -235,6 +240,191 @@ void saveMouseEnabled(bool on) { mouseEnabled = on; } +// --------------------------------------------------------------------------- +// The TLS identity (NVS) +// --------------------------------------------------------------------------- +// Made on the board, once, and kept in the same namespace as the SSID and the +// passphrase: per-board, never in this repo, and it survives reflashing because NVS does. +// Three records -- certificate, key, fingerprint -- and the fingerprint is *stored* rather +// than recomputed so that `cert` and the status frame cost no hashing at all. It is public +// data; storing it raises no secret-at-rest question that storing the certificate does not +// already raise, and a stale stored copy is caught the same way a stale certificate is: +// selfCheck() at boot. +// +// What may never be printed is the key or the certificate body, in any form, including a +// length that identifies which text is which. `token` already refuses to echo a passphrase +// into a serial log for the same reason; the fingerprint and mbed TLS's error code are the +// only facts about this identity that reach a log. +// +// A record that will not pass selfCheck() is regenerated rather than used. That costs the +// operator a re-pin and the board says so loudly, once. The alternative -- starting a +// listener on a key that may not match its certificate -- produces a device that answers +// every handshake with a fatal alert and nothing to print about it. + +constexpr size_t kFingerprintPrintPairs = 16; // 16 pairs is 47 characters: one safe line + +// The label the status frame carries, and the flag beside it. Both exist in every build -- +// the rollback leaves them empty and false -- because `fillStatus()` is one function and the +// frame always has both fields; the certificate itself does not exist at all in a KR_TLS=0 +// build, which is what the #if below is for. +char tlsFingerprint[kr::tls::kFingerprintCap] = ""; +bool tlsIdentityReady = false; + +#if KR_TLS + +// 2 kB of buffers, so they live here rather than in any function's automatic storage +// (src/tls.h: "not in an auto variable on the 8 kB loop stack"). +kr::tls::PemPair tlsIdentity; + +// An NVS String into a PEM buffer, refusing rather than truncating: half a certificate is +// not a shorter certificate, it is a handshake failure with no explanation. +bool copyPemField(const String &from, char *to, size_t cap) { + if (from.length() == 0 || from.length() >= cap) return false; + memcpy(to, from.c_str(), from.length()); + to[from.length()] = '\0'; + return true; +} + +void clearTlsIdentity() { + tlsIdentity.cert[0] = '\0'; + tlsIdentity.key[0] = '\0'; + tlsFingerprint[0] = '\0'; + tlsIdentityReady = false; +} + +// The whole fingerprint, 16 colon-separated pairs to a line. The wrapping is not styling: +// the global CDC transmit timeout is 0, so a line longer than the 128-byte TX ring is +// dropped silently in the middle of the value somebody is trying to copy (README bug 1). +void printTlsFingerprint() { + char line[kr::tls::kSha256Len * 3]; + for (size_t first = 0; first < kr::tls::kSha256Len; first += kFingerprintPrintPairs) { + const size_t left = kr::tls::kSha256Len - first; + const size_t count = left < kFingerprintPrintPairs ? left : kFingerprintPrintPairs; + if (kr::tls::fingerprintPairs(tlsFingerprint, first, count, line, sizeof(line)) == 0) { + Serial.println(F(" (the stored fingerprint is not hex -- run `cert`)")); + return; + } + Serial.printf(" %s\n", line); + } +} + +/** + * Store the identity and read it back. + * + * The read-back is what turns the assumption into an observation: ~900 bytes of PEM added to + * the SSID, the passphrase and the token has to fit the 20 KB NVS partition of + * huge_app.csv, and `putString()` returning 0 is the only other signal the API gives. If it + * does not fit, that is said out loud, in as many words, rather than discovered later as a + * fingerprint that changes on every boot. + * + * Lengths are compared; contents never are, and nothing about either buffer is printed. + */ +bool storeTlsIdentity() { + prefs.begin(kNvsNamespace, false); + const size_t wroteCert = prefs.putString(kKeyTlsCert, tlsIdentity.cert); + const size_t wroteKey = prefs.putString(kKeyTlsKey, tlsIdentity.key); + const size_t wroteFp = prefs.putString(kKeyTlsFp, tlsFingerprint); + prefs.end(); + if (wroteCert == 0 || wroteKey == 0 || wroteFp == 0) { + // A 0 from putString is the whole API's signal that a value did not go in. The three + // numbers here are what distinguishes "the partition is full" from "one record was + // rejected", and a length identifies a record, never a secret. + Serial.printf("[tls] NVS put refused: cert=%u key=%u fp=%u\n", (unsigned)wroteCert, + (unsigned)wroteKey, (unsigned)wroteFp); + return false; + } + + prefs.begin(kNvsNamespace, true); + const size_t backCert = prefs.getString(kKeyTlsCert, "").length(); + const size_t backKey = prefs.getString(kKeyTlsKey, "").length(); + const String backFp = prefs.getString(kKeyTlsFp, ""); + prefs.end(); + if (backCert != strlen(tlsIdentity.cert) || backKey != strlen(tlsIdentity.key) || + !backFp.equals(tlsFingerprint)) { + Serial.printf("[tls] NVS read-back mismatch: cert %u->%u key %u->%u fp %d\n", + (unsigned)strlen(tlsIdentity.cert), (unsigned)backCert, + (unsigned)strlen(tlsIdentity.key), (unsigned)backKey, backFp.length()); + return false; + } + return true; +} + +/** + * Load the identity, or make one. + * + * Runs at boot, once, and prints the fingerprint only when it just generated it -- the value + * is meant to be copied once, and a boot log that repeats it trains whoever reads it to + * ignore it. `cert` reprints it on demand; that pairing is what makes printing once safe. + */ +void loadTlsIdentity() { + prefs.begin(kNvsNamespace, true); + const String nvCert = prefs.getString(kKeyTlsCert, ""); + const String nvKey = prefs.getString(kKeyTlsKey, ""); + const String nvFp = prefs.getString(kKeyTlsFp, ""); + prefs.end(); + + const bool hadSomething = nvCert.length() || nvKey.length() || nvFp.length(); + const bool shaped = + copyPemField(nvCert, tlsIdentity.cert, sizeof(tlsIdentity.cert)) && + copyPemField(nvKey, tlsIdentity.key, sizeof(tlsIdentity.key)) && + nvFp.length() == kr::tls::kFingerprintHexChars && + kr::tls::fingerprintPrefix(nvFp.c_str(), kr::tls::kFingerprintHexChars, tlsFingerprint, + sizeof(tlsFingerprint)); + if (shaped && kr::tls::selfCheck(tlsIdentity)) { + tlsIdentityReady = true; + // No fingerprint on this line, on purpose: it prints on every boot, and the number the + // operator is meant to write down is printed when it changes. + Serial.println(F("[tls] identity loaded from NVS (reprint with `cert`)")); + return; + } + + if (hadSomething) { + // Loud, once, and *before* the new fingerprint, so the lines read as cause and effect. + // An identity that changed quietly is a pinned fingerprint that stopped meaning anything + // quietly, which is the one failure mode of self-signed TLS worth engineering against. + // The cause line says WHICH gate refused -- record absent, wrong shape, or whole but + // failing to verify -- with lengths only, because a length identifies a record and + // never a secret. Without it, "unusable" is the only fact the board offers, and a + // fingerprint that changes every boot is exactly the bug that needs a real diagnosis. + Serial.printf("[tls] stored records cert=%u key=%u fp=%u -- %s\n", (unsigned)nvCert.length(), + (unsigned)nvKey.length(), (unsigned)nvFp.length(), + shaped ? "selfCheck FAILED" : "shape refused (empty/oversized/bad fp)"); + Serial.println(F("[tls] !! THE STORED TLS IDENTITY IS UNUSABLE -- generating a new one")); + Serial.println(F("[tls] !! any fingerprint you pinned for this board is now wrong")); + } + clearTlsIdentity(); + + if (!kr::tls::generateIdentity(tlsIdentity, tlsFingerprint, sizeof(tlsFingerprint))) { + // The error code is the only thing here that identifies the failure, and it identifies a + // library condition, not the key. Nothing about a failed generation is a secret. + Serial.printf("[tls] IDENTITY GENERATION FAILED (mbed TLS error %d)\n", + kr::tls::lastError()); + Serial.println(F("[tls] this board has no TLS identity; arming is unchanged either way")); + clearTlsIdentity(); + return; + } + + if (!storeTlsIdentity()) { + Serial.println(F("[tls] IDENTITY NOT STORED -- NVS refused it or truncated it")); + Serial.println(F("[tls] this boot can use it; the next one will generate another, so")); + Serial.println(F("[tls] do not pin this fingerprint unless you must. Check the partition.")); + } + tlsIdentityReady = true; + Serial.println(F("[tls] identity generated -- pin this certificate:")); + printTlsFingerprint(); +} + +#else // KR_TLS == 0 -- the plaintext rollback + +void loadTlsIdentity() { + // No identity, no listener, no NVS records: :80 serves exactly what it served before + // phase 02. Said once so a board that quietly grew a KR_TLS=0 is not a mystery later -- + // `cert` says the same thing on demand, and `status` answers `"tls":0,"fp":""`. + Serial.println(F("[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel")); +} + +#endif // KR_TLS + // --------------------------------------------------------------------------- // The emit side // --------------------------------------------------------------------------- @@ -487,6 +677,7 @@ const char *netMode() { // point at them -- these buffers outlive the call. char ssidBuf[40]; char ipBuf[20]; +char tlsFpBuf[kr::kStatusFingerprintHex + 1]; void fillStatus(void *, kr::StatusSnapshot &s) { s.version = kr::kFirmwareVersion; @@ -521,6 +712,18 @@ void fillStatus(void *, kr::StatusSnapshot &s) { s.mouseFrames = mouseFrames; s.defaultToken = strcmp(token, kr::kDefaultToken) == 0; s.apForced = apMode == 1; + // The TLS half. `tls` describes the identity this board holds, not the link this frame is + // travelling on -- the listener is later in phase 02, and a controller running over + // plaintext must not be told otherwise by a field in the frame it received in clear. + // `fp` is the first bytes of the fingerprint so the page can show the same label `cert` + // prints; the full value stays on the console (src/status.h). + s.tls = tlsIdentityReady; + if (!tlsIdentityReady || + !kr::tls::fingerprintPrefix(tlsFingerprint, kr::kStatusFingerprintHex, tlsFpBuf, + sizeof(tlsFpBuf))) { + tlsFpBuf[0] = '\0'; + } + s.fp = tlsFpBuf; } void linkChanged(void *, bool linked) { @@ -894,6 +1097,8 @@ void printHelp() { " wifi clear forget the station network\n" " ap auto|force|off access point policy (auto = when station is down)\n" " token control passphrase, max 32 chars\n" + " cert TLS certificate: fingerprint, validity, names (never\n" + " the key, and nothing here writes one)\n" " arm | disarm same as the button, from the console\n" " mouse on|off offer the target a pointer (needs a reboot)\n" " reboot\n" @@ -926,6 +1131,53 @@ void printStatus() { (unsigned)stage.mouseQ.count()); } +// The `cert` command: what the operator needs in order to decide whether to trust what the +// browser is about to accept. There is deliberately no argument form and no subcommand -- +// nothing here can write a key, and nothing here prints one. The PEM body is not offered on +// request either: the fingerprint is the thing to compare, and a terminal with the key in +// its scrollback is a private key on somebody's laptop. +void printCert() { +#if KR_TLS + if (!tlsIdentityReady) { + Serial.println(F("cert no usable TLS identity on this board (see the boot log)")); + return; + } + Serial.println(F("cert SHA-256 of this board's certificate -- pin this:")); + printTlsFingerprint(); + + kr::tls::CertDetails d; + if (!kr::tls::certificateDetails(tlsIdentity.cert, d)) { + Serial.println(F(" validity: the stored certificate did not parse")); + return; + } + Serial.printf(" valid %s\n", d.validFrom); + Serial.printf(" .. %s\n", d.validTo); + + char names[128]; + if (kr::tls::sanList(names, sizeof(names)) == 0) { + Serial.println(F(" names: this build's name table would not render")); + return; + } + Serial.printf(" names %s\n", names); + if (!d.namesAsPrinted) { + // The stored certificate does not contain the extension these names come from, so the + // list above is this build's table and not the certificate's. Say which one it is, and + // what the consequence is: without that extension a client checks the CN instead, which + // browsers stopped accepting as a name years ago, so the certificate can be pinned by + // fingerprint but never recognised by name. `nvs erase` is the remedy and it is loud -- + // it reissues the identity, prints a new fingerprint once, and also forgets the + // passphrase and the network, so it is the operator's call and not the board's. + Serial.println(F(" !! the stored certificate does NOT carry that name list --")); + Serial.println(F(" it was issued by a different build. Treat it as unknown:")); + Serial.println(F(" no client can check the name, only the fingerprint above.")); + Serial.println(F(" `nvs erase` reissues it (new fingerprint, re-pin, and it")); + Serial.println(F(" also forgets the token and the Wi-Fi: operator's call).")); + } +#else + Serial.println(F("cert this build has no TLS identity (KR_TLS=0: plaintext rollback)")); +#endif +} + // Split a line into `verb` and the rest of the line, trimmed. Returns false if the // line is empty. bool splitLine(char *buf, char *&verb, char *&rest) { @@ -947,6 +1199,7 @@ void handleConsoleLine(char *buf) { if (strcmp(verb, "status") == 0) { printStatus(); return; } if (strcmp(verb, "reboot") == 0) { Serial.println(F("[boot] rebooting")); delay(50); esp_restart(); return; } + if (strcmp(verb, "cert") == 0) { printCert(); return; } if (strcmp(verb, "arm") == 0) { setArmed(true, "console"); return; } if (strcmp(verb, "disarm") == 0) { setArmed(false, "console"); return; } @@ -1113,13 +1366,45 @@ void setup() { : "CONTROL PLANE ONLY (this build cannot type)"); wifiBringUp(); + // The identity before the server that would serve it. Generating one costs a few hundred + // milliseconds of DRBG and ECDSA on this board (no PSRAM, so it is heap the loop needs); + // it happens here rather than next to loadSettings() so a first boot spends it after the + // USB descriptors are fixed and the network has started, and so the fingerprint lands in + // the log where somebody reading the boot output is already looking. + loadTlsIdentity(); + +#if KR_TLS + // The identity before the listener that would serve it: control.begin() opens :443 + // only when it is handed a usable pair, and opens nothing at all rather than a port + // that fails every handshake (control_server.cpp::startTlsListener). + if (tlsIdentityReady) control.setIdentity(&tlsIdentity); +#endif control.begin(onMessage, linkChanged, fillStatus, getToken, nullptr); +#if KR_TLS + // Numbers about the TLS listener and the heap it starts on -- no content, no key, + // nothing but sizes (invariant 4). This is the boot line the phase's heap numbers are + // read from, next to the sessions= lines loop() prints whenever a session opens or + // closes; .agents/HW_VERIFICATION.md's phase 02 rows say which lines to collect. + Serial.printf("[tls] https :%u %s heap=%u\n", (unsigned)kr::kHttpsPort, + tlsIdentityReady ? "listening" : "OFF (no identity)", + (unsigned)ESP.getFreeHeap()); +#endif + if (strcmp(token, kr::kDefaultToken) == 0) { Serial.println(F("!! the control passphrase is still the default -- set one with" " `token ` before plugging into a target")); } - Serial.print(F("url http://")); + Serial.print(F("url ")); + // The URL the operator is told to type is the URL this build answers. With TLS on, :80 + // carries a redirect and two 426s and nothing else, so printing http:// here would point + // at the one port that no longer has the page. The IP is the printed form on purpose -- + // mDNS is the first thing to fail on a strange network (README, Troubleshooting). +#if KR_TLS + Serial.print(F("https://")); +#else + Serial.print(F("http://")); +#endif Serial.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : String(kr::kApGw)); Serial.println(F("button short press = arm / disarm. Console: `help`")); @@ -1138,6 +1423,21 @@ void loop() { pollConsole(); wifiMaintain(now); +#if KR_TLS + // Heap cost of the TLS sessions, logged once per change and as numbers only + // (invariant 4): boot, first session, second session, and after both close, is + // exactly the sequence .agents/HW_VERIFICATION.md asks phase 02 to read off the + // serial line. A session open or close is a state change, not content; there is + // nothing here for the transcript to remember about who connected. + static uint32_t lastTlsSessions = 0xFFFFFFFF; + const uint32_t tlsSessions = control.tlsLiveSessions(); + if (tlsSessions != lastTlsSessions) { + lastTlsSessions = tlsSessions; + Serial.printf("[tls] sessions=%u heap=%u\n", (unsigned)tlsSessions, + (unsigned)ESP.getFreeHeap()); + } +#endif + // Silence disarms (config.h kNoClientTimeoutMs). A live controller says something // every couple of seconds -- the page's keepalive, or its automatic ping replies -- // so this only ever fires when the controller is gone without having said goodbye: diff --git a/src/status.h b/src/status.h new file mode 100644 index 0000000..cae1931 --- /dev/null +++ b/src/status.h @@ -0,0 +1,143 @@ +/** + * keyrelay -- the status frame: what the board tells its controller about itself. + * + * Pure: no Arduino include, no ESP-IDF include, so the frame the board puts on the wire is + * the frame `tests/test_proto.cpp` renders and measures here. That is why this file exists + * apart from control_server.h: the struct and its renderer used to sit behind `#include + * `, which made every claim about the status frame untestable on a host -- including + * the one that matters most, that the frame still fits `kStatusJsonCap`. A status that does + * not fit is a status that renders as nothing (`statusJson` returns 0 rather than a + * truncated lie), and the controller is then flying blind with no indication that the + * instrument panel is what failed. Adding a field used to be the sort of edit that could + * only be noticed on the device. + * + * Rules every field has to follow (AGENTS.md invariants 4 and 5): + * - counters and state, never content. `typed` and `typing` count characters; the + * characters themselves are nowhere in here, and no new field may carry any. + * - `fp` is the operator-visible label of a public value -- the leading bytes of the + * certificate's SHA-256 -- and never anything about the key. + * - a field is rendered from a value the board actually measured or holds; nothing here + * guesses. Absent means empty string or 0, and the page treats those as unknown. + */ +#pragma once + +#include +#include +#include + +#include "config.h" + +namespace kr { + +// The operator-visible slice of the certificate's SHA-256 fingerprint: its first +// kStatusFingerprintBytes bytes, rendered as hex. +// +// Eight bytes because it is long enough to distinguish two boards at a glance, short enough +// to read aloud over a shoulder, and short enough to sit next to a button on a phone. It is +// a label, not the pin: `cert` on the serial console prints all 64, and that is the value +// to compare against `openssl s_client … | x509 -fingerprint -sha256`. +constexpr size_t kStatusFingerprintBytes = 8; +constexpr size_t kStatusFingerprintHex = kStatusFingerprintBytes * 2; // 16 characters + +// What the board knows about itself, as shown to the controller. The JSON field +// names below are the contract the page in web_page.h reads. +struct StatusSnapshot { + const char *version = "0"; + uint32_t uptimeS = 0; + bool usbHost = false; // TinyUSB is attached to a target + bool armed = false; + bool netUp = false; + const char *net = "down"; // "sta" | "ap" | "down" + const char *ssid = ""; + const char *ip = ""; + uint32_t keysSent = 0; + uint32_t chordsSent = 0; + uint32_t dropped = 0; + uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text) + uint16_t typing = 0; // characters still queued in the paste in progress + bool rollover = false; + // The mouse: whether it can report at all, which buttons the board believes are + // down, and how many reports have gone out. `mouse` is false when the interface is + // switched off on the board or when the target has forced boot protocol -- in both + // cases the keyboard is untouched and M= frames are refused, not ignored. + bool mouse = false; + uint8_t mouseButtons = 0; + uint32_t mouseFrames = 0; + uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports + bool defaultToken = false; + bool apForced = false; + // The TLS identity: `tls` is true when this build compiled TLS in *and* the board holds + // a keypair-plus-certificate that passed its boot-time check -- the identity the :443 + // listener serves. It is still a statement about the identity, not about the transport + // of THIS frame: a status arriving over plaintext :80 with tls:1 means "this board does + // TLS", and only the URL the controller loaded on says whether the bytes in hand are + // encrypted. `fp` is the label above, "" when there is no identity to label (the + // KR_TLS=0 rollback, or a generation that failed). + bool tls = false; + const char *fp = ""; +}; + +namespace detail { + +// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather than +// trusting whatever a neighbouring access point is called. +inline void jsonSafe(char *dst, size_t cap, const char *src) { + size_t o = 0; + for (; src && *src && o + 1 < cap; src++) { + const char ch = *src; + if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue; + dst[o++] = ch; + } + dst[o] = '\0'; +} + +} // namespace detail + +/** + * Renders the status frame. + * + * Returns the length written, or 0 when `cap` could not hold it -- in which case nothing + * usable was written and the caller must not send the buffer. Never a truncated frame: the + * page reads `armed` out of this, and half a status is a status that lies. + * + * Callable from the serial console as well as the socket, which is the reason it is a free + * function on the snapshot rather than a method of the server. + */ +inline size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) { + char ssid[40]; + detail::jsonSafe(ssid, sizeof(ssid), s.ssid); + char fp[kStatusFingerprintHex + 1]; + // Two refusals, both deliberate. An identity that is not there has no label: a snapshot + // with tls:0 renders fp:"" however the caller filled it, so the page can never show a + // fingerprint for a board that has no certificate. And this is the JSON boundary -- a + // stored value that had somehow gone non-hex is cut where it stops being hex rather than + // pasted in, because a stray quote in a field is a field forgery. + size_t fplen = 0; + if (s.tls) { + for (; s.fp && s.fp[fplen] && fplen < kStatusFingerprintHex; fplen++) { + const char ch = s.fp[fplen]; + const bool hexish = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); + if (!hexish) break; + } + memcpy(fp, s.fp, fplen); + } + fp[fplen] = '\0'; + + const int n = snprintf( + out, cap, + "{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\"," + "\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu," + "\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u," + "\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"tls\":%u,\"fp\":\"%s\"}", + s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u, + s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent, + (unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState, + s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u, + (unsigned long)s.typedChars, (unsigned)s.typing, + s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames, + s.tls ? 1u : 0u, fp); + return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n; +} + +} // namespace kr diff --git a/src/tls.cpp b/src/tls.cpp new file mode 100644 index 0000000..209771b --- /dev/null +++ b/src/tls.cpp @@ -0,0 +1,455 @@ +/** + * keyrelay -- the mbed TLS half of the board's identity. See tls.h for the reasoning + * (why on-board, why ECDSA) and for the evidence that these symbols link; see tls_pem.h + * for everything that is strings, which is host-tested. + * + * The shape of every function here is the same: acquire contexts through the RAII + * wrappers below and return as soon as anything fails. mbed TLS contexts own heap + * allocations, so an early `return` past a `*_free()` is a leak that repeats on every + * boot retry -- and on a board with no PSRAM, a leak on the error path is how a device + * becomes unbootable one failed provisioning attempt at a time. The wrappers make the + * error path and the success path free the same things. + */ +#include "tls.h" + +#if KR_TLS + +#include "mbedtls/ctr_drbg.h" +#include "mbedtls/entropy.h" +#include "mbedtls/md.h" +#include "mbedtls/pk.h" +#include "mbedtls/sha256.h" +#include "mbedtls/version.h" +#include "mbedtls/x509_crt.h" + +// The configuration the core ships has to have these or this file cannot do its job. +// Naming the missing one beats a wall of undefined references at the end of the link. +#if !defined(MBEDTLS_ECP_C) +#error "KR_TLS needs MBEDTLS_ECP_C (CONFIG_MBEDTLS_ECP_C)" +#endif +#if !defined(MBEDTLS_ECDSA_C) +#error "KR_TLS needs MBEDTLS_ECDSA_C (CONFIG_MBEDTLS_ECDSA_C)" +#endif +#if !defined(MBEDTLS_ECP_DP_SECP256R1_ENABLED) +#error "KR_TLS needs MBEDTLS_ECP_DP_SECP256R1_ENABLED" +#endif +#if !defined(MBEDTLS_X509_CRT_WRITE_C) || !defined(MBEDTLS_X509_CREATE_C) +#error "KR_TLS needs MBEDTLS_X509_CRT_WRITE_C and MBEDTLS_X509_CREATE_C" +#endif +#if !defined(MBEDTLS_PK_WRITE_C) || !defined(MBEDTLS_PK_PARSE_C) +#error "KR_TLS needs MBEDTLS_PK_WRITE_C and MBEDTLS_PK_PARSE_C" +#endif +#if !defined(MBEDTLS_ENTROPY_C) || !defined(MBEDTLS_CTR_DRBG_C) +#error "KR_TLS needs MBEDTLS_ENTROPY_C and MBEDTLS_CTR_DRBG_C" +#endif +#if !defined(MBEDTLS_MD_C) || !defined(MBEDTLS_SHA256_C) +#error "KR_TLS needs MBEDTLS_MD_C and MBEDTLS_SHA256_C for the SHA-256 signature" +#endif +#if !defined(MBEDTLS_X509_CRT_PARSE_C) +#error "KR_TLS needs MBEDTLS_X509_CRT_PARSE_C for selfCheck()" +#endif + +namespace kr { +namespace tls { + +namespace { + +// Contexts, RAII-wrapped. Constructed on the stack of whoever called us; freed on every +// exit path including the ones that return from the middle of a sequence. +struct Entropy { + mbedtls_entropy_context ctx; + Entropy() { mbedtls_entropy_init(&ctx); } + ~Entropy() { mbedtls_entropy_free(&ctx); } +}; + +struct Drbg { + mbedtls_ctr_drbg_context ctx; + Drbg() { mbedtls_ctr_drbg_init(&ctx); } + ~Drbg() { mbedtls_ctr_drbg_free(&ctx); } +}; + +struct Key { + mbedtls_pk_context ctx; + Key() { mbedtls_pk_init(&ctx); } + ~Key() { mbedtls_pk_free(&ctx); } +}; + +struct CertWriter { + mbedtls_x509write_cert ctx; + CertWriter() { mbedtls_x509write_crt_init(&ctx); } + ~CertWriter() { mbedtls_x509write_crt_free(&ctx); } +}; + +struct Cert { + mbedtls_x509_crt ctx; + Cert() { mbedtls_x509_crt_init(&ctx); } + ~Cert() { mbedtls_x509_crt_free(&ctx); } +}; + +int g_lastError = 0; + +// Records the failure and returns it, so a call site reads `if (fail(rc = ...))`. +inline int note(int code) { + if (code != 0) g_lastError = code; + return code; +} + +// Distinguishes "the library said no" from "this file said no" for the steps that are +// ours alone (a PEM that would not fit, a build date that would not parse). Those are +// bugs in this tree, not library conditions, and they must not read as 0 in the log. +constexpr int kErrArmourTooSmall = -1; +constexpr int kErrBuildDate = -2; +constexpr int kErrNoBuffer = -3; + +// Personalises the DRBG so a stream taken from this generator can never be mistaken for +// one from another device with the same seed source. It is not a secret and carries no +// entropy; it exists so the bytes have a named purpose. +const unsigned char kPersonalisation[] = "keyrelay"; + +// RFC 5280 4.2.1.6. The writer takes the OID as the *content bytes* of the DER OBJECT +// IDENTIFIER, not as a dotted string -- see kr::tls::kSanOidTlv in tls_pem.h for what +// passing the dotted string does (it is not an error: it is a well-formed certificate with +// the SAN under an OID no client recognises). The constant is host-tested; this call site +// only hands it over. +// +// Welded at compile time as well, the way tls_session.cpp welds the three copied error codes +// to the library's macros. There is no library macro to compare against here -- the SAN OID is +// spelled out inside x509write.c rather than in a public header -- so this pins the bytes the +// writer must receive, and the certificate a board produced pins the framing around them. If it +// ever stops compiling, somebody put the dotted text back where the DER content belongs, and +// that mistake does not fail a handshake: it produces a valid certificate whose SAN no client +// can find (README bug 11). +static_assert(kSanOidTlvLen == 5, "subjectAltName is one OBJECT IDENTIFIER with 3 content bytes"); +static_assert(kSanOidTlv[0] == 0x06 && kSanOidTlv[1] == 0x03, "tag 6, length 3"); +static_assert(kSanOidTlv[2] == 0x55 && kSanOidTlv[3] == 0x1D && kSanOidTlv[4] == 0x11, + "OID 2.5.29.17 must go to the writer as the DER content bytes 55 1D 11, never as text"); + +// 16 bytes of serial: under the 20-byte RFC 5280 cap, and 64 bits of randomness would be +// plenty but a collision between two boards is the sort of thing worth not thinking +// about. The high bit is cleared because a DER INTEGER with it set is negative and some +// verifiers reject a negative serial outright; the low bit is set so an all-zero draw +// (which no self-respecting DRBG produces, and which no verifier accepts) cannot happen. +constexpr size_t kSerialBytes = 16; + +// Scratch for the certificate DER. mbed TLS writes it backwards from the end of the +// buffer, so the buffer has to be at least as large as the finished certificate: +// measured ~430 bytes for this identity, 704 here. +// +// It is static rather than local on purpose. This runs once, at boot, before loop() has +// anything to interleave with -- and 704 bytes is most of a spare 8 kB task stack that +// the WebSocket path is going to want back. If a second caller ever appears, this +// becomes a parameter, not a silent second static. +constexpr size_t kDerScratchMax = 704; +uint8_t gDerScratch[kDerScratchMax]; + +// The validity window, from the build date. See tls_pem.h: the arithmetic is pure and +// tested; this is where it is applied. +bool validityWindow(char *notBefore, size_t beforeCap, char *notAfter, size_t afterCap) { + int y = 0; + int mo = 0; + int d = 0; + if (!parseBuildDate(__DATE__, y, mo, d)) { + g_lastError = kErrBuildDate; + return false; + } + // One day back: __DATE__ is the build machine's local date, and a controller whose + // clock is a few hours behind it would otherwise see "not yet valid" on a certificate + // generated minutes ago. + addDays(y, mo, d, -1); + if (utcTime(notBefore, beforeCap, y, mo, d, 0, 0, 0) == 0) { + g_lastError = kErrBuildDate; + return false; + } + addYears(y, mo, d, kCertValidityYears); + if (utcTime(notAfter, afterCap, y, mo, d, 23, 59, 59) == 0) { + g_lastError = kErrBuildDate; + return false; + } + return true; +} + +// Everything generateIdentity() does, without the all-or-nothing guarantee the public +// entry point adds. The two-PEM buffer pair is only ever handed to the caller complete. +bool generateInto(PemPair &out, char *fpHex, size_t fpCap) { + out.cert[0] = '\0'; + out.key[0] = '\0'; + if (fpHex && fpCap) fpHex[0] = '\0'; + + Entropy entropy; + Drbg drbg; + if (note(mbedtls_ctr_drbg_seed(&drbg.ctx, mbedtls_entropy_func, &entropy.ctx, + kPersonalisation, sizeof(kPersonalisation) - 1)) != 0) { + return false; + } + + Key key; + if (note(mbedtls_pk_setup(&key.ctx, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY))) != 0) { + return false; + } + // The task says what happens on failure and it is not "silently retry with RSA": a + // board that cannot draw a key has an entropy problem, and the caller reporting a + // failed identity is the correct end of the story. + if (note(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key.ctx), + mbedtls_ctr_drbg_random, &drbg.ctx)) != 0) { + return false; + } + + // The key first. A key this context cannot export is a key no certificate should be + // issued for, and the exporter is the step with a hard buffer requirement. + if (note(mbedtls_pk_write_key_pem( + &key.ctx, reinterpret_cast(out.key), sizeof(out.key))) != 0) { + out.key[0] = '\0'; + return false; + } + + char subject[64]; + if (subjectLine(subject, sizeof(subject)) == 0) { + g_lastError = kErrArmourTooSmall; + return false; + } + + uint8_t san[kSanDerMax]; + const size_t sanLen = sanExtensionDer(san, sizeof(san)); + if (sanLen == 0) { + g_lastError = kErrArmourTooSmall; + return false; + } + + uint8_t serial[kSerialBytes]; + if (note(mbedtls_ctr_drbg_random(&drbg.ctx, serial, sizeof(serial))) != 0) return false; + serial[0] = static_cast(serial[0] & 0x7F); + serial[sizeof(serial) - 1] = static_cast(serial[sizeof(serial) - 1] | 0x01); + + char notBefore[16]; + char notAfter[16]; + if (!validityWindow(notBefore, sizeof(notBefore), notAfter, sizeof(notAfter))) return false; + + CertWriter crt; + mbedtls_x509write_crt_set_version(&crt.ctx, MBEDTLS_X509_CRT_VERSION_3); + if (note(mbedtls_x509write_crt_set_serial_raw(&crt.ctx, serial, sizeof(serial))) != 0) { + return false; + } + if (note(mbedtls_x509write_crt_set_validity(&crt.ctx, notBefore, notAfter)) != 0) { + return false; + } + // Self-signed: the board is its own one-member CA, so issuer and subject are the same + // string and the same key signs. That is what makes the fingerprint the whole trust + // story -- there is no chain to check it against, which is exactly why the operator + // pins the fingerprint once instead of trusting a name. + if (note(mbedtls_x509write_crt_set_subject_name(&crt.ctx, subject)) != 0) return false; + if (note(mbedtls_x509write_crt_set_issuer_name(&crt.ctx, subject)) != 0) return false; + mbedtls_x509write_crt_set_subject_key(&crt.ctx, &key.ctx); + mbedtls_x509write_crt_set_issuer_key(&crt.ctx, &key.ctx); + mbedtls_x509write_crt_set_md_alg(&crt.ctx, MBEDTLS_MD_SHA256); + // A CA, no path-length constraint, with the key identifier so a pinned fingerprint can + // be matched against the certificate a handshake actually presented. + if (note(mbedtls_x509write_crt_set_basic_constraints(&crt.ctx, 1, -1)) != 0) return false; + if (note(mbedtls_x509write_crt_set_subject_key_identifier(&crt.ctx)) != 0) return false; + // The extension browsers check. Without it every modern client reports the name as + // mismatched no matter what the CN says -- CN-only matching was removed from + // browsers years ago. + size_t oidLen = 0; + const uint8_t *oid = sanOidDer(&oidLen); + if (oidLen != 3 || + note(mbedtls_x509write_crt_set_extension(&crt.ctx, reinterpret_cast(oid), + oidLen, 0, san, sanLen)) != 0) { + return false; + } + + const int written = mbedtls_x509write_crt_der(&crt.ctx, gDerScratch, sizeof(gDerScratch), + mbedtls_ctr_drbg_random, &drbg.ctx); + if (written <= 0) { + note(written); + out.key[0] = '\0'; // no certificate means this key is not an identity either + return false; + } + const uint8_t *der = gDerScratch + sizeof(gDerScratch) - static_cast(written); + const size_t derLen = static_cast(written); + + if (wrapPem(kPemCertLabel, der, derLen, out.cert, sizeof(out.cert)) == 0) { + g_lastError = kErrArmourTooSmall; + out.key[0] = '\0'; + return false; + } + + // The fingerprint is taken over the DER that went into the armour, which is what every + // client hashes too -- not over the PEM text, and not over the public key. + if (fpHex && fpCap) { + uint8_t digest[kSha256Len]; + if (note(mbedtls_sha256(der, derLen, digest, 0)) != 0) { + out.cert[0] = '\0'; + out.key[0] = '\0'; + return false; + } + if (!fingerprintHex(digest, sizeof(digest), fpHex, fpCap)) { + g_lastError = kErrNoBuffer; + out.cert[0] = '\0'; + out.key[0] = '\0'; + return false; + } + } + return true; +} + +} // namespace + +bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap) { + g_lastError = 0; + const bool ok = generateInto(out, fpHex, fpCap); + if (!ok) { + // Never half an identity: whatever the caller stores is either complete or empty. + out.cert[0] = '\0'; + out.key[0] = '\0'; + if (fpHex && fpCap) fpHex[0] = '\0'; + } + return ok; +} + +bool selfCheck(const PemPair &pair) { + g_lastError = 0; + if (!pair.cert[0] || !pair.key[0]) { + g_lastError = kErrNoBuffer; + return false; + } + + Entropy entropy; + Drbg drbg; + if (note(mbedtls_ctr_drbg_seed(&drbg.ctx, mbedtls_entropy_func, &entropy.ctx, + kPersonalisation, sizeof(kPersonalisation) - 1)) != 0) { + return false; + } + + // The length passed includes the terminating NUL: that is how the parser tells PEM + // from DER, and a length one short is a parse failure that looks like corruption. + Cert cert; + if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast(pair.cert), + strlen(pair.cert) + 1)) != 0) { + return false; + } + + Key key; + if (note(mbedtls_pk_parse_key(&key.ctx, reinterpret_cast(pair.key), + strlen(pair.key) + 1, nullptr, 0, mbedtls_ctr_drbg_random, + &drbg.ctx)) != 0) { + return false; + } + + // Not just "both parsed": the point of the boot check is that this certificate's + // public key is the one this private key signs with. A cert from one generation and a + // key from another is exactly what a torn NVS write can produce, and both parse fine. + return note(mbedtls_pk_check_pair(&cert.ctx.pk, &key.ctx, mbedtls_ctr_drbg_random, + &drbg.ctx)) == 0; +} + +bool certificateDetails(const char *certPem, CertDetails &out) { + g_lastError = 0; + out.validFrom[0] = '\0'; + out.validTo[0] = '\0'; + out.namesAsPrinted = false; + if (!certPem || !certPem[0]) { + g_lastError = kErrNoBuffer; + return false; + } + + Cert cert; + if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast(certPem), + strlen(certPem) + 1)) != 0) { + return false; + } + + // The window as the certificate states it. dateTimeText() has already range-checked every + // component, so a stored certificate that parses but carries a date this tree would never + // have written is reported as unreadable rather than printed as something merely odd. + const mbedtls_x509_time &from = cert.ctx.valid_from; + const mbedtls_x509_time &to = cert.ctx.valid_to; + if (dateTimeText(out.validFrom, sizeof(out.validFrom), from.year, from.mon, from.day, + from.hour, from.min, from.sec) == 0 || + dateTimeText(out.validTo, sizeof(out.validTo), to.year, to.mon, to.day, to.hour, + to.min, to.sec) == 0) { + g_lastError = kErrBuildDate; + out.validFrom[0] = '\0'; + out.validTo[0] = '\0'; + return false; + } + + // The names, checked rather than assumed. Deliberately not a parse of the parsed + // certificate: the library hands the SAN back as a per-entry sequence whose exact span its + // own documentation describes differently from version to version, and a decoder that is + // wrong about that prints a name list the board never issued. Searching the certificate's + // raw DER for the extension this build writes is version-independent, and can only ever + // answer "these exact names, under that OID: yes or no". The OID is part of the search + // because it is part of the question -- a certificate whose SAN value sits under a + // mislabelled OID carries no SAN to a client, and must not be reported as carrying one. + out.namesAsPrinted = derCarriesSan(cert.ctx.raw.p, cert.ctx.raw.len); + return true; +} + +bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap) { + g_lastError = 0; + if (!certPem || !certPem[0] || !fpHex || !fpCap) { + g_lastError = kErrNoBuffer; + return false; + } + + Cert cert; + if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast(certPem), + strlen(certPem) + 1)) != 0) { + return false; + } + uint8_t digest[kSha256Len]; + if (note(mbedtls_sha256(cert.ctx.raw.p, cert.ctx.raw.len, digest, 0)) != 0) return false; + if (!fingerprintHex(digest, sizeof(digest), fpHex, fpCap)) { + g_lastError = kErrNoBuffer; + return false; + } + return true; +} + +int lastError() { return g_lastError; } + +} // namespace tls +} // namespace kr + +#else // KR_TLS == 0 -- the plaintext rollback. See README "Rolling TLS back". + +namespace kr { +namespace tls { + +// The rollback build compiles the identity away rather than deleting it: the callers in +// main.cpp and control_server.cpp keep linking, the 2 kB PemPair stays a definition and +// not a bug, and the only thing that changes on the wire is that nothing is encrypted. +// A caller that asks for an identity in this build gets a refusal, never a fake one. +bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap) { + (void)fpCap; + out.cert[0] = '\0'; + out.key[0] = '\0'; + if (fpHex) fpHex[0] = '\0'; + return false; +} + +bool selfCheck(const PemPair &pair) { + (void)pair; + return false; +} + +bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap) { + (void)certPem; + (void)fpHex; + (void)fpCap; + return false; +} + +bool certificateDetails(const char *certPem, CertDetails &out) { + (void)certPem; + out.validFrom[0] = '\0'; + out.validTo[0] = '\0'; + out.namesAsPrinted = false; + return false; +} + +int lastError() { return 0; } + +} // namespace tls +} // namespace kr + +#endif // KR_TLS diff --git a/src/tls.h b/src/tls.h new file mode 100644 index 0000000..57885ff --- /dev/null +++ b/src/tls.h @@ -0,0 +1,174 @@ +/** + * keyrelay -- the board's TLS identity: an ECDSA secp256r1 keypair and a self-signed + * certificate for the names in config.h, both as PEM, plus the certificate's SHA-256 + * fingerprint. + * + * This is the half that needs the crypto library. Everything that is *strings* -- PEM + * armour, the fingerprint's hex, the subject and SAN text, the extension's DER, the + * validity arithmetic -- lives in tls_pem.h, which is pure and runs on a host under + * sanitisers (tests/test_tls_pem.cpp). This file deliberately has no host suite: it is + * a sequence of library calls whose failure modes are error codes, and pretending + * otherwise would mean either a fake library or a test that asserts nothing. + * + * Why the key is made here rather than shipped + * -------------------------------------------- + * A key in the tree is a key on every board forever, and a key the person building this + * has to generate by hand is a first build that fails for everyone who does not. So the + * board makes one on first boot: per-board, never leaves this device, survives + * reflashing because NVS does, and dies only with an NVS erase -- which is the + * documented rotation, and prints a new fingerprint. See README "Security model and + * trust boundaries". + * + * Why ECDSA secp256r1 rather than RSA 2048 + * ---------------------------------------- + * This board has no PSRAM. An EC keypair costs a fraction of the RAM and flash of RSA + * during both generation and handshake, and is the stronger key at that size. Every + * browser and every `openssl s_client` on the platforms the README targets accepts a + * self-signed EC certificate; nothing here needs anything rarer. + * + * What this identity does NOT protect + * ----------------------------------- + * It is not a credential and nothing checks it as one. Client certification is deliberately + * off (tls_session.h): the passphrase is the controller's authority, the board's button is the + * operator's, and no code path anywhere may treat "the handshake completed" as permission to + * type. A stolen key buys an attacker a convincing certificate against controllers that never + * compared a fingerprint -- and still not one keystroke past the passphrase, the 5-try lockout + * or the armed state. Two rules follow, and both outlive this file: the key is written exactly + * once, by main.cpp, into NVS, and never printed, hashed-in-a-way-that-reads-as-the-key, or + * committed; the fingerprint (a digest of the public certificate) is the only identity string + * that is ever shown to anybody. + * + * How this was verified to LINK, not assumed to + * --------------------------------------------- + * The core ships mbed TLS prebuilt, so the question is whether the symbols and the + * build-time configuration are actually there. They are, in + * `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`: + * + * flags/ld_libs -lmbedtls_2 -lmbedcrypto -lmbedx509 -lesp-tls -llwip + * flags/includes mbedtls/mbedtls/include, esp-tls, tcp_transport/include + * sdkconfig.h CONFIG_MBEDTLS_ECP_C, CONFIG_MBEDTLS_ECDSA_C, + * CONFIG_MBEDTLS_ECP_DP_SECP256R1_ENABLED, + * CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA, + * CONFIG_MBEDTLS_PEM_WRITE_C, CONFIG_MBEDTLS_TLS_SERVER + * esp_config.h MBEDTLS_X509_CRT_WRITE_C, MBEDTLS_X509_CREATE_C, + * MBEDTLS_PK_WRITE_C, MBEDTLS_ASN1_WRITE_C, MBEDTLS_BASE64_C, + * MBEDTLS_ENTROPY_C, MBEDTLS_CTR_DRBG_C, MBEDTLS_MD_C, + * MBEDTLS_SHA256_C, MBEDTLS_X509_CRT_PARSE_C, MBEDTLS_PK_PARSE_C + * + * and every call below was checked against the archives with the toolchain's nm: + * mbedtls_x509write_crt_der / _init / _set_extension / _set_serial_raw / _set_validity + * / _set_subject_name / _set_issuer_name / _set_md_alg / _set_basic_constraints + * / _set_subject_key_identifier in libmbedx509.a; mbedtls_pk_setup, mbedtls_pk_ec, + * mbedtls_ecp_gen_key, mbedtls_pk_write_key_pem, mbedtls_pk_parse_key, + * mbedtls_pk_check_pair, mbedtls_ctr_drbg_seed, mbedtls_ctr_drbg_random, + * mbedtls_entropy_init, mbedtls_sha256 in libmbedcrypto.a. + * + * The static asserts below turn "the symbol was not there" from a linker error at the + * end of a four-minute build into a compile error naming the feature, which is the + * difference between a two-second diagnosis and a confusing one. + */ +#pragma once + +#include +#include + +#include "config.h" +#include "tls_pem.h" + +namespace kr { +namespace tls { + +/** + * One identity, both halves armoured. Two kPemMax buffers, so a PemPair is ~2 kB: keep + * it at namespace scope or in whatever long-lived buffer reads it out of NVS, not in an + * auto variable on the 8 kB loop stack. + */ +struct PemPair { + char cert[kPemMax]; + char key[kPemMax]; +}; + +/** + * Generate a fresh keypair and self-sign a certificate for sanEntries(). + * + * secp256r1 keypair -> out.key, "EC PRIVATE KEY" PEM + * CN == issuer == subjectLine() -> out.cert, "CERTIFICATE" PEM + * SHA-256 of the certificate DER -> fpHex, lowercase hex (pass fpHex == nullptr to skip) + * + * Signed with SHA-256, serial from the board's own DRBG, valid from the build date + * (backdated one day, so a controller whose clock is slightly behind the build machine + * does not see "not yet valid") for kCertValidityYears, marked as a CA with a subject + * key identifier, and carrying the subjectAlternativeName extension built by + * tls_pem.h's sanExtensionDer() under the OID from sanOidDer() -- because the browser + * checks the SAN, not the CN, and it checks it *by OID*: the value alone, labelled with + * anything else, is a certificate with no SAN at all (README bug 11). + * + * Returns false, with both PEM buffers and the fingerprint left empty, if any step + * fails. Nothing is written to NVS here; whoever calls this owns storage, and a + * half-written identity is what the boot-time selfCheck() exists to catch. + */ +bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap); + +/** + * Reparse the certificate and the key and prove they are a pair. + * + * Called at boot over whatever NVS returned. A record that survived a power loss in the + * middle of a write parses as *something* often enough to be worse than nothing: it + * produces a listener that answers every handshake with a fatal alert, and a browser + * that says only "wrong certificate". Reporting it is the whole point. + */ +bool selfCheck(const PemPair &pair); + +/** + * SHA-256 fingerprint of an armoured certificate, as lowercase hex -- the value the + * operator pins and the value `openssl s_client ... | x509 -fingerprint -sha256` prints. + * Works on a certificate loaded from NVS, so the fingerprint can be re-derived after a + * reflash instead of trusted from a stale stored copy. + */ +bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap); + +/** + * What a stored certificate says about its own lifetime, and whether it matches the + * names this build issues certificates for. `cert` on the serial console prints exactly + * this, which is the whole reason it exists: the operator deciding whether to trust a + * pinned fingerprint should read it off the certificate, not off a build-time constant. + * + * The two date strings are rendered by tls_pem.h's dateTimeText() from the parsed + * certificate, so they are what the certificate carries -- including for a certificate + * generated by an older firmware and re-read from NVS, whose window came from that build's + * date and nothing to do with this one. + */ +constexpr size_t kCertTimeTextCap = 24; // "2026-11-08 00:00:00 UTC" plus the NUL + +struct CertDetails { + char validFrom[kCertTimeTextCap]; + char validTo[kCertTimeTextCap]; + // True when the certificate's own bytes contain exactly the SAN extension that + // sanEntries() + sanExtensionDer() + sanOidDer() produce -- OID included, because that + // is what makes it the extension. False does not mean the certificate is bad; + // it means the name list printed next to it came from this build's table and the + // certificate did not confirm it -- so the console says so rather than implying it did. + bool namesAsPrinted; +}; + +/** + * Parse a stored certificate and fill the above. False means it did not parse at all -- + * the same verdict selfCheck() reaches by a longer route -- and `out` is then empty. + * + * Nothing here consults the board's clock. There is no time source on this device: an + * "expired" verdict computed against a clock that resets to 1970 on every boot would be a + * guess dressed up as a measurement, so the window is printed and the operator, who has a + * wristwatch, decides. + */ +bool certificateDetails(const char *certPem, CertDetails &out); + +/** + * The library's error code from the last call that failed, 0 when the last call + * succeeded. Diagnostic only, and never any part of the identity: mbed TLS returns + * negative bit-coded errno values, which is the only thing about a failed generation a + * log is allowed to print. + */ +int lastError(); + +} // namespace tls +} // namespace kr diff --git a/src/tls_pem.h b/src/tls_pem.h new file mode 100644 index 0000000..345e303 --- /dev/null +++ b/src/tls_pem.h @@ -0,0 +1,876 @@ +/** + * keyrelay -- the string half of the board's TLS identity. + * + * Pure: no Arduino include, no IDF include, no crypto-library include, so the exact code + * that runs on the board is compiled and exercised on a host under ASan/UBSan + * (tests/test_tls_pem.cpp). That rule (AGENTS.md "Editing rules") is the only reason + * this file is separate from tls.h: every way an identity can go silently wrong goes + * wrong here, and none of them is visible from a failed handshake. A PEM one byte short + * of its buffer, a fingerprint off by one nibble, a base64 body that decodes to + * *nearly* the right bytes -- each of those produces a certificate that parses, pins to + * the wrong value, and fails closed with no clue what was wrong. + * + * What lives here: + * - PEM armour in both directions (wrapPem / pemFind) + * - the operator-facing rendering of a certificate digest (fingerprintHex, and the two + * shapes the console and the status frame need: fingerprintPairs / fingerprintPrefix) + * - derContains(), so a claim about what a certificate carries can be checked against the + * certificate instead of against the table that was supposed to have gone into it + * - the ONE table of names the certificate is issued for (sanEntries), and the two + * renderings built from it: subjectLine() for the subject string the certificate + * carries, sanList() for the human-readable list the operator is told to expect. + * The certificate and the strings a human compares it against come from the same + * three entries, which is the only reason they cannot drift apart. + * - the DER value of the subjectAlternativeName extension (sanExtensionDer), so the + * bytes that end up inside the certificate are the host-tested ones rather than a + * library call whose IP-address encoding differs between versions; the OID that labels + * it (sanOidDer, as DER content bytes because that is what the writer takes); the whole + * Extension SEQUENCE as it goes on the wire (sanExtensionFullDer); and + * derCarriesSan(), which asks whether a stored certificate carries those bytes under + * that OID -- the question a client asks, rather than the easier one that a + * mislabelled extension still passes. + * - the validity-window arithmetic (parseBuildDate / addDays / addYears / utcTime) and + * its human-readable twin, dateTimeText(): "2026-11-08 00:00:00 UTC". + * + * What does NOT live here: keys, curve arithmetic, key or certificate parsing. That is + * tls.h / tls.cpp, which this suite must never include -- including it would take the + * host build with it and the pure-module rule would be gone. + * + * What these strings do NOT protect + * --------------------------------- + * Rendering an identity is not authorising anything. No decision about arming, about who may + * type, or about whether a link is alive may ever be taken from a value in this file: the + * fingerprint is a public digest that a human compares, not a token a machine trusts. What this + * module owes the operator is the opposite of cleverness -- refuse rather than truncate, refuse + * rather than decode *nearly* the right bytes, never sanitise a malformed armour block into + * something that parses (AGENTS.md invariant 5). Nothing in this file prints anything -- it + * returns lengths and bools and lets the caller speak -- and the caller's rule is the one in + * AGENTS.md: the key's PEM goes to NVS and nowhere else, so `cert` prints a fingerprint, a + * validity window and a name list, never a body. The only identity string that ever leaves the + * board is a digest of the certificate, which is public by construction; that asymmetry is + * deliberate, and it is why a log line from this code can be trusted not to be a leak. + */ +#pragma once + +#include +#include +#include + +#include "config.h" + +namespace kr { +namespace tls { + +// --------------------------------------------------------------------------- +// PEM armour +// --------------------------------------------------------------------------- + +// The label the certificate is armoured under, and the label a SEC1 EC private key is +// exported under. Both are wire formats, not free text: whatever reads the PEM looks for +// these exact strings. +constexpr char kPemCertLabel[] = "CERTIFICATE"; +constexpr char kPemKeyLabel[] = "EC PRIVATE KEY"; + +constexpr size_t kPemLineChars = 64; // characters of base64 per armour line +constexpr size_t kPemLabelMax = 32; // sanity bound: a longer label is a bug, not a label +constexpr char kPemBeginMark[] = "-----BEGIN "; +constexpr char kPemEndMark[] = "-----END "; +constexpr char kPemMarkTail[] = "-----"; +constexpr size_t kPemMarkTailLen = 5; + +namespace detail { + +constexpr char kB64Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// -1 for anything that is not a base64 digit, including whitespace and padding, so the +// callers can test one thing. +inline int b64Value(char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +inline bool isArmourSpace(char c) { + return c == '\r' || c == '\n' || c == ' ' || c == '\t'; +} + +// How many '\n'-terminated lines a base64 body of b64Len characters occupies. +inline size_t armourLineCount(size_t b64Len) { + return b64Len == 0 ? 0 : (b64Len + kPemLineChars - 1) / kPemLineChars; +} + +// base64 with a newline after every 64 characters, and after the last short line. +// Returns the characters written (including newlines); does not NUL-terminate. +// +// 64 is a multiple of 4 and every input group emits exactly 4 characters, so a group +// never straddles a line boundary -- that is what makes the wrap above correct rather +// than almost correct. +inline size_t base64Wrapped(const uint8_t *in, size_t len, char *out) { + size_t o = 0; + size_t i = 0; + size_t col = 0; + for (; i + 3 <= len; i += 3) { + const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8) | in[i + 2]; + out[o++] = kB64Alphabet[(v >> 18) & 63]; + out[o++] = kB64Alphabet[(v >> 12) & 63]; + out[o++] = kB64Alphabet[(v >> 6) & 63]; + out[o++] = kB64Alphabet[v & 63]; + col += 4; + if (col == kPemLineChars) { + out[o++] = '\n'; + col = 0; + } + } + const size_t rem = len - i; + if (rem) { + uint32_t v = uint32_t(in[i]) << 16; + if (rem == 2) v |= uint32_t(in[i + 1]) << 8; + out[o++] = kB64Alphabet[(v >> 18) & 63]; + out[o++] = kB64Alphabet[(v >> 12) & 63]; + out[o++] = rem == 2 ? kB64Alphabet[(v >> 6) & 63] : '='; + out[o++] = '='; + // The padded group is four characters like any other, and it has to be counted or a + // body whose last line is only that group (1 or 2 bytes mod 48, which is exactly + // where a key or a certificate often lands) gets written with no terminator and runs + // straight into the END marker. Round-tripping still works -- pemFind ignores + // whitespace, including its absence -- so nothing but this count and the cross-check + // against an independent base64 would ever have noticed. + col += 4; + } + if (col) out[o++] = '\n'; + return o; +} + +// "-----BEGIN