phase: 03_session_takeover
**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/`.
This commit is contained in:
@@ -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=<btn>:<dx>:<dy>:<wheel>:<pan>` | **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<code>:<text>` | 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<code>`** 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<token>`), 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<token>` 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<token>`), 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<code>:<text>` 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 |
|
||||
|
||||
Reference in New Issue
Block a user