phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)** - All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label). - Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no). - Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page. **Tests / lint (100 % green)** - `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`. - Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2). - `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free. **Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓. **Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`. **Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit. **Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
This commit is contained in:
@@ -5,13 +5,13 @@ genuine USB HID input — including Enter and Ctrl+Alt+Del. Capture the pointer
|
||||
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.
|
||||
board itself, over TLS, with a certificate the board made on its own first boot.
|
||||
|
||||
```
|
||||
your laptop target machine
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ browser tab │ WiFi │ │
|
||||
│ keydown/keyup ├────WebSocket──►│ AtomS3 Lite │
|
||||
│ keydown/keyup ├──wss://(TLS)──►│ AtomS3 Lite │
|
||||
│ mousemove │ (token) │ keyrelay │
|
||||
│ (Pointer Lock) │ │ │
|
||||
└──────────────────┘ └────────┬─────────┘
|
||||
@@ -37,6 +37,15 @@ that one is the browser's own guarantee, not something this firmware has to hono
|
||||
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
|
||||
@@ -47,10 +56,16 @@ that one is the browser's own guarantee, not something this firmware has to hono
|
||||
script, nothing it can type later, no gesture it can replay. Pasted text is typed from
|
||||
RAM and forgotten; the serial console has no command that types or clicks anything at
|
||||
all. It is a keyboard and a mouse, not a payload dropper.
|
||||
* **3406 checks** cover the key-state machine, the mouse state, the report queues and the
|
||||
* **4826 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 controller page's own script, the CLI's frames and the target-side verifier
|
||||
itself — all before anything is flashed.
|
||||
codec, the TLS identity's PEM armour, name table and fingerprint renderings, the TLS
|
||||
session's policy layer (handshake deadline, slot admission, library-return table,
|
||||
refusal record and frame ring), the transport view's response head, `:80` answer table,
|
||||
redirect builder and page send cursor, the status frame's fields and the buffer they have
|
||||
to fit in, the controller page's own script (including its refusal to type a passphrase
|
||||
from a page that arrived in the clear), the CLI's frames and its TLS trust decisions
|
||||
(no trust argument → refuse; wrong fingerprint → nothing sent), and the target-side
|
||||
verifier itself — all before anything is flashed.
|
||||
|
||||
---
|
||||
|
||||
@@ -153,8 +168,8 @@ connector it already uses.
|
||||
|
||||
```bash
|
||||
cd keyrelay
|
||||
tests/run_tests.sh # 3406 checks, no hardware needed
|
||||
pio run -e bridge # ~1.1 MB image, ~36% of the 3 MB app slot
|
||||
tests/run_tests.sh # 4826 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
|
||||
```
|
||||
@@ -173,22 +188,107 @@ python3 tools/console.py token "a-long-passphrase-you-pick" # do this one,
|
||||
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 | `http://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP |
|
||||
| its own access point | `http://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed |
|
||||
| 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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -244,7 +344,7 @@ Enforced in this order, each independent of the others:
|
||||
indefinitely with the last key still pressed (bug 10). A board armed by its own button
|
||||
with no controller at all is deliberately left running: it has no controller to go
|
||||
quiet. This backstop is not gated on the link still existing, and must never be — see
|
||||
[Ten bugs](#ten-bugs-this-verification-caught-all-fixed).
|
||||
[Eleven bugs](#eleven-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).
|
||||
@@ -274,6 +374,14 @@ Enforced in this order, each independent of the others:
|
||||
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.
|
||||
@@ -283,7 +391,7 @@ specifically, and it is the browser's, not ours.
|
||||
the target rather than only clearing the board's own idea of what is down. That is the
|
||||
fixed ninth bug: a held key stayed held on the target after silence-disarm, target loss or
|
||||
a reload, because the report that would have released it was never queued (see
|
||||
[Measured behaviour](#ten-bugs-this-verification-caught-all-fixed)).
|
||||
[Measured behaviour](#eleven-bugs-this-verification-caught-all-fixed)).
|
||||
|
||||
---
|
||||
|
||||
@@ -292,29 +400,69 @@ a reload, because the report that would have released it was never queued (see
|
||||
| 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; open a socket; learn that a keyrelay exists | 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 |
|
||||
| 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) |
|
||||
| The owner of the WiFi | join the network, then everything above | — the network *is* the perimeter; see below |
|
||||
| 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; it
|
||||
is not confidentiality for keystrokes. The page is served over plain HTTP and the
|
||||
WebSocket is `ws://`, so on a network you do not control, keystrokes are readable by
|
||||
anyone who can see the traffic and the token is visible at first contact. This is a
|
||||
deliberate v1 trade: WSS would need either a trusted certificate (no way to get one for
|
||||
`keyrelay.local`) or certificate-pinning ceremony in a browser (which the target may not
|
||||
even have). Documented in [Backlog](#backlog) with the two honest fixes.
|
||||
**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).
|
||||
|
||||
Practical rule: **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`).
|
||||
Both are better than either alone: the AP means you never have to put the board on
|
||||
someone else's LAN.
|
||||
**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 is refused with `503` / `E3`
|
||||
rather than queued, swapped or shared. Two controllers typing into one machine is precisely
|
||||
the ambiguity a keyboard must not have, and "who owns this bridge" should never be a tie.
|
||||
The refusal is one slot wide: the board holds at most `kTlsMaxSessions` (2) live
|
||||
connections — the controller's, and one read-only spare — and a third connection is
|
||||
refused outright (a TLS alert record on `:443`, a close on `:80`, both counted), never
|
||||
queued and never half-opened, because a TLS session's I/O buffers are ~32 KB each and
|
||||
"accept first, find no memory later" is not an option on a board with no PSRAM.
|
||||
|
||||
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
|
||||
@@ -442,18 +590,24 @@ 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`, 48 598 bytes — the exact count
|
||||
Served by the board from flash (`src/web_page.h`, 51 626 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.
|
||||
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** | "will my keystrokes land, and where", and whether the pointer is yours right now |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| **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 |
|
||||
@@ -461,7 +615,7 @@ target live.
|
||||
| *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 |
|
||||
| **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 |
|
||||
@@ -502,7 +656,22 @@ and impossible for one malformed field to desynchronise what follows it.
|
||||
| c → d | `K` | keepalive |
|
||||
| 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 |
|
||||
| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex |
|
||||
|
||||
**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
|
||||
@@ -550,6 +719,17 @@ no mouse report can be sent safely.
|
||||
`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.
|
||||
|
||||
**The refusals that are not `E<code>`** live at the HTTP layer, because they happen before a
|
||||
WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, if the socket
|
||||
got as far as an upgrade) when another controller already holds the bridge; `404` on an
|
||||
unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location`
|
||||
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.
|
||||
|
||||
@@ -572,6 +752,7 @@ python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
|
||||
| `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` | |
|
||||
@@ -603,17 +784,65 @@ src/
|
||||
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
|
||||
web_page.h the controller page, in PROGMEM (~48 KB now, with Pointer Lock)
|
||||
control_server.{h,cpp} sockets: HTTP routes, WebSocket lifecycle, auth, heartbeat
|
||||
main.cpp glue: USB identity/HID composite, WiFi, chords, text run,
|
||||
LED, button, watchdogs, serial console
|
||||
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 (~51 KB now, with Pointer Lock
|
||||
and the pinning banner; one #if KR_TLS build marker, equal-length
|
||||
branches, assembled and size-pinned by three separate test readers)
|
||||
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
|
||||
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,
|
||||
@@ -621,24 +850,26 @@ tools/
|
||||
tests/
|
||||
run_tests.sh builds and runs everything (host g++ or a local container)
|
||||
test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp
|
||||
test_ws_codec.cpp
|
||||
test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.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, both firmware builds,
|
||||
the page byte count, repo hygiene — no device, no upload, no network
|
||||
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 three
|
||||
tables that must agree, the build traps, the verification standard
|
||||
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 seven modules marked *pure* 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
|
||||
The nine 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
|
||||
@@ -693,10 +924,13 @@ $ for i in /sys/bus/usb/devices/*:1.*/; do echo "$(basename $i) class=$(cat $i/b
|
||||
7-1:1.2 class=0a sub=00 proto=00 <- CDC-ACM data
|
||||
```
|
||||
|
||||
Two build environments: `bridge` (the real thing, USB-OTG + TinyUSB) and `console`
|
||||
(`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).
|
||||
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)).
|
||||
|
||||
---
|
||||
|
||||
@@ -705,8 +939,9 @@ rebuilding. Arming refuses with `E5` there, on purpose).
|
||||
```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 + `bridge` and `console`
|
||||
# firmware builds + the page's byte count + repo hygiene,
|
||||
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
|
||||
```
|
||||
|
||||
@@ -715,11 +950,14 @@ bash .agents/validate.sh # the gate: both runs above + `bridge` and `cons
|
||||
| `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` | 2327 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, and every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims |
|
||||
| `test_proto` | 2359 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (322 bytes) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs |
|
||||
| `test_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_relayctl` | 160 | 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` |
|
||||
| `test_page` | 84 | 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** |
|
||||
| `test_tls_pem` | 862 | the identity's strings, walked exhaustively (100 % of `src/tls_pem.h`): PEM armour against an RFC 4648 vector and line-by-line at 0/1/45/46/47/48/49 bytes and every 64-character boundary up to 704, the padded final group terminating its own line, a refusal (writing nothing) one byte short of `cap`, the armour read back through CRLF / missing-newline / stray-space shapes and torn or re-labelled blocks rejected rather than decoded, the fingerprint against the published SHA-256 of `abc` and refused at one byte short, **the two renderings of it the board actually prints** (the status frame's 16-character label, and the console's colon pairs -- rejoining the two printed lines reproduces the digest exactly, each line is short enough for the 128-byte CDC transmit ring, and a refusal writes nothing rather than half a fingerprint), **`derContains`** deciding whether a stored certificate really carries the SAN bytes this build writes (one changed byte anywhere, in the needle or in the certificate, stops matching), the name table pinned to `kHostname`/`kApGw`/`kCertCommonName` with the CN asserted to be `kHostname + ".local"`, the SAN extension's 34 DER bytes (cross-checked byte-for-byte against what OpenSSL writes for the same three names), **the OID that turns those bytes into a `subjectAltName`** (`06 03 55 1D 11`, pinned as DER content bytes with an explicit check that no byte of the dotted text survives into what the writer is handed, and the whole 43-byte `SEQUENCE { OID, OCTET STRING }` pinned against the certificate OpenSSL issues for the same three names), and **`derCarriesSan`** answering no to the exact extension a live board produced before that was fixed -- the right value bytes under a mislabelled OID, which `derContains` still calls a match and no client does -- plus one mutated OID byte, value-without-OID, OID-without-value, and a haystack cut one byte short of carrying it, strict dotted-quad parsing, `__DATE__` with its padded day, the leap-day + 10 years clamp, `utcTime` refusing every component UTCTime cannot carry, and `dateTimeText` rendering a validity bound for `cert` -- same component checks, deliberately without UTCTime's 1950..2049 window because reading a date is not the permission to write one |
|
||||
| `test_tls_session` | 118 | the TLS session's policy layer, walked without mbed TLS (100 % of the pure half of `src/tls_session.h`): the handshake/stall deadline in its wrap-safe `int32_t(now - start) > limit` form including the 49.7-day `millis()` rollover and the exact-boundary case, the slot-admission table (controller, spare, refused) with the cap checked before the free slot and *no transport parameter to consult* -- the third connection is refused the same way whichever listener it knocked on -- `kTlsMaxSessions` pinned to the 2 the RAM arithmetic in `config.h` is written against, the library-return table that decides Bytes / Want / Closed / Error (a WANT misread as fatal drops working controllers on slow WiFi; a fatal misread as WANT spins the loop; the three error-code literals it branches on are pinned against the real mbed TLS by `static_assert` inside `tls_session.cpp`, so neither half can drift silently), the 7-byte fatal-alert record byte-for-byte against RFC 5246 -- including the description byte drafted once as `0x40 because forty`, which is the entire reason a wire format gets its own test, never confidence -- and the frame ring's whole-frame-or-nothing push, wrap-span arithmetic and stable head (which is what makes re-handing identical bytes after a refused write possible at all), its default-constructed / unseated state -- cap coerced to 1, nothing accepted, no modulo by zero on the first tick before any socket exists -- and `reseat()`, because `kr::Conn` binds to a different transport between sockets and a byte left behind would be delivered to the new occupant. Task 04 deleted its `WriteQueue` (and with it the queue checks): the transport view queues head and body through the ring plus `kr::FlashSend` instead, so a second streaming cursor here would be exactly the second path this phase exists to remove |
|
||||
| `test_conn` | 268 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 51 626 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 36 chunks / 9 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them |
|
||||
| `test_relayctl` | 271 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T<token>`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested |
|
||||
| `test_page` | 113 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing** |
|
||||
|
||||
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
|
||||
@@ -727,6 +965,18 @@ glob found them in), movement coalescing one report into one line, net displacem
|
||||
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
|
||||
@@ -739,9 +989,36 @@ 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.
|
||||
The HTTP routing and auth-lifecycle code in `control_server.cpp` is the least-tested part
|
||||
of the project; it is deliberately the thinnest layer, but it is not covered by a unit
|
||||
test.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -756,6 +1033,7 @@ 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
|
||||
@@ -825,7 +1103,11 @@ the stream, and give `--seconds` enough room for the pace (`2048` characters at
|
||||
|
||||
_On real hardware: AtomS3 Lite → this laptop (Linux, US layout), board on the same WiFi,
|
||||
firmware 1.0.0, `bridge` environment._ Fill in / re-run after any change to the keystroke
|
||||
path; the numbers below are the shape a correct run has.
|
||||
path; the numbers below are the shape a correct run has. (Those frames predate the TLS
|
||||
identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and `cert`
|
||||
exists and has been confirmed against the live handshake — the pinning rows below — but
|
||||
the status fields themselves and the keystroke path *over a pinned session* need an
|
||||
authenticated controller, and the board's passphrase is the operator's, not this repo's.)
|
||||
|
||||
```
|
||||
$ python3 tools/console.py status
|
||||
@@ -853,12 +1135,18 @@ 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 |
|
||||
| footprint | RAM 26.7 % (87 592 / 327 680); Flash 36.1 % (1 135 293 / 3 145 728) of the 3 MB app slot — the pair `.agents/validate.sh` prints on every run, re-measured 2026-11-09 |
|
||||
| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the phase-02 image is 1 246 145 B (see the footprint row below), so the same math applies to a body ~10 % larger |
|
||||
| footprint | `bridge` with TLS on: **RAM 30.4 % (99 616 / 327 680); Flash 39.6 % (1 246 145 / 3 145 728)** of the 3 MB app slot, `console` 20.5 % (67 224 B) / 37.1 % (1 166 397 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-02 final pass on 2026-11-09. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phase 02 — identity, listener, transport view, the pinning page, the SAN fix — cost **+12 024 B of RAM and +110 852 B of flash**, 3.5 % more of the app slot. The RAM figure has not moved since task 06 even though the final pass added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions |
|
||||
| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) — unchanged by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 160 B of RAM and +100 008 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 |
|
||||
| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page (36 chunks over 9 `loop()` ticks) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start |
|
||||
| **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 /` → 200 and **48 598 bytes** — byte-for-byte the count the gate prints, re-measured 2026-11-09 with one capped `curl`; zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS (the first cold probe can miss — glibc does not cache mDNS, so retry) |
|
||||
| arming from the browser | the page armed the board over `ws://` on first try (observed as `controller=yes armed=1`) |
|
||||
| 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 |
|
||||
@@ -874,7 +1162,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
|
||||
| lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop |
|
||||
| queues | `drop=0 roll=0` throughout; `queue 0 keys pending, 0 held, ok` at rest |
|
||||
|
||||
### Ten bugs this verification caught (all fixed)
|
||||
### Eleven bugs this verification caught (all fixed)
|
||||
|
||||
Worth listing, because each one is a class of bug that HID-over-network tools ship with:
|
||||
|
||||
@@ -963,6 +1251,42 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh
|
||||
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.
|
||||
|
||||
### The hazard nobody warns you about
|
||||
|
||||
**When the target is the machine you are working on, your keystrokes land on your own
|
||||
@@ -984,9 +1308,12 @@ way. So:
|
||||
|
||||
| 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 -s http://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 |
|
||||
| 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 |
|
||||
@@ -999,6 +1326,9 @@ way. So:
|
||||
| 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 |
|
||||
|
||||
@@ -1040,15 +1370,75 @@ A library buys fragmentation, extensions, TLS plumbing and many-client handling
|
||||
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 HTTP/WS without TLS.** See [Security model](#security-model-and-trust-boundaries).
|
||||
The honest options in the backlog are a user-installed CA (works, annoying) or a
|
||||
client-side proxy that terminates TLS with a pinned key. Pretending confidentiality here
|
||||
would be worse than documenting its absence.
|
||||
**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.
|
||||
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.
|
||||
@@ -1067,33 +1457,43 @@ Ordered by what would make the biggest difference first.
|
||||
"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. **TLS with a pinned certificate** (WSS): either an on-board CA the laptop trusts once,
|
||||
or a `localhost`-terminating proxy. Until then: trusted network or the board's AP.
|
||||
3. **A second, independent key path for the panic release**: a GPIO or button
|
||||
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.
|
||||
4. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or
|
||||
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 ~48 KB, and `no-store` means a
|
||||
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).
|
||||
5. **mDNS also on the AP interface**, verified on each chipset — some builds answer only
|
||||
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".
|
||||
6. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot,
|
||||
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`.
|
||||
7. **Report-rate telemetry**: measure the achieved report interval against the host's
|
||||
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.
|
||||
8. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that
|
||||
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.
|
||||
9. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table
|
||||
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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user