From d629a0ff51ea319f24660bc2899acbba4ccb0f16 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 9 Nov 2026 08:57:35 -0500 Subject: [PATCH] phase: 03_session_takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 03 `session_takeover` — final verification pass. All green.** All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation. **Completion criteria** 1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`). 2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s. 3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off). 4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion). 5. *Gate green + README/AGENTS say takeover cannot arm* — yes. **Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records). **Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`. **Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`. **Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`. --- .agents/HW_VERIFICATION.md | 12 +- .../03_session_takeover/00_phase.md | 0 .../01_pending_claim_socket.md | 0 .../03_session_takeover/02_takeover_funnel.md | 0 .../03_status_page_takeover_ui.md | 0 .../04_takeover_tests_docs.md | 0 .../03_session_takeover__00_phase.a1.err | 0 .../03_session_takeover__00_phase.a1.md | 18 + ...03_session_takeover__00_phase.a1.md.status | 1 + .../03_session_takeover__00_phase.a1.validate | 213 +++++++++ ...n_takeover__01_pending_claim_socket.a1.err | 0 ...on_takeover__01_pending_claim_socket.a1.md | 15 + ...over__01_pending_claim_socket.a1.md.status | 1 + ...eover__01_pending_claim_socket.a1.validate | 207 +++++++++ ...ession_takeover__02_takeover_funnel.a1.err | 0 ...session_takeover__02_takeover_funnel.a1.md | 15 + ..._takeover__02_takeover_funnel.a1.md.status | 1 + ...n_takeover__02_takeover_funnel.a1.validate | 209 +++++++++ ...akeover__03_status_page_takeover_ui.a1.err | 0 ...takeover__03_status_page_takeover_ui.a1.md | 13 + ...r__03_status_page_takeover_ui.a1.md.status | 1 + ...er__03_status_page_takeover_ui.a1.validate | 213 +++++++++ ...on_takeover__04_takeover_tests_docs.a1.err | 0 ...ion_takeover__04_takeover_tests_docs.a1.md | 12 + ...eover__04_takeover_tests_docs.a1.md.status | 1 + ...keover__04_takeover_tests_docs.a1.validate | 213 +++++++++ AGENTS.md | 22 +- README.md | 237 +++++++--- src/claim.h | 139 ++++++ src/config.h | 45 +- src/control_server.cpp | 419 ++++++++++++++++-- src/control_server.h | 100 ++++- src/main.cpp | 24 +- src/proto.h | 19 + src/status.h | 13 +- src/tls_pem.h | 2 +- src/tls_session.h | 21 + src/web_page.h | 186 +++++++- tests/run_tests.sh | 2 +- tests/test_claim.cpp | 186 ++++++++ tests/test_conn.cpp | 12 +- tests/test_page.js | 113 +++++ tests/test_proto.cpp | 58 ++- tests/test_relayctl.py | 209 ++++++++- tools/relayctl.py | 93 +++- 45 files changed, 2884 insertions(+), 161 deletions(-) rename .agents/phases/{todo => complete}/03_session_takeover/00_phase.md (100%) rename .agents/phases/{todo => complete}/03_session_takeover/01_pending_claim_socket.md (100%) rename .agents/phases/{todo => complete}/03_session_takeover/02_takeover_funnel.md (100%) rename .agents/phases/{todo => complete}/03_session_takeover/03_status_page_takeover_ui.md (100%) rename .agents/phases/{todo => complete}/03_session_takeover/04_takeover_tests_docs.md (100%) create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.err create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md.status create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.validate create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.err create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md.status create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.validate create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.err create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md.status create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.validate create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.err create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md.status create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.validate create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.err create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md.status create mode 100644 .agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.validate create mode 100644 src/claim.h create mode 100644 tests/test_claim.cpp diff --git a/.agents/HW_VERIFICATION.md b/.agents/HW_VERIFICATION.md index 528e31f..2be9713 100644 --- a/.agents/HW_VERIFICATION.md +++ b/.agents/HW_VERIFICATION.md @@ -49,10 +49,10 @@ 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 | skipped (no passphrase) — task 04, 2026-11-09: the board is reachable on `:443` and the TLS/`wss://` transport rows below are measured, but this board's control passphrase is not the default (`status` says `dflt:0`) and a verification run will not change a credential on a device it cannot put back. To close it: `console.py token ` on a board you are willing to re-provision, then drive `wss://` | -| a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | | +| 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) | skipped (no device run) — phase-03 close, 2026-11-09. This row is still open for the same standing reason as every row in the phase-03 table below: the board is a live device this pass neither reflashed nor armed (arming means typing into whatever this operator's console has focus, and the passphrase is the operator's). Host-side the claim is pinned instead: `test_proto` asserts every labelled chord button's `data-chord` bytes against the combination its label promises, so `04:2b` is Alt+Tab or the suite fails. To close: reflash `bridge`, arm, run the command above with the watcher open | | 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` | | +| 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` | skipped (no device run) — phase-03 close, 2026-11-09, same standing as the row above. Phase 03 is the phase that makes this row sharper (a *handover* is a link teardown with a new occupant waiting, and the claim path must not keep anything alive), so the host-side pins are the ones named in the phase-03 table: `test_output`'s release-funnel suite and `test_relayctl`'s funnel-runs-before-adoption pin. To close: the run described in the phase-03 table | ### Phase 02 — HTTPS + `wss://` @@ -84,9 +84,11 @@ identity. The instrumentation stays worth having — it is what makes the next c | claim | command | expected observation | outcome | |---|---|---|---| -| takeover releases the incumbent's held keys | two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over`), all while `watch_target.py` runs **on the target** | `watch_target.py` shows the key **up** *before* the new session's first key-down — the ordering is the whole claim | | -| the board comes back disarmed | `python3 tools/relayctl.py --host keyrelay.local status` immediately after the handover | `armed: 0` across the handover; the newcomer must arm again on purpose; nothing in the handover path arms | | -| a third controller is still refused | a third `/ws` upgrade while two sessions exist | `E3` — takeover consumes the one spare slot, it does not open the door | | +| a second `/ws` upgrade arrives as a pending claim, not a 503; a third socket is still refused (task 01) | after reflashing `bridge` from this tree: controller A connects and authenticates; a second socket does a raw `/ws` upgrade (`openssl s_client -connect keyrelay.local:443` + upgrade headers, or a python one-liner) and sends **one** deliberately-wrong `T`; a third connection opens in parallel; `watch_target.py` runs on the target throughout | second upgrade answers **101** (not 503), then exactly `E1:wrong passphrase` and close (one attempt only — the claim shares the board-wide 5-fail lockout); the third connection is refused (fatal TLS alert / close) and `slotrefused`/`rejected` move while both listeners keep serving; A keeps typing throughout and the silence clock never moves | **skipped (not run on device), 2026-11-09** — the live board runs the phase-02 firmware; the claim path is not flashed, and the authenticating half needs the operator's passphrase, which verification runs must not guess (the same call phase 02 recorded, and more pointed here: a wrong claim token feeds the shared lockout this task wires). Host-side the gate is green: 95 new `test_claim` checks (whitelist matrix over every `proto::Cmd` × auth × role, both deadline clocks at the boundary and across the `millis()` rollover, oversized-frame refusal through the real codec), 4921 checks in both suite runs, all three firmware builds; bridge RAM 99 728 B vs the phase-02 99 616 B — **+112 B**, the claim's whole footprint (64 B buffer + reader state + role/auth bytes + the 4 B clock, matching the comment in `control_server.h`) | +| takeover releases the incumbent's held keys | after reflashing `bridge` from this tree, two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over` — landed in task 03; or a raw `/ws` upgrade sending `T` then `X`), all while `watch_target.py` runs **on the target** | `watch_target.py` shows the key **up** *before* the new session's first key-down — the ordering is the whole claim | **skipped (no device run), 2026-11-09** — same standing as the task-01 row directly above: the live board still runs the phase-02 firmware (no claim path), and both controllers need the operator's passphrase, which a verification run must not guess into the shared 5-fail lockout. The ordering is what the run will observe; on the host it is *pinned*: `test_relayctl.py` fails if `closeLink("taken over")` ever moves after `_authed = true` in `handOverClaim` (demonstrated failing on an inverted tree, then reverted), and the funnel it runs is `test_output`'s release-funnel suite. To close: reflash `bridge`, run the two-controller procedure above, capture the transcript | +| the board comes back disarmed | `python3 tools/relayctl.py --host keyrelay.local status` immediately after the handover | `armed: 0` across the handover; the newcomer must arm again on purpose; nothing in the handover path arms | **skipped (no device run), 2026-11-09** — same reason as the row above. Host-side the sentence is the (b) pin of `test_relayctl.py`'s **bridge handover** section: no `setArmed` / `armed = true` / `arm(` exists in `handOverClaim`'s code lines nor in the controller-side `X` case (demonstrated failing when a `…_rearm();` line was inserted, then reverted); `forgetPendingWrite()` is the one TLS state the adoption touches, and it writes no arming state. To close: the same reflash and run, `armed: 0` in the status read between handover and the newcomer's first `A1` | +| a third controller is still refused | a third `/ws` upgrade while two sessions exist | `E3` — takeover consumes the one spare slot, it does not open the door | **skipped (no device run), 2026-11-09 (phase close)** — the live board still runs the pre-phase-03 firmware, so the second session would be the only claim-shaped thing on it, and opening the two sessions first needs the operator's passphrase. Host-side: `test_claim` pins `claim::acceptsUpgrade()` (a slot that already carries a Claim takes no second upgrade) and `test_tls_session` walks the admission table (`controller`, `spare`, `refused`) with the cap checked before the free slot. Note the mechanism, as the README's Security model states it: a third *socket* never reaches HTTP, so what it gets is the slot refusal — a fatal TLS alert record on `:443`, a close on `:80`, counted in `slotrefused` — and `503`/`E3` is the sentence for an upgrade that reaches the spare slot and cannot be a claim (a malformed upgrade, or that slot already holding one). To close: reflash `bridge`, hold controller + claim, open the third connection, read `slotrefused` moving while both listeners keep serving | +| the locked-out operator sees `held by another controller` and takes the bridge with one click; the incumbent's page says it was taken over, disarmed, in its log (task 03) | reflash `bridge` from this tree (check the build's own exit status first); laptop tab holds the bridge; on the phone open `https://keyrelay.local/`, type the passphrase, connect — the pill must read `held by another controller`, the **take over** button appears, and nothing further leaves the phone until it is clicked; press it while `watch_target.py` runs **on the target**; then read the laptop tab | pill `held by another controller`; one click sends `T` then `X` and nothing else; the target shows every key and button up before the phone's next key; the phone is DISARMED after the handover; the laptop tab shows pill `taken over` and a log line naming "another controller took over", and does not reconnect (no second `/ws` from it); `--take-over` from the terminal produces the same frames and states | **skipped (no device run), 2026-11-09** — same standing as the rows above: the live board still runs pre-phase-03 firmware, the gesture needs the operator's passphrase on two devices (guessing feeds the shared 5-fail lockout), and a phone-and-laptop dance is not a headless attempt. Host-side the gate is green: `test_page.js` executes exactly this flow (claim-close → button appears → nothing sent until the click → click sends `T` then `X` and nothing else → first status logged as the DISARMED start; `E12` disarms, releases the mouse, ends a typing run, marks `taken over`, and stays down through the close; `E3` keeps its own "no room" sentence), and `test_relayctl.py` pins the CLI's `T,X,H` ordering, the default refusal naming `--take-over`, and the free-board no-op. To close: the reflash and two-device run above, transcript captured | ### Phase 04 — responsive page diff --git a/.agents/phases/todo/03_session_takeover/00_phase.md b/.agents/phases/complete/03_session_takeover/00_phase.md similarity index 100% rename from .agents/phases/todo/03_session_takeover/00_phase.md rename to .agents/phases/complete/03_session_takeover/00_phase.md diff --git a/.agents/phases/todo/03_session_takeover/01_pending_claim_socket.md b/.agents/phases/complete/03_session_takeover/01_pending_claim_socket.md similarity index 100% rename from .agents/phases/todo/03_session_takeover/01_pending_claim_socket.md rename to .agents/phases/complete/03_session_takeover/01_pending_claim_socket.md diff --git a/.agents/phases/todo/03_session_takeover/02_takeover_funnel.md b/.agents/phases/complete/03_session_takeover/02_takeover_funnel.md similarity index 100% rename from .agents/phases/todo/03_session_takeover/02_takeover_funnel.md rename to .agents/phases/complete/03_session_takeover/02_takeover_funnel.md diff --git a/.agents/phases/todo/03_session_takeover/03_status_page_takeover_ui.md b/.agents/phases/complete/03_session_takeover/03_status_page_takeover_ui.md similarity index 100% rename from .agents/phases/todo/03_session_takeover/03_status_page_takeover_ui.md rename to .agents/phases/complete/03_session_takeover/03_status_page_takeover_ui.md diff --git a/.agents/phases/todo/03_session_takeover/04_takeover_tests_docs.md b/.agents/phases/complete/03_session_takeover/04_takeover_tests_docs.md similarity index 100% rename from .agents/phases/todo/03_session_takeover/04_takeover_tests_docs.md rename to .agents/phases/complete/03_session_takeover/04_takeover_tests_docs.md diff --git a/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.err b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md new file mode 100644 index 0000000..9c65078 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md @@ -0,0 +1,18 @@ +**Phase 03 `session_takeover` — final verification pass. All green.** + +All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation. + +**Completion criteria** +1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`). +2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s. +3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off). +4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion). +5. *Gate green + README/AGENTS say takeover cannot arm* — yes. + +**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records). + +**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`. + +**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`. + +**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`. diff --git a/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md.status b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.validate b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.validate new file mode 100644 index 0000000..1d68aa0 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__00_phase.a1.validate @@ -0,0 +1,213 @@ +== 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 99744 bytes from 327680 bytes) +Flash: [==== ] 40.0% (used 1257933 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: [== ] 20.6% (used 67352 bytes from 327680 bytes) +Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.85 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.6% (used 93568 bytes from 327680 bytes) +Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.84 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.838 +console SUCCESS 00:00:00.848 +rollback SUCCESS 00:00:00.837 +========================= 3 succeeded in 00:00:02.523 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 59286 (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 (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.err b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md new file mode 100644 index 0000000..d3e484a --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md @@ -0,0 +1,15 @@ +All green. Report: + +**Implemented (task `03_session_takeover/01_pending_claim_socket.md`)** +- `src/claim.h` (new pure module): `ViewerRole`, `acceptsUpgrade()` (one claim at a time), the **one visible whitelist** `honours()` — allowed: `T` (unauth+auth) and `K` (auth); refused by name: `A1`/`A0`, `S=`, `C`, `Y=`, `P=`, `M=`, `R`, `H`, `Bad`/`None`; `X` reserved for task 02 on one marked line; two-clock `expired()` deadline arithmetic (wrap-safe). +- `control_server.{h,cpp}`: spare slot gained `_viewerRole`/`_viewerPhase`/`_claimAuthed`/`_claimAtMs`/`_claimMsg[64]`+`_claimReader` (~110 B, commented); `/ws` upgrade on the spare is accepted into Claim (same `ws::acceptFor` + one shared 101 writer — the "exactly one status line" pin holds); claim auth = same constant-time compare, same shared `_authFails`/`_lockUntilMs`, E1/E2 + drop on failure; whitelist violation = E6 + drop; claim never touches `_lastInboundMs`/`_controllerSeen`/`_onMessage`; `_claimAtMs` silence/auth timeouts drop and free the session; stopViewer/both accept paths reset claim state; third socket still refused at the slot table. +- `config.h`: `kClaimMsgCap = 64`, `kClaimTimeoutMs = 15000` with reasoning. +- `tests/test_claim.cpp` (95 checks) registered in `run_tests.sh`. + +**Verification** — `bash .agents/validate.sh` → **validation OK**: 4921 checks plain and sanitised (12 Tests-table rows match; `test_proto` 2359 and `test_relayctl` 271 **untouched and green** — no wire change, `X` still parses `Bad`); bridge/console/rollback all build; page 51 626 B unchanged; hygiene OK. Bridge RAM 99 728 B vs 99 616 B baseline (**+112 B**, matching the field comment); rollback +112 B likewise. + +**Docs** — README: check counts (intro + quick start), new `test_claim` Tests-table row, `claim.h` in the Architecture map (nine→ten pure), rewrote the stale "second upgrade refused 503/E3" Security-model paragraph and the Wire-protocol refusals paragraph. AGENTS.md: invariant 8 restated (claim cannot drive the keyboard; dispatcher never sees a claim frame) + count. `.agents/HW_VERIFICATION.md`: task-01 device row recorded as **skipped (not run on device)** — live board still runs phase-02 firmware and the authenticating half needs the operator's passphrase (guessing feeds the shared 5-fail lockout this task wires); exact close-out commands written into the row. + +**Deviations** — task text said the claim buffer is "a sixteenth" of the controller's; 64 vs `kWsMessageCap` 256 is a **quarter** — code, tests and README carry the measured ratio and say so. `ViewerRole` lives in `claim.h` with a `using` alias in `ControlServer` so the host suite walks the same type. No live changes made. + +**Next pending task:** `03_session_takeover/02_takeover_funnel.md` (`X` claims the bridge; `E12`; funnel before adoption). diff --git a/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md.status b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.validate b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.validate new file mode 100644 index 0000000..bdf3b0d --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__01_pending_claim_socket.a1.validate @@ -0,0 +1,207 @@ +== 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 +test_claim 95 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 +test_claim 95 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 99728 bytes from 327680 bytes) +Flash: [==== ] 39.7% (used 1249045 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 67336 bytes from 327680 bytes) +Flash: [==== ] 37.2% (used 1169305 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.6% (used 93568 bytes from 327680 bytes) +Flash: [==== ] 36.5% (used 1148945 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.870 +console SUCCESS 00:00:00.864 +rollback SUCCESS 00:00:00.863 +========================= 3 succeeded in 00:00:02.597 ========================= + +== 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 921 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.err b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md new file mode 100644 index 0000000..baf0e5b --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md @@ -0,0 +1,15 @@ +Everything is 100 % green. Final report: + +**Implemented (`03_session_takeover/02_takeover_funnel.md`)** +- `proto.h`: `Cmd::Takeover` for a lone `X` (trailing bytes → `Bad`), header direction table + `E12` error-list entry; `formatError` untouched. +- `control_server.{h,cpp}`: `handOverClaim()` — exact order `E12`→incumbent (best-effort) → `closeLink("taken over")` (the bug-10 funnel: disarm + release) → transport transplant → adopt (`_authed/_controllerSeen/_lastInboundMs/_connectedAtMs`, reader reset, `OK`+`sendStatus()`, `_onLinkChange(true)`) → claim slot fully vacated (state + buffers zeroed). `kErrTakenOver=12`, `_takeovers` counter + `takeovers()`, `sendOk()`; `arm()` never called. TLS slot transplant done as a *pointer swap* over two `Session` storage objects (no crypto state moves) + new `Session::forgetPendingWrite()` (documented: mbed TLS resumes half-records from its own buffer; without it the adopted TX path would wedge). +- `claim.h`: `X` joins the whitelist on its marked line, authenticated branch only; `main.cpp`: controller-side `X` = no-op answered `OK` (not `E6`); serial `link … taken=N`. +- Defect fixed (pre-existing, serves `E12`): the page read error codes as one character — `E10`/`E11`/`E12` misread as `E1`; now parsed to the first `:` (page 51 626 → 52 041 B, all pins moved with it). + +**Verification** — `bash .agents/validate.sh` → **validation OK**: 4 954 checks plain + sanitised, 0 failures (`test_proto` 2 369, `test_claim` 102, `test_relayctl` 287 incl. the three handover pins — each demonstrated FAILING on an inverted tree, then reverted); `bridge/console/rollback` all build (bridge RAM 99 744 B = +16 B over task 01); page 52 041 B; hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — live board still runs phase-02 firmware and the procedure needs the operator's passphrase (guessing feeds the shared lockout); no live changes made; close-out commands written into the rows. + +**Docs** — README: wire table `X` row, `E12` error code, "Single controller" paragraph now states takeover runs through the funnel and **cannot arm**, refusals/troubleshooting "no takeover" sentences corrected, Tests-table rows + totals, page-size quotes. AGENTS.md: check count + invariant 8's now-false "does nothing else" sentence only. + +**Deviations** — the status-frame `takeovers`/JSON field is left to task 03 (whose file explicitly assigns "StatusSnapshot gains … `uint32_t takeovers`"); here the counter is published via the getter and the serial `status` line. Frames pipelined behind `X` become ordinary controller frames after adoption (documented — the handover itself still never arms). + +**Next pending task:** `03_session_takeover/03_status_page_takeover_ui.md` (`busy`, TAKE OVER affordance, `E12` page handling). diff --git a/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md.status b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.validate b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.validate new file mode 100644 index 0000000..1b3750f --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__02_takeover_funnel.a1.validate @@ -0,0 +1,209 @@ +== 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 2369 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 +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 287 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 2369 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 +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 287 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 99744 bytes from 327680 bytes) +Flash: [==== ] 39.8% (used 1250533 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.6% (used 67352 bytes from 327680 bytes) +Flash: [==== ] 37.2% (used 1170789 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 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.6% (used 93568 bytes from 327680 bytes) +Flash: [==== ] 36.6% (used 1150201 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.859 +console SUCCESS 00:00:00.866 +rollback SUCCESS 00:00:00.870 +========================= 3 succeeded in 00:00:02.595 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 52041 (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 954 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 52 041 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.err b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md new file mode 100644 index 0000000..8c3c882 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md @@ -0,0 +1,13 @@ +Everything is 100 % green. Final report: + +**Implemented (`03_session_takeover/03_status_page_takeover_ui.md`)** +- `status.h` + `main.cpp`: `StatusSnapshot` gains `busy` (= `control.linked()`) and `takeovers`; `statusJson()` emits `"busy":u` / `"tkov":u` — a link state and an event counter, no address/agent/identity anywhere (pinned). Longest legal frame measured at **349 B**; `kStatusJsonCap` stayed 384 because the test-enforced ≥32-byte headroom rule holds (35 left) — `config.h` comment rewritten with the new arithmetic (measured, not grown by guess; the task's "grow" instruction was conditional on the numbers, which the suite re-measures). +- `web_page.h`: link pill's third state `held by another controller` (from `s.busy` / claim non-adoption); **take over** button appears only after the board says held; one click sends `T` then `X` and nothing else, page lands DISARMED with a log line; `E12` disarms + releases mouse + aborts typing + pill `taken over` + auto-reconnect off (no re-claim fight); `E3` keeps its "no room for a claim" meaning with its own wording; Connection-card hint states takeover cannot arm. +- `relayctl.py`: `--take-over`; adoption probe (`OK`-then-status vs `OK`-then-silence = the board reporting busy; no probing frame sent); default = refusal naming `--take-over` with no `X` on the wire; flag on a free board = stated no-op; `_poll` now drains the whole batch (a status riding behind `OK` is kept, not abandoned — bug found and fixed). +- `claim.h`: the `H`-refusal comment corrected — the claimant's answer *is* its own non-adoption; `busy` serves pollers and post-handover frames. + +**Verification** — `bash .agents/validate.sh` → **validation OK, exit 0**: 5 026 checks plain + sanitised, 0 failures (`test_proto` 2 379, `test_page` 142, `test_relayctl` 308 incl. T→X ordering / default refusal / free-board no-op / `T,X,H` through `main()`); `bridge/console/rollback` all build (bridge RAM 99 744 B — unchanged; Flash 1 257 933 B, page +7 245 B); page 59 286 B, `test_conn` chunk math updated (41 chunks / 11 ticks); hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — a board is reachable but runs pre-phase-03 firmware, and the two-device gesture needs the operator's passphrase (guessing feeds the shared lockout); **no live changes made**; close-out commands written into the row. + +**Docs** — README: check count ×2, page 59 286 B, S-frame row (`busy`/`tkov`), trust-table row "someone holding the passphrase … can take the bridge over, cannot arm it by taking it", Controller-page pill/take-over rows, Security-model busy-is-not-identity sentence, CLI `--take-over` docs + example, Troubleshooting page/CLI rows, Tests-table rows/descriptions. AGENTS.md: check count only (its stale-fact test). + +**Next pending task:** `03_session_takeover/04_takeover_tests_docs.md` (`relayctl`/frame test completion, pin-inversion demonstration, README final pass). diff --git a/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md.status b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.validate b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.validate new file mode 100644 index 0000000..9c232d2 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__03_status_page_takeover_ui.a1.validate @@ -0,0 +1,213 @@ +== 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 99744 bytes from 327680 bytes) +Flash: [==== ] 40.0% (used 1257933 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.6% (used 67352 bytes from 327680 bytes) +Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.85 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.6% (used 93568 bytes from 327680 bytes) +Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.86 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.866 +console SUCCESS 00:00:00.855 +rollback SUCCESS 00:00:00.858 +========================= 3 succeeded in 00:00:02.579 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 59286 (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 (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.err b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md new file mode 100644 index 0000000..1847ca8 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md @@ -0,0 +1,12 @@ +**Report — task 03/04 (`04_takeover_tests_docs.md`)** + +- Implemented: + - Verified the three takeover source pins in `tests/test_relayctl.py` (funnel-before-adoption; no `arm(`/`setArmed`/`armed = true` in `handOverClaim` or the controller-side `X` case; whitelist consulted before the Takeover branch and `X` only in `claim.h`'s authenticated branch; `E12` named code + incumbent send) — all present and re-read against `control_server.cpp`/`claim.h`/`main.cpp`. + - Re-read bug 10's three existing pins + the 4-writer `_lastInboundMs` census against the phase-03-touched code: sentences still true (not just green). + - README: takeover bullet in "Disarmed is the only default"; end-to-end takeover procedure (`watch_target` + `key KeyA --hold 30000` + `--take-over status`, key-up-before-first-newcomer-key ordering) + evidence-table row; bugs 12 (page read `E` as one char — `E10/E11/E12` misread as `E1`) and 13 (relayctl `_poll` abandoned the batch behind `OK`, breaking the adoption probe) added; section → "Thirteen bugs", all 6 cross-refs (README ×3, AGENTS.md ×2, `tls_pem.h` comment) updated. + - Page safety sentences confirmed in `test_page.js` (disarm/mouse-release/run-end/`taken over`/no-auto-reclaim on `E12`; nothing sent before the click; `E3` distinct). AGENTS.md: stale bug-count titles fixed only; invariant 8 already described the claim. +- Tests/lint: `bash .agents/validate.sh` → **validation OK**: 5 026 checks ×2 (plain + `SANITIZE=1`), 12 Tests-table rows match suites, page 59 286 B, Backlog contiguous, ws:// sentences clean; `bridge`/`console`/`rollback` builds SUCCESS; hygiene OK. +- Pin inversions proven (then reverted, no leftovers): (a) `closeLink` moved after adoption → exactly 1 FAIL ("funnel runs BEFORE…"); (b) `rearm();` inserted → 1 FAIL ("no `arm(`…"); (c) `claim.h` admits `X` unauthed → pin FAIL + `test_claim` matrix cell FAIL. +- README/AGENTS changes: as above; AGENTS.md gained no prose, only stale-fact fixes. +- Live infra: **no live changes.** A board is reachable but runs pre-phase-03 firmware; the two-controller takeover needs the operator's passphrase (guessing feeds the shared 5-fail lockout) and a reflash is a device side effect — the takeover rows in `.agents/HW_VERIFICATION.md` stand as `skipped (no device run)` with the exact close-out commands. +- Next pending: phase-03 final pass; then `04_controller_page_responsive/01_responsive_layout.md`. diff --git a/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md.status b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.validate b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.validate new file mode 100644 index 0000000..aa44491 --- /dev/null +++ b/.agents/reports/03_session_takeover/03_session_takeover__04_takeover_tests_docs.a1.validate @@ -0,0 +1,213 @@ +== 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 2379 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 280 checks, 0 failures +test_claim 102 checks, 0 failures +handshake +frame encoding +frame decoding +key table +pasted text +mouse +protocol strings +the fail-safe wiring +the bridge handover (phase 03) +one routing path, two transports +the client side of pinning +the busy board and --take-over +the target-side verifier + t= 0.00s >>> Ctrl+Alt+Del <<< +relayctl 308 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 +the busy board and the take-over +element ids +page 142 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 99744 bytes from 327680 bytes) +Flash: [==== ] 40.0% (used 1257933 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.6% (used 67352 bytes from 327680 bytes) +Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.87 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.6% (used 93568 bytes from 327680 bytes) +Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes) +========================= [SUCCESS] Took 0.88 seconds ========================= + +Environment Status Duration +------------- -------- ------------ +bridge SUCCESS 00:00:00.879 +console SUCCESS 00:00:00.867 +rollback SUCCESS 00:00:00.882 +========================= 3 succeeded in 00:00:02.628 ========================= + +== page size: src/web_page.h raw strings == +page bytes: 59286 (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 (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback) + +== repo hygiene == +repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits) + +== hardware context (informational; cannot change the gate result) == +hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board) +hw-probe mdns keyrelay.local resolves -> 10.2.0.22 +hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up) +hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape) +hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console) +hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available +hw-probe node /usr/bin/node — test_page.js runs +hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target) +validation OK (suites + sanitised suites + bridge/console/rollback builds + page size + + the numbers README/AGENTS quote + hygiene) diff --git a/AGENTS.md b/AGENTS.md index b9dd964..bbc587b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea This file is the operating manual: what must not break, how to build and verify, and where the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy drifts, and two paths that can disagree about arming is the exact failure this project keeps -having to fix (see README, "Eleven bugs"). +having to fix (see README, "Thirteen bugs"). ## Phased execution @@ -57,9 +57,17 @@ This repository runs work through `.agents/phases/`: 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. +8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade cannot drive + the keyboard: it may only stand in the spare slot as a pending claim that + authenticates (same passphrase, same shared lockout) and may then send nothing but + `K` and the deliberate `X` — `X` hands the bridge over *through the release funnel* + (incumbent told with `E12`, every key and button released) and the board lands + **disarmed**; nothing reachable from `X` may arm. Every other frame is refused by + the one whitelist in `src/claim.h`, which drops the claim, and no claim frame ever + reaches the command dispatcher. A second claim, or a third socket, is + refused (`503`/`E3`, or the slot refusal); 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 @@ -90,7 +98,7 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi ## Build, test, flash ```bash -tests/run_tests.sh # 4826 checks, no hardware +tests/run_tests.sh # 5026 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: @@ -159,7 +167,7 @@ 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`, `status.h`, `text.h`, - `ws_codec.h`, `sha1.h`, `tls_pem.h`) include no Arduino or ESP-IDF headers. That is what lets the + `ws_codec.h`, `sha1.h`, `tls_pem.h`, `claim.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` @@ -194,7 +202,7 @@ wired now — but ask, of every green check, *what would still pass if this were 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 + README: a fixed bug belongs in "Thirteen 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. diff --git a/README.md b/README.md index 950a989..baa0e55 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,20 @@ that one is the browser's own guarantee, not something this firmware has to hono script, nothing it can type later, no gesture it can replay. Pasted text is typed from RAM and forgotten; the serial console has no command that types or clicks anything at all. It is a keyboard and a mouse, not a payload dropper. -* **4826 checks** cover the key-state machine, the mouse state, the report queues and the +* **5026 checks** cover the key-state machine, the mouse state, the report queues and the release funnel, the wire protocol, the text→keystroke map, SHA-1/base64, the WebSocket codec, the TLS identity's PEM armour, name table and fingerprint renderings, the TLS session's policy layer (handshake deadline, slot admission, library-return table, refusal record and frame ring), the transport view's response head, `:80` answer table, - redirect builder and page send cursor, the status frame's fields and the buffer they have - to fit in, the controller page's own script (including its refusal to type a passphrase - from a page that arrived in the clear), the CLI's frames and its TLS trust decisions - (no trust argument → refuse; wrong fingerprint → nothing sent), and the target-side + redirect builder and page send cursor, the pending takeover claim's whitelist and + clocks (which frames a claim may send at all — arming is not one of them), the status + frame's fields (including `busy`/`tkov`, which say a bridge is held without ever saying + by whom) and the buffer they have + to fit in, the controller page's own script (its refusal to type a passphrase from a + page that arrived in the clear, and the take-over gesture that stays down until one + deliberate click), the CLI's frames and its TLS trust decisions + (no trust argument → refuse; wrong fingerprint → nothing sent) and its `--take-over` + ordering, and the target-side verifier itself — all before anything is flashed. --- @@ -168,7 +173,7 @@ connector it already uses. ```bash cd keyrelay -tests/run_tests.sh # 4826 checks, no hardware needed +tests/run_tests.sh # 5026 checks, no hardware needed pio run -e bridge # ~1.2 MB image, ~40% of the 3 MB app slot (TLS on) python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode pio run -e bridge -t upload # flash @@ -247,7 +252,11 @@ component that makes all of this pointless. A pinned run hashes the peer certifi **before sending its first frame** and aborts on mismatch, so a wrong pin never even leaks the passphrase attempt. `--plain` selects the plaintext `ws://` on :80 — the `-D KR_TLS=0` rollback board is the only thing that answers it, explicit opt-in, scriptable like everything -else. +else. A board held by another controller is a refusal, never a fallback: the tool says so +and names `--take-over`, which is the same one deliberate `X` the page's TAKE OVER button +sends — the incumbent is told (`E12`), every key and every mouse button is released through +the release funnel, and the session continues **disarmed**. Against a free board +`--take-over` is a stated no-op; neither path can arm. To send text instead of pressing keys, paste it into **Send text** and press its button: it says how many characters it will type, the board types them as keystrokes at a pace the @@ -321,6 +330,14 @@ Enforced in this order, each independent of the others: 1. **Disarmed is the only default.** Boot, page reload, WebSocket reconnect and page load all begin disarmed. ` armed` is a state the board holds, not something the page assumes — the page's own arming only becomes visible when the board confirms it. + A **takeover** lands disarmed too: when a second authenticated socket takes the + bridge — `X`, or `--take-over` in the terminal — the handover runs through + `releaseEverything()` *before* the newcomer is adopted, so every key **and every + mouse button** the incumbent held comes up, and the handover itself cannot arm — + nothing reachable from `X` calls `arm()`; only an ordinary authenticated `A1` + (rule 2), the board's button, or the console arms the session that now holds it. + The displaced incumbent sees `E12` and mirrors the same gesture locally: disarm, + release the mouse, end the typing run, and stay down until a human clicks again. 2. **Arming requires authority**: the passphrase over the network, the button on the board, or the serial console. Nothing else can arm it, and nothing at all reaches the target while disarmed — not one key, not one chord, not even a "hello". @@ -344,7 +361,7 @@ Enforced in this order, each independent of the others: indefinitely with the last key still pressed (bug 10). A board armed by its own button with no controller at all is deliberately left running: it has no controller to go quiet. This backstop is not gated on the link still existing, and must never be — see - [Eleven bugs](#eleven-bugs-this-verification-caught-all-fixed). + [Thirteen bugs](#thirteen-bugs-this-verification-caught-all-fixed). 5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys up" and disarms (unless you tick *stay armed if this tab loses focus*, which still releases the keys — you cannot keep a held Ctrl alive by tabbing away). @@ -391,7 +408,7 @@ specifically, and it is the browser's, not ours. the target rather than only clearing the board's own idea of what is down. That is the fixed ninth bug: a held key stayed held on the target after silence-disarm, target loss or a reload, because the report that would have released it was never queued (see -[Measured behaviour](#eleven-bugs-this-verification-caught-all-fixed)). +[Measured behaviour](#thirteen-bugs-this-verification-caught-all-fixed)). --- @@ -405,6 +422,7 @@ a reload, because the report that would have released it was never queued (see | Anyone with a copy of this firmware (or of this repository) | build their own keyrelay; read every byte of the protocol and of this README | impersonate *your* board or decrypt a session to it. The keypair is generated on your device, lives only in its NVS, and is not in the image, the tree, a log or a URL; every board's fingerprint is different, which is the only reason pinning means anything | | Whoever holds the laptop's mouse | the browser tab's pointer, only after a click on **Capture pointer**, and only while armed | move the target's pointer without the LED being red, or keep moving it after `Esc` — Pointer Lock is revoked by the browser and cannot be retaken without another click | | Someone guessing the passphrase | 5 attempts before a 30 s lockout during which the token is not evaluated at all | learn anything from the failure: same error, same close, no timing oracle (constant-time compare) | +| Someone holding the passphrase, arriving on a second socket — a second tab, another laptop, a colleague | stand in the one pending-claim slot, keep itself warm, and take the bridge with one deliberate `X` (or `--take-over` from the terminal) | arm it by doing so — **someone with the passphrase can take the bridge over, and cannot arm it by taking it**: the handover runs through the release funnel and lands disarmed, and only an ordinary authenticated `A1` arms. And they cannot learn *who* holds or held it: the status frame says `busy` and counts takeovers — a boolean and a counter, never addresses, user agents or identities | | The owner of the WiFi | join the network, learn that a keyrelay is there, watch the size and timing of a session | read or forge what a controller sends, or arm anything. The perimeter used to be the network alone; now it is the network *and* whether the certificate was checked — see below | | You, holding the board | arm/disarm (button), reconfigure or reflash (console), stop everything (unplug) | — | | The target's user | notice a red LED next to their monitor, and pull it out | — | @@ -455,14 +473,33 @@ secret (`kr::kApPass`, change it in `src/config.h`) — the AP means you never h board on someone else's LAN, which is a smaller blast radius even when the traffic is encrypted. -**Single controller, by design.** A second WebSocket upgrade is refused with `503` / `E3` -rather than queued, swapped or shared. Two controllers typing into one machine is precisely -the ambiguity a keyboard must not have, and "who owns this bridge" should never be a tie. -The refusal is one slot wide: the board holds at most `kTlsMaxSessions` (2) live -connections — the controller's, and one read-only spare — and a third connection is -refused outright (a TLS alert record on `:443`, a close on `:80`, both counted), never -queued and never half-opened, because a TLS session's I/O buffers are ~32 KB each and -"accept first, find no memory later" is not an option on a board with no PSRAM. +**Single controller, by design.** A second WebSocket upgrade cannot drive the keyboard. +It is accepted into the board's one spare slot as a **pending claim**: it completes the +same handshake, authenticates against the same passphrase and the same lockout counters, +and may then send nothing but `K` to keep itself warm and one deliberate frame — `X`, +**take this bridge over**. Arm, key state, chords including `Ctrl+Alt+Del`, pasted text, +mouse frames and even `R` are each refused by one whitelist (`src/claim.h`, host-tested) +with `E6`, and the claim is dropped: no frame from a claim reaches the target, disturbs +the incumbent, or moves the arming line, and no claim frame is ever handed to the command +dispatcher at all. `X` is the one act the door exists for — the operator locked out by +somebody's forgotten tab (usually their own) — and it moves the bridge **through the +release funnel, never around it**: the incumbent is told (`E12`), every key and every +mouse button is released, and the handover lands **disarmed**. A takeover can never +produce an armed board — nothing reachable from `X` calls `arm()`; the newcomer arms with +an ordinary authenticated `A1`, exactly like a controller that connected cold, or it never +arms (the ordering is pinned by source checks in `tests/test_relayctl.py`). A claim that +goes quiet is dropped (`kClaimTimeoutMs`, 15 s) and its session freed, so a parked claim +cannot hold one of the board's two TLS sessions hostage. Two controllers typing into one +machine is precisely the ambiguity a keyboard must not have, and "who owns this bridge" +should never be a tie. What the locked-out operator can *see* is `busy` and `tkov` in the +status frame — a link state and a count of events — which is why the page can say "held by +another controller" and the CLI can refuse by name without either ever saying *who* holds +it, or from where. The refusal is one slot wide: the board holds at most +`kTlsMaxSessions` (2) live connections — the controller's, and one spare (read-only +viewer *or* the one pending claim, never both) — and a third connection is refused +outright (a TLS alert record on `:443`, a close on `:80`, both counted), never queued +and never half-opened, because a TLS session's I/O buffers are ~32 KB each and "accept +first, find no memory later" is not an option on a board with no PSRAM. What is exclusive is the **keyboard**, not the board: `GET /` and `/status?token=` are served even while a controller holds the bridge, so a page can be reloaded, a second window @@ -590,7 +627,7 @@ tool's validator and the board's map accept exactly the same 128 codes. ## Controller page -Served by the board from flash (`src/web_page.h`, 51 626 bytes — the exact count +Served by the board from flash (`src/web_page.h`, 59 286 bytes — the exact count `.agents/validate.sh` prints on every run, which is what a reflashed device must serve; no CDN, no fonts, no framework, nothing external — a keyboard bridge must work on an airplane and on a network that is down). Its script is executed by `tests/test_page.js`, because a @@ -605,9 +642,10 @@ clear, while the rollback build keeps its `ws://`. |---|---| | big ARM/DISARM bar | the state must be unmissable; it only changes when the board confirms | | red page border + red LED while armed | two independent indicators of the same fact | -| status pills: network / target / link / armed / caps-num / **mouse** / **tls** | "will my keystrokes land, and where", whether the pointer is yours right now, and whether the channel is encrypted (`tls` vs `plaintext`, from the status frame — not from what the page hopes) | +| status pills: network / target / link / armed / caps-num / **mouse** / **tls** | "will my keystrokes land, and where", whether the pointer is yours right now, and whether the channel is encrypted (`tls` vs `plaintext`, from the status frame — not from what the page hopes). The link pill has three honest states: `linked`, `link down`, and `held by another controller` — from the status frame's `busy`, never from a guess, and never saying *who* | | certificate line in the Connection card | the board's own `fp` from the status frame, verbatim, plus where to confirm it (`cert` on the serial console). Rendered only when the frame carries TLS and a fingerprint; the page invents nothing | | *"This page arrived in the clear"* banner | a TLS build's page on an `http://` origin — bytes no TLS board ever served on :80 — disables the passphrase box and the connect button outright and says why, rather than collecting a secret it cannot protect | +| **take over** (beside the passphrase field, visible only when the board says it is held) | the accident this is for: a session left connected somewhere else. The button appears only after the board said so — un-adopted claim, `busy` status frame, or the `E12` that says *this* session was displaced — and one click sends `T` then `X` and nothing else. The incumbent is told, the release funnel runs, the board lands **disarmed**, and the page that lost the bridge stops reconnecting until a human clicks again: auto-reclaim is how two tabs become a fight | | **Ctrl+Alt+Del** with two-press confirm | the OS eats the real one; on Linux it reboots | | VT1–VT7, Alt+Tab, Win, PrtSc, Menu | the chords the laptop's compositor steals | | **⇦ snap left / snap right ⇨ / ⛶ fullscreen** | Super+Left, Super+Right, Super+PageUp — KDE window management, sent by the board so your laptop does not answer it with its own windows | @@ -654,9 +692,10 @@ and impossible for one malformed field to desynchronise what follows it. | c → d | `M=::::` | **the complete mouse button state** plus this report's relative movement | | c → d | `R` | release every key *and every mouse button*, stay armed — **and drop a text run in progress** | | c → d | `K` | keepalive | +| c → d | `X` | claim this bridge for myself — honoured **only** on an authenticated pending claim: the incumbent is told (`E12`), the release funnel runs, the board lands **disarmed**, then the claimant is adopted. From the incumbent controller it is a no-op answered `OK` (you already hold it) | | d → c | `OK` | accepted | | d → c | `E:` | error (codes below) | -| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex | +| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `busy` = a controller holds the keyboard (to a `/status` poller, someone else; to the adopted controller, itself — a link state, never an identity), `tkov` = completed bridge handovers (a count of events, never who or from-where), `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex | **The transport is `wss://` on `:443`, and that is the only thing TLS changed about this table.** Every frame above is the same bytes in the same order — the phase that encrypted the @@ -710,19 +749,30 @@ controller sends the next frame when the status says `typing: 0` — the board's not a guess at timing. Error codes: `1` not authenticated · `2` locked out · `3` another controller holds the -bridge · `4` not armed · `5` no USB host attached · `6` bad message · `7` queue overflow -(released and disarmed) · `8` text buffer full · `9` a character with no US-ANSI keystroke · -`10` the mouse interface is off on this board · `11` the target chose HID boot protocol, so -no mouse report can be sent safely. +bridge (your claim found no room) · `4` not armed · `5` no USB host attached · `6` bad +message · `7` queue overflow (released and disarmed) · `8` text buffer full · `9` a +character with no US-ANSI keystroke · `10` the mouse interface is off on this board · +`11` the target chose HID boot protocol, so no mouse report can be sent safely · +`12` your session was taken over by another controller — you were **dropped**, which is +a different sentence from `3`'s "refused": one of them should be alarming on your screen +and the other should not. -`10` and `11` are two digits, which changed the encoder: `formatError` used to emit +`10`, `11` and `12` are two digits, which changed the encoder: `formatError` used to emit `E` + `code % 10` and would have rendered both of them as `E0:` — a refusal that names -nothing. Codes now render in full, every single-digit code byte-identical to before. +nothing. Codes now render in full, every single-digit code byte-identical to before, and +clients read the code up to the first `:` rather than assuming one character — the +controller page's single-character read turned `E10`/`E11` into a bogus "E1: no +passphrase" and would have hidden `E12`'s reason inside the same misread; the page now +parses to the colon, like the protocol says. **The refusals that are not `E`** live at the HTTP layer, because they happen before a -WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, if the socket -got as far as an upgrade) when another controller already holds the bridge; `404` on an -unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location` +WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, for a client +that maps status codes to them) on a `/ws` upgrade that cannot be accepted as the board's +one **pending claim** — a malformed upgrade, or a second claimant when the one claim slot +is already filled. The first claimant's upgrade now succeeds (`101`) and authenticates, and +what it may then do is wait, keep itself warm with `K`, or send the one deliberate `X` -- +never anything that arms, types, or disturbs the incumbent +([Security model](#security-model-and-trust-boundaries)); `404` on an unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location` pointing at `https://` the same address and nothing else — plus **`426 Upgrade Required`** for a `/ws` upgrade or for **any** request carrying `?token=`, which is refused rather than redirected precisely so the passphrase is not echoed a second time on the wire the redirect @@ -789,6 +839,11 @@ src/ measured against kStatusJsonCap, which is why it is not in the server sha1.h pure SHA-1 + base64 for the WebSocket handshake ws_codec.h pure handshake request parsing + RFC 6455 frame codec + claim.h pure the pending takeover claim (phase 03): the spare slot's role, the + ONE whitelist of frames a pending claim may have honoured (T, K, + and the deliberate X; arm/state/text/mouse/release refused by + name -- X only after authentication), and + its two-clock deadline arithmetic, as data a host suite walks tls_pem.h pure the TLS identity as strings: PEM armour in both directions, the fingerprint's hex and the two shapes the console and the status frame need (colon pairs, short label), the ONE table of names the certificate @@ -831,9 +886,10 @@ src/ is the only HTTP answer for either listener and either port, so the encrypted and plaintext paths cannot disagree about what is served -- they differ only in that :80's answer is a redirect or a 426 - web_page.h the controller page, in PROGMEM (~51 KB now, with Pointer Lock - and the pinning banner; one #if KR_TLS build marker, equal-length - branches, assembled and size-pinned by three separate test readers) + web_page.h the controller page, in PROGMEM (~58 KB now, with Pointer Lock, + the pinning banner and the take-over affordance; one #if KR_TLS + build marker, equal-length branches, assembled and size-pinned by + three separate test readers) main.cpp glue: USB identity/HID composite, WiFi, chords, text run, the TLS identity's NVS records, LED, button, watchdogs, serial console tools/ @@ -850,7 +906,7 @@ tools/ tests/ run_tests.sh builds and runs everything (host g++ or a local container) test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp - test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.cpp + test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.cpp test_claim.cpp test_relayctl.py test_page.js (node, skipped when absent) .agents/ PLAN.md the roadmap and the decisions a phase may not move @@ -867,7 +923,7 @@ TODO.md open items, kept with their root cause rather than deleted If you are an agent or a new contributor, read `AGENTS.md` before changing anything: several rules here are invisible to a compiler and to the test suite at the same time. -The nine modules marked *pure* — plus `conn.h`, marked `pure*` because only its tables are — +The ten modules marked *pure* — plus `conn.h`, marked `pure*` because only its tables are — have no Arduino and no ESP-IDF include, so the identical code compiles and runs on a host under `-fsanitize=address,undefined`. That is not convenience for its own sake: the sanitiser caught five real defects in the frame decoder @@ -950,14 +1006,15 @@ bash .agents/validate.sh # the gate: both runs above + all three firmware | `test_keystate` | 40 | idempotence, transition preservation (a fast tap is two reports), 6-key limit, ErrorRollOver and recovery from it, dedupe, `clear()`, the exact Ctrl+Alt+Del bytes `05 00 4c 00 00 00 00 00` | | `test_mouse` | 48 | the 5-byte report layout, every button bit where the HID spec says it is, idle frames producing **no** report, a button change with no movement still producing one, the button byte being restated rather than evented, `releaseAll` being idempotent, and that no frame can put a value outside one signed byte on the wire | | `test_output` | 153 | **the release funnel.** Queues hold their order and collapse duplicates; releasing with keys held queues an all-keys-up report *when there is a target to receive it*; releasing drops a press that was queued but never sent; releasing with a mouse button held queues all-buttons-up; boot protocol or `mouse off` means no mouse report is queued **but the key release still is**; a pasted run and an in-flight chord are both cancelled; and overflow is detected on both queues. Regression-proven: reinstating the old `releaseEverything()` fails 23 of these | -| `test_proto` | 2359 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (322 bytes) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs | +| `test_proto` | 2379 | every command form including `X` (the takeover: parsed alone, `Bad` with any trailing bytes, and the exact claimant-side frame run through the firmware's own parser), strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including all three two-digit codes -- `E12` renders at full width so the dropped incumbent reads "taken over", never a truncated `E1`), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, the `busy`/`tkov` pair that says a bridge is held without ever saying by whom (a link boolean and a counter; the fields carry no address, agent or peer, and walking them is what makes the next "just add the client IP" a deliberate edit rather than a quiet one), `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (349 bytes, `busy` set and `tkov` at `UINT32_MAX`) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs | | `test_text` | 459 | the whole printable-ASCII map walked (nothing may be unmapped, nothing may claim a key it has no glyph for), shifted glyphs and the digit row, what is refused rather than guessed at, all-or-nothing frame acceptance, pace arithmetic and clamping, the exact press/release report sequence for two characters, overlay on the operator's held keys, ErrorRollOver propagation, cancel, buffer-full, `P=` render/parse round-trip including the largest legal frame, and one end-to-end case that takes the exact frame the page sends and asserts the HID report sequence the target ends up seeing | | `test_ws_codec` | 135 | SHA-1 against published vectors including the million-`a` case, base64 against RFC 4648, the RFC 6455 handshake example, frame encode/decode at the 125/126 and 64-bit boundaries, byte-at-a-time delivery, and rejection of unmasked, fragmented, reserved-bit, binary, over-long and oversized frames | | `test_tls_pem` | 862 | the identity's strings, walked exhaustively (100 % of `src/tls_pem.h`): PEM armour against an RFC 4648 vector and line-by-line at 0/1/45/46/47/48/49 bytes and every 64-character boundary up to 704, the padded final group terminating its own line, a refusal (writing nothing) one byte short of `cap`, the armour read back through CRLF / missing-newline / stray-space shapes and torn or re-labelled blocks rejected rather than decoded, the fingerprint against the published SHA-256 of `abc` and refused at one byte short, **the two renderings of it the board actually prints** (the status frame's 16-character label, and the console's colon pairs -- rejoining the two printed lines reproduces the digest exactly, each line is short enough for the 128-byte CDC transmit ring, and a refusal writes nothing rather than half a fingerprint), **`derContains`** deciding whether a stored certificate really carries the SAN bytes this build writes (one changed byte anywhere, in the needle or in the certificate, stops matching), the name table pinned to `kHostname`/`kApGw`/`kCertCommonName` with the CN asserted to be `kHostname + ".local"`, the SAN extension's 34 DER bytes (cross-checked byte-for-byte against what OpenSSL writes for the same three names), **the OID that turns those bytes into a `subjectAltName`** (`06 03 55 1D 11`, pinned as DER content bytes with an explicit check that no byte of the dotted text survives into what the writer is handed, and the whole 43-byte `SEQUENCE { OID, OCTET STRING }` pinned against the certificate OpenSSL issues for the same three names), and **`derCarriesSan`** answering no to the exact extension a live board produced before that was fixed -- the right value bytes under a mislabelled OID, which `derContains` still calls a match and no client does -- plus one mutated OID byte, value-without-OID, OID-without-value, and a haystack cut one byte short of carrying it, strict dotted-quad parsing, `__DATE__` with its padded day, the leap-day + 10 years clamp, `utcTime` refusing every component UTCTime cannot carry, and `dateTimeText` rendering a validity bound for `cert` -- same component checks, deliberately without UTCTime's 1950..2049 window because reading a date is not the permission to write one | | `test_tls_session` | 118 | the TLS session's policy layer, walked without mbed TLS (100 % of the pure half of `src/tls_session.h`): the handshake/stall deadline in its wrap-safe `int32_t(now - start) > limit` form including the 49.7-day `millis()` rollover and the exact-boundary case, the slot-admission table (controller, spare, refused) with the cap checked before the free slot and *no transport parameter to consult* -- the third connection is refused the same way whichever listener it knocked on -- `kTlsMaxSessions` pinned to the 2 the RAM arithmetic in `config.h` is written against, the library-return table that decides Bytes / Want / Closed / Error (a WANT misread as fatal drops working controllers on slow WiFi; a fatal misread as WANT spins the loop; the three error-code literals it branches on are pinned against the real mbed TLS by `static_assert` inside `tls_session.cpp`, so neither half can drift silently), the 7-byte fatal-alert record byte-for-byte against RFC 5246 -- including the description byte drafted once as `0x40 because forty`, which is the entire reason a wire format gets its own test, never confidence -- and the frame ring's whole-frame-or-nothing push, wrap-span arithmetic and stable head (which is what makes re-handing identical bytes after a refused write possible at all), its default-constructed / unseated state -- cap coerced to 1, nothing accepted, no modulo by zero on the first tick before any socket exists -- and `reseat()`, because `kr::Conn` binds to a different transport between sockets and a byte left behind would be delivered to the new occupant. Task 04 deleted its `WriteQueue` (and with it the queue checks): the transport view queues head and body through the ring plus `kr::FlashSend` instead, so a second streaming cursor here would be exactly the second path this phase exists to remove | -| `test_conn` | 268 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 51 626 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 36 chunks / 9 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them | -| `test_relayctl` | 271 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested | -| `test_page` | 113 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing** | +| `test_conn` | 280 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 59 286 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 41 chunks / 11 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them | +| `test_claim` | 102 | the pending takeover claim's predicates (`src/claim.h`), walked as a matrix: every `proto::Cmd` against both authentication states and both spare-slot roles -- unauthenticated a claim may send only `T` (the explicit cell that an `X` before the passphrase is refused: no promotion path without `_claimAuthed`), authenticated only `T`, `K` and the deliberate `X` (allowed exactly there, and named as the one act that handover runs through the funnel and cannot arm), with the refusals named one by one (arm, disarm, key state, `Ctrl+Alt+Del`, chords, pasted text, mouse, `R`, `H`) because that list *is* the reason "a pending claim can arm nothing" is checkable rather than believed -- one claim at a time; both clocks (the controller's `kAuthTimeoutMs` before authentication, `kClaimTimeoutMs` after) at the exact boundary and across the 49.7-day `millis()` rollover; and the claim's 64-byte frame budget measured through the real codec: the widest legal `T` parses with room to spare, a frame *at* the cap completes, and one byte over it is an `Err::TooLong` refusal -- never a truncation, never an allocation | +| `test_relayctl` | 308 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **bridge handover** pins (the release funnel runs *before* the claimant is adopted; no `arm`/`setArmed`/`armed = true` is reachable from the `X` handler or from the controller-side no-op; `claim::honours(_claimAuthed)` is consulted before the Takeover branch and `X` lives in `claim.h`'s authenticated branch only, so no promotion without the passphrase; `E12` is the named code the incumbent is told with, distinct from `E3`), the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, the **busy board and `--take-over`** (the fake board answers `T` as a controller -- `OK` *plus* the adoption status -- or parks the client as a pending claim: `OK` and the silence that means `busy`; so the default refusal names the flag and puts no `X` on the wire and frees the slot, the flag sends `T` then `X` in that order and nothing else, a free board gets a stated no-op with no `X` wasted on it, and through `main()` the whole gesture is `T`, `X`, then `H` -- claim, handover, status -- with the terminal told the board came back DISARMED and no credential printed), and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested | +| `test_page` | 142 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing**; and **the busy board and the take-over**: `busy` drives the link pill's third state (`held by another controller`) while the adopted controller still reads its own busy as `linked`; an un-adopted claim close shows the take-over button and sends nothing on its own, with the reconnect loop off -- no retry fight; one click sends `T` then `X` in that order and nothing else, and the first status after it is logged as the DISARMED start it is; `E12` disarms, releases the mouse, ends a typing run mid-frame, marks the pill `taken over` and stays down through the close (coming back is one deliberate click, never zero); and `E3` keeps its separate meaning -- it disarms the ask, logs "no room for a claim", and does not claim anyone was displaced** | The verifier is verified too, because twice now it was the verifier that was wrong: node classification (EV_REL means a pointing device — not the device's name, not the order the @@ -1043,6 +1100,7 @@ printf 'Hello from keyrelay\n' | \ python3 tools/relayctl.py --host keyrelay.local paste --yes # 19 characters python3 tools/relayctl.py --host keyrelay.local listen 20 # heartbeats, live python3 tools/relayctl.py --host keyrelay.local disarm +python3 tools/relayctl.py --host keyrelay.local --take-over status # take a bridge a forgotten tab still holds (lands disarmed) # 3. the mouse, same two terminals python3 tools/relayctl.py --host keyrelay.local mouse move 120 -40 @@ -1065,6 +1123,35 @@ press **capture the pointer**, move the mouse (the target's cursor moves, the la hidden), hold left and move for a drag, press `Esc`. Then arm it, capture, hold a button, and **close the tab** — the target must stop clicking within 8 s of the silence timeout. +And the bridge handover — the one claim that needs two controllers, and the only ordering +claim in this file: the incumbent's key must come **up** before the newcomer's first key +reaches the target. Terminal A watches the target; terminal B holds the bridge with a key +pressed and held; a third controller — a phone page, or a second terminal holding the same +passphrase — takes it: + +```bash +# A. on the target, left running: what actually arrived +python3 tools/watch_target.py --seconds 120 + +# B. on the laptop: hold the bridge and press-and-hold a key long enough to act against +python3 tools/relayctl.py --host keyrelay.local arm +python3 tools/relayctl.py --host keyrelay.local key KeyA --hold 30000 + +# C. while B still holds it: the deliberate act. On the phone, open the page and press +# TAKE OVER once the pill says "held by another controller"; from a terminal: +python3 tools/relayctl.py --host keyrelay.local --take-over status +``` + +What must appear, in this order: A shows `A press` (B's held key), then `A release` +**caused by the handover** — and *nothing from C before it*, because C's session starts +disarmed and types nothing until it arms on purpose. C's own output says the bridge was +taken and the board came back **DISARMED**; the `status` it prints reads `armed: 0`, +`busy: 1`, `tkov: 1`. B is dropped with `E12` mid-hold; a browser tab in B's seat marks +its pill `taken over`, logs "another controller took over", releases its mouse, and stops +reconnecting. If `A release` ever appears *after* C's first key, or C is armed without +having sent its own `A1`, the funnel moved after the adoption — on the host that same +ordering is pinned by the **bridge handover** section of `tests/test_relayctl.py`. + What a correct run proves, and how to tell: | claim | evidence | @@ -1080,6 +1167,7 @@ What a correct run proves, and how to tell: | target LEDs are read back | `LED CapsLock on` on the target, caps indicator on the page | | silence disarms | arm it, close the tab, wait 8 s, `status` says `armed: 0` | | auth is real | `relayctl` with a wrong token fails; the board logs `authfails=1`; 5 tries → `E2` | +| **a takeover releases before it adopts** | in `watch_target.py`, the incumbent's `A release` appears **before** anything from the new session — the newcomer starts disarmed and types nothing until its own `A1`; the taker's `status` says `armed: 0`, `busy: 1`, `tkov: 1`; the displaced page's pill says `taken over`, its log says "another controller took over", and it does not reconnect | | **movement reaches the target as movement** | `watch_target.py` prints a `movement dx … dy …` line per report and the summary's `net position` matches the direction and rough distance you moved — the keyboard half of the watcher cannot show this, which is why the mouse node is watched separately | | **the button bits are the buttons they claim to be** | `mouse click --yes` gives `BTN Left press/release`; `--button right` gives `BTN Right`. A button that arrives as the wrong one is a bit-order bug and looks identical from the page | | **a drag is down, move, up in that order** | `BTN Left press`, then `movement` lines, then `BTN Left release` — any other order is a drag that clicks at the start and drags nothing | @@ -1104,10 +1192,12 @@ the stream, and give `--seconds` enough room for the pace (`2048` characters at _On real hardware: AtomS3 Lite → this laptop (Linux, US layout), board on the same WiFi, firmware 1.0.0, `bridge` environment._ Fill in / re-run after any change to the keystroke path; the numbers below are the shape a correct run has. (Those frames predate the TLS -identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and `cert` -exists and has been confirmed against the live handshake — the pinning rows below — but -the status fields themselves and the keystroke path *over a pinned session* need an -authenticated controller, and the board's passphrase is the operator's, not this repo's.) +identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and a +phase-03 board adds `busy` and `tkov` — the link state and the handover count, and never +an identity — while its serial `link` line ends with `taken=`. `cert` exists and has been +confirmed against the live handshake — the pinning rows below — but the status fields +themselves and the keystroke path *over a pinned session* need an authenticated +controller, and the board's passphrase is the operator's, not this repo's.) ``` $ python3 tools/console.py status @@ -1135,10 +1225,10 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0. | what | result | |---|---| -| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the phase-02 image is 1 246 145 B (see the footprint row below), so the same math applies to a body ~10 % larger | -| footprint | `bridge` with TLS on: **RAM 30.4 % (99 616 / 327 680); Flash 39.6 % (1 246 145 / 3 145 728)** of the 3 MB app slot, `console` 20.5 % (67 224 B) / 37.1 % (1 166 397 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-02 final pass on 2026-11-09. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phase 02 — identity, listener, transport view, the pinning page, the SAN fix — cost **+12 024 B of RAM and +110 852 B of flash**, 3.5 % more of the app slot. The RAM figure has not moved since task 06 even though the final pass added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions | -| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) — unchanged by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 160 B of RAM and +100 008 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 | -| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page (36 chunks over 9 `loop()` ticks) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start | +| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the image this tree builds is 1 257 933 B (see the footprint row below), so the same math applies to a body ~11 % larger | +| footprint | `bridge` with TLS on: **RAM 30.4 % (99 744 / 327 680); Flash 40.0 % (1 257 933 / 3 145 728)** of the 3 MB app slot, `console` 20.6 % (67 352 B) / 37.5 % (1 178 173 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-03 final pass on 2026-11-09. Phase 03 — the claim slot, its whitelist, `busy`/`tkov`, the handover and the take-over affordance — is **+128 B of RAM and +11 788 B of flash** over the phase-02 final pass (RAM 30.4 % / 99 616 B, Flash 39.6 % / 1 246 145 B), most of the flash the page. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phases 02 and 03 cost **+12 152 B of RAM and +122 640 B of flash**, 3.9 % more of the app slot. The RAM figure has barely moved since phase 02 task 06 even though both phases added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions, and what phase 03 adds per slot is one 64-byte buffer, one frame decoder and four small counters | +| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.6 % (93 568 B) / Flash 36.8 % (1 157 609 B) — unchanged in kind by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 176 B of RAM and +100 324 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 | +| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page of that build (36 chunks over 9 `loop()` ticks; the page is 59 286 B since phase 03, and the served-bytes half of this check is a row in `.agents/HW_VERIFICATION.md`) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start | | **TLS session heap** | the board's own `[tls] sessions=N heap=…` serial lines: boot `[tls] https :443 listening heap=187936` → **1 session ≈ 145.5 k** → **2 sessions ≈ 107.9 k**; the task-04 run with the transport view on both ports read 140 092 → 109 228 → close one 144 836 → close all 182 256. A second session is ~31–38 kB (two 16 kB mbed TLS record buffers plus the context), the memory comes back, and 20 connect/disconnect cycles showed no downward drift. This is why `kTlsMaxSessions` is 2 and why a third connection is refused outright rather than queued | | enumeration | `class=03 sub=01 proto=01` — boot-protocol keyboard, the Windows secure-attention precondition | | reflashing | 1200-baud touch into ROM download mode works with this firmware running, so no button and no GPIO0 | @@ -1151,7 +1241,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0. | chord path | `chords=1` counted and delivered (Shift+F15) | | LED read-back | `LED CapsLock on` then `off`, coming from the *target's* HID output reports | | **silence disarms** | controller process killed → `armed:0` within 11 s, keys released. **Worth knowing:** that run passed *before* the silence timer existed — killing a process closes the socket, and the socket-close path disarmed. The FIN-less death (laptop slept, network vanished) is what the timer covers, and it is wired now. A check that passes for the wrong reason is still a check that passes, which is exactly the problem | -| single controller | a second client got `E3` / `503` and the board logged `rejected=1` — no takeover | +| single controller | a second client got `E3` / `503` and the board logged `rejected=1` — no takeover *(observed pre-phase-03; a second client is a pending claim now — the takeover claims are rows in `.agents/HW_VERIFICATION.md`)* | | **mouse enumeration** | the target created **two** input devices from one HID interface — `keyboard=/dev/input/event19`, `mouse=/dev/input/event20` — with `caps rel: 1943`, i.e. REL_X, REL_Y, REL_WHEEL, REL_HWHEEL and both hi-res axes | | **mouse movement** | `mouse move 120 -40` → `movement dx +120 dy -40` on the target, exact, one report | | **mouse buttons** | `click` → `BTN Left press` + `BTN Left release`; `--button right` → `BTN Right`. The bit order the page claims is the bit order the target sees | @@ -1162,7 +1252,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0. | lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop | | queues | `drop=0 roll=0` throughout; `queue 0 keys pending, 0 held, ok` at rest | -### Eleven bugs this verification caught (all fixed) +### Thirteen bugs this verification caught (all fixed) Worth listing, because each one is a class of bug that HID-over-network tools ship with: @@ -1187,11 +1277,16 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh a 503: `curl` of the page, a second browser, and worst of all **pressing reload on the page that already held the bridge**, since the board could still be holding the slot the instant the new request arrives. The fix moves the gate to what it actually protects: a - second `/ws` upgrade is still refused (that is a controller claim), while read-only HTTP - is served on a separate short-lived socket through the same routing code (`serveReadonly`) - so the two paths cannot drift. Verified live: with your browser holding the bridge, the - page returns 200 and `/status` returns 401 without the token, 200 with it, and a second - upgrade still gets 503 while the incumbent stays `controller=yes` and healthy. + second `/ws` upgrade was still refused then (that is a controller claim — phase 03 turned + that refusal into the one **pending claim**, so a 503 now answers a *second* claim or a + malformed upgrade), while read-only HTTP is served on a separate short-lived socket + through the same routing code (`serveReadonly`) so the two paths cannot drift. Verified + live: with your browser holding the bridge, the page returns 200 and `/status` returns + 401 without the token, 200 with it, and a second upgrade still gets 503 while the + incumbent stays `controller=yes` and healthy. *(That last sentence is the pre-phase-03 + observation, kept because it is what the fix was measured against at the time; the + read-only half of it is unchanged, and what a second upgrade gets now is in* + [Security model](#security-model-and-trust-boundaries).*) 7. **The Alt+Tab button sent Ctrl+Alt+A.** Its `data-chord` was `05:04` — LCtrl|LAlt with usage `0x04`, which is the A key. It parsed cleanly, it was correctly gated by arming, it arrived intact and it was the wrong combination: Alt+Tab is `04:2b`. Reported by a @@ -1287,6 +1382,37 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh wrong-but-parseable argument is undocumented until it has bitten you, and a wire format has to be checked against **somebody else's** implementation, not against your own encoder. +12. **The page read the error code as one character, so every two-digit code arrived as + `E1`.** The controller page parsed `E:` with `raw[1]` — fine while every + code was one digit, wrong ever since `E10` (mouse interface off) and `E11` (target + chose boot protocol) existed: the board's "the pointer is gone" or "boot protocol, no + mouse" reached a fully authenticated page as the wrong-passphrase message, clearing + `authed` and painting "no passphrase" on a session that was working. Phase 03 found + it by writing the test for the code it was adding: `E12` — the one sentence a dropped + operator most needs read back verbatim ("your session was taken over") — would have + arrived as the same bogus `E1`, hiding the takeover inside the misread. Fixed by + parsing the code up to the first `:`, which is what the wire format always was; + `test_page.js` now walks `E12` end to end (disarm, mouse released, typing run ended, + pill `taken over`, no auto-reclaim) with `E3`'s separate "no room for a claim" + sentence pinned beside it, and `test_proto` pins the two-digit codes' full-width + rendering so the board cannot send a truncated one. The lesson is the mouse codes' + lesson again: + a client that assumes a field's width has stopped parsing the protocol and started + guessing it. + +13. **`relayctl`'s `_poll` returned from inside the read loop, abandoning the rest of the + batch.** A status frame riding in behind the reply the call was waiting for was lost + with the abandoned generator — invisible while every caller asked for one thing at a + time, and fatal for the phase-03 adoption probe, which reads what *follows* the `OK`: + a controller gets `OK` then status back to back (auth answers them in one write), a + pending claim gets `OK` and silence, and a drain that dropped the status would report + a **free** board as busy — inviting `--take-over` against a bridge nobody held. Found + by writing the suite's fake board the way the real one writes; fixed by draining the + whole batch, parking an un-wanted status in `_pending_status`, and returning only once + the socket is quiet. `test_relayctl.py` pins both probe answers — the fake puts `T`'s + `OK` and the adoption status on one write — so an early-return regression makes the + free board look held and fails the stated no-op row instead of passing quietly. + ### The hazard nobody warns you about **When the target is the machine you are working on, your keystrokes land on your own @@ -1318,10 +1444,11 @@ way. So: | `arm` does nothing, LED blinks red | no USB host attached (`usb:0`); the board is not plugged into the target, or the target suspended the bus | | typing does nothing, `armed` says yes | the tab lost focus: click into the page. Or the target's focus moved — that is the target's answer, not a bug | | `E4 not armed` | it is not armed. `arm` first; chords and states are never free passes | -| `E3 another controller` | another tab/machine holds it. Close it; there is no takeover, deliberately | +| `E3 another controller` | another tab/machine holds it **and** the one pending-claim slot was already filled — your claim found no room. Close the other tab, or wait for its claim to expire (`kClaimTimeoutMs`, 15 s) and claim again; a takeover lands the board disarmed | +| the page's link pill says `held by another controller` | the board said so — an authenticated socket it declined to adopt, a `busy` status frame, or the `E12` that says *this* session was displaced. It never says who: a link state, not an identity. The take-over button beside the passphrase field is one deliberate click (`T` then `X`, nothing else); the page that lost the bridge stays down until someone presses it — auto-reclaim is two tabs fighting over a keyboard | | `E2 locked out` | 5 wrong passphrases; wait 30 s | | keys arrive out of order or missing | check `dropped` (queue overflow) and that the laptop's signal is good; `WiFi.setSleep(false)` is already set. If a *verifier* says keys are missing, check it names the codes — see [the hazard nobody warns you about](#the-hazard-nobody-warns-you-about) | -| the CLI says `another controller holds this bridge` | your browser page has it, and that is the rule working. Close the tab; there is no takeover | +| the CLI says `another controller holds this bridge` | your browser page has it, and that is the rule working. Close the tab — or re-run with `--take-over`, which is the same one deliberate `X` the page's TAKE OVER button sends: the incumbent is told (`E12`), every key and every mouse button is released through the release funnel, and the board comes back **disarmed** so the new session arms on purpose. Against a free board `--take-over` is a stated no-op | | pasted text arrives as different characters | the **target** is not on a US layout — characters travel as positions, exactly like the live keys. Or the run was sent with *drop characters I cannot type* / `--strip`, which reported how many it removed | | a paste stops before the end | something cancelled it, on purpose: the tab lost focus, the bridge disarmed, the target suspended the bus, `E7`/`E8`, or 8 s with nothing inbound from the controller. The page's log says which, and how far it got | | a chord button does something other than its label | the bytes on the button are wrong — the page carries the wire form itself, so nothing browser-side can catch it. `curl` the page and read `data-chord`; this was the case for Alt+Tab (`05:04`, Ctrl+Alt+A) until the label-vs-bytes test was written | diff --git a/src/claim.h b/src/claim.h new file mode 100644 index 0000000..91ab15d --- /dev/null +++ b/src/claim.h @@ -0,0 +1,139 @@ +/** + * keyrelay -- the pending claim: the state machine of the second socket that may + * authenticate but can do nothing else. Pure logic, host-tested + * (tests/test_claim.cpp): no Arduino, no IDF, no sockets. + * + * Why this door exists + * -------------------- + * "No room" used to be the only answer a second `/ws` upgrade ever got, which turned + * the most ordinary accident -- a laptop tab that kept the socket open -- into a dead + * board until you found the other tab or rebooted. A claim is therefore not a new + * privilege: it is the SAME privilege (the passphrase) exercised from a different + * socket, plus one deliberate act. This module holds the predicates of that act's + * first half -- stand in the doorway -- because the predicates are the whole safety + * argument, and an argument nothing executes is an argument a later edit can undo + * without noticing. control_server.cpp keeps the routing and the sockets; the answer + * to "what may this frame be, in this role, doing that?" lives here and is walked by + * tests under sanitisers, in the style of output.h. + * + * The load-bearing rule of everything here: a pending claim can never produce an + * armed board. Nothing in this file knows that arming exists; the whitelist below is + * what makes that true by construction rather than by care. + */ +#pragma once + +#include + +#include "config.h" +#include "proto.h" +#include "tls_session.h" // tls::deadlinePassed -- one wrap-safe clock arithmetic + +namespace kr { +namespace claim { + +/** + * What the spare slot is for. + * + * `Readonly` is the long-standing behaviour: the read-only routes through + * `serveReadonly()`, one response, close. `Claim` is a pending takeover claim -- + * a second controller that has completed the WebSocket upgrade and (if its + * passphrase was right) authenticated, and holds nothing. The spare slot exists + * exactly once -- one incumbent plus one more, never one incumbent plus two + * claimants -- which is the same shape the server already had and the TLS-session + * ceiling from phase 02 (`kTlsMaxSessions` = 2), so a claim costs no new socket, + * no new buffer class and no new auth path. + */ +enum class ViewerRole : uint8_t { Readonly, Claim }; + +/** + * One claim at a time. A second `/ws` upgrade on a slot that already carries a + * claim gets the `503` the slot always answered with; a third *socket* never gets + * this far -- it is refused at the slot table (`tls::admitConnection`), which is + * RAM policy, not takeover policy, and does not move. + */ +inline bool acceptsUpgrade(ViewerRole role) { return role != ViewerRole::Claim; } + +/** + * THE WHITELIST -- the whole safety argument of the pending claim, as one visible + * list. A frame from the spare slot is honoured only when this says so: + * + * allowed, unauthenticated: T -- the credential the claim is made of + * allowed, authenticated: T -- re-present it (same compare, same lockout) + * K -- keepalive, freshness only + * X -- claim the bridge: the one + * deliberate act this door exists for. It + * joins the whitelist HERE, nowhere else, + * and only in the authenticated branch -- + * the handover it triggers + * (control_server.cpp::handOverClaim) + * runs the release funnel, lands the + * board DISARMED, and cannot arm: an + * unauthenticated `X` would be the whole + * privilege with none of the proof. + * + * refused in both states, spelled out because this list is why "a pending claim can + * arm nothing" is checkable rather than believed: + * + * A1 / A0 arm / disarm -- arming is never reachable from a claim + * S= key state -- nothing a claim sends reaches the target + * C, Y= chords, Ctrl+Alt+Del included (it is fixed on the device, and the + * device never sees this frame at all) + * P= pasted text + * M= mouse reports + * R release -- a claim must not be able to disturb the + * incumbent's keyboard either; only the funnel + * on a real link change does that + * H status -- the status frame is the controller's, and + * phase 03 does not weaken that even for the + * claimant: the claimant's answer IS its own + * non-adoption (`OK` with no status frame + * behind it means the bridge is held -- the + * `busy` field exists so a /status poller and + * the post-handover frames say the same thing + * in counter-and-link-state terms). Asking + * `H` here earns `E6` and the drop, which is + * exactly what the page's own detection reads + * None/Bad -- an unparseable frame from a non-privileged socket is a drop, not + * a conversation + * + * A `Readonly` slot has no WebSocket at all, so no frame is honoured in that role; + * the parameter is here so the predicate is the single authority over both roles and + * the test can walk the whole matrix. + */ +inline bool honours(ViewerRole role, bool authed, proto::Cmd cmd) { + if (role != ViewerRole::Claim) return false; + if (!authed) return cmd == proto::Cmd::Auth; + switch (cmd) { + case proto::Cmd::Auth: + case proto::Cmd::KeepAlive: + case proto::Cmd::Takeover: // `X` -- the deliberate act, authenticated branch only + return true; + default: + return false; + } +} + +/** + * Is the claim past its clock? One anchor (`_claimAtMs`), two limits: + * + * unauthenticated kAuthTimeoutMs -- exactly the controller's rule: open the + * socket, present the passphrase, or go away + * authenticated kClaimTimeoutMs -- a claim that has not asked for the bridge + * by now is dropped and its session freed + * (config.h: an unanswered claim sitting on + * one of two TLS sessions is a nuisance + * attack, and 15 s is plenty for a click) + * + * The anchor is refreshed only by frames of this claim, and it is NEVER + * `_lastInboundMs`: a claim attempt is not the operator being alive, and the + * controller-silence fail-safe must not be held open by a socket that is trying to + * take over. That distinction is `readSome()`'s comment and bug 10; this function is + * where the claim's own clock lives instead. Wrap-safe via deadlinePassed, so the + * 49.7-day `millis()` rollover can neither make a claim immortal nor evict a live one. + */ +inline bool expired(bool authed, uint32_t nowMs, uint32_t anchorMs) { + return tls::deadlinePassed(nowMs, anchorMs, authed ? kClaimTimeoutMs : kAuthTimeoutMs); +} + +} // namespace claim +} // namespace kr diff --git a/src/config.h b/src/config.h index 8500bec..478f78f 100644 --- a/src/config.h +++ b/src/config.h @@ -139,12 +139,12 @@ constexpr size_t kConnWriteChunk = 4096; // write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one // segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The // cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase -// report against the phase 01 baseline. The served page is 51 626 B (the count the gate -// prints), so a page is 36 chunks. +// report against the phase 01 baseline. The served page is 59 286 B (the count the gate +// prints), so a page is 41 chunks. constexpr size_t kPageChunkBytes = 1460; -// The per-tick budget for draining a slot's queue: 4 chunks, so the 51 626-byte page leaves -// in 9 ticks of pump() and no single loop() iteration can spend more than four transport +// The per-tick budget for draining a slot's queue: 4 chunks, so the 59 286-byte page leaves +// in 11 ticks of pump() and no single loop() iteration can spend more than four transport // writes on one socket -- whatever that socket's peer is doing. loop() runs continuously // (nothing in it delays), so the page is out in milliseconds; the budget is not about // throughput, it is the ceiling on how long one iteration can be busy sending. @@ -230,13 +230,16 @@ constexpr uint32_t kStatusIntervalMs = 1000; // // The arithmetic, with every field at its widest legal value -- a 32-character SSID // (staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each of the five -// counters, UINT16_MAX in `typing`, and the whole 16-hex `fp`: 322 bytes, measured by the -// longest-legal-snapshot check in tests/test_proto.cpp and asserted below this cap, not -// believed. `tls` and `fp` together cost 32 of those 322 -- `,"tls":1` is 8 bytes and -// `,"fp":"0123456789abcdef"` is 24 -- which is why the cap did not have to move when they -// were added, and why the measurement exists: the next field added without re-measuring is -// how this constant stops meaning anything. The 62 bytes left over are one more field's -// worth of headroom, asserted in the test, not slack to be spent by accident. +// counters, UINT16_MAX in `typing`, `busy` set and `tkov` at UINT32_MAX, and the whole +// 16-hex `fp`: 349 bytes, measured by the longest-legal-snapshot check in +// tests/test_proto.cpp and asserted below this cap, not believed. The two phase-03 fields +// cost 27 of those 349 -- `,"busy":1` is 9 bytes and `,"tkov":4294967295` is 18 -- which +// is why this cap did not have to grow when they were added and the measurement still +// says so; it is also why the measurement is re-run every time: the 35 bytes left over +// are one more field's worth of headroom (the test asserts at least 32), no longer the +// comfortable two it were, so the next field starts by re-measuring. `tls`/`fp` cost 32 +// the same way in, and the constant stops meaning anything the first time it is edited +// without the number. constexpr size_t kStatusJsonCap = 384; // The widest HTTP response head the board can build, in bytes: one formatter @@ -258,4 +261,24 @@ constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger tha constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast +// A pending takeover claim (phase 03, src/claim.h) is a controller-shaped socket that +// holds nothing, so it gets a controller-shaped slice of the buffers: one quarter of +// kWsMessageCap. (One quarter, not the sixteenth an early draft said -- kWsMessageCap +// is 256, and the arithmetic here is the measurement, not the prose.) The widest frame +// a claim may ever send is `T` at kTokenMax + 1 = 33 bytes; `K` is one byte and +// `X` (task 02) is one byte, so 64 is the whole legal range with headroom, and a frame +// larger than it is refused by the codec (ws::Err::TooLong), never buffered -- which is +// why a hostile claimant cannot make this board allocate: the buffer is static, sized +// here, exactly one exists, and nothing grows it. +constexpr size_t kClaimMsgCap = 64; + +// A pending claim that has not asked for the bridge this long is dropped and its +// session freed. 15 s is plenty for a human click, and an unanswered claim holding one +// of this board's two TLS sessions open indefinitely is a nuisance attack: with the +// ceiling at 2, one parked claim plus one lost controller is enough to lock the actual +// operator out of the board. The anchor moves only on frames from the claim itself -- +// never on `_lastInboundMs`, the controller-silence clock (see control_server.cpp: +// readSome(), and README bug 10 for why that distinction exists at all). +constexpr uint32_t kClaimTimeoutMs = 15000; + } // namespace kr diff --git a/src/control_server.cpp b/src/control_server.cpp index d2129b8..d977e69 100644 --- a/src/control_server.cpp +++ b/src/control_server.cpp @@ -66,6 +66,25 @@ constexpr char kBodyPlaintextToken[] = constexpr char kBodyRedirect[] = "keyrelay: this port redirects. Follow Location, or use https:// on this host\n"; +// The 101 that switches either slot to WebSocket: the controller's and a pending +// claim's are byte-identical, and they are built in ONE place on purpose -- +// tests/test_relayctl.py pins that this file contains exactly one literal HTTP status +// line, because a second response writer is the drift invariant 8 forbids. The accept +// hash is computed by the caller (ws::acceptFor, host-tested) because the two slots +// answer a bad key differently: the controller slot says 400, the spare slot's claim +// attempt is simply not claimable. False means the head would not fit or the socket +// refused it; the caller owns the goodbye. +bool pushUpgradeHead(Conn &conn, const char *accept) { + char head[224]; + const int n = snprintf(head, sizeof(head), + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n\r\n", + accept); + return n > 0 && conn.push(reinterpret_cast(head), (size_t)n); +} + } // namespace void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange, @@ -160,8 +179,12 @@ void ControlServer::acceptNew(uint32_t nowMs) { if (admit == tls::Admit::Spare) { // The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket - // is still allowed to look at the board. Only a /ws upgrade -- a controller claim -- - // is refused, and serveViewer() is what decides that. + // may look at the board, and it may stand in this slot as a pending claim (phase 03 + // -- serveViewer/serveClaim decide, claim.h gates). Either way the occupant starts + // from nothing: stopViewer() above resets the role/phase/auth/reader when the slot + // was open, and saying it again here costs nothing and keeps the invariant visible + // at the accept itself -- a claim that survived its own slot would be exactly the + // bug this file keeps catching. if (_viewOpen) stopViewer(); // the spare is dead-but-flagged; settle it first _viewer = fresh; _viewer.setNoDelay(true); @@ -171,6 +194,11 @@ void ControlServer::acceptNew(uint32_t nowMs) { _viewerLen = 0; _viewerHttp[0] = '\0'; _viewerAtMs = nowMs; + _viewerRole = ViewerRole::Readonly; + _viewerPhase = Phase::Http; + _claimAuthed = false; + _claimAtMs = 0; + _claimReader.reset(); return; } fresh.setNoDelay(true); @@ -407,14 +435,7 @@ void ControlServer::serveHttp(uint32_t nowMs) { respondAndClose(rsp); return; } - char head[224]; - const int n = snprintf(head, sizeof(head), - "HTTP/1.1 101 Switching Protocols\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Accept: %s\r\n\r\n", - accept); - if (n <= 0 || !_ctl.push(reinterpret_cast(head), (size_t)n)) { + if (!pushUpgradeHead(_ctl, accept)) { closeLink("handshake failed"); return; } @@ -528,9 +549,14 @@ void ControlServer::serveReadonly(const ws::Request &req, BuiltResponse &rsp, // and close. This exists because the first version refused everything, which turned the // most ordinary gesture -- pressing reload on the page that already holds the bridge -- // into a wall of text, and left a running controller with no way to be inspected. -// Still refused here: a /ws upgrade, because that is a controller claim -- and on the -// plaintext port it is refused as a transport question (426) before it is refused as a -// slot question (503), so a client never learns "busy" when the answer is "TLS only". +// A /ws upgrade on this slot is no longer a flat refusal: one newcomer at a time may be +// accepted into it as a PENDING CLAIM (phase 03) -- same handshake, same passphrase, +// and the whitelist in claim.h until it can do anything else. What is still refused: +// a second upgrade on a slot that already carries a claim (503 -- the claim is not a +// second controller either), and on the plaintext port the transport question (426) +// still answers before any slot question, so a client never learns "busy" when the +// answer is "TLS only". A third SOCKET never reaches this function at all: the slot +// table refused it, counted in _slotRefused. void ControlServer::serveViewer(uint32_t nowMs) { if (!_view.attached()) { stopViewer(); return; } @@ -543,6 +569,11 @@ void ControlServer::serveViewer(uint32_t nowMs) { if (_viewAfter != AfterDrain::Nothing) return; // draining; pumpSlots owns it + // A slot whose upgrade completed speaks WebSocket, not HTTP, and what lives there is + // a pending claim -- its frames, clocks and refusals are serveClaim's, never this + // loop's. + if (_viewerPhase == Phase::WebSocket) { serveClaim(nowMs); return; } + for (;;) { const size_t space = sizeof(_viewerHttp) - _viewerLen - 1; if (space == 0) { @@ -578,9 +609,9 @@ void ControlServer::serveViewer(uint32_t nowMs) { } if (req.upgradeWebsocket) { - _rejected++; #if KR_TLS if (!_view.isTls()) { + _rejected++; // a controller claim refused for want of TLS: no room on this port BuiltResponse rsp; buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8", BuiltResponse::Body::Text, kBodyPlaintextWs, @@ -589,14 +620,36 @@ void ControlServer::serveViewer(uint32_t nowMs) { return; } #endif - // One controller. The incumbent keeps the bridge; the newcomer is told why -- over - // either transport with the same 503, because the refusal to hand over the keyboard - // is not a transport question either. - BuiltResponse rsp; - const char *busy = "keyrelay: another controller already holds this bridge.\n"; - buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8", - BuiltResponse::Body::Text, busy, strlen(busy)); - respondViewer(rsp); + // One newcomer may stand here as a pending claim: the same accept handshake as + // the controller path (same ws::acceptFor, same pushUpgradeHead -- one literal + // 101 in this file, pinned), then the whitelist, then nothing it can do. + // Refused -- with the 503 this slot has always answered, because the refusal to + // hand over the keyboard is not a transport question either -- is anything that + // cannot BE a claim: a malformed /ws upgrade, or a second upgrade on the one + // slot that already carries one. `_rejected` means "no room": a controller claim + // this board would not take. An ACCEPTED claim is not a rejection and never + // touches the counter; the third SOCKET is a different counter entirely + // (_slotRefused, at the slot table) and never gets this far. + char accept[64]; + const bool claimable = strcmp(req.path, kPathWs) == 0 && req.wsKey[0] && + claim::acceptsUpgrade(_viewerRole) && + ws::acceptFor(req.wsKey, strlen(req.wsKey), accept, + sizeof(accept)); + if (!claimable) { + _rejected++; + BuiltResponse rsp; + const char *busy = "keyrelay: another controller already holds this bridge.\n"; + buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8", + BuiltResponse::Body::Text, busy, strlen(busy)); + respondViewer(rsp); + return; + } + if (!pushUpgradeHead(_view, accept)) { stopViewer(); return; } + _viewerRole = ViewerRole::Claim; + _viewerPhase = Phase::WebSocket; + _claimAuthed = false; + _claimAtMs = nowMs; // the claim's clock starts here; never _lastInboundMs + _claimReader.reset(); return; } @@ -607,6 +660,260 @@ void ControlServer::serveViewer(uint32_t nowMs) { } } +// --------------------------------------------------------------------------- +// The pending claim: the WebSocket that lives on the spare slot once an upgrade +// turned Readonly into Claim. Rules and whitelist: claim.h. Nothing below may +// reach the target, the arming state, or the controller-silence clock -- the +// strongest form of that sentence is structural: no claim frame is ever handed +// to _onMessage, so main.cpp's command dispatcher cannot see one. +// --------------------------------------------------------------------------- + +void ControlServer::serveClaim(uint32_t nowMs) { + // Two clocks, one anchor. Unauthenticated, the claim gets exactly the controller's + // kAuthTimeoutMs; authenticated and not claiming, kClaimTimeoutMs (config.h: an + // unanswered claim parked on one of the board's two TLS sessions is a nuisance + // attack, and 15 s is plenty for a human click). Neither of them is _lastInboundMs + // -- a claim attempt is not the operator being alive, which is readSome()'s comment + // and bug 10; these lines exist precisely so that rule keeps holding while the door + // is open. + if (claim::expired(_claimAuthed, nowMs, _claimAtMs)) { + if (!_claimAuthed) sendClaimError(kErrNotAuthed, "passphrase required"); + dropClaim(); + return; + } + + uint8_t chunk[64]; + for (;;) { + // Directly on the Conn, the way the read-only viewer reads -- whoever is waiting + // for the bridge is not whoever is typing. + const size_t got = _view.read(chunk, sizeof(chunk)); + if (got == 0) return; + for (size_t i = 0; i < got; i++) { + const ws::In r = _claimReader.feed(chunk[i]); + if (r == ws::In::NeedMore) continue; + if (r == ws::In::Error) { + // A frame the codec refuses is refused whole (TooLong means "larger than the + // claim's buffer", which is a policy statement, not an accident) -- say so once + // and drop, exactly like the controller treats a frame error, except that the + // claimant has nothing to lose and the incumbent must not lose a tick to it. + sendClaimError(kErrBadMessage, ws::describe(_claimReader.error())); + dropClaim(); + return; + } + if (!handleClaimFrame(nowMs)) return; + } + } +} + +bool ControlServer::handleClaimFrame(uint32_t nowMs) { + // Freshness of the claim, refreshed by every frame it completes -- and it is the + // only clock a claim may move. _lastInboundMs never appears in this function. + _claimAtMs = nowMs; + + const uint8_t opcode = _claimReader.opcode(); + if (opcode == ws::kOpPing) { + // The same echo the controller gets: a browser keeps its sockets alive, and a pong + // is the only answer a ping should have. Copy first -- the payload lives inside + // _claimMsg, which is this reader's own buffer, and control payloads are capped at + // 125 bytes by the codec, so both buffers here are small and bounded. + uint8_t payload[128]; + uint8_t frame[136]; + const size_t plen = + _claimReader.payloadLen() < sizeof(payload) ? _claimReader.payloadLen() + : sizeof(payload); + memcpy(payload, _claimReader.payload(), plen); + _claimReader.reset(); + const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame)); + if (n && _view.alive() && !_view.push(frame, n)) _txDropped++; + return true; + } + if (opcode == ws::kOpPong) { _claimReader.reset(); return true; } + if (opcode == ws::kOpClose) { dropClaim(); return false; } + + proto::Message msg; + const proto::Cmd cmd = proto::parse((const char *)_claimReader.payload(), + _claimReader.payloadLen(), msg); + _claimReader.reset(); + + if (cmd == proto::Cmd::Bad) { + sendClaimError(kErrBadMessage, "unrecognised command"); + dropClaim(); + return false; + } + + // One lockout for the board, not one per socket: the counters are the controller + // path's own, so a claimant cannot burn attempts the incumbent would not pay for, + // and while the lock is on the token is not even looked at. + if (!_claimAuthed && _lockUntilMs != 0 && (int32_t)(nowMs - _lockUntilMs) < 0) { + sendClaimError(kErrLockedOut, "too many failures, try later"); + dropClaim(); + return false; + } + + // THE WHITELIST, as one call to one list: claim::honours() (claim.h) refuses by name + // A1/A0 (arm/disarm), S= (key state), C and Y= (chords, Ctrl+Alt+Del included), P= + // (pasted text), M= (mouse), R (release -- a claim may not disturb the incumbent + // either) and H (status), plus Bad and None. What it allows is T always and, + // once authenticated, K and X. The ordering below is load-bearing: the whitelist is + // consulted BEFORE the Takeover branch, so `X` cannot reach handOverClaim (the one + // path that sets `_authed = true` on this slot) without `_claimAuthed` having been + // checked -- that is what "a claim cannot be promoted unauthenticated" means in + // code, and tests/test_relayctl.py pins it. A refusal here is not a conversation: + // the claim is dropped, the incumbent never notices, and no frame of this slot's + // ever reaches _onMessage or arm(). + if (!claim::honours(_viewerRole, _claimAuthed, cmd)) { + if (!_claimAuthed) sendClaimError(kErrNotAuthed, "authenticate first"); + else sendClaimError(kErrBadMessage, "a pending claim may not send that"); + dropClaim(); + return false; + } + + if (cmd == proto::Cmd::Auth) { + // The same constant-time compare, the same shared failure counters, and the same + // close-with-nothing on failure as the controller path -- and on success, the same + // `OK` and nothing else: no status frame, no _controllerSeen, no link change, no + // _lastInboundMs. The claim exists; the incumbent is untouched; the board is no + // closer to armed than it was. + const char *want = _getToken ? _getToken(_ctx) : ""; + if (!proto::constTimeEq(msg.text, strlen(msg.text), want, strlen(want))) { + _authFails++; + if (_authFails >= kTokenMaxFails) _lockUntilMs = nowMs + kTokenLockMs; + sendClaimError(kErrNotAuthed, "wrong passphrase"); + dropClaim(); + return false; + } + _claimAuthed = true; + _authFails = 0; + _lockUntilMs = 0; + sendClaimText("OK", 2); + return true; + } + + if (cmd == proto::Cmd::Takeover) { + // The one deliberate act. Everything the handover owes the incumbent -- the E12, + // the funnel, the disarm -- happens inside handOverClaim(), in that order; the + // claim slot ceases to exist there, so this returns false and serveClaim stops + // reading a socket that has just become the controller's. + handOverClaim(nowMs); + return false; + } + + // KeepAlive is all the whitelist has left: freshness only, already recorded at the + // top of this function. + return true; +} + +/** + * The bridge handover (`X` from an authenticated pending claim). + * + * NOTHING on this path arms the board, and that sentence is the reason the function is + * named handOver and not takeover: it calls no arm(), sets no `armed`, and reaches + * main.cpp only through the two _onLinkChange callbacks that already exist -- (false) + * from closeLink, which is the release funnel itself (disarm + releaseEverything, bug + * 10's fix, deliberately not re-narrowed here), and (true) after the adoption, which + * only announces that a controller exists. The newcomer arms with an ordinary + * authenticated `A1`, exactly like any controller that connected cold, or it never + * arms. The order below is the safety property: the incumbent is told, the funnel + * runs, the board is disarmed with every key and every mouse button released -- and + * only THEN is the claimant adopted, so it can never inherit a held key or an armed + * bridge. If the incumbent is already gone by the time `X` arrives, closeLink() is the + * no-op it has always been on a dead slot and the same steps still run: one path. + * + * What the incumbent gets first is E12 ("you were dropped"), best-effort: an + * unreadable incumbent must not block the handover, so the frame is queued and only + * ever gets the same bounded shove the WS-close goodbye always got inside closeLink. + * It is not counted as a rejection -- `_rejected` keeps meaning "no room", and this + * socket found room; the separate `_takeovers` counter says what actually happened. + */ +void ControlServer::handOverClaim(uint32_t nowMs) { + // 1. Tell the incumbent, best-effort, while its link is still up to carry it. + sendError(kErrTakenOver, "session taken over"); + + // 2. The funnel, before anything is adopted. closeLink() sends the close frame and + // the bounded shove, tears the transport down, clears _authed/_phase/_reader, + // stamps the silence clock, and runs _onLinkChange(false) -- which in main.cpp + // is disarm("controller disconnected") -> releaseEverything(). Anything the + // incumbent left pressed is pressed no more, and the board lands DISARMED. + closeLink("taken over"); + + // 3. Transplant the claimant's transport into the controller slot. One bounded pump + // first (the resume pump of whatever the claim had queued -- an `OK` or a pong); + // anything still queued after it is retired with the slot. Losing it cannot + // strand state: the bytes a claim may have queued are its own `OK` and pongs, + // and the claimant that just sent `X` has everything it needs to be a controller + // -- it gets a fresh `OK` and status below. + if (_view.attached()) _view.pump(kPageChunkBytes); +#if KR_TLS + if (_view.isTls()) { + // The claim's half-written record (if any) belonged to the retiring Conn's queue; + // the library resumes it from its own buffer on the next write (see + // Session::forgetPendingWrite). Then swap the slot pointers, so the controller + // slot NAMES the session object the claim was using -- no crypto state moves, and + // the spare's pointer lands on the freshly-closed incumbent session, ready for + // the next accept exactly as admitTls() expects. + _tlsView->forgetPendingWrite(); + tls::Session *const wasSpare = _tlsView; + _tlsView = _tlsCtl; + _tlsCtl = wasSpare; + _ctl.bindTls(_tlsCtl, _ctlTx, sizeof(_ctlTx)); + } else +#endif + { + // Plaintext: NetworkClient is a shared handle, and `_client = _viewer` is the same + // copy acceptNew() does with a freshly accepted socket. Clearing _viewer drops one + // reference; the controller slot's copy keeps the fd open. This is also the only + // shape a KR_TLS=0 rollback ever takes, which is why the claim needs no new + // transport anywhere. + _client = _viewer; + _viewer = WiFiClient(); + _ctl.bindPlain(&_client, _ctlTx, sizeof(_ctlTx)); + } + _ctlAfter = AfterDrain::Nothing; + + // Adopt: exactly the state a freshly authenticated controller holds, and nothing + // more. _lastInboundMs is stamped here for the same reason auth stamps it -- the + // controller exists from here, and this is the fourth and last writer of that clock + // (readSome, auth, closeLink, here; tests/test_relayctl.py counts them). Whatever + // bytes the claimant pipelined behind `X` are now ordinary controller frames: it + // holds the passphrase, so an immediate `A1` from it is an ordinary authenticated + // arm, and nothing about the handover itself did anything the operator could not + // have typed a second later. + _phase = Phase::WebSocket; + _authed = true; + _controllerSeen = true; + _lastInboundMs = millis(); + _connectedAtMs = nowMs; + _lastStatusMs = nowMs; + _lastPingMs = nowMs; + _httpLen = 0; + _reader.reset(); + _takeovers++; + + // 4. Vacate the claim slot completely -- a stale claim must not be promotable twice, + // and the spare must read as genuinely free to the next accept (this is exactly + // what stopViewer() does for a dying slot; it cannot be called here because the + // slot's transport is not being stopped, it was just handed to the controller). + _view.unbind(); + _viewAfter = AfterDrain::Nothing; + _viewOpen = false; + _viewerLen = 0; + _viewerHttp[0] = '\0'; + _viewerAtMs = 0; + _viewerRole = ViewerRole::Readonly; + _viewerPhase = Phase::None; + _claimAuthed = false; + _claimAtMs = 0; + _claimReader.reset(); + memset(_claimMsg, 0, sizeof(_claimMsg)); // the payload buffer goes too: nothing a + // future claim reads back is yesterday's + + // The greeting a new controller gets, and the link change the rest of the firmware + // recognises. Status is the board's, not a guess; arming is not in any of it. + writeTextFrame("OK", 2); + sendStatus(); + if (_onLinkChange) _onLinkChange(_ctx, true); +} + void ControlServer::stopViewer() { if (_view.bound()) { _view.flushBeforeClose(); @@ -617,6 +924,50 @@ void ControlServer::stopViewer() { _viewer = WiFiClient(); // the shared handle closes the fd when this last copy dies _viewOpen = false; _viewerLen = 0; + // The claim dies with its slot: role, phase, auth and deadline all go back to + // "read-only viewer", so the next occupant starts from nothing and a dropped claim + // cannot be resurrected by a socket that inherits this slot. Releasing the Conn + // above released the TLS session with it -- kTlsMaxSessions has one more slot, and + // that is what "no leak over N attempts" means here: every path out of the spare + // slot, timeout and silence included, arrives here. + _viewerRole = ViewerRole::Readonly; + _viewerPhase = Phase::None; + _claimAuthed = false; + _claimAtMs = 0; + _claimReader.reset(); +} + +// End a pending claim. Whatever was queued to be told to it (an E-frame, the 101) gets +// ONE bounded shove out first -- the rule closeLink lives by: say what you mean, never +// wait for a peer that is not reading -- then stopViewer() frees the slot, the session +// and the claim's whole state. +void ControlServer::dropClaim() { + if (_view.attached()) { + _view.pump(kPageChunkBytes); + _view.flushBeforeClose(); + } + stopViewer(); +} + +void ControlServer::sendClaimText(const char *text, size_t len) { + // Sized for the widest thing this sends (an `E:` line, 96 bytes, plus a + // 4-byte frame header) rather than for the claim's own inbound cap -- a refusal that + // silently does not fit because the claimant may not SEND big frames would be the + // claimant's problem to misread as silence. + uint8_t frame[112]; + const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame)); + if (n == 0) return; + if (!_view.alive()) return; + // Whole frame or nothing, like every other write on either transport: a dropped + // refusal is a socket that got told nothing and gets dropped anyway, a half-sent + // one is a corrupted stream. + if (!_view.push(frame, n)) _txDropped++; +} + +void ControlServer::sendClaimError(uint8_t code, const char *text) { + char line[96]; + const size_t len = proto::formatError(line, sizeof(line), code, text); + if (len) sendClaimText(line, len); } void ControlServer::serveWebSocket(uint32_t nowMs) { @@ -745,6 +1096,13 @@ void ControlServer::sendError(uint8_t code, const char *text) { if (len) writeTextFrame(line, len); } +void ControlServer::sendOk() { + // The protocol's "accepted". Only ever meaningful on a live controller link -- a + // claim gets its OK from the claim path (sendClaimText), never from here. + if (_phase != Phase::WebSocket || !_authed) return; + writeTextFrame("OK", 2); +} + void ControlServer::closeLink(const char *why) { (void)why; // named for the readers of this file, not printed: the CDC ring truncates // "A controller could have left keys down on the target" is the question here, and the @@ -863,23 +1221,32 @@ void ControlServer::admitTls(uint32_t nowMs) { // _viewerHttp/_viewerLen state, or the previous viewer's half-written response // would be pumped to the new one. stopViewer(); - if (!_tlsView.open(fd, *_identity)) { // open consumed the fd either way + if (!_tlsView->open(fd, *_identity)) { // open consumed the fd either way _slotRefused++; return; } - _view.bindTls(&_tlsView, _viewTx, sizeof(_viewTx)); + _view.bindTls(_tlsView, _viewTx, sizeof(_viewTx)); _viewOpen = true; _viewerLen = 0; _viewerHttp[0] = '\0'; _viewerAtMs = nowMs; + // Fresh occupant, fresh state -- the same belt-and-braces as the plaintext accept: + // stopViewer() above already reset role/phase/auth/reader, and naming it again at + // the accept is what keeps "a claim never outlives its slot" visible on both + // transports rather than true by inheritance. + _viewerRole = ViewerRole::Readonly; + _viewerPhase = Phase::Http; + _claimAuthed = false; + _claimAtMs = 0; + _claimReader.reset(); return; } - if (!_tlsCtl.open(fd, *_identity)) { // open consumed the fd either way + if (!_tlsCtl->open(fd, *_identity)) { // open consumed the fd either way _slotRefused++; return; } - _ctl.bindTls(&_tlsCtl, _ctlTx, sizeof(_ctlTx)); + _ctl.bindTls(_tlsCtl, _ctlTx, sizeof(_ctlTx)); _ctlAfter = AfterDrain::Nothing; _authed = false; _httpLen = 0; diff --git a/src/control_server.h b/src/control_server.h index e6606a3..f6ccac9 100644 --- a/src/control_server.h +++ b/src/control_server.h @@ -3,12 +3,21 @@ * over a plaintext socket or a TLS session -- into one controller slot. * * Shape of it: - * - one controller at a time. A second WebSocket upgrade is refused outright rather - * than queued or swapped silently, because two controllers typing into one machine - * is exactly the ambiguity a keyboard must not have. The page says so (E3). - * Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board. - * Refusing everything meant pressing reload on your own tab failed, and a live - * controller could not be inspected by anything else (serveViewer). + * - one controller at a time. Two controllers typing into one machine is exactly the + * ambiguity a keyboard must not have, so the second socket never *drives* one. What + * it may now do (phase 03) is stand in the spare slot as a **pending claim**: it + * completes the same handshake, authenticates with the same passphrase against the + * same lockout counters, and may then send nothing but `K` and the deliberate `X` + * -- every arm, key state, chord, text, mouse and release frame is refused by the + * one whitelist in claim.h and the claim is dropped. `X` hands the bridge over + * *through the funnel*: the incumbent is told (`E12`), closeLink() runs (which + * disarms and releases every key and button -- bug 10's fix, un-narrowed), and only + * then is the claimant adopted as the controller, DISARMED. No path reachable from + * `X` can call arm(); the newcomer arms with an ordinary `A1` or not at all + * (handOverClaim). Before phase 03 the answer was `503`/`E3` outright; read-only + * HTTP is *not* and was never refused: what is exclusive is the keyboard, not the + * board. Refusing everything meant pressing reload on your own tab failed, and a + * live controller could not be inspected by anything else (serveViewer). * - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both * feed the same accept decision -- tls::admitConnection -- the same serveHttp() / * serveWebSocket() routing and the same serveReadonly(); the only thing a socket's @@ -37,6 +46,7 @@ #include +#include "claim.h" #include "config.h" #include "conn.h" #include "proto.h" @@ -63,6 +73,11 @@ enum ErrorCode : uint8_t { kErrUntypeable = 9, kErrMouseDisabled = 10, kErrMouseBootProtocol = 11, + // "you were DROPPED", sent on the incumbent's socket just before the handover closes + // it -- distinct from kErrBusy ("you were refused"), because the controller that was + // never let in and the controller that was deliberately displaced deserve different + // sentences on their screens (proto.h carries the list). + kErrTakenOver = 12, }; class ControlServer { @@ -82,6 +97,9 @@ class ControlServer { // arm/disarm, so the page's one source of truth is the board, not its own guess. void sendStatus(); void sendError(uint8_t code, const char *text); + // The bare `OK` the protocol promises as "accepted": what `X` from the incumbent + // controller gets (it already holds the bridge; there is nothing to do but say so). + void sendOk(); bool linked() const { return _phase == Phase::WebSocket && _authed; } /** @@ -96,6 +114,11 @@ class ControlServer { uint32_t rejected() const { return _rejected; } uint32_t authFails() const { return _authFails; } + // Successful bridge handovers (phase 03): a count of events, never addresses, user + // agents or anything else that would identify the other session (invariant 4 -- the + // README's never-stored list is about exactly this temptation). + uint32_t takeovers() const { return _takeovers; } + // Frames the transport could not take (its queue was full) -- a periodic status frame // loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either // transport: the queue is the transport view's, so the count means one thing everywhere. @@ -115,9 +138,11 @@ class ControlServer { void setIdentity(const tls::PemPair *identity) { _identity = identity; } // Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this - // changes. A number, about numbers; nothing about content (invariant 4). + // changes. A number, about numbers; nothing about content (invariant 4). Walked over + // the STORAGE, not the slot pointers: a bridge handover swaps which object serves + // which slot (handOverClaim), and the count means the same thing either way. uint32_t tlsLiveSessions() const { - return (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u); + return (_tlsStore[0].alive() ? 1u : 0u) + (_tlsStore[1].alive() ? 1u : 0u); } #endif @@ -168,6 +193,18 @@ class ControlServer { // 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback. void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext); void serveViewer(uint32_t nowMs); + // The pending claim's half of the spare slot: the WebSocket that lives there once + // an upgrade turned Readonly into Claim (claim.h for the rules). + void serveClaim(uint32_t nowMs); + bool handleClaimFrame(uint32_t nowMs); + // The bridge handover (`X`): tell the incumbent, run the release funnel, adopt the + // claimant -- in that order and no other. Named handOver, never takeover, so a + // reader cannot mistake it for an arming path: it is the opposite, the board lands + // disarmed and nothing here may call arm(). + void handOverClaim(uint32_t nowMs); + void dropClaim(); + void sendClaimText(const char *text, size_t len); + void sendClaimError(uint8_t code, const char *text); void stopViewer(); void serveWebSocket(uint32_t nowMs); void closeLink(const char *why); @@ -217,6 +254,40 @@ class ControlServer { size_t _viewerLen = 0; uint32_t _viewerAtMs = 0; + // ---- the pending claim (phase 03) ------------------------------------------ + // What the spare slot is for, and how far its request has got. The role type and + // the frame whitelist live in claim.h (pure, host-tested) because the whitelist is + // the whole safety argument of this slot and an argument nothing executes is one a + // later edit can undo silently; `ViewerRole` is spelled here so the slot reads the + // way the task that added it named it. + // + // What each field costs, in the static RAM this board has no PSRAM of: + // _viewerRole, _viewerPhase 1 byte each (uint8_t enums; they pack together) + // _claimAuthed 1 byte -- the claim's OWN auth flag. The + // controller's `_authed` stays a separate + // fact on purpose: linked() is the + // controller link, and a claim must never + // make one look live + // _claimAtMs 4 bytes -- the claim's own deadline anchor; the + // controller-silence clock (_lastInboundMs) + // is not it and is never moved by a claim + // _claimMsg + _claimReader 64 bytes + ws::Reader's ~40 B of decode state (the + // payload IS _claimMsg; the reader owns no + // buffer of its own) + // Total ~110 B. The claim buffer is a QUARTER of the controller's kWsMessageCap -- + // 64 against 256 -- because the widest frame a claim may ever send is `T` at + // kTokenMax + 1 = 33 bytes (config.h re-derives this); anything larger is refused by + // the codec, not buffered, so this slot cannot be made to grow. + using ViewerRole = claim::ViewerRole; + ViewerRole _viewerRole = ViewerRole::Readonly; + Phase _viewerPhase = Phase::None; + bool _claimAuthed = false; + uint32_t _claimAtMs = 0; + uint8_t _claimMsg[kClaimMsgCap]; + ws::Reader _claimReader{_claimMsg, sizeof(_claimMsg)}; + // Task 02 adopts the claim's Conn on the bridge handover; until then the claim + // travels on `_view`, the slot it authenticated on, and nothing else. + uint8_t _wsBuf[kWsMessageCap]; ws::Reader _reader{_wsBuf, sizeof(_wsBuf)}; @@ -231,6 +302,7 @@ class ControlServer { uint32_t _rejected = 0; uint32_t _txDropped = 0; uint32_t _slotRefused = 0; + uint32_t _takeovers = 0; // completed handovers: counters only, never identities #if KR_TLS // The encrypted listener: a raw LWIP socket rather than WiFiServer, because the @@ -240,8 +312,16 @@ class ControlServer { int _tlsListen = -1; const tls::PemPair *_identity = nullptr; - tls::Session _tlsCtl; // the controller slot, when _ctl.isTls() - tls::Session _tlsView; // the spare slot, when _view.isTls() + // Two session objects (kTlsMaxSessions of RAM is what sized them), and two pointers + // that say which one the controller slot uses. Normally _tlsCtl is store[0] and the + // spare's is store[1]; a bridge handover SWAPS the pointers so the claimant's live + // session is named by the controller slot without one crypto context having to move + // inside another (mbedtls contexts own heap buffers and are not moveable state -- + // a pointer swap is a fact about slot naming, which is all the handover needs). + tls::Session _tlsStore[kTlsMaxSessions]; + static_assert(kTlsMaxSessions == 2, "the slot pointers below name store[0] and store[1]"); + tls::Session *_tlsCtl = &_tlsStore[0]; // the controller slot, when _ctl.isTls() + tls::Session *_tlsView = &_tlsStore[1]; // the spare slot, when _view.isTls() #endif OnMessage _onMessage = nullptr; diff --git a/src/main.cpp b/src/main.cpp index 5618b1b..9453d65 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -712,6 +712,13 @@ void fillStatus(void *, kr::StatusSnapshot &s) { s.mouseFrames = mouseFrames; s.defaultToken = strcmp(token, kr::kDefaultToken) == 0; s.apForced = apMode == 1; + // The bridge half: "a controller holds the keyboard" and a count of handovers. A + // link state and a number -- no address, device or timing anywhere near them + // (invariant 4; the README's never-stored list names this temptation exactly). + // To a /status poller busy:1 means someone else is driving; to the adopted + // controller it means itself, and the page is the only thing that knows which. + s.busy = control.linked(); + s.takeovers = control.takeovers(); // The TLS half. `tls` describes the identity this board holds, not the link this frame is // travelling on -- the listener is later in phase 02, and a controller running over // plaintext must not be told otherwise by a field in the frame it received in clear. @@ -809,6 +816,17 @@ void onMessage(void *, const proto::Message &msg) { case proto::Cmd::KeepAlive: return; // freshness is already recorded + case proto::Cmd::Takeover: + // `X` from the controller that already holds the bridge: there is nothing to + // take, and this is a no-op -- answered `OK`, not `E6`, because the frame means + // "I hold this" and it does. The handover itself lives in + // control_server.cpp::handOverClaim, reachable only from a pending claim socket; + // a controller cannot use `X` to reset or re-arm its own session, and nothing + // here calls setArmed: the only frames that arm are A1 (this dispatcher) and the + // board's own button and console. + control.sendOk(); + return; + case proto::Cmd::Arm: setArmed(true, "controller"); return; @@ -1114,9 +1132,11 @@ void printStatus() { char json[kr::kStatusJsonCap]; kr::statusJson(s, json, sizeof(json)); Serial.printf("status %s\n", json); - Serial.printf("link controller=%s rejected=%u authfails=%u\n", + // `taken=` is a count of completed handovers, not an identity: who took the bridge + // is not something this board knows, stores, or may be made to print (invariant 4). + Serial.printf("link controller=%s rejected=%u authfails=%u taken=%u\n", control.linked() ? "yes" : "no", (unsigned)control.rejected(), - (unsigned)control.authFails()); + (unsigned)control.authFails(), (unsigned)control.takeovers()); Serial.printf("queue %u keys pending, %u held, %s\n", (unsigned)stage.keyQ.count(), (unsigned)stage.keys.held(), stage.keys.rollover() ? "ROLLOVER" : "ok"); diff --git a/src/proto.h b/src/proto.h index db7c71c..5d4b9f4 100644 --- a/src/proto.h +++ b/src/proto.h @@ -22,6 +22,11 @@ * c -> d R release every key AND every mouse button * (kept armed) * c -> d K keepalive + * c -> d X claim this bridge for myself; requires + * authentication, and nothing else is honoured + * on a claim socket (phase 03 -- the handover + * runs the release funnel and lands DISARMED; + * from the incumbent controller it is a no-op) * d -> c OK accepted, or the reply to H * d -> c E: error, e.g. E4:arm required * d -> c S{json} status heartbeat, 1 Hz and on change @@ -70,6 +75,7 @@ enum class Cmd : uint8_t { Mouse, // one mouse report: button byte plus movement ReleaseAll, KeepAlive, + Takeover, // `X`: the pending claim asks for the bridge (phase 03) Bad, }; @@ -112,6 +118,7 @@ inline const char *name(Cmd c) { case Cmd::Mouse: return "mouse"; case Cmd::ReleaseAll: return "release-all"; case Cmd::KeepAlive: return "keepalive"; + case Cmd::Takeover: return "takeover"; default: return "bad"; } } @@ -291,6 +298,15 @@ inline Cmd parse(const char *data, size_t len, Message &out) { if (len != 1) return out.cmd = Cmd::Bad; return out.cmd = Cmd::SecureAttention; + case 'X': + // One deliberate act on a claim socket (claim.h whitelists it there; the handover + // itself lives in control_server.cpp::handOverClaim and cannot arm). Trailing + // bytes are Bad rather than "X plus noise": one frame is one command, and a + // frame that would silently carry a second intent is the failure this protocol + // keeps refusing to have. + if (len != 1) return out.cmd = Cmd::Bad; + return out.cmd = Cmd::Takeover; + case 'A': if (len != 2) return out.cmd = Cmd::Bad; if (data[1] == '1') return out.cmd = Cmd::Arm; @@ -475,6 +491,9 @@ inline size_t formatText(char *out, size_t cap, uint16_t periodMs, // 8 text buffer full 9 a character this keyboard map cannot type // 10 the mouse interface is off on this board // 11 the target asked for boot protocol, so a mouse report cannot be sent +// 12 your session was taken over by another controller (you were DROPPED, which is +// a different sentence from 3's "you were refused" -- one of them should be +// alarming on the incumbent's screen and the other should not) // // The code is written in decimal at full width -- `code % 10` truncated 10 to `E0`, // which is a code no client would recognise. Codes below 10 render byte-identically diff --git a/src/status.h b/src/status.h index cae1931..9f2dba6 100644 --- a/src/status.h +++ b/src/status.h @@ -66,6 +66,15 @@ struct StatusSnapshot { uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports bool defaultToken = false; bool apForced = false; + // The bridge, in the only terms the board is allowed to know about it: `busy` is + // "a controller holds the keyboard" (the link state), `takeovers` counts completed + // handovers. No address, no user agent, no timing that would identify the other + // session -- the README's never-stored list is about exactly this temptation, and + // it is what lets the page say "held by another controller" without saying who. + // A /status poller reads busy:1 as "someone is driving"; the adopted controller + // reads the same 1 about itself, and only the page knows which one it is. + bool busy = false; + uint32_t takeovers = 0; // The TLS identity: `tls` is true when this build compiled TLS in *and* the board holds // a keypair-plus-certificate that passed its boot-time check -- the identity the :443 // listener serves. It is still a statement about the identity, not about the transport @@ -129,13 +138,15 @@ inline size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) { "{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\"," "\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu," "\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u," - "\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"tls\":%u,\"fp\":\"%s\"}", + "\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"busy\":%u,\"tkov\":%lu," + "\"tls\":%u,\"fp\":\"%s\"}", s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u, s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent, (unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState, s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u, (unsigned long)s.typedChars, (unsigned)s.typing, s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames, + s.busy ? 1u : 0u, (unsigned long)s.takeovers, s.tls ? 1u : 0u, fp); return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n; } diff --git a/src/tls_pem.h b/src/tls_pem.h index 345e303..8cf4788 100644 --- a/src/tls_pem.h +++ b/src/tls_pem.h @@ -641,7 +641,7 @@ inline size_t sanExtensionDer(uint8_t *out, size_t cap) { // extension is the problem. That is how this shipped once: `openssl x509 -text` on the // live board's certificate showed `1.10.46.53.46.50.57.46.49.55` where `X509v3 Subject // Alternative Name` should have been, and every host test stayed green because they walked -// the value and the name table and never the label. See README "Eleven bugs", item 11. +// the value and the name table and never the label. See README "Thirteen bugs", item 11. constexpr uint8_t kSanOidTlv[] = {0x06, 0x03, 0x55, 0x1D, 0x11}; constexpr size_t kSanOidTlvLen = sizeof(kSanOidTlv); diff --git a/src/tls_session.h b/src/tls_session.h index 0a23623..5921c86 100644 --- a/src/tls_session.h +++ b/src/tls_session.h @@ -319,6 +319,27 @@ class Session { // ::close(fd). Idempotent -- safe from the destructor and from any error path. void close(); + /** + * Forget the resume bookkeeping of a half-finished write, leaving the connection + * otherwise untouched. Used by exactly one caller: the bridge handover + * (control_server.cpp::handOverClaim), where the bytes that were in flight belonged + * to a Conn whose queue is being retired with the claim slot, so no caller will ever + * re-hand them -- and without this, the resume guard below would refuse every write + * from the adopted controller forever, wedging its outbound path. + * + * Why this cannot corrupt the TLS stream: the half-sent RECORD lives in mbed TLS's + * own output buffer (ssl->out_buf/out_left), not in the caller's, and + * mbedtls_ssl_write() flushes that buffer before it touches new arguments -- the + * record already half on the wire completes byte-identically from the library's copy. + * The guard here is stricter than the library requires (it exists to keep kr::Conn's + * resume bookkeeping simple), so relaxing it at a slot transplant is relaxing our own + * rule, not breaking the library's. + */ + void forgetPendingWrite() { + _pendPtr = nullptr; + _pendLen = 0; + } + // The library code of the last failure this session hit; diagnostic, never identity. int lastError() const { return _lastError; } diff --git a/src/web_page.h b/src/web_page.h index aa5107b..7398dfb 100644 --- a/src/web_page.h +++ b/src/web_page.h @@ -22,6 +22,15 @@ * and cancels a run of pasted text that is mid-flight. A keyboard that keeps * typing after you tabbed away is the failure mode this design cannot be allowed * to have. + * - A session left holding the bridge can be taken over from the screen that is + * locked out, not only from whichever tab forgot to disconnect. The TAKE OVER + * button appears only when the board says it is held -- by a claim the board + * authenticated and refused to adopt, by a busy status frame, or by E12, the + * frame that says THIS session was displaced. One click sends T then X and + * nothing else; the handover runs through the release funnel and lands + * DISARMED, and the page that lost the bridge stops reconnecting until someone + * clicks again -- an auto-reclaiming page turns two tabs into a fight over a + * keyboard. * - The mouse is captured, never grabbed. "Capture the pointer" asks the browser for * Pointer Lock -- the same thing a web game asks for -- so the cursor cannot escape * the tab while the board is forwarding it, and Escape always gives it back. Capture @@ -222,6 +231,7 @@ body.off{opacity:.92}
+