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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user