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
+55 -18
View File
@@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea
This file is the operating manual: what must not break, how to build and verify, and where
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
drifts, and two paths that can disagree about arming is the exact failure this project keeps
having to fix (see README, "Ten bugs").
having to fix (see README, "Eleven bugs").
## Phased execution
@@ -34,7 +34,7 @@ This repository runs work through `.agents/phases/`:
button, or the serial console — nothing else may gain the ability. The mouse is captured,
never grabbed: Pointer Lock, so `Esc` (the browser's own, never forwarded) always frees
it. `mouse off` over serial removes the pointer interface at the descriptor level, which
needs a reboot and is the rollback.
needs a reboot and is the pointer's rollback (the transport rollback is `-D KR_TLS=0`).
3. **Every fail-safe cancels work in flight; nothing "pauses"** — and **no fail-safe may be
gated on the absence of the thing it detects.** `ControlServer::linked()` is false when a
controller is gone, so gating the silence-disarm on it made the backstop for a vanished
@@ -61,7 +61,7 @@ This repository runs work through `.agents/phases/`:
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
through `serveReadonly()` so they cannot drift apart.
## Three tables that must agree
## Tables that must agree
| table | where | names | pinned by |
|---|---|---|---|
@@ -70,8 +70,11 @@ This repository runs work through `.agents/phases/`:
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
| mouse button bits | `src/mouse.h`, `src/web_page.h` (`MOUSE_BTN`), `tools/relayctl.py` (`MOUSE_BUTTONS`) | HID bit → DOM `MouseEvent.button` **and** name | `test_proto.cpp` (page↔config), `test_relayctl.py` (tool↔page↔config) |
| evdev axis codes | `tools/watch_target.py` (`REL_*`) | kernel code → label, for the *verifier* | `test_relayctl.py` — pinned to `<linux/input-event-codes.h>`, **not** to the report's byte order |
| certificate names | `src/tls_pem.h` (`sanEntries`, `kSanOidTlv`, `sanExtensionFullDer`) | the CN + SAN the certificate carries, **and the OID that labels the SAN**, and the same list rendered for the operator | `test_tls_pem.cpp` — the CN must be `kHostname` + `.local`, the 34 DER bytes were cross-checked against what OpenSSL writes for those three names, and so were the 43 bytes of the whole extension: the writer takes the OID as **DER content bytes** (`55 1D 11`), and handing it the dotted text produces a valid certificate with no SAN (README bug 11) |
| mbed TLS return codes + the refusal alert | `src/tls_session.h` (`kMbed*`, `mapIo`, `renderFatalAlert`) | "would block" vs "peer gone" vs "fatal" for every session call; the 7-byte RFC 5246 alert record a slot-cap refusal sends | `test_tls_session.cpp` walks the tables; `static_assert`s in `tls_session.cpp` weld the three copied error-code literals to the shipped library's macros — the code values differ between mbed TLS 2.x and 3.x, which is exactly how a confident transplant goes wrong |
| page build marker | `src/web_page.h` (`#if KR_TLS` between the raw-string chunks) | which build served the page — the TLS page refuses an `http:` origin outright; only the rollback page may speak plaintext | `test_page.js`, `test_relayctl.py` and `validate.sh` each assemble the served bytes from the chunks, and the two marker branches are asserted **equal length**, so one page byte count covers both builds |
The last row exists because it was guessed from the HID report's field order and was wrong
The evdev row exists because it was guessed from the HID report's field order and was wrong
(`REL_WHEEL` is 8, not 2): a working wheel looked dead and the summary said no scroll
happened. Codes the verifier has no name for are now counted and printed rather than
dropped — the same fix as giving F13–F24 names. **When you add a table that decodes
@@ -87,11 +90,13 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
## Build, test, flash
```bash
tests/run_tests.sh # 3406 checks, no hardware
tests/run_tests.sh # 4826 checks, no hardware
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console -e rollback
# `rollback` is [env:bridge] with -DKR_TLS=0: the plaintext way back. The gate builds it on
# every run, because a way back that stopped compiling is not a way back.
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
uv run --python 3.14 --with platformio python -m platformio run -e bridge -t upload
```
@@ -113,8 +118,13 @@ Environment facts that will otherwise cost you a cycle:
thing that checks the no-HID `#else` branches.
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
until the board is reflashed — and a tab that is already open keeps the old page in memory
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
until it is reloaded. Confirm with `curl -sk https://keyrelay.local/ | wc -c` against the
`page bytes:` line the gate prints (the raw-string chunks plus the 19-byte build marker;
equal-length branches make it one number for both builds), and grep the served bytes for
the element you added. Plain `http://` returns a 301 and no page bytes at all (task 04), so a `curl -s
http://…` that prints a redirect body means the transport is working, not that the page is
stale — fetch over `https://`, and `curl -s -L` is not a substitute: it follows to the same
bytes whether or not the flash changed.
- **This directory is a git repository** with a short history that predates most of the
current tree. Use it: `git diff` before and after any sweeping edit, and remember that
`git checkout <file>` reverts far more than you asked for — restore a *region*, not a
@@ -148,12 +158,23 @@ wired now — but ask, of every green check, *what would still pass if this were
## Editing rules
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `text.h`, `ws_codec.h`,
`sha1.h`) include no Arduino or ESP-IDF headers. That is what lets the identical code run
on a host under sanitisers. Keep it.
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
that every element id the script reaches for exists in the markup).
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `status.h`, `text.h`,
`ws_codec.h`, `sha1.h`, `tls_pem.h`) include no Arduino or ESP-IDF headers. That is what lets the
identical code run on a host under sanitisers. Keep it — and keep the *crypto library*
out too: `tls_pem.h` handles the identity's strings, `tls.h`/`tls.cpp` handle the
library, and the host suite includes the first and never the second. `tls_session.h`
follows the same split inside one file: its policy half (deadline, admission,
library-return table, alert record, queues) is pure and host-tested, and its
`kr::tls::Session` class hides behind `KR_TLS && ESP_PLATFORM` — with the mbed TLS
includes **outside** the namespace, because inside it they declare `::FILE` as
`kr::tls::FILE` and every later stdio user in the translation unit dies. `conn.h` splits the
same way and for the same reason: `plaintextRoute()`, the HTTP response head, the redirect
builder and the page send cursor are pure and walked by `test_conn.cpp`; only `kr::Conn`
itself needs `WiFiClient`.
- **`web_page.h`** is C++ raw string (plural since task 05: chunks joined by one
`#if KR_TLS` build marker of equal-length branches): no compiler looks at the HTML or JS.
Run `tests/test_page.js` after touching it (it executes the assembled page against a DOM
shim and checks that every element id the script reaches for exists in the markup).
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
@@ -161,12 +182,19 @@ wired now — but ask, of every green check, *what would still pass if this were
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
restate the behaviour.
restate the behaviour. `.agents/validate.sh` checks the mechanical half so nobody has to
remember it: the check count and the page byte count the docs quote must equal what the run
measured, the Backlog must be numbered 1..N with nothing closed left inside it, and every line
that mentions a plaintext `ws://` session must name the rollback build on the same line (wrap
the sentence so it does — the point is that no sentence can read as if plaintext were default).
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
the point of that endpoint — do not add the token to any other URL.
the point of that endpoint — do not add the token to any other URL. The TLS private key is
the same rule with more teeth: it is generated on the board and lives only in NVS, so
nothing may print it, hash-and-print it in a way that reads as the key, or be added to the
tree as a `.pem`. Fingerprint yes, key never.
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
README: a fixed bug belongs in "Ten bugs this verification caught" with its root cause and
README: a fixed bug belongs in "Eleven bugs this verification caught" with its root cause and
how it was proven, a new capability in the section that describes it, and a rule worth
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
what is left to do.
@@ -174,6 +202,15 @@ wired now — but ask, of every green check, *what would still pass if this were
## Current defaults worth knowing
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
`keyrelay-ctl` / `keyrelay-ctl-pass`; **`https://keyrelay.local`** or `https://192.168.4.1`
(port 443 — `:80` answers a 301 to it, a 426 to a plaintext `/ws` upgrade or to any request
carrying `?token=`, and nothing else; plaintext only in the `-D KR_TLS=0` rollback, which is
`pio run -e rollback`); the TLS identity is an ECDSA secp256r1 keypair and self-signed
certificate generated on the board's first boot and stored in NVS (`tlscrt`, `tlskey`,
`tlscrtfp`) so it survives reflashing — rotated only by `nvs erase`, printed once at that first
boot and again on demand by the serial `cert` command, which prints the SHA-256 fingerprint, the
validity window and the names, and has no argument form that yields the key; at most 2 live TLS
sessions (`kTlsMaxSessions`, RAM-sized, a third is refused with an alert) and a 10 s handshake
deadline (`kTlsHandshakeMs`); paste pace
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
station credentials and passphrase and survives reflashing.