Files
keyrelay/AGENTS.md
T
ducoterra e0780f58e0 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`.
2026-11-09 05:51:57 -05:00

17 KiB
Raw Blame History

AGENTS.md — working on keyrelay

A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target. The target runs no software and sees a Logitech G815. Everything the board does is gated on one idea: the difference between a remote keyboard and an unattended injection device is a human deciding, live, with the arming light on. Changes that quietly move that line are the ones to catch here.

The README is the single source of truth for behaviour, protocol, safety and measurement. 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, "Eleven bugs").

Phased execution

This repository runs work through .agents/phases/:

  1. Always read .agents/PLAN.md first to understand the project context and goals.
  2. Follow the phased execution protocol in .agents/phases/.
  3. Never modify .agents/PLAN.md or any files in .agents/phases/complete/.
  4. If you need to update any file in .agents/phases/todo/, you must ask the user for permission first.
  5. Strictly adhere to the LOCKED DECISIONS listed in .agents/PLAN.md.

The invariants

  1. State, not events. S=<mods>:<usages> restates the entire desired key state. Applying the same state twice is a no-op; any one message resynchronises the target completely. Never introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every stuck-key horror story in HID injection tooling is an event-protocol bug.

  2. Disarmed is the only default. Boot, reload, reconnect, focus loss, socket loss, target detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming releases every key and every mouse button. Arming needs the passphrase, the board 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 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 controller uncallable exactly when a controller vanished. Same shape: closeLink() releasing only if (linked()) skipped the release for every path that cleared _authed first. If a rule fires when X stops existing, X still exists cannot be a precondition of it. See README bug 10; test_relayctl.py pins the three sentences.

    releaseEverything() in main.cpp is the single funnel: it clears the report queue, raises the keys, ends the chord and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a run) must be cancelled there too, or it will keep acting after the operator stopped asking.

  4. Nothing is stored. Counters only. No keystrokes, characters, key names or text in NVS, logs, Serial.printf, error messages or status frames — src/main.cpp prints lengths for text runs, never content. No macro store, no schedule, no replay, no auto-arm.

  5. Reject, never guess. A malformed frame leaves the previous state in force. A character the map cannot express is E9 and none of that frame is typed — a paste that quietly loses a character is worse than one that refuses. Silent sanitising is never the default.

  6. Ctrl+Alt+Del is fixed on the device. It is never composed from wire bytes; on a Linux VT it reboots the target. Same reasoning for the VT-switch chords.

  7. One frame is one command. ASCII, no JSON inbound, ≤ kWsMessageCap. Long payloads are the controller's problem to split, with the board reporting how much it still holds.

  8. The keyboard is exclusive; the board is not. A second /ws upgrade is refused (E3); read-only HTTP is served even while a controller holds the bridge. Keep both routes going through serveReadonly() so they cannot drift apart.

Tables that must agree

table where names pinned by
CODE_TO_USAGE src/web_page.h physical position → usage tests/test_relayctl.py
CODE_TO_USAGE tools/relayctl.py same, for the terminal tests/test_relayctl.py
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_asserts 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 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 something somebody else defined, test the table, not your confidence in it.

Chord buttons carry their wire bytes in data-chord, not a name — which is why tests/test_proto.cpp asserts every labelled chord against the combination its label promises. A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key; Alt+Tab was 05:04 (Ctrl+Alt+A) until that test existed. TEXT_FRAME_MAX / TEXT_JOB_MAX in the page and TEXT_FRAME_MAX / MAX_PASTE_CHARS in relayctl.py are hand-copied from config.h and are asserted equal in tests — change one, the suite fails, that is the point.

Build, test, flash

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 -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

Environment facts that will otherwise cost you a cycle:

  • pio on PATH is broken here (ModuleNotFoundError: platformio), and /tmp/piovenv is Python 3.15 so it can build but not upload. Use the uv invocation above for both. If it is unavailable, find another interpreter with the package: find / -maxdepth 6 -name platformio -type d -path '*site-packages*'.
  • pio run -t upload reports SUCCESS on a stale binary if the build before it failed. This happened: a diagnostic build failed to compile, the upload line right after it said SUCCESS, and the board was running the previous firmware. Never chain build and upload with ; — check the build's own exit status first, or the verification you run next is verifying something that is not in the tree.
  • No g++ on the host. run_tests.sh falls back to a local podman image listed in its images[]. The page suite deliberately runs on the host so it works without node in there.
  • Build console too. ARDUINO_USB_MODE=1 compiles KR_HAVE_HID == 0, which is the only 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 -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 file.
  • Never do a multi-line .*? regex rewrite across a source file. One did #if KR_DEBUG_LINK.*?#endif with DOTALL to remove a diagnostic block and deleted ~500 lines of main.cpp — onMessage(), setup(), the console, the USB handlers — because the earliest #if in the file was elsewhere. The compiler was the only thing that noticed. Remove instrumentation with exact-string edits, or per-function slices, and compile immediately after. When it happens anyway: the last good firmware.elf still has the .rodata strings (console text, printf formats) and the symbol table, which is enough to rebuild faithfully against the surviving headers.

Verification standard

A feature that lives on the device is not done when the source builds — it is done when the device is running it and something observed it. Concretely:

  • Protocol/page claims → the host suites, plus curl against the served page.
  • Keystroke delivery → tools/watch_target.py on the target (evdev, focus-independent); it is the only thing that says what the target actually received, including which keys a chord really was.
  • Text runs → check the typed: summary and press count, not the scrolling stream.

When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's safety list while nothing compared lastInboundMs() to kNoClientTimeoutMs; the documented verification passed because killing the controller process closes the socket, and the socket-close path disarms. The check proved a different property from the one it was named after and nobody noticed, until a run that keeps typing on its own made the difference matter. It is wired now — but ask, of every green check, what would still pass if this were broken?

Editing rules

  • 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.
  • Docs move with the code. Update in the same change: README intro bullets and check count, 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. .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 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 "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.

Current defaults worth knowing

Default passphrase changeme (the page and status shout about it via dflt:1); AP 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.