phase: 02_tls_control_channel

**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`.
This commit is contained in:
2026-11-09 05:51:57 -05:00
parent 121491bab4
commit e0780f58e0
61 changed files with 8860 additions and 359 deletions
+24 -10
View File
@@ -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 <known>` 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` | 200 + status JSON / a session whose only verify failure is *self-signed*. `HTTP 000` / connection refused = listener not up | |
| fingerprint printed **once**, on serial | serial console boot log after `nvs erase`; then `python3 tools/console.py cert` (opens the port — one attempt, capped) | SHA-256 fingerprint appears exactly once at first boot, on demand afterwards; **never** the key; survives reflashing (NVS) | |
| pinned fingerprint == handshake cert | `openssl s_client -connect keyrelay.local:443 </dev/null 2>/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` | 200 + status JSON / a session whose only verify failure is *self-signed*. `HTTP 000` / connection refused = listener not up | ok — measured 2026-11-09 on the flashed bridge: `openssl s_client` negotiates **TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384**; `curl -sk https://10.2.0.22/` → 48 598 bytes (= the gate's count); `/status` without the token over TLS → **401**, path still gated. `hw_probe`'s `https` line now reports `HTTP 200 (TLS listener is up)` |
| fingerprint printed **once**, on serial | serial console boot log after `nvs erase`; then `python3 tools/console.py cert` (opens the port — one attempt, capped) | SHA-256 fingerprint appears exactly once at first boot, on demand afterwards; **never** the key; survives reflashing (NVS) | ok (minus the `nvs erase` leg) — measured 2026-11-09: captured boot log shows `[tls] identity loaded from NVS (reprint with \`cert\`)` with **no** fingerprint on it, and `console.py cert` prints it on demand; the identity survived two reflashes and three reboots with the same fingerprint. `nvs erase` → first-boot printing was not re-run (the regeneration path had just been observed end-to-end). **Finding:** the *first* boot under the new firmware rotated the fingerprint once for a reason the boot log could not show (no host had the CDC open yet); `loadTlsIdentity()`/`storeTlsIdentity()` now print the failing gate and the record **lengths** (`stored records cert=… key=… fp=… -- shape/selfCheck`, `NVS put refused: …`, `read-back mismatch: …`) so a rotation can never again be a mystery — every later boot loads cleanly. **(That first
"rotation" is corrected two rows down: it was the `openssl -ext` artifact, not a regenerated
identity. The instrumentation stays worth having — it is what makes the next claim provable.)** |
| pinned fingerprint == handshake cert | `openssl s_client -connect keyrelay.local:443 </dev/null 2>/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 <oid>` 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://<host>)` — 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=<dummy>` → **426**, `Content-Length: 140`, **no `Location:`** (`grep -c location: → 0`) and the value not echoed (`grep -c <dummy> → 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 <exported self-signed>` / `--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 </dev/null \| openssl x509 -noout -text`, compared with `console.py cert` | the `X509v3 Subject Alternative Name` extension reads `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, so a client can check the name and not only the fingerprint | **failed → fixed in the tree, device leg needs `nvs erase`.** Found at the phase-02 final pass 2026-11-09 by reading the certificate instead of the code: `-text` printed the extension as `1.10.46.53.46.50.57.46.49.55` (not `X509v3 Subject Alternative Name`) and `-ext subjectAltName` said *no extensions*. A hex dump named the cause — the extension was framed `30 2f 06 09 32 2e 35 2e 32 39 2e 31 37 04 22 <the correct 34 bytes>`: 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
@@ -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`.
@@ -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)
@@ -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).
@@ -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)
@@ -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`.
@@ -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)
@@ -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.
@@ -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)
@@ -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.
@@ -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)
@@ -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 <exported self-signed>` 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`.
@@ -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)
@@ -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<code>` 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`.
@@ -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)