Files
keyrelay/README.md
T
ducoterra d629a0ff51 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/`.
2026-11-09 08:57:35 -05:00

1635 lines
143 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# keyrelay — a WiFi-remote USB keyboard and mouse on the M5Stack AtomS3 Lite
Type on your laptop; the keystrokes land on a *different* machine, over the air, as
genuine USB HID input — including Enter and Ctrl+Alt+Del. Capture the pointer and your
mouse moves the other machine's cursor. The board enumerates on the target as a
boot-protocol keyboard with a relative mouse on the same HID interface, so the target
needs no software, no driver and no network. The controller is a web page served by the
board itself, over TLS, with a certificate the board made on its own first boot.
```
your laptop target machine
┌──────────────────┐ ┌──────────────────┐
│ browser tab │ WiFi │ │
│ keydown/keyup ├──wss://(TLS)──►│ AtomS3 Lite │
│ mousemove │ (token) │ keyrelay │
│ (Pointer Lock) │ │ │
└──────────────────┘ └────────┬─────────┘
│ USB HID: boot keyboard
│ + relative mouse
▼
whatever has keyboard focus:
a login prompt, a BIOS setup,
a VT, a text editor
```
```
disarmed LED dark nothing you type or move goes anywhere
armed LED solid red every keystroke in the tab goes to the target
```
The mouse is opt-in twice over: the bridge has to be armed *and* the page has to be
handed the pointer, which the browser only does when you click. `Esc` gives it back —
that one is the browser's own guarantee, not something this firmware has to honour.
* **Nothing types until you arm it.** Boot, reload, reconnect, focus loss, socket loss
and 8 s of silence all disarm, and disarming sends "all keys up" **and "all mouse
buttons up"** — to the target, as reports, not just to the board's own bookkeeping.
* **Arming needs a passphrase** (or a thumb on the board's button). The LED is readable
from across the room, so the state is not a secret between you and the screen.
* **Nothing that matters crosses the network in the clear.** The board serves the page, the
WebSocket and `/status` over TLS on `:443` with an ECDSA keypair and self-signed
certificate it generated on its first boot and keeps in NVS, so the passphrase, the
keystrokes, the pasted text and the pointer deltas are encrypted or they are not sent.
Port `:80` answers nothing but the way to `:443` — a `301`, and a `426 Upgrade Required`
to a plaintext `/ws` upgrade or to a request carrying `?token=`. The certificate's SHA-256
fingerprint is printed **once** at first boot and reprinted by the serial `cert` command;
checking it against the board's own console is the one link in this chain that a human
has to close, because encryption you have not identified is encryption you have assumed.
* **The mouse is captured, not grabbed.** The page asks the browser for Pointer Lock —
the same thing a web game asks for — so the cursor cannot escape the tab while it is
being forwarded, and `Esc` always returns it. Losing the lock, the tab or the link
releases every button. Movement is relative: the board says how far, never where, so
there is no position for the two machines to disagree about.
* **The keystrokes are not stored, logged or replayable.** The board keeps counters and
the live key and button state, and nothing else — no sequence storage, no macro, no
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.
* **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 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.
---
## Contents
- [Is this the right tool](#is-this-the-right-tool)
- [Quick start](#quick-start)
- [The one USB port, and why the control path is WiFi](#the-one-usb-port-and-why-the-control-path-is-wifi)
- [Safety model](#safety-model)
- [Security model and trust boundaries](#security-model-and-trust-boundaries)
- [Linux notes (the target and the laptop)](#linux-notes-the-target-and-the-laptop)
- [Layout caveats](#layout-caveats)
- [Controller page](#controller-page)
- [Wire protocol](#wire-protocol)
- [Serial console](#serial-console)
- [Architecture](#architecture)
- [Tests](#tests)
- [End-to-end verification](#end-to-end-verification)
- [Measured behaviour](#measured-behaviour)
- [Troubleshooting](#troubleshooting)
- [Design decisions](#design-decisions)
- [Backlog](#backlog)
- [Restoring the sibling firmware](#restoring-the-sibling-firmware)
---
## Is this the right tool
**Good fits**
- Driving a machine that has no keyboard and should not get one: a headless box, a
rack server at a console, a kiosk, an instrument, a second machine mid-install.
- Working on a machine you are allowed to touch but not sit at — BIOS/UEFI setup,
bootloader and recovery screens, a login prompt, a full-screen game on a laptop
whose keyboard you would rather not wear out.
- Keeping an agent session live on a machine you are not typing into (that is the
sibling project [`../jiggler`](../jiggler), which is a simpler build of the same board).
- Pointing at something on a machine you cannot reach — a BIOS menu item, a dialog in a
installer, a button in a kiosk app — with the cursor you can already see on that
machine's own monitor.
- Anything where a KVM-over-IP would be right and you do not need video or audio, and you
have 8 dollars and a USB port to spare.
**Bad fits — use something else**
- **You need the screen.** There is no video path. You steer the target's cursor without
being able to see it, which is fine for a monitor you are looking at and hopeless for one
you are not. PiKVM or TinyPilot do video + keyboard + mouse.
- **You need to know where the cursor is.** Movement is relative — see
[Layout caveats](#layout-caveats). There is no absolute pointer, so "click the OK
button" is not a thing this can express, deliberately: an absolute pointer would give
the board a position to be wrong about.
- **You want unattended automation.** Deliberately impossible here (see above). If you
want scripted input on a machine you own, `xdotool`/`ydotool`/`uinput` on that machine
is the honest tool, and a Rubber Ducky is what this design is *not*.
- **A machine you are not authorised to input to.** Physical access is the whole
security model of every USB HID tool, and "I could plug it in" is not authorisation.
Use it on your own hardware or where you have explicit permission.
### What this deliberately is not
There is no macro store, no auto-arm at boot, no stored or scheduled sequence, no replay,
and no key logging — in firmware or in the tools. Not because it is hard (it is about ten
lines of `Keyboard.print()`), but because the difference between "a remote keyboard" and
"an unattended injection device" is exactly whether a human is deciding, live, with the
arming light on.
**Where the line sits.** Text you have just pasted and then deliberately sent is a
keyboard doing its job, so there is a command for it: the page's **Send text** box, or
`wl-paste | relayctl paste --yes`. It is gated like everything else — armed, a target
attached, one deliberate press of a button that says how many characters it will type —
and it cannot outlive that decision. The characters sit in RAM until they have gone out
and then they are gone: nothing is stored, nothing is scheduled, and release-all, disarm,
focus loss, a silent controller or a detached target cancels the rest of a run mid-word.
What stays absent is the part that would change what this device *is* — something the
board remembers, assembles on its own, or fires without a human in the loop.
**The mouse is the same line, drawn in the same place.** There is no auto-click, no
double-click macro, no drag recorded and replayed, no "move to (x, y)", no idle wiggle
that keeps a screen unlocked, and nothing that moves the pointer because a timer said so.
`relayctl mouse click` and `mouse drag` exist because verifying that the button bits
arrive is a thing the operator has to be able to do, and each needs `--yes`; a movement
(`mouse move`) does not, because moving a cursor is reversible by definition. If nobody is
moving a mouse, nothing moves. The pointer is captured with the browser's own Pointer Lock,
so `Esc` — which the browser keeps for itself and never forwards — is an escape hatch that
does not depend on the firmware, the network or this page being correct.
Two consequences hold either way. The CLI's ordinary commands still press one key per
invocation, which is all verification needs; scripting 30 of them is possible and would
still be 30 deliberate actions with the LED on. And a captured board is a keyboard, not a
recording — pull it out and there is nothing in it to find.
---
## Quick start
Needs [PlatformIO](https://platformio.org/) and the board's source on this machine.
The board is an ESP32-S3 with no USB-UART bridge, so all flashing is over the USB-C
connector it already uses.
```bash
cd keyrelay
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
```
Build `console` as well as `bridge` before flashing: it compiles with `ARDUINO_USB_MODE=1`,
which is the only thing that checks the no-HID `#else` branches. And if `pio` on this machine is
a broken launcher rather than a working command (`ModuleNotFoundError: platformio`),
`python3 -m platformio` from an interpreter that has the package works instead — see
[`AGENTS.md`](AGENTS.md), which lists this and the other environment traps.
Then, with the board plugged into the **target**:
```bash
python3 tools/console.py wifi "My SSID" "the wifi passphrase" # optional: join a LAN
python3 tools/console.py token "a-long-passphrase-you-pick" # do this one, always
python3 tools/console.py status
```
A board with nothing in NVS makes its TLS identity on that first boot — an ECDSA secp256r1
keypair and a self-signed certificate, kept in NVS — and prints the certificate's SHA-256
fingerprint **once**, wrapped over two lines so the serial console cannot drop half of it:
```
[tls] identity generated -- pin this certificate:
ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23
b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad
```
(Two 47-character lines, never one 95-character line. The bytes above are the SHA-256 of
`abc`, so nobody reads them as their own board's.)
That is the only time the board prints it. `python3 tools/console.py cert` prints it again,
on demand, with the certificate's validity window and the names it is issued for — and
never the key, in either direction: there is no command that writes an identity and none
that dumps one. The fingerprint **survives reflashing** (NVS does) and changes only when NVS
is erased (`nvs erase`, or the factory reset): the board then generates a new identity and
says so loudly, because a fingerprint that changed quietly is a pinned value that stopped
meaning anything. The first `https://` connection will ask the browser to accept the
certificate once per browser — that acceptance is per device and per browser profile, and a
*second* prompt after a pinning is the same signal as a changed fingerprint. `https://` is
how the page is reached: port 80 answers a `301` to the encrypted port and a `426 Upgrade
Required` to anything it must not answer in plaintext (a `/ws` upgrade, a request carrying
`?token=`), and serves no content of its own.
Now, on the **laptop**:
| the board is on | open in a browser | because |
|---|---|---|
| your WiFi network | `https://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP |
| its own access point | `https://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed |
Enter the passphrase, press **connect**, press **arm** — the LED goes solid red — and
type in that tab. Enter goes. Ctrl+Alt+Del is the button (twice), because your laptop's
own OS swallows the real combination before a browser can see it.
The Connection card then shows the board's certificate fingerprint — `certificate
SHA-256 aabbccdd… — confirm it against the cert command on the board's serial console`
— and a header pill reads `tls`. Confirm those digits against
`python3 tools/console.py cert`; a mismatch means the board regenerated its identity or
somebody else is answering `keyrelay.local`, which is exactly the thing the fingerprint is
for. The browser's own certificate warning is accepted **once per browser**, or you skip
it for the terminal and pin instead:
```bash
python3 tools/console.py cert # the fingerprint, printed on demand
export KEYRELAY_TLS_FP='<the 64 hex digits cert printed>' # 2:2 colon form works too
curl -sk 'https://keyrelay.local/status?token=a-long-passphrase-you-pick'
python3 tools/relayctl.py --host keyrelay.local status # pins via $KEYRELAY_TLS_FP
```
`relayctl.py` requires exactly one trust argument — `--fingerprint` (also honoured as
`$KEYRELAY_TLS_FP`), `--ca <pem>`, or `--insecure` (which warns on stderr every single
run) — and refuses to connect without one, because a tool that quietly downgrades is the
component that makes all of this pointless. A pinned run hashes the peer certificate
**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. 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
board keeps, and **stop typing** abandons the rest. From a terminal it is
`wl-paste | relayctl.py paste --yes` (or `xclip -o -selection clipboard`, `pbpaste`).
### The rollback — building it, and what it costs
If TLS itself is the thing that broke — a provisioning change you cannot undo from the
network, a client that will not do TLS, a board you have to reach *now* — the way back is one
reflash, and it is a **build** rather than a setting on purpose. A board that has lost its way
over TLS should not also have to be trusted with the switch that would let it be reached in
the clear; the same reasoning makes `mouse off` a reboot. Turning TLS out saves about 6 kB of
RAM and 99 kB of flash, and the image fits the 3 MB app slot either way, so the way back is
never a question of space:
```bash
pio run -e rollback # env:bridge with -DKR_TLS=0, i.e. the pre-phase-02 build
python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode
pio run -e rollback -t upload # if pio here is a broken launcher (ModuleNotFoundError),
# use: python3 -m platformio run -e rollback -t upload
```
`[env:rollback]` in `platformio.ini` is `env:bridge` with the default `-DKR_TLS=1` unflagged
and `-DKR_TLS=0` in its place. Unflagged, not overridden: two `-D` flags for one macro is a
redefinition whose winner depends on flag order, and this PlatformIO's `run` has no
`--build-flags` option to argue with, so the environment *is* the documented command.
`.agents/validate.sh` builds all three environments on every run for the same reason — a way
back that stopped compiling is not a way back, and the phase that added the encryption is not
the phase that gets to decide it cannot be reversed.
What you get is the pre-phase-02 device: page and socket on `:80` in plaintext, no `:443`, no
identity generated and no NVS records for one, a boot line that says `[tls] TLS compiled out
(KR_TLS=0) -- plaintext control channel` so nobody has to guess which board is answering, and
a page carrying `var KR_TLS_BUILD=0;` so it connects over the plaintext socket instead of
refusing an `http:` origin it was legitimately served. What you give up is the whole
[Security model](#security-model-and-trust-boundaries): the passphrase and every keystroke are
readable on the network again. Nothing about arming, the release funnel or the LED moves with
the flag — it changes which port carries bytes, never who may type them. The rollback's `ws://`
is the only plaintext socket this README calls a normal way to reach a board, one reflash away.
To put the sibling project back on the board:
`cd ../jiggler && python3 .../to_bootloader.py && pio run -t upload`.
---
## The one USB port, and why the control path is WiFi
The AtomS3 Lite's USB-C is wired straight to the ESP32-S3's native USB pads (GPIO19/20),
and those pads belong to exactly one peripheral and exactly one host at a time. A board
that is a keyboard to machine A therefore cannot simultaneously be a serial modem to your
laptop. That single hardware fact decides the architecture:
| option | works? | why this project chose as it did |
|---|---|---|
| control over USB, HID to the same laptop | yes, pointless | the target *is* the laptop; that is the `jiggler` use case |
| second USB port / USB host shield | no | the board has one connector, and ESP32-S3 USB-host + device at once is not a thing |
| USB gadget on a Linux SBC (PiKVM-style) | yes | real answer for video + mouse; needs a Pi, a PoE HAT, and 10× the cost |
| **WiFi control, USB HID out** | **yes** | one connector stays free for the target, no host software at all |
The consequence worth stating plainly: the board draws its power and its USB identity
from the target, and its *instructions* from your network. Pulling it out stops everything
immediately — that is a feature.
---
## Safety model
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".
3. **Armed is displayed on the device.** Solid red LED while armed and attached. If the
target is not attached the LED blinks red instead, because "armed" without a target
means keystrokes are being dropped, and that deserves to look wrong.
4. **Silence disarms.** No inbound frame for `kNoClientTimeoutMs` (8 s) → all keys up and
all buttons up, disarmed. Covers the laptop sleeping, the browser being killed, the WiFi
dying, the network going down — every way a controller can vanish without saying goodbye.
The browser sends a keepalive every 2 s and answers the board's own 15 s WebSocket pings,
so a live tab holds it open indefinitely; `relayctl` does the same while it types. This
is checked in `loop()`, and it is checked *before* the typewriter is driven: it is the
one rule that stops a run of pasted text when the controller dies without a parting
packet.
**What counts as silence, and what does not.** The clock is moved only by the
*authenticated controller*: by its authentication, and by bytes on its WebSocket. An
accepted TCP connection is not a live operator, and neither is anything reading the
board — a monitoring script, a health check, a second tab pointed at `/status`, or
`relayctl listen` used to hold this timer at zero and keep the bridge armed
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
[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).
6. **The target taking the bus away disarms.** USB detach or host suspend clears the
report queue, releases the keys and disarms; `ARDUINO_USB_*` events drive it.
7. **A full queue is a stop, not a drop.** 48 queued key states or 32 queued mouse
reports without a drain means something is outrunning the USB path, so the bridge
releases everything, reports `E7` and disarms rather than typing out of step with what
the operator sees. The queue is not a buffer to absorb loss: 255 queued reports once
sat through a disconnect, which is ~2 s of keystrokes delivered after the operator had
stopped asking.
8. **State, not events.** The controller restates the *whole* key state on every event
(see [Wire protocol](#wire-protocol)), so a lost frame cannot leave a modifier stuck
on the target. This is the single most important design decision in the project: every
"stuck Ctrl" horror story in HID injection tooling is an event-protocol bug. Mouse
buttons follow the same rule — every `M=` frame carries the complete button byte — and
movement is the one thing that is *not* state because a delta has no state to lose: a
dropped movement frame moves the cursor slightly less, never leaves anything stuck.
9. **A paste cancels; it never pauses.** `R` means "stop typing", not "hold on". The run
is dropped on release-all, disarm, target detach or suspend, queue overflow, focus
loss and silence — every fail-safe above routes through the same `releaseEverything()`
that clears the queue, raises the keys, lets go of the mouse buttons and discards what
was still to type. The board never holds a run that outlives the decision to send it.
10. **Losing the pointer releases the pointer.** Pointer Lock going away, the tab
blurring, the tab being hidden, disarm, `R`, silence and target detach all send an
all-buttons-up mouse report. *stay armed if this tab loses focus* exempts the
keyboard only — a held mouse button is never something to keep across a tab switch.
The page stops capturing rather than queueing movement to send later.
**TLS is transport, not an arming input.** Every rule above reads the same on `:443` as it
read on `:80`, because nothing in the arming path consults the transport: a TLS session that
is authenticated and not armed types nothing, and a TLS session that drops is a lost link,
which runs `releaseEverything()` and disarms exactly as a dropped plaintext socket did. The
`-D KR_TLS=0` rollback changes which port carries the bytes, never who may type them — which
is why it is a way back rather than a second security model (see
[Design decisions](#design-decisions)).
The physical kill switch is the button: press it and the keys come up and the buttons come
up, no network, no passphrase, no software required. `Esc` is a second one for the pointer
specifically, and it is the browser's, not ours.
**One release funnel, and it has to actually send.** Every item above ends in
`releaseEverything()`, which now queues an all-keys-up *and* an all-buttons-up report to
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](#thirteen-bugs-this-verification-caught-all-fixed)).
---
## Security model and trust boundaries
| actor | can | cannot |
|---|---|---|
| Anyone on the target (its own OS) | see a Logitech G815 keyboard and a pointer in its device list; read the CDC console; type on itself | arm or drive the bridge remotely, detect that a second controller exists |
| Anyone on the board's network | fetch the page over `https://`; open a socket; learn that a keyrelay exists and read the public names on its certificate | arm, send a key, send a chord, type a run of text, move the pointer, click, read `/status` — all need the passphrase, and all but `/status` need the bridge armed. They no longer get to *hear* any of it: the passphrase and the keystrokes are inside a TLS record, and `:80` hands out a redirect and two `426`s and nothing else |
| Anyone who can see the traffic — a café access point, a mirror port, a hostile neighbour | learn that this address answers TLS, when, and how many bytes; read the certificate's own public names (`keyrelay.local`, `keyrelay`, `192.168.4.1`) | read a passphrase, a keystroke, one pasted character or one pointer delta; get a plaintext page or a plaintext socket out of this board. **One honest exception:** an attacker who can see the traffic *and* is first to answer `keyrelay.local` is a certificate authority of one for any connection nobody pinned — the browser warning, or the fingerprint you compared against `cert`, is what closes that window, and nothing in the firmware can close it for you |
| 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 | — |
**What the passphrase does and does not do.** It authenticates the *control* channel;
confidentiality is a separate layer, and that layer now carries everything: the board serves
the page, the WebSocket and `/status` over TLS on `:443` (`https://`, `wss://`) with the
identity it generated on first boot (`src/tls.h`, `src/tls_session.h`), and `:80` answers
**nothing** but the way to `:443` — a `301` for a page request, and a `426 Upgrade Required`
for a plaintext `/ws` upgrade or for a request carrying `?token=` (`src/conn.h`,
`plaintextRoute()`). So the passphrase, the keystrokes, the pasted text and the pointer now
cross the network encrypted or not at all, and a page loaded over `http://` — which an
on-path attacker could rewrite, at which point it is their page asking for your keystrokes —
is not a page this board serves. A plaintext `/ws` upgrade is *refused* rather than accepted:
the weaker option is never taken quietly, and the refusal says `wss://` in the body so the
operator knows what to reconnect as. The controller slot stays shared between the two
listeners, so a controller on either transport holds the keyboard — but never two at once,
encrypted or not. The only build that carries keystrokes in plaintext is the `-D KR_TLS=0`
rollback, which is a reflash and a decision, not a mode a running board can fall into; see
[The rollback](#the-rollback--building-it-and-what-it-costs).
**What TLS does not buy.** Encryption is not identity until you look at the certificate. It
is self-signed, so the first connection to a board you have never pinned is a
decision-you-have-not-made-yet window, and closing it costs one comparison: 64 hex digits
from `cert`, read on the board's *own* serial console, where nobody on a network can follow
you. Ticking "accept the risk" in a browser is the same decision made without looking at
anything, once per browser profile — and a *second* prompt for a board you already accepted is
the same alarm as a fingerprint that moved. Encryption buys nothing for
the things outside this channel, and they are worth naming in the same breath: **the target**
sees every keystroke you send it and can log all of it, and that machine is not in this
threat model at all; **the laptop** keeps the passphrase in `sessionStorage` and keeps
`--token` in your shell history if you use it, so a controller someone else has touched is a
controller you must re-passphrase; **whatever you type while armed** goes to the target
whether or not you meant it to, which is what the LED and the focus-loss rule are for; and
**the board you walked away from** is stopped by disarming and unplugging, not by crypto.
Why `?token=` gets 426 instead of a redirect: a `Location:` that echoes the query exposes
the passphrase **twice** on the wire the redirect is escaping, and it trains operators to
type the secret into a `http://` bar. The body of that answer names the fix without
repeating the value — verified on the device, where the 426 body contains no token.
Practical rule: **the certificate you did not check is the one thing to check.** Everything
else on this device is automated, reviewable and testable; comparing 64 hex digits against
`cert` on the serial console is not, and it is the one step an on-path attacker can defeat
merely by being there first. Next to it, still worth doing for a different reason: use it on a
network you trust, or on the board's own access point whose WPA2 passphrase is a separate
secret (`kr::kApPass`, change it in `src/config.h`) — the AP means you never have to put the
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 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
can watch the counters, and `curl` keeps working mid-session. Read-only access is still
gated by what it reveals — `/status` needs the passphrase, the page needs it before it can
arm or type. If that ever goes too far, the `ap off`/network config is the blunt instrument;
there is no per-client ACL.
**The mouse widens what a compromised controller can do, so it is gated the same twice
over.** Arm plus Pointer Lock. It is also the reason there is a `mouse off` switch: the
composite descriptor is fixed when USB starts, so turning the pointer interface off is a
reboot, not a setting change — and a board that advertises no pointer cannot be made to
move one. When a target selects HID **boot protocol** (some BIOS setups, some KVMs) the
mouse is switched off by itself, because a 5-byte mouse report on a boot-protocol
interface would be parsed as keyboard data: the keyboard keeps working, the pointer stops,
and `E11` says why rather than the command silently doing nothing.
**Identity.** The board reports itself as `046d:c33f` "Logitech Gaming Keyboard G815",
matching the sibling `jiggler` firmware so the two are indistinguishable from the host
side and `/dev/input/by-id` names stay stable across reflashes. This makes the device
unremarkable in `lsusb`; it does not hide it from anyone who looks for a keyboard that
appeared at 14:32. It is your hardware and a real Logitech id, so: personal use only.
To make it honest instead, change the five `kUsb*` constants in `src/main.cpp` — nothing
else depends on them.
**What is never stored:** keystrokes, characters, key names, SSIDs beyond the configured
one, client addresses, or anything else about what was typed. Counters only (`keys`,
`chords`, `dropped`, and `typed`/`typing` for the paste path), which is what the status
display needs to be useful. A run of pasted text exists in RAM from the moment a frame is
accepted until the last character is typed or the run is cancelled, and nothing about it
survives that — no queue across a disconnect, no place to write it. The board has no clock
it trusts and no filesystem it writes to. Turning this into a logger would be a fork, not
a feature flag.
---
## Linux notes (the target and the laptop)
**Ctrl+Alt+Del on a Linux target can reboot it.** On the graphical session it is usually
inert; on a virtual terminal `systemd` maps it to `ctrl-alt-del.target`, which by default
**reboots the machine** (seven CAD presses in two seconds force it). This is why the
combination is a button with a two-press confirm and a 3-second window, never a forwarded
keypress, and why `relayctl cad` refuses to run without `--yes`.
**Ctrl+Alt+F1…F7 works** — which is a bigger deal than CAD: it switches the target's
virtual terminal, and a getty on an unused VT with **autologin** enabled will hand you a
shell as that user. This project puts the VT switches on the special-keys pad so they are
deliberate clicks rather than a chord you can fat-finger.
**Alt+SysRq+b etc.** are reachable too (the magic SysRq key is off by default on most
distros). The pad does not offer them; the keyboard table can produce them, which is what
a keyboard is.
**Keys the laptop's OS steals** (this is the laptop side, and it is the browser-capture
limitation, not a bug): `Ctrl+Alt+Del`, `Ctrl+Alt+F1…F7`, `Alt+Tab`, `Super`,
`PrintScreen`, and on GNOME/KDE a growing list of media and tiling shortcuts are grabbed
by the compositor and never reach the page. Three answers, in order of preference:
1. the **special-keys pad** on the page — each of those is one deliberate click, and the
board synthesises the chord so the laptop never sees it;
2. run the laptop's compositor in a mode with fewer grabs while you work;
3. the **OS-wide capture client** (see [Backlog](#backlog)) — an evdev reader on the laptop
that forwards everything before the compositor gets it. The protocol is already
designed for it; it is a client-side addition, not a firmware change.
**While armed, the page swallows everything** with `preventDefault()`, so `Ctrl+W` will
not close the tab, `Ctrl+R` will not reload it and F11 will not fullscreen it. It also
means you cannot type into the page's own fields while armed — disarm first.
**While the pointer is captured, `Esc` belongs to the browser.** That is the point: it
revokes Pointer Lock, the page sees `pointerlockchange`, and every button goes up. What it
cannot do is disarm the bridge — the keyboard is still armed and still typing, so the status
line says so and you can press `R` or the board's button. Nothing else the laptop's
compositor grabs takes the pointer away: `Super` and `Alt+Tab` are inert while locked,
which is another reason capture is the design and a plain `mousemove` relay would not be.
**The laptop's own pointer settings apply to what gets forwarded.** Acceleration,
`libinput Accel Speed` and a high pointer speed on the laptop mean the same hand movement
sends different deltas going out, and the target then accelerates them again. Set the
laptop's pointer speed to the middle and disable acceleration while steering something you
cannot see the response curve of; the target's speed is what it is.
---
## Layout caveats
`KeyboardEvent.code` names a *physical position* on a US-ANSI keyboard, and those
positions are what travel as HID usage ids. If the **target** is set to a non-US layout,
`KeyQ` still means "the key left of A" and the target maps it to whatever its layout says.
This is identical to what a physical KVM does, and the fixes are the same:
- set the target's layout to English (US) — the one-line fix;
- or remap `CODE_TO_USAGE` in `src/web_page.h` for your laptop's board (it is one object
literal; `tools/relayctl.py` carries the same table and
`tests/test_relayctl.py` fails if the two drift).
**The pointer is relative, on purpose.** The board reports a boot mouse: buttons, ±127 per
axis per report, wheel and horizontal pan. It does not know where the target's cursor is,
and the target will not say, so there is no "move to" and no way to desynchronise — the
same property that makes `S=` the right model for keys. Two consequences to know before you
depend on it:
- **Acceleration is applied twice**, once by the laptop's driver before the page sees the
movement and once by the target's driver after the board sends it. The deltas that leave
the page are the laptop's, not your hand's, and the cursor moves by the target's idea of
those. Fine for steering; not fine for a pixel-exact drag.
- **The target clamps at its screen edges**, and the board cannot see that happen. Moving
5000 px right from the far right of the screen does nothing, and the board has no way to
know. This is why `mouse move` exists as a *verifiable* primitive and no macro of
movements is ever stored: relative motion is only meaningful while a human is watching
the screen it lands on.
ISO keyboards: `IntlBackslash` (0x32) is the extra key next to Left Shift on ISO and
`Backslash` (0x31) is the ANSI one; both are in the table. `AltRight` is `AltGr` and
travels as its own modifier bit, which is what layout engines on the target expect.
**Pasted text goes through a third table**, `strokeFor()` in `src/text.h`: character →
modifier + usage, US-ANSI, and deliberately not position-based, because what is being
pasted is characters. Same caveat, same fix — the target must be on a US layout. A
character with no entry is refused (`E9`) rather than approximated, and `test_text` walks
the entire printable range so an omission cannot hide, while `test_relayctl` proves the
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`, 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
C++ raw string is invisible to every compiler and a typo there fails in a browser, with a
target live. The page carries exactly one byte-of-build-identity: a 19-character
`#if KR_TLS` marker (`var KR_TLS_BUILD=1;` / `…=0;`, equal lengths, so the served page is
the same size in either build) that lets the TLS build's page refuse an `http://` origin
before any socket exists — refusing to type a passphrase into a page that arrived in the
clear, while the rollback build keeps its `ws://`.
| element | why it is there |
|---|---|
| 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). 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 |
| **Release keys** and `Ctrl+Alt+Esc` | panic: every key up, disarm |
| *stay armed if this tab loses focus* | off by default; for the one workflow that needs it |
| **Send text** box, its button and its *stop typing* | paste something and have it typed as keystrokes; the button says how many characters, refuses to be armed-into accidentally, and is the only path text takes |
| *drop characters I cannot type* | off by default: a paste that quietly loses a character is worse than one that refuses |
| **capture the pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag |
| **Release buttons** under the pad | panic for the pointer, separate from the keyboard's because the two fail independently — held-button-then-lost-link is the mouse's stuck-key, and it is the same one release funnel |
| held-keys readout and the mouse line under it, keys/chords/text/chords/mouse counters | so "it is typing" and "it is moving" are facts you can see; the mouse line names the buttons held and the button byte, because "left is down" and "the target has 0x01" are different claims and only the second one is on the wire |
| log, *log each key sent*, **clear** | the board's errors verbatim plus one line per key pressed — never one per release, never a blank line, and a repeating line is counted rather than stacked |
The caps/num indicators are read *from the target* (its HID output reports drive them),
not guessed — a caps-lock desync between controller and target is a real failure mode of
remote keyboards and this design cannot have it. The mouse counters are read from the board
for the same reason: `mframes` counts the reports the board accepted, which is the number
`watch_target.py` on the target can be compared against.
**What capture does and does not do.** The pointer is captured only while the bridge is
armed, and only after a click; `Esc`, blur, a hidden tab, disarm, `R` and the board
reporting it cannot do mouse reports (boot protocol, or `mouse off`) all end it, each
releasing the buttons. The page never accumulates movement to send later: if the link is
down, movement is dropped, because a queue of pointer motion delivered after you stopped
moving is the pointer version of a stuck key. `mouse frames 0` in the status line while you
are moving means the frames are not getting there — that is a fault to look at, not noise,
so it is shown rather than smoothed.
---
## Wire protocol
One WebSocket text frame = one command. ASCII, no JSON inbound, trivially hand-typed,
and impossible for one malformed field to desynchronise what follows it.
| dir | message | meaning |
|---|---|---|
| c → d | `T<token>` | authenticate; must be first (`kAuthTimeoutMs` = 5 s) |
| c → d | `H` | hello: status now |
| c → d | `A1` / `A0` | arm / disarm |
| c → d | `S=<mods>:<u>,<u>…` | **the complete key state** |
| c → d | `C` | Ctrl+Alt+Del (fixed in firmware, not read off the wire) |
| c → d | `Y=<mods>:<u>,<u>…` | one synthetic chord, `kChordHoldMs` (40 ms) |
| c → d | `P=<ms>:<hex>` | type pasted text, `<ms>` ms per character (0 = board default) |
| 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, `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
channel deliberately did not touch the protocol, so a controller written before it still
parses and every host test still means what it meant. What is different is that `T<token>`
no longer crosses the network in the clear: on a TLS board the first thing anybody else can
read about a controller is that a session exists. A plaintext socket is refused rather than
accepted: the rollback build is the only thing that will ever take a `ws://` upgrade on `:80`,
and here that upgrade is answered `426 Upgrade Required` before a frame is read. So a session
that is typing has never been plaintext by accident, in either direction.
The status frame is rendered by `src/status.h` — a pure module, so the whole frame including
the `kStatusJsonCap` arithmetic is measured on a host (`test_proto`) instead of discovered on
a device: a frame that would not fit renders as **nothing** rather than as half a status, and
`fp` is cut at the first non-hex byte so a stored value cannot forge a field.
`<mods>` is the modifier byte as two hex digits, `<u>` a HID usage id as two hex digits.
Both are strict: exactly two hex digits, no spaces, no trailing comma, usage `00` is not
a key, and a rejected message leaves the previous state in force — the safe outcome.
**`S=` carries state, not events.** `S=00:` means "nothing is pressed". A press and its
release are two frames and therefore two HID reports, so a fast tap cannot vanish;
identical consecutive reports collapse, which is what keeps the queue short. A single
message resynchronises the target completely, which is why no key can get stuck.
**`M=` restates the buttons and carries one report's movement.** `<btn>` is the button byte
as two hex digits — `01` left, `02` right, `04` middle, `08` back, `10` forward, and any
combination — and is the *whole* button state every time, exactly like `S=`. A frame that
lifts one button restates the ones still held; there is no button-up event to lose, so no
button can get stuck on the target. The four signed decimal fields are this report's
movement, each `±127` (`kMouseDeltaMax`, the boot-mouse byte), and all five fields are
required — `M=01` is `E6`, not "left, no movement", because a controller that forgot three
fields is a controller whose next frame is also wrong. Movement is the one field that is
not state: losing a frame moves the cursor slightly less and leaves nothing behind.
The page chunks movement to 127 per axis and keeps the diagonal shape (a 200×200 move
leaves as 127,127 then 73,73, never as two axis-parallel legs), so a fast flick cannot turn
into a silently clamped one, and it flushes the pending movement under the *old* button
state before restating a new one — that ordering is what makes a drag a drag.
**`P=` is text, typed by the board.** `<hex>` is the bytes to type, so one frame carries
at most `kTextFrameMax` (120) characters and a longer paste is several frames; `<ms>` is
the pace asked for, and the board keeps it — expansion and timing live on the USB side
because a controller's clock, arriving over WiFi in whatever order the network delivers
it, is not a keyboard timing source. Each character is pressed and released on top of
whatever the operator is physically holding, and released back to it, so a paste can
neither let go of a held key nor strand one. The board answers `E9` for a character the
US-ANSI map cannot express and types none of the frame, rather than sending a paste that
quietly lost one; `E8` means it already holds `kTextJobCap` (2048) characters. A
controller sends the next frame when the status says `typing: 0` — the board's own count,
not a guess at timing.
Error codes: `1` not authenticated · `2` locked out · `3` another controller holds the
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`, `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, 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`, 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
is escaping. All of it is `src/conn.h`'s `plaintextRoute()`, one table, consulted in one
place, and compiled away by the rollback. See [Security model](
#security-model-and-trust-boundaries).
The same protocol is what a future OS-wide capture client would speak — see
[Backlog](#backlog) — which is why it lives in `src/proto.h` instead of inside the server.
---
## Serial console
`/dev/ttyACM0` is the CDC half of the same composite USB device, so the console appears
on whatever machine the board is plugged into:
```bash
python3 tools/console.py # interactive, Ctrl-] to quit
python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
```
| command | effect |
|---|---|
| `status` | the same JSON the controller gets, plus link, queue and text-run state (`text N chars typed, M still to go`) |
| `wifi <ssid> [passphrase]` | station network to join (stored in NVS, reconnects) |
| `wifi clear` | forget it; the access point becomes the only way back |
| `ap auto\|force\|off` | AP policy: when the station is down / always / never |
| `token <passphrase>` | control passphrase, 6–32 chars, never echoed back |
| `cert` | the TLS certificate: SHA-256 fingerprint, validity window, and the names it carries — and a warning when the stored certificate does not actually contain that extension, so the list is this build's table rather than the certificate's. **Never the key, never the PEM body** — there is no argument form that writes an identity and no subcommand that reads one out |
| `arm` / `disarm` | same as the button |
| `mouse on\|off` | whether the bridge enumerates a pointer interface at all. Stored, and it needs a **reboot**: the composite descriptor is fixed when USB starts, so this cannot change under a live target. Off means the pointer cannot be moved by anything, ever, until it is turned back on |
| `reboot`, `help` | |
`status` also says which of the two the running bridge is, and why: a line like
`mouse interface=present target=report protocol buttons=0x00 41 reports, 0 queued`,
where `target=BOOT protocol (refused)` means the target asked for boot protocol and the
pointer is disabled by itself until it re-selects report protocol, and `interface=off` is
the `mouse off` switch. The queue line reads `queue 0 keys pending, 0 held, ok`.
There is deliberately **no command that types or clicks anything** — the console is
configuration, not an input path. That is why `mouse on|off` is the only mouse command and
it changes a capability rather than performing an action. Replies are printed with a blocking write (the global serial timeout is
0 so logging into a port nobody reads can never stall `loop()`); without that, multi-line
replies truncate at the 128-byte CDC TX ring, which is exactly what happened on the first
build and is why `pollConsole()` bumps the timeout around each reply.
---
## Architecture
```
src/
config.h defaults: network, timeouts, caps, buffers (NVS overrides)
keystate.h pure modifier byte + pressed-set -> 8-byte HID report, rollover
mouse.h pure button byte + signed deltas -> 5-byte relative report; the
buttons are restated, not evented, exactly like keystate
text.h pure pasted text -> US-ANSI keystrokes, paced; run buffer and cancel
output.h pure the one place keystrokes, pasted runs, chords and mouse reports
become two ordered queues, and the one place anything is released
proto.h pure the wire protocol: parse/render commands, constant-time compare
status.h pure the status frame: StatusSnapshot and its renderer, the tls/fp
identity fields, and the SSID's JSON-boundary sanitising -- host
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
is issued for, that table's SAN extension in DER *and the OID that
labels it* (sanOidDer -- DER content bytes, because that is what the
writer takes; the dotted string writes a well-formed certificate with
no SAN), the whole Extension SEQUENCE as it goes on the wire
(sanExtensionFullDer), derContains() and derCarriesSan() for checking a
claim against the certificate itself, and the validity-window arithmetic
over __DATE__ with its human-readable rendering
tls.{h,cpp} the mbed TLS half of the identity: secp256r1 keypair, self-signed
certificate, reparse-and-match check, fingerprint and validity of a
stored certificate, all behind KR_TLS (config.h; 1 is the default, 0
builds the plaintext rollback). main.cpp generates it on first boot,
keeps it in NVS and prints the fingerprint once
tls_session.{h,cpp} the TLS session: a raw-mbedTLS server session over an accepted fd,
handshake driven one call per tick with a kTlsHandshakeMs deadline,
non-blocking read/write with a strict same-buffer resume contract,
and an explicit slot-cap refusal (alert record, no context built).
Its POLICY half (deadline test, accept table, library-return table,
alert record, write queue, frame ring) is pure and host-tested; the
library half is compiled only where mbed TLS exists (KR_TLS &&
ESP_PLATFORM), which is what keeps the suite compiling without it
conn.h pure* the transport view the routing sits on: one byte pipe with two
implementations (a WiFiClient, or a tls::Session) so serveHttp() and
serveViewer() do not know which they are talking to. Queued outbound
bytes (whole frame or nothing), a resumable pump capped per write and
per tick, and a flash body streamed through a bounded RAM chunk --
which is what lets a ~50 KB page leave across ticks on either
transport instead of in one call that can wait (the plaintext write
helper retries a blocking select ten times at one second each). Its
pure half also holds the HTTP response head, the `:80` answer table
(serve / 301 / 426), the redirect builder that turns a client `Host:`
into a `Location:` only when it is a name this board is reached at,
and the page send cursor (sent/total). `kr::Conn` itself is
ESP-only, like tls::Session; the tables are host-tested
control_server.{h,cpp} sockets: two listeners (:80, and :443 when an identity exists)
feeding ONE controller slot, ONE routing table and ONE transport view;
HTTP routes, WebSocket lifecycle, auth, heartbeat; `serveReadonly()`
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 (~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/
to_bootloader.py 1200-baud touch into ROM download mode (stdlib termios)
console.py serial console, one command or interactive (stdlib)
relayctl.py controller from a terminal: status/arm/disarm/release/cad/key/keys/
chord/listen/paste/mouse; pinned TLS by default (--fingerprint /
--ca / --insecure, refused without one), --plain for -D KR_TLS=0
rollback boards
watch_target.py on the target: decode the board's evdev events (keyboard *and*
mouse), prove delivery
hw_probe.sh what device context exists right now (serial nodes by stat only, mDNS,
http/https, uv, node, target evdev); informs, never gates, always 0
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_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
validate.sh the gate the pipeline stops on: both suite runs, all three firmware
builds (bridge, console, the KR_TLS=0 rollback), the page byte count, the
numbers README/AGENTS quote, repo hygiene — no device, no upload, no network
HW_VERIFICATION.md the device half: every claim only hardware can settle, the command
that settles it, and the outcome someone actually observed
AGENTS.md operating manual for whoever edits this next: the invariants, the tables
that must agree, the build traps, the verification standard
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 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
before the first flash (a `reset()` that left the reader latched on the previous frame, an
unsequenced `_buf[_got++] = b ^ _mask[_got & 3]`, a missing FIN check that silently
accepted fragmentation, a mask key skipped on zero-length frames, and a
`"sec-websocket-key:"` prefix compared at the wrong length so the accept hash was
computed from `"ey: …"`).
The keystroke path, end to end:
```
keydown/keyup -> full state S=.. -> Reader (RFC6455) -> proto::parse -> auth gate
-> KeyboardState::apply (dirty only if the report changed)
-> ReportQueue (dedupe identical, overflow = release + disarm)
-> drainReports: tud_hid_n_ready(0) ? USBHIDKeyboard::sendReport : keep queued
-> HID interrupt endpoint, boot protocol, 8-byte report -> target
pointer move -> M=btn:dx:dy:wheel:pan -> same gate (armed + attached + mouse allowed)
-> MouseState::apply (button byte restated; idle frames produce no report at all)
-> a second ReportQueue, same dedupe and the same overflow rule
-> drainReports, after the keyboard queue -> report #2 on the *same* HID interface
fail-safe (any of: disarm, R, blur, silence, detach, suspend, overflow)
-> releaseEverything() -> OutputStage::releaseAll()
clears both queues, both trackers, the chord and the text run, THEN queues
all-keys-up and (if the target can take it) all-buttons-up
```
`USBHID::SendReport` in the core is synchronous (it waits on a semaphore for the transfer
to complete, up to 100 ms), so `drainReports()` takes at most `kMaxReportsPerLoop` = 8
reports per pass across **both** devices — bounded time in the USB path, and the control
plane never waits on it.
**Both devices live on one HID interface, which is a constraint with three consequences.**
The keyboard's `USBHIDKeyboard` is the first HID instance, so it claims the boot keyboard
subclass; the mouse is added as the second report on that same instance, which is why
`mouseDev` is created after `kbd` and never before. Because the descriptor is written when
`USB.begin()` runs, `mouse off` cannot take effect until a reboot. And because a
boot-protocol host parses every report on that interface as an 8-byte keyboard report, a
target that selects boot protocol gets the pointer switched off under it (`E11`) rather than
sending 5-byte mouse reports that would arrive as garbage keystrokes.
Chords (Ctrl+Alt+Del, VT switches) are synthetic: the press is pushed, and after the hold
the *tracked* state is pushed back, so keys that were genuinely held before a chord
survive it and the tracker is never corrupted by something the operator did not type.
The USB interface is confirmed boot-protocol on real hardware, which is the precondition
for Windows accepting Ctrl+Alt+Del as the secure attention sequence:
```
$ for i in /sys/bus/usb/devices/*:1.*/; do echo "$(basename $i) class=$(cat $i/bInterfaceClass) sub=$(cat $i/bInterfaceSubClass) proto=$(cat $i/bInterfaceProtocol)"; done
7-1:1.0 class=03 sub=01 proto=01 <- HID, boot subclass, keyboard protocol
7-1:1.1 class=02 sub=02 proto=00 <- CDC-ACM control
7-1:1.2 class=0a sub=00 proto=00 <- CDC-ACM data
```
Three build environments, one flag apart. `bridge` is the real thing (USB-OTG + TinyUSB, TLS
on). `console` is `ARDUINO_USB_MODE=1`: no HID, so it cannot type, but WiFi/page/WebSocket/
LED/button all live — for separating "the network side is broken" from "the HID side is
broken" without rebuilding; arming refuses with `E5` there, on purpose. `rollback` is
`bridge` with `-DKR_TLS=0`: the plaintext pre-phase-02 control channel, kept buildable by the
gate because it is the documented way back from a TLS problem and not a mode a live board can
choose (`src/config.h`, README [The rollback](#the-rollback--building-it-and-what-it-costs)).
---
## Tests
```bash
tests/run_tests.sh # uses g++ if present, else a local container image
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 bugs)
bash .agents/validate.sh # the gate: both runs above + all three firmware builds
# (bridge, console, the KR_TLS=0 rollback) + the page's byte
# count + the numbers README/AGENTS quote + repo hygiene,
# and never a device, an upload or a network fetch
```
| suite | checks | what it pins down |
|---|---|---|
| `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` | 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` | 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
glob found them in), movement coalescing one report into one line, net displacement
arithmetic, hi-res wheel events not counted as extra notches, and a stuck button being
called out in those words.
A second pinned section, **`one routing path, two transports`**, does for the transport
split what the fail-safe pins do for the release funnel: it asserts sentences no host test
can execute and no compiler can see -- that `readSome()` stamps the controller-silence clock
only for `_phase == WebSocket && _authed` (so a TLS handshake, a `/status` poll and a viewer
refresh cannot hold a dead controller's armed state open), that the read-only slot never
goes through `readSome()` at all, that exactly one formatter in `src/` writes an HTTP status
line, that `serveReadonly()` is reached from both slots and defined once, that the `:80`
table is consulted in exactly one place, and that the page's `wss://`-or-rollback-`ws://`
choice exists exactly once. It also pins the served page's byte count against the constant `test_conn.cpp` drains
through the chunk arithmetic -- the same *test the table, not your confidence in it* rule as
the mouse bits and the evdev codes.
Five checks in `test_relayctl`'s `the fail-safe wiring` section are **source pins**, and
the suite says so out loud: they assert that `loop()`'s silence disarm runs off
`controllerSeen()` rather than `linked()` (and that nothing else gates it on the link still
being there), that `acceptNew()` never stamps the controller clock, and that `closeLink()`
stamps it only for a link that reached the WebSocket phase. They cannot execute — that code
needs a socket and a board — but each one is a sentence a later edit can undo with no
compiler error and no behavioural test failing on the host, which is exactly how bug 10
survived. Measured, not asserted: reinstating the `linked()` gate fails 2 of them,
stamping in `acceptNew()` fails 1, reinstating the bug as it was fails 3.
Gaps, stated plainly: nothing here exercises real sockets, real WiFi associations, or real
USB transactions — those are the [end-to-end procedure](#end-to-end-verification) below.
`control_server.cpp` is still the least-executed part of the project, deliberately the
thinnest layer, and task 04 narrowed what that means: the transport decision, the `:80`
answer table, the redirect construction, the response head and the page send cursor used to
be lines in front of `WiFi.h` that only a device could prove, and they now live in `conn.h`
where `test_conn` walks them — what remains untestable there is the socket plumbing itself
and the auth *lifecycle* (the ordering of accept → handshake → upgrade → `T=` → close),
which is why the sentences around it are pinned as source assertions. What it *used* to
render is: the status frame moved to `src/status.h` so that
`test_proto` can build one and measure it, which it could not do while the code sat behind
`#include <WiFi.h>` — the frame's field list, its sanitising and its buffer arithmetic were
previously verifiable only on a device. `src/tls.cpp` and the session half of
`src/tls_session.cpp` are the same kind of gap on purpose: they are sequences of mbed TLS
calls whose failure mode is an error code, so they are proven by the two firmware builds
(the symbols resolve, both environments link) and by the device checks in
`.agents/HW_VERIFICATION.md`, not by a host suite that would have to fake the library to
run -- which is also exactly why every decision those calls make (deadlines, admission,
return-code meaning, buffer-resume bookkeeping) was pushed out of them into pure,
host-tested helpers in `tls_pem.h` and `tls_session.h`. The one thing a host build cannot establish — that a section nothing yet calls still
has every mbed TLS reference it needs — was settled by a throwaway link with `-Wl,-u,`
forcing those three functions to survive garbage collection.
The gate also checks two things in the other direction, because a build that succeeds proves
nothing about a build that is supposed to *disappear*: `-e rollback` has to link (`-D KR_TLS=0`
is the documented way back, so it is built on every run), and the image it produces carries no
`mbedtls_ssl_*` symbol at all — 0 against 115 in `bridge`, by `nm`, quoted under
[Measured behaviour](#measured-behaviour). And since this README is the source of truth for
behaviour, the gate compares the numbers it quotes against the commands that produce them: the
check count in this table, the page's byte count, a Backlog numbered 1..N with nothing closed
left inside it, and the rule that any line naming a plaintext `ws://` must name the rollback
build on that very line, so no sentence can be read as if plaintext were the default.
---
## End-to-end verification
Three machines' worth of roles on two boxes: the laptop is the controller, the board is
the bridge, and any Linux box (including the laptop itself) is the target.
```bash
# 1. on the target: what actually arrived, decoded from evdev, focus-independent
python3 tools/watch_target.py --seconds 60
# 2. on the laptop: drive it from the terminal
export KEYRELAY_TOKEN='the-passphrase-you-set'
export KEYRELAY_TLS_FP='<the 64 hex digits `cert` printed>' # the pin; --fingerprint works too
python3 tools/relayctl.py --host keyrelay.local status
python3 tools/relayctl.py --host keyrelay.local arm
python3 tools/relayctl.py --host keyrelay.local key Enter
python3 tools/relayctl.py --host keyrelay.local chord 04 2b # Alt+Tab, by hand
python3 tools/relayctl.py --host keyrelay.local cad --yes
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
python3 tools/relayctl.py --host keyrelay.local mouse click --yes
python3 tools/relayctl.py --host keyrelay.local mouse wheel -3
python3 tools/relayctl.py --host keyrelay.local mouse drag 200 0 --button right --yes
python3 tools/relayctl.py --host keyrelay.local mouse release
```
`.agents/HW_VERIFICATION.md` is where the outcome of each device claim gets recorded, with
the command that was run; `bash tools/hw_probe.sh` says, cheaply and before you try, whether
a board is reachable at all. Both obey one rule: **a device step is attempted once, capped
with `timeout`, and the outcome is written down** — `ok`, `skipped (no device)` or
`failed: …`. Nothing here loops on a port, reflashes to see if it works, or belongs in
`.agents/validate.sh`, which stays device-free so a gate never fails on the schedule of
whoever is not holding a board.
And in the browser, which is where the pointer is actually usable: open the page, arm it,
press **capture the pointer**, move the mouse (the target's cursor moves, the laptop's is
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 |
|---|---|
| HID reaches the host | `watch_target.py` prints every press/release with a timestamp |
| text is correct | its `typed` line, with case from the shift state |
| Enter works | `Enter press/release` and `<Enter>` in the typed text |
| Ctrl+Alt+Del works as a *combination* | `>>> Ctrl+Alt+Del <<<`, one line, not three |
| no stuck keys | `still held` is absent at the end of the run |
| **pasted text is keystrokes, not a shortcut** | the run's `typed` line reproduces the text, and the press count is roughly twice the character count — a paste that arrived as nothing, or as a single odd chord, means the character map or the pace, not the target |
| **Alt+Tab is Alt+Tab** | `LAlt press` then `Tab press` — and *not* `LCtrl`. This combination was `Ctrl+Alt+A` on the wire until it was caught by the label-vs-bytes test; `watch_target.py` is what settles any doubt about which keys the board actually sent |
| a paste stops | start a long one, press **stop typing** (or blur the tab, or press the board's button): the `typed` text stops mid-sentence and nothing further appears |
| 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 |
| **no stuck buttons** | `still held` absent from the mouse summary at the end. Check it the hard way: hold a button and close the tab |
| **the wheel is notches, not pixels** | `mouse wheel -3` produces `REL_WHEEL -3` three times, and hi-res events do not inflate the count |
| **boot protocol refuses the pointer, not the keyboard** | on a BIOS/KVM that selects boot protocol, keys keep working and `M=` answers `E11`; `console.py status` says `target=BOOT protocol (refused)` |
| **`mouse off` really removes the capability** | `console.py mouse off` + reboot → `lsusb`/evdev show no pointing device, `watch_target.py` notes the missing interface, the page's Capture button is disabled, and keys still work |
`watch_target.py` finds the board by USB id, not device name, so it keeps working on a
different port or a renamed input node, and it accepts both the Logitech identity and the
Espressif default so it works on older builds.
A pasted run prints one line per press *and* release, so a few hundred characters is a
lot of scrolling: watch the `key presses` and `typed` summary lines at the end rather than
the stream, and give `--seconds` enough room for the pace (`2048` characters at the default
12 ms is about 25 s of typing, plus the run's own margins).
---
## Measured behaviour
_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 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
status {"v":"1.0.0","up":7,"usb":1,"armed":0,"net":"ap","ssid":"keyrelay-ctl",
"ip":"192.168.4.1","keys":0,"chords":0,"drop":0,"roll":0,"leds":0,
"dflt":1,"ap":0,"typed":0,"typing":0,"mouse":1,"mbtn":0,"mframes":0}
link controller=no rejected=0 authfails=0
queue 0 keys pending, 0 held, ok
$ python3 tools/relayctl.py --host <ip> status # armed, typing, then disarmed
armed YES -- keystrokes are being forwarded
counters keys=… chords=0 dropped=0
$ python3 tools/watch_target.py --seconds 30 # on the same laptop
t= 0.42s A press
t= 0.48s A release
...
key presses …
typed '...'
```
### Confirmed on hardware
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 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 |
| page delivery | `GET /` over TLS → 200 and **51 626 bytes**, `sha256` equal to the locally **assembled** page from `src/web_page.h` (raw-string chunks + the 19-byte KR_TLS=1 marker; `dd16e195…f04e9`) — re-measured 2026-11-09 after the pinning-banner page change — so the resumable, chunked flash send (1460 B per write, 5840 B per tick) delivers the page intact across many `loop()` iterations; the served bytes carry `var KR_TLS_BUILD=1;` and the new `certFp`/`pTls`/`clearWarn` elements; zero external references; 404 on unknown paths; 401 on `/status` without the passphrase |
| **pinning from the terminal** | `relayctl.py --fingerprint 00…00` aborts with `certificate fingerprint mismatch … Nothing was sent` — the presented digest (`61:54:19:30:ed:df:…`, same identity the serial `cert` printed, across reflashes) and *zero* wire bytes; the real fingerprint passes the pin and stops only at authentication (`E1`, operator's passphrase — typing over a pinned session is the operator's confirmation to make); `--ca <self-signed exported via openssl>` verifies with hostname checking on; `--insecure` prints its warning on every run; no trust argument → the four-option refusal, exit 2, no socket; `--plain` reads the board's 426 as "board refuses plaintext", never as a passphrase problem — measured 2026-11-09, one capped run each |
| `:80` is redirect-only | `GET /` → `301` with `Location: https://<the address used>/`; `/index.html` → `301` to the same path over `https://`; `/nope` → `301` to `/` (an unknown target is a 404 on the encrypted port, and the redirect does not echo an arbitrary request target); `GET /status?token=…` → **426** with `Upgrade: TLS/1.2, HTTP/1.1` and **no `Location`**, body free of the token (`grep -c` of the value in the body: 0); `/ws` upgrade → **426**, refused rather than downgraded — measured 2026-11-09, one capped `curl` each |
| controller over `wss://` | upgrade → `101` with the correct `Sec-WebSocket-Accept`, TLSv1.2 / `ECDHE-ECDSA-AES256-GCM-SHA384`; a wrong passphrase over that session → `E1:wrong passphrase` then close, never a silent pass; while that slot was open a second TLS connection fetched the page (200) and `/status` (401) — measured 2026-11-09 |
| arming from the browser | the page armed the board on first try (observed as `controller=yes armed=1`); pre-TLS runs used `ws://`, phase 02+ runs use `wss://` |
| keystroke delivery | 6/6 inert keys arrived on the target with matched press **and** release: F13, F15, F17, ScrollLock, CapsLock ×2 |
| 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 *(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 |
| **the wheel** | `mouse wheel -3` → `REL_WHEEL -3`. It took a wrong axis table in the *verifier* to prove this worked — see the note under Tests |
| **drag ordering** | `drag 200 0 --button right` → `BTN Right press`, `movement dx +127`, `movement dx +73`, `BTN Right release`. Both halves of the property at once: the ordering is down-then-move-then-up, and the 200 px left as two reports that stay inside one signed byte |
| **keyboard and mouse together** | keys, chords, a 14-character paste and mouse reports in one session, `drop=0 roll=0`, `keys=3` and `mframes` advancing independently |
| **a controller that dies holding a button** | `BTN Left press` → client `os._exit()` (no close, no disarm) → **`BTN Left release` 9.4 s later**, and a live controller holding the same link with keepalives stayed armed for the 24 s it was tested. This is bug 10, and it is the property the whole release funnel exists to provide |
| 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 |
### Thirteen bugs this verification caught (all fixed)
Worth listing, because each one is a class of bug that HID-over-network tools ship with:
1. **CDC replies truncated at 128 bytes** — `setTxTimeoutMs(0)` meant "drop what does not
fit", and `serial status` came back cut mid-JSON. Replies now temporarily switch to a
blocking write and flush. A diagnostic tool that lies about state is worse than none.
2. **The CLI lowercased the whole handshake header block** — base64 is case-sensitive, so
every `Sec-WebSocket-Accept` compared wrong and the board looked broken while answering
correctly. Header *names* are case-insensitive; values are not.
3. **The CLI reported stale status** — the board heartbeats at 1 Hz and the key helpers do
not read while typing, so `disarm` printed `armed YES` from a heartbeat sent *before*
the command. `arm`/`disarm` now drain, then poll until the board actually reports the
state asked for — confirm, don't assume.
4. **The target-side verifier mislabelled every LED** — its table was copied from the HID
bit numbering, but Linux `EV_LED` indices are zero-based (`LED_CAPSL` is 1, not 2).
Verified against real events.
5. **Unnamed key codes looked like lost keystrokes** — F13–F24 had no names in the
verifier's table, so a perfectly delivered run appeared to drop its first three keys.
Now named, and unknown codes are counted rather than silently skipped.
6. **The single-controller gate was applied at `accept()`**, before the request was even
read — so while a controller was connected, *anything* else that touched the board got
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 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
user who could not work out why the Pi ignored it, and provable because the page stores
the chord bytes on the button instead of a name to look up — so nothing on the browser
side could ever disagree with a label. Every labelled chord is now pinned to the
combination its label promises, by modifier byte and usage id.
8. **`relayctl`'s drain called two methods that do not exist** (`_send_frame`,
`_check_error`) while answering a ping, so any session outliving the board's 15 s ping
died on an `AttributeError` instead of reading its next frame. Only reachable on long
sessions, which is exactly what typing text is; it answered pings the way `_poll` does
and raises on error frames now.
9. **"Silence disarms" was documented and not implemented.** `kNoClientTimeoutMs` existed,
`ControlServer::lastInboundMs()` existed, and nothing ever compared them. Harmless while
the only input was a human keystroke (a vanished controller sends no more keystrokes),
and not harmless once a run can type for twenty seconds on its own: a laptop that slept
mid-paste, with a link that never got a FIN, would keep typing into the target.
`loop()` now checks it, before driving the typewriter, and clients keep-alive while they
type. The lesson is the one the whole project keeps relearning: a safety property that
is only written down is not a safety property.
10. **A held mouse button stayed held, forever, and anything on the network could keep it
that way.** Found while verifying the mouse, with the mouse innocent: `releaseEverything()`
queued the release reports (bug 9's fix), and they still did not go out.
Three things stacked up. `closeLink()` ran the release callback only `if (linked())`,
and `linked()` is `_phase == WebSocket && _authed` — so any path that cleared `_authed`
on its way to closing the socket closed it with the keys down and nothing downstream
able to notice. The board then sat `armed=1 mbtn=01` with `phase=none`. And the
backstop that should have caught it, `loop()`'s silence timer, was gated on
`control.linked()` — **a controller that has gone away is precisely the case in which
`linked()` is false**, so the fail-safe designed for a vanished controller could only
ever run while the controller was there. Measured: `BTN Left press`, controller killed,
target still holding the button **26 s later**, and the armed state never expired.
The second half was worse. `acceptNew()` stamped `_lastInboundMs` for *any* incoming
TCP connection, so a read-only `GET /status` — which is served by design while a
controller holds the bridge — reset the silence clock. Any authenticated monitor,
second tab or health probe kept a dead controller's armed state open indefinitely. The
first fix traded that for stamping in `closeLink()`, which moved the reset from
*accept* to *every close* and changed nothing observable; `wasLinked` is the honest
test, because it is true exactly when the thing being closed can leave key state
behind.
Fixed as: `wasLinked = (_phase == Phase::WebSocket)`; the silence clock moved only by
an authenticated controller's own traffic or by that link closing; `controllerSeen()`
distinguishes "no controller since boot" (button-armed, leave it alone) from "one came
and went" (never stay armed on its strength). Verified on hardware with the monitor
running the whole time: `BTN Left press` → `BTN Left release` at 9.4 s, armed 0 and
staying 0 for 37 s of continued polling, while a live controller holding the link with
keepalives stayed armed through the same test.
What to take from it: **never gate a fail-safe on the absence of the thing its
condition detects.** And a backstop needs its own test, because this one passed every
keyboard suite for exactly the reason bug 9 did — the socket-close path happened to
cover the case being checked. `tests/test_relayctl.py` now pins all three sentences in
source — five checks — and reinstating the `linked()` gate fails 2 of them, the
accept-time stamp fails 1, the bug as it stood fails 3.
11. **The certificate carried its names under the wrong OID, so no client could read them.**
`mbedtls_x509write_crt_set_extension()` takes an extension OID as the **content bytes of
the DER OBJECT IDENTIFIER**, and this tree handed it the dotted string `"2.5.29.17"`. The
library said nothing, because nine bytes of ASCII *are* a well-formed OID —
`1.10.46.53.46.50.57.46.49.55` — so the board issued a certificate with the correct
34-byte GeneralNames under a label nobody recognises. That is not a broken certificate: the
key is fine, the handshake completes, the page is served, the fingerprint pins. It is a
certificate with **no `subjectAltName`**, and browsers stopped matching hostnames on the CN
years ago, so the name check fails with a message about the *name* while every part of the
board insists the names are correct.
It was found by reading the certificate the live board was serving instead of the code that
writes it: `openssl x509 -text` on the peer certificate printed
`1.10.46.53.46.50.57.46.49.55` where `X509v3 Subject Alternative Name` belongs, `-ext
subjectAltName` answered *no extensions*, and a hex dump showed `06 09 32 2e 35 2e 32 39
2e 31 37` — the literal text — where `06 03 55 1d 11` should have been. The host suite
could not have caught it as written, because every check in it walked the **value** and the
name table, never the label. Same shape as the verifier's LED table (bug 4) and its
`REL_WHEEL` axis code: a table cross-checked against itself, green the whole time — which
is why AGENTS.md says to test the table, not your confidence in it.
Fixed where the other DER lives, so it is host-tested: `kSanOidTlv` / `sanOidDer()` are the
bytes the writer takes, `sanExtensionFullDer()` is the whole `SEQUENCE { OID, OCTET STRING }`
pinned to the 43 bytes OpenSSL writes for the same three names, and `derCarriesSan()` asks
the certificate the question a client asks — so `cert` now refuses to claim names a stored
certificate does not carry. The regression test embeds the pre-fix extension byte for byte:
`derContains` says the value is there and `derCarriesSan` says the extension is not, which
is the difference between those two functions and the reason both exist.
A board reflashed with the fix **keeps its old certificate** — NVS survives reflashing and
rotating an identity is the operator's decision, not the build's — so `cert` prints a
warning and the remedy is `nvs erase`, which reissues it, prints the new fingerprint once,
and also forgets the passphrase and the network. Two rules out of it: an API that accepts a
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
focused window.** The first live test sent `Escape`, and the target was the laptop running
this agent — so the keystroke interrupted the very session that sent it. That is not a bug
in the bridge; it is exactly the transparency the thing is designed for, pointed the wrong
way. So:
- verify with keys that nothing reacts to — **F13–F24 and the Lock keys** are inert almost
everywhere, and they exercise the same code path as Enter;
- before arming against your own machine, decide what has focus; a terminal will *execute*
what you send;
- the page releases keys and disarms when its tab loses focus, which is also how you get
control back: click anything else, then click back.
---
## Troubleshooting
| symptom | cause, and what to do |
|---|---|
| the page does not have a feature the README describes | **the page is served from the board's flash**, so the image you built is not the image on the device. `curl -sk https://keyrelay.local/ \| wc -c` and compare with the page in `src/web_page.h`; if they differ, reflash (`to_bootloader.py` then upload) and *reload the tab* — one already open still holds the old page in memory, passphrase notwithstanding |
| `pio run -t upload` sits at `Connecting…` | the app owns the USB pads. `python3 tools/to_bootloader.py`, or hold the button 3 s while powering up (LED solid red) |
| page will not connect | wrong address: `console.py status` for the real `ip=`; `net:"ap"` means you must join `keyrelay-ctl` |
| the browser says `SSL_ERROR_…`, `curl` says `certificate verify failed`, or `relayctl.py` says `certificate fingerprint mismatch` | the board regenerated its identity (`nvs erase` — the boot log prints the new fingerprint once, loudly) **or you are on the wrong network and a different device is answering**. That is exactly the attack the fingerprint is for, in those words: re-read `cert` on the board's *own serial console* — where nobody on a network can fake it — before typing the passphrase anywhere again. A browser that shows a *second* acceptance prompt after you already trusted it is the same signal |
| TLS itself is the thing that is broken and you need the board **now** | the way back is a reflash, not a repair: `pio run -e rollback` and upload it (that is `[env:bridge]` with `-DKR_TLS=0`), which is the pre-phase-02 build — page and socket on `:80` in plaintext, no `:443`, booting with `[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel` so there is no doubt which board is answering. `relayctl.py` needs `--plain` to talk to one. Everything in [Security model](#security-model-and-trust-boundaries) is off while you run it; nothing about arming changes. Details and the measured cost: [The rollback](#the-rollback--building-it-and-what-it-costs) |
| the page says *"this page arrived in the clear; reload over https"* and will not take a passphrase | you loaded `http://…`. A TLS board answers :80 with a redirect and never serves the page there, so those bytes came from a cache, a downgrade attempt, or somebody else's server. Open `https://keyrelay.local` (or `https://<ip>`) and confirm the fingerprint the Connection card shows |
| `keyrelay.local` does not resolve | mDNS is queried per-process with no cache on glibc (`mdns4_minimal [NOTFOUND=return]`), so a cold probe that misses returns "not known" immediately — **just retry**, or use the IP. Across VLANs/VPN it is often blocked outright |
| `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 **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 — 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 |
| a key is stuck on the target | `R` / **Release keys** / the button. Then check whether the tab lost focus without the page noticing (a hidden tab still gets blur on most browsers) |
| the boot log printed `!! THE STORED TLS IDENTITY IS UNUSABLE`, or `cert` says there is none | NVS holds a record that does not check out: a write lost to a power cut, an `nvs erase`, a key from one generation beside a certificate from another. The board regenerates and says so, once, before printing the new fingerprint -- **that is the moment to re-pin**. Nothing about arming changed either way: keystrokes are gated on arming, never on TLS |
| `cert` warns that the stored certificate does not carry the name list it prints | the certificate in NVS was issued by a different build than the one running now: the names come from the one table in `src/tls_pem.h` (`sanEntries`), and `cert` searches the certificate's own bytes for that extension, OID included. The usual reason is **an identity generated before bug 11 was fixed** — that certificate has the names in it under an OID no client reads, so encryption and pinning work and the hostname check cannot, and a browser complains about the *name* rather than about the extension. It is not dangerous and it is not a rotation: the fingerprint above that line still identifies the board. `nvs erase` reissues it (new fingerprint — re-pin — and it also forgets the token and the Wi-Fi credentials), which is the operator's call, not the board's |
| the fingerprint changes on every boot | NVS is refusing or losing the identity records. The boot log says `IDENTITY NOT STORED` when it happened; check the partition table (`huge_app.csv`: NVS is 20 KB and already holds the SSID, the passphrase and the token) |
| Ctrl+Alt+Del does nothing on the target | on Linux this is normal on a graphical session and *fatal* on a VT; on Windows the interface must be boot protocol (confirmed above) |
| target keeps suspending the bus | the target suspends, which disarms by design; disable suspend or plug into a port that stays powered |
---
## Design decisions
**Why a web page and not a host daemon.** Because "install something on the laptop" is
the difference between a tool you can use in ten minutes on a machine you are borrowing
and a project you set up once at your own desk. The page is served by the board, so the
only software that has to exist is the browser you already opened.
**Why full state instead of key events.** Covered under [Safety
model](#safety-model) point 8, and it is the decision that makes every other guarantee
here cheap: a lost message is corrected by the next one, so there is no resynchronisation
protocol, no sequence numbers, no stuck-key reconciliation timer.
**Why the board expands and paces text, not the browser.** It is tempting to turn a paste
into existing `S=` frames client-side and change no firmware at all. Timing is the reason
not to: a controller reaches the board over WiFi, in frames, with whatever gaps the network
inserts, and a character is a press and a release with a hold either side of it on a target
that samples every millisecond. A browser tab that stalls for a second would produce either
a burst of keys or a run that outran the 48-deep report queue and tripped the
overflow-disarm. Expanding on the board also means the page and the CLI cannot disagree
about what a character types, which is the same argument that put the protocol in
`proto.h`. Cost: the character map exists twice — positions in `web_page.h`/`relayctl.py`,
characters in `text.h` — and a test pins all three together.
**Why a paste refuses rather than sanitises.** Curly quotes, em dashes and accented
characters have no key on a US-ANSI keyboard. Dropping them quietly would send a passphrase,
a `rm` line or a config value that is *nearly* what was pasted, which is the failure mode no
one notices until it has landed. So the board answers `E9` naming the code point and types
none of the frame, the page says the same before it sends anything, and dropping is an
explicit, per-paste opt-in that says how many characters it removed. Fail-closed, and
loud.
**Why no WebSocket library.** The control channel needs text frames, ping/pong and close.
A library buys fragmentation, extensions, TLS plumbing and many-client handling — attack
surface and heap, none of it wanted. The hand-rolled codec is ~200 lines of pure logic
with 135 host checks behind it, and every deviation from the subset is a hard close.
**Why TLS rather than a challenge–response over the passphrase.** The original ask was "doesn't
need to be proper x509, but something", and the cheapest something is a nonce and an HMAC: the
passphrase never appears on the wire, and there is no certificate, no warning screen, no
fingerprint, no NVS record. It was rejected because hiding the secret is not the same as
protecting the session. Under a challenge–response the keystrokes, the pasted text and the
pointer deltas stay readable, and the page that collects them is still served in the clear —
which anybody on the path can rewrite, at which point the attacker does not need the passphrase
at all, only one keystroke from a page they wrote in a browser that already believes this is the
keyboard bridge. Encrypting the *channel* is the option that closes that, and the only one that
makes the trust-boundary table in [Security model](#security-model-and-trust-boundaries) true
rather than aspirational. The cost is stated rather than hidden: about 99 kB of flash and 6 kB
of RAM, a handshake measured at ~0.35 s per new connection, and a certificate every browser
complains about until you look at it once.
**Why the identity is generated on the board rather than shipped as a PEM.** Three reasons, in
order of how much they matter. A key in this repository is one key for every board ever built,
so reading the repo would buy a position between every deployment — and a per-user key generated
by a build script makes the first `pio run` fail for anyone who has not run the script. Second,
a private key is where this project's "never in a file, a log or a URL" rule gets teeth, and
`.agents/validate.sh` refuses tracked `*.pem`, `*.key` and `*.crt` for exactly that reason; a
key that exists only in NVS cannot be committed by accident, printed by a diagnostic, or shipped
in a release. Third, per-board keys are what make pinning mean something: with a shared key any
other board answers for yours and the fingerprint you checked identifies a *model*, not the
device in your hand. So the board makes one on first boot, stores it in NVS (`tlscrt`, `tlskey`,
`tlscrtfp`), keeps it across reflashing because NVS does, and regenerates it only when NVS is
erased — printing the new fingerprint, loudly, because a rotated identity that stays quiet is a
pinned value that stopped meaning anything. Where it lives is a separate decision, below.
**Why raw mbed TLS rather than `esp_tls`.** `esp_tls.h` was read before choosing, and its server
entry point `esp_tls_server_session_create()` is documented to create the context **and perform
the handshake** inside the call; the asynchronous pair (`esp_tls_server_session_init` +
`_continue_async`) still takes ownership of the session and its teardown, layers its own
error-code translation over mbed TLS's, and keeps its own fd bookkeeping. `loop()` needs the
opposite on all three counts: one library call per tick, an fd whose lifetime is exactly the
`Session` object's so it is closed on every path including `open()`'s failures, and `WANT_READ`
distinguished from a real failure by the same table the read and write paths use (misread a
`WANT` as fatal and slow WiFi drops working controllers; misread a fatal as `WANT` and the loop
spins). `esp_tls` remains the right tool for a *client*; for a server that must not block a
keyboard it is a wrapper whose convenience is the obstacle. The reasoning is in
`src/tls_session.h`, not in a diff, so nobody "simplifies" it back.
**Why a `token`-carrying request on `:80` is refused with 426 instead of redirected.** Because
the obvious redirect *is* the leak: a `301` whose `Location:` echoes `?token=…` puts the
passphrase on the plaintext wire twice — once in the request the attacker already sees, once in
the header written for their benefit — and it trains everyone who reads a `curl -v` to type the
secret into an `http://` address bar. So a plaintext `/ws` upgrade and any request carrying
`?token=` get `426 Upgrade Required` with a one-line body that names `https://` and contains no
value (checked on the device: `grep -c` of the token in that body is 0), and everything else on
that port gets the `301` it can safely have. `AGENTS.md` keeps the token a query parameter on
`/status` because `curl` is the point of that endpoint — this is what makes that acceptable
without the plaintext path becoming leakier than the encrypted one.
**Why the rollback is a build, not a setting.** A runtime switch for "accept keystrokes in
plaintext" is a value the board could be talked into, and one that a future operator would leave
on because turning it off costs a reboot. So the way back is `-D KR_TLS=0` — `pio run -e
rollback`, a reflash, same standing as `mouse off`, and built by the gate on every run so the
reversal cannot rot. Nothing about arming moves with the flag: it decides which port carries
bytes, never who may type them.
**Why NVS for configuration.** So the passphrase and network live in the board rather than
in a header you recompile, and survive reflashing — and so a passphrase never has to enter
a shell history, a git diff or a build log. The `token` command deliberately does not echo
it back. The TLS identity lives in the same namespace (`tlscrt`, `tlskey`, `tlscrtfp`) for
the same reason: reflashing must not change which certificate the operator pinned. Storing
the *fingerprint* alongside it, rather than rehashing the certificate at every boot, is a
trivial cost on a device with no PSRAM and no clock, and the value is public — the thing that
is secret is the key, and the only outputs that touch it are `mbedtls_pk_write_key_pem` into
NVS and the listener that will read it back. Nothing in `src/` prints it, and the serial
console has no command that could ask it to.
**Why the sibling project's USB identity.** Two firmwares on one board that are
indistinguishable from the host side, and stable `/dev/input/by-id` names for the tools.
Cost: it is a borrowed VID/PID, so it is documented as personal-use and one edit away
from being honest.
---
## Backlog
Ordered by what would make the biggest difference first.
1. **OS-wide capture client** (`tools/keyrelay-capture.py`, laptop side): grab the
keyboard with evdev, forward full state over the same protocol, `uinput` a shadow if
needed. Solves every compositor-stolen key and is the missing piece for
"transparently drive another machine". Firmware unchanged — the protocol was designed
for it. Needs root or `input` group + a `udev` rule; documented as opt-in because it
swallows the laptop's keyboard while running.
2. **A second, independent key path for the panic release**: a GPIO or button
double-click that force-releases without waiting for the loop's disarm path, so the
panic release does not depend on the code that just failed.
3. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or
hostile client cannot pump the queue into the overflow-disarm path as a nuisance
attack (currently it disarms: safe, but noisy), and a cap on page fetches now that the
page is served even while a controller is live (each is ~50 KB, and `no-store` means a
determined client will ask for it again and again).
4. **mDNS also on the AP interface**, verified on each chipset — some builds answer only
on the station interface, which is why the docs say "IP if the name fails".
5. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot,
instead of one shared constant in `config.h`.
6. **Report-rate telemetry**: measure the achieved report interval against the host's
poll rate, so the queue-depth choice is measured rather than reasoned.
7. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that
wants to send more has to split the job itself. The flow-control hook already exists —
`typing` in the status frame is the board's own remaining count — so what is missing is
only a client policy that refills as the board drains, plus the `E8` retry that goes
with it. Deliberately not done yet: unbounded buffering on a keyboard bridge buys nothing
and enlarges the window in which text sits in RAM waiting to be typed.
8. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table
for characters; positions live in `web_page.h`/`relayctl.py`. A second layout means a
second character table *and* a second `CODE_TO_USAGE`, with the existing refusal
semantics intact (no entry → `E9`, never a guess). Until then the answer is "set the
target to US", which is one line on the target and zero bytes on the board.
9. **A certificate a browser already trusts.** The channel is encrypted, the fingerprint is
pinnable and `relayctl.py` proves the pin; what is still manual is the trust itself — one
warning accepted per browser profile, or 64 hex digits compared against the board's serial
`cert`. The candidates are a **per-board CA** whose root the operator installs once (real
chain validation, and a file to carry onto every controller that will ever connect) or
**ACME against a real hostname** when the board has one (no warning at all, and it needs
DNS this project does not control). Deliberately absent: a shared CA shipped in this
repository — one key for every board ever built, which is the thing phase 02 moved
generation onto the device to avoid. So are session tickets and resumption, on the blunter
grounds that a board with no PSRAM and one controller is not paying a handshake cost worth
optimising (measured: ~0.35 s per new connection, see
[Measured behaviour](#measured-behaviour)).
---
## Restoring the sibling firmware
```bash
cd ../jiggler && pio run -t upload # after tools/to_bootloader.py, if needed
```
The `jiggler` source is untouched by this project; they are separate PlatformIO projects
for one board, and switching is one flash each way.