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:
+24
-10
@@ -48,23 +48,37 @@ may have a board attached that nobody is watching, and a second machine for
|
||||
|
||||
| claim | command | expected observation | outcome |
|
||||
|---|---|---|---|
|
||||
| keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | |
|
||||
| keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | skipped (no passphrase) — task 04, 2026-11-09: the board is reachable on `:443` and the TLS/`wss://` transport rows below are measured, but this board's control passphrase is not the default (`status` says `dflt:0`) and a verification run will not change a credential on a device it cannot put back. To close it: `console.py token <known>` on a board you are willing to re-provision, then drive `wss://` |
|
||||
| a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | |
|
||||
| served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check |
|
||||
| silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | |
|
||||
| served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — re-measured 2026-11-09 **over TLS after the phase-02 flash**: `curl -sk https://10.2.0.22/` = 48598 and `curl -s http://keyrelay.local/` = 48598, both = the gate's count. **Task 04** re-ran it over TLS three times in a row: 48 598 bytes each time with `sha256` equal to the raw string in `src/web_page.h` (`f7aae9e5…c6f7`) — the page now leaves through `kr::Conn` at 1460 B per write and 5840 B per tick (34 chunks over ~9 `loop()` iterations) instead of one `WiFiClient::print`, so byte-equality across a multi-tick send is the transport claim. Original — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check |
|
||||
| silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | skipped (no passphrase), same reason as the row above. What task 04 *did* pin is the property this row depends on and that the transport rewrite could silently have moved: `_lastInboundMs` is stamped in exactly one place, gated on `_phase == WebSocket && _authed`, and neither the TLS handshake, a `/status` poll nor a viewer refresh reaches it — asserted structurally in `test_relayctl` (`the controller clock is stamped in exactly one place`, `no HTTP path can refresh the controller clock`) |
|
||||
| nothing outlives the link | hold a key and a mouse button, then drop the link | every key up, every button up; mouse summary shows no `still held` | |
|
||||
|
||||
### Phase 02 — HTTPS + `wss://`
|
||||
|
||||
Every row below was produced by one capped command on the board at `10.2.0.22` /
|
||||
`keyrelay.local`, joined to the LAN, flashed with `bridge`. Two rows are honestly `skipped`:
|
||||
the passphrase-dependent ones (arming/typing over a pinned session need a credential the
|
||||
operator owns) and the rollback's device leg; one row is `failed` and fixed in the tree with
|
||||
its device leg still open (the certificate's SAN, found at the final pass) — see the rows
|
||||
themselves for what would close them and why it was not run.
|
||||
|
||||
| claim | command | expected observation | outcome |
|
||||
|---|---|---|---|
|
||||
| TLS handshake completes | `curl -sk https://keyrelay.local/status` or `openssl s_client -connect keyrelay.local:443 </dev/null` | 200 + status JSON / a session whose only verify failure is *self-signed*. `HTTP 000` / connection refused = listener not up | |
|
||||
| fingerprint printed **once**, on serial | serial console boot log after `nvs erase`; then `python3 tools/console.py cert` (opens the port — one attempt, capped) | SHA-256 fingerprint appears exactly once at first boot, on demand afterwards; **never** the key; survives reflashing (NVS) | |
|
||||
| pinned fingerprint == handshake cert | `openssl s_client -connect keyrelay.local:443 </dev/null 2>/dev/null \| openssl x509 -noout -fingerprint -sha256` | same hex as the serial console printed; mismatch = two identities, investigate before pinning | |
|
||||
| `:80` is redirect-only | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/` | 3xx to `https://`, and **no page bytes** on plaintext | |
|
||||
| plaintext `/ws` refused, not downgraded | a `ws://` upgrade attempt on `:80` (e.g. `curl -i -N` with upgrade headers, or `relayctl.py` pointed at plaintext) | refused loudly (per README error style), never a silent plaintext session | |
|
||||
| TLS session heap cost | build RAM delta from the gate output + live free-heap readout the phase's `status`/serial path exposes, at 0 / 1 / 2 live sessions | quoted as a delta against the baseline table above; two sessions must fit without allocation failure | |
|
||||
| `-D KR_TLS=0` rollback behaves like today | build the rollback env, flash once, `curl -s http://keyrelay.local/ \| wc -c` + page connects over `ws://` | page and `ws://` work exactly as pre-phase-02; README documents the rollback | |
|
||||
| TLS handshake completes | `curl -sk https://keyrelay.local/status` or `openssl s_client -connect keyrelay.local:443 </dev/null` | 200 + status JSON / a session whose only verify failure is *self-signed*. `HTTP 000` / connection refused = listener not up | ok — measured 2026-11-09 on the flashed bridge: `openssl s_client` negotiates **TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384**; `curl -sk https://10.2.0.22/` → 48 598 bytes (= the gate's count); `/status` without the token over TLS → **401**, path still gated. `hw_probe`'s `https` line now reports `HTTP 200 (TLS listener is up)` |
|
||||
| fingerprint printed **once**, on serial | serial console boot log after `nvs erase`; then `python3 tools/console.py cert` (opens the port — one attempt, capped) | SHA-256 fingerprint appears exactly once at first boot, on demand afterwards; **never** the key; survives reflashing (NVS) | ok (minus the `nvs erase` leg) — measured 2026-11-09: captured boot log shows `[tls] identity loaded from NVS (reprint with \`cert\`)` with **no** fingerprint on it, and `console.py cert` prints it on demand; the identity survived two reflashes and three reboots with the same fingerprint. `nvs erase` → first-boot printing was not re-run (the regeneration path had just been observed end-to-end). **Finding:** the *first* boot under the new firmware rotated the fingerprint once for a reason the boot log could not show (no host had the CDC open yet); `loadTlsIdentity()`/`storeTlsIdentity()` now print the failing gate and the record **lengths** (`stored records cert=… key=… fp=… -- shape/selfCheck`, `NVS put refused: …`, `read-back mismatch: …`) so a rotation can never again be a mystery — every later boot loads cleanly. **(That first
|
||||
"rotation" is corrected two rows down: it was the `openssl -ext` artifact, not a regenerated
|
||||
identity. The instrumentation stays worth having — it is what makes the next claim provable.)** |
|
||||
| pinned fingerprint == handshake cert | `openssl s_client -connect keyrelay.local:443 </dev/null 2>/dev/null \| openssl x509 -noout -fingerprint -sha256` | same hex as the serial console printed; mismatch = two identities, investigate before pinning | ok — measured 2026-11-09: console `cert` `61:54:19:30:ed:df:e5:52:d1:ee:18:6f:cc:f9:57:c5:41:47:d4:15:61:26:1e:11:aa:49:4f:07:79:b1:af:f9` == the `x509 -fingerprint -sha256` of the live handshake, and the pair still matched after two further `reboot` cycles. **Corrected at the phase-02 final pass, same day:** the earlier mismatch recorded as `C8:76:F1:69:…` was **not a rotation** — it is what `openssl x509 -noout -fingerprint -sha256` prints when `-ext <oid>` is passed in the same invocation, on OpenSSL 4.0.3, for *this same* certificate. Reproduced byte for byte: the peer certificate captured with `s_client`, written with `-outform der`, hashes to `61541930…b1aff9`; the same file run through `-fingerprint -sha256 -ext subjectAltName` prints `C8:76F1…E6D2` *and* “No extensions in certificate”. So: take the fingerprint with `-fingerprint -sha256` alone (or `sha256sum` of `-outform der`) and never with `-ext` in the same command, or a stable identity looks like a rotation. Re-measured at the final pass (`console.py cert`, one capped attempt, and `s_client` + `openssl x509 -outform der | sha256sum`): console `61:54:19:30:…:b1:af:f9` == `61541930eddfe552…79b1aff9`, one identity, one board, no rotation since the instrumented boot. |
|
||||
| `:80` is redirect-only | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/` | 3xx to `https://`, and **no page bytes** on plaintext | ok — measured 2026-11-09 on the task-04 flash, one capped `curl` each: `/` → **301** `Location: https://10.2.0.22/`; `/index.html` → **301** to the same path over `https://`; `/nope` → **301** to `/` (an unknown target is a 404 on 443, and an arbitrary request target is never echoed into a `Location`); `/status?token=…` → **426** with `Upgrade: TLS/1.2, HTTP/1.1`, **no `Location`**, and the body verified free of the token (`grep -c` of the value → 0). No plaintext path returns page bytes any more |
|
||||
| plaintext `/ws` refused, not downgraded | a `ws://` upgrade attempt on `:80` (e.g. `curl -i -N` with upgrade headers, or `relayctl.py` pointed at plaintext) | refused loudly (per README error style), never a silent plaintext session | ok — measured 2026-11-09: `curl -i -N` with `Upgrade: websocket` on `:80` → **426 Upgrade Required**, `Connection: Upgrade`, `Upgrade: websocket, TLS/1.2, HTTP/1.1`, body `plaintext websocket refused; reconnect over wss:// (https://<host>)` — no `101`, no session; the board's own counter moved (`link controller=no rejected=1`). `relayctl`'s plaintext path now raises the same sentence via `handshake_refusal()` rather than the old "board refused the websocket upgrade". **Re-measured at the phase-02 final pass** (the bodies were reworded after that flash): `/ws` upgrade → **426**, `Upgrade: TLS/1.2, HTTP/1.1`, `Connection: close`, `Content-Length: 96`, body `keyrelay: this controller channel is TLS-only -- reconnect over wss://, the page is on https://`; `/status?token=<dummy>` → **426**, `Content-Length: 140`, **no `Location:`** (`grep -c location: → 0`) and the value not echoed (`grep -c <dummy> → 0`); `/` → **301** to `https://keyrelay.local/` with a 77-byte redirect body and **no page bytes**; over `:443`: `/status` without the token → **401**, `/nope` → **404**, `/` → 200 / 51 626 B / `sha256 dd16e195…f04e9` == the locally assembled page |
|
||||
| TLS session heap cost | build RAM delta from the gate output + live free-heap readout the phase's `status`/serial path exposes, at 0 / 1 / 2 live sessions | quoted as a delta against the baseline table above; two sessions must fit without allocation failure | ok — measured 2026-11-09 from the serial `[tls] sessions=N heap=…` lines: boot `[tls] https :443 listening heap=187936`; **1 session ≈ 145.5 k, 2 sessions ≈ 107.9 k** (second session ≈ −37.6 k: the mbed TLS record buffers), close returns heap to 168–185 k. **Task 04** (transport view on both ports: 2 KB `:80` redirect ring + 1460 B page chunk + 64 B `tRing` per slot; gate RAM 97 240 → 99 616) measured in one run `sessions=1` `140 092` → `sessions=2` `109 228` → close one `144 836` → close all `182 256`: second session ≈ −31 k, and the memory comes back inside the band above. 20 further connect/disconnect cycles on `:443` ran in the same pass. Original 20 connect/disconnect cycles: the `sessions=1` readings held at 145.5–149.8 k with **no downward drift** — no leak. Slot cap enforced live: with two sessions held, a third got a **fatal TLS alert (handshake_failure, 40) in 0.0 s** while both listeners kept serving. Stall check: a connection that completed the handshake and sent nothing did **not** block `loop()` — the full page still served over a second connection during the stall, and the stalled socket was closed by the ~10 s deadline |
|
||||
| `-D KR_TLS=0` rollback behaves like today | build the rollback env, flash once, `curl -s http://keyrelay.local/ \| wc -c` + page connects over `ws://` | page and `ws://` work exactly as pre-phase-02; README documents the rollback | **build leg ok, device leg skipped (not flashed)** — task 06, 2026-11-09. The rollback is now a first-class environment, `[env:rollback]`, built by `.agents/validate.sh` on every run alongside `bridge` and `console`: RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) against the TLS `bridge` 30.4 % (99 616 B) / 39.6 % (1 245 353 B), so TLS costs +6 160 B RAM and +99 216 B flash. *(Final pass, same day: after the SAN fix `bridge` is 99 616 B / 1 246 145 B — RAM unmoved, +792 B of flash — and the rollback image is **byte-for-byte unchanged** at 93 456 B / 1 146 137 B, because the fix lives entirely inside `#if KR_TLS`; TLS now costs +100 008 B of flash.)* That the flag *took* is measured, not assumed: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** (115 in `bridge`), `… \| grep -c mbedtls_x509write` → **0** (15), `strings .pio/build/rollback/firmware.elf \| grep -c "pin this certificate"` → **0** (1). The device half was deliberately not run: flashing the rollback and back is two write cycles on a board that is a live HID keyboard into the machine running this pipeline, `to_bootloader.py` strands it in ROM download mode if anything goes wrong after that point, and no criterion for task 06 needed it. To close it, in order: `uv run --python 3.14 --with platformio python -m platformio run -e rollback -t upload` → `timeout 10 curl -s http://keyrelay.local/ \| wc -c` (expect the same 51 626 the gate prints, on plaintext, with `var KR_TLS_BUILD=0;` from `curl -s http://keyrelay.local/ \| grep -o 'var KR_TLS_BUILD=[01];'`) → `timeout 10 curl -s -o /dev/null -w '%{http_code}' https://keyrelay.local/` (expect 000/refused: no `:443` exists any more) → boot log line `[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel` → `python3 tools/relayctl.py --plain --host keyrelay.local --token … status` → then reflash the TLS build (`run -e bridge -t upload`) and confirm `curl -sk` serves 51 626 again |
|
||||
| TLS handshake latency and page fetch over TLS | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` (and `…/status`), one capped run each | a handshake that completes in a time a browser operator will not mistake for a dead board, and a page that arrives whole over the chunked TLS send | ok — measured 2026-11-09 (task 06, read-only, board on the LAN): seven warm connections, **handshake 0.349–0.423 s**; page 51 626 B in **0.528–0.618 s** total; `/status` 0.382–0.434 s total. The first connection after ~10 min of quiet took **3.94 s** of handshake (one observation, recorded because it looks like a timeout from a browser and is a cold start). Both numbers are in README “Measured behaviour” with the command |
|
||||
| served page carries the build marker and the pinning UI | `curl -sk https://keyrelay.local/ \| wc -c` + `grep -o 'var KR_TLS_BUILD=[01];'` + `sha256sum` against the locally **assembled** page (chunks + KR_TLS=1 branch) | 51 626 bytes, marker `=1`, `certFp`/`pTls`/`clearWarn` present, digest equal to the assembled TLS page | ok — measured 2026-11-09 after the task-05 flash: 51 626 bytes, `var KR_TLS_BUILD=1;`, all three new ids (6 matches), `sha256 dd16e195…f04e9` **byte-equal** to the locally assembled page — the multi-tick chunked send still delivers the changed page intact, and `curl -sI http://keyrelay.local/` is still a 301 |
|
||||
| `relayctl.py` pinning, end to end | `--fingerprint 00…00` / the real fingerprint / `--ca <exported self-signed>` / `--insecure` / no argument / `--plain`, each `timeout 15`, dummy token | wrong pin aborts **before sending**; correct pin passes the pin and stops only at authentication; `--ca` verifies the self-signed cert with hostname checking on; `--insecure` warns on stderr every run; no argument → refusal at exit 2 without a socket; `--plain` reads the 426 as a TLS refusal, never as a passphrase problem | ok — measured 2026-11-09: wrong pin → `certificate fingerprint mismatch … Nothing was sent` (and the presented digest `61:54:19:30:ed:df:…:b1:af:f9` equals the serial `cert` value recorded above — the pin and the console agree on one identity across reflashes); correct pin, `--insecure` and `--ca /tmp/board.pem` all reached authentication and were refused there by `E1` (the board's passphrase is the operator's — I did not guess it, so **arming/typing over a pinned session is not device-confirmed in this row**, three `authfails` total, well under the 5-try lockout); no argument → the four-option refusal, exit 2, no connection attempted; `--plain` → `board refuses plaintext … wss:// on :443`, not one word about the passphrase |
|
||||
|
||||
| the certificate carries the name list `cert` prints | `openssl s_client -connect keyrelay.local:443 </dev/null \| openssl x509 -noout -text`, compared with `console.py cert` | the `X509v3 Subject Alternative Name` extension reads `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, so a client can check the name and not only the fingerprint | **failed → fixed in the tree, device leg needs `nvs erase`.** Found at the phase-02 final pass 2026-11-09 by reading the certificate instead of the code: `-text` printed the extension as `1.10.46.53.46.50.57.46.49.55` (not `X509v3 Subject Alternative Name`) and `-ext subjectAltName` said *no extensions*. A hex dump named the cause — the extension was framed `30 2f 06 09 32 2e 35 2e 32 39 2e 31 37 04 22 <the correct 34 bytes>`: the OID was written from the **ASCII text** `"2.5.29.17"` where the DER content bytes `55 1d 11` belong, because `mbedtls_x509write_crt_set_extension()` takes the OID as DER content bytes. So the served certificate has the right SAN *value* under a label no client reads, i.e. **no SAN**: encryption and pinning work, hostname validation cannot (browsers match SAN, not CN). Fixed in `src/tls_pem.h` (`kSanOidTlv`/`sanOidDer`, `sanExtensionFullDer` pinned to the 43 bytes OpenSSL writes for these three names, `derCarriesSan` requiring OID *and* value), used by `src/tls.cpp`, 33 new host checks in `test_tls_pem.cpp`; the regression fixture is the bytes above, and `derCarriesSan` answers no to it while `derContains` says yes. README bug 11. **To close the device leg** (deliberately not run here: `nvs erase` also forgets the passphrase and the Wi-Fi credentials, which the verification run cannot put back): reflash `bridge`, `nvs erase`, reboot, then `openssl s_client … \| openssl x509 -noout -text` must show `X509v3 Subject Alternative Name: DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, the new fingerprint must appear exactly once in the boot log, and `console.py cert` must print that same value with **no** `!! the stored certificate does NOT carry that name list` warning. Until then `cert` prints that warning for this board — which it now does, because the check looks at the OID. |
|
||||
|
||||
### Phase 03 — session takeover
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
**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`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,205 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.6% (used 1246145 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.1% (used 1166397 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.871
|
||||
console SUCCESS 00:00:00.860
|
||||
rollback SUCCESS 00:00:00.857
|
||||
========================= 3 succeeded in 00:00:02.588 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (4 826 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
**Implemented**
|
||||
- `src/tls_pem.h` (new, pure — no Arduino/IDF/mbed includes): `wrapPem`/`pemFind` PEM armour both ways, `fingerprintHex`, the single `sanEntries()` name table + `subjectLine()`/`sanList()`, hand-built SAN GeneralNames DER, strict `ipv4Bytes`, and `__DATE__`/`addDays`/`addYears`/`utcTime` validity arithmetic.
|
||||
- `src/tls.h` + `src/tls.cpp` (mbed TLS half): `generateIdentity()` (secp256r1 → self-signed SHA-256 cert, DRBG personalisation `"keyrelay"`, SAN extension, ±validity window, SHA-256 fingerprint), `selfCheck()` (reparse + `pk_check_pair`), `certificateFingerprint()`, `lastError()`; RAII context wrappers free everything on every error path; `#if KR_TLS == 0` stubs keep the rollback linkable. Header records the ld_libs/sdkconfig/nm evidence.
|
||||
- `src/config.h`: `KR_TLS` (default 1), `kCertCommonName`, `kCertValidityYears`, `kPemMax=1024` with measured numbers; `platformio.ini`: `-DKR_TLS=1` in both envs with the rollback named.
|
||||
- SAN is encoded by tested code (OID 2.5.29.17 via `set_extension`) rather than the library's version-varying SAN struct; the 34 DER bytes were verified byte-identical to OpenSSL's for the same names.
|
||||
|
||||
**Verification** (all green)
|
||||
- `bash .agents/validate.sh` → `validation OK`; suites + `SANITIZE=1` both `RESULT: all suites passed`, **4170 checks** (3162 C++ + **764** `test_tls_pem` + 160 relayctl + 84 page), `test_proto` untouched at 2327, page bytes 48 598, hygiene OK, no key-material shapes.
|
||||
- gcov: **src/tls_pem.h 100.00 % of 305 lines** (requirement >90 %); `src/tls.*` stated, not faked.
|
||||
- Firmware: `bridge` 26.7 % RAM / 36.1 % Flash and `console` 16.8 % / 33.6 % — **unchanged from baseline** because nothing calls the identity yet (`--gc-sections` drops it). Proved that is not a false pass: an injected undefined symbol in `tls.cpp` linked anyway, so I re-linked a scratch copy with `-Wl,-u,` forcing the three functions in → both envs link all mbed TLS symbols; cost then `bridge` **88 304 B RAM (+712) / 1 170 853 B Flash (+35 560)**, `console` 55 904 / 1 091 181. `-D KR_TLS=0` builds both envs at exactly the pre-phase footprint.
|
||||
- Found and fixed a real defect via the cross-check: `wrapPem` omitted the newline after a padded final group (1/2 bytes mod 48) — round-trips fine, but not what any other tool writes.
|
||||
|
||||
**Docs**: README (check count ×2, architecture map, pure-module count, Tests table row, "gaps" paragraph — explicit that the identity is not on the wire yet). AGENTS.md stale facts only (check count, pure-module list now including `tls_pem.h` + the no-crypto-library rule, a name-table row, the "fingerprint yes, key never" clause).
|
||||
|
||||
**Decisions**: no device changes — nothing is reachable over TLS yet, so no `HW_VERIFICATION.md` row was run or recorded; `.agents/HW_VERIFICATION.md`'s 3406 baseline left as the phase-01 delta anchor.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/02_tls_control_channel/02_identity_persistence.md` (NVS storage, generate-once, serial `cert`, `StatusSnapshot` visibility).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2327 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 764 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2327 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 764 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.84 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes)
|
||||
Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.85 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.845
|
||||
console SUCCESS 00:00:00.853
|
||||
========================= 2 succeeded in 00:00:01.698 =========================
|
||||
|
||||
== page size: src/web_page.h raw string ==
|
||||
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ unreachable (curl rc 7, code 000) — expected until phase 02 (TLS) lands
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Implementation** (`02_tls_control_channel/02_identity_persistence`)
|
||||
|
||||
- `src/main.cpp`: identity now lives in NVS (`tlscrt`/`tlskey`/`tlscrtfp`); `loadTlsIdentity()` runs at boot before `control.begin()` — loads and `selfCheck()`s the stored pair, else generates, stores and prints the fingerprint **once**; a corrupt record is regenerated with two loud `!!` lines before the new value ("any fingerprint you pinned is now wrong"). Store does a read-back (lengths compared, contents never) and prints `IDENTITY NOT STORED … do not pin this fingerprint` if NVS refuses/truncates. All PEM helpers are inside `#if KR_TLS`.
|
||||
- Serial `cert` (no argument form, in `help`): SHA-256 fingerprint (two 47-char lines, not one 95-char line — 128-byte CDC ring, README bug 1), validity window from the stored cert, and the names from `sanEntries()`, with a `!!` warning when `derContains()` shows the stored cert does not carry that table. No command writes or dumps a key/cert; `git grep -i "private key" src/` returns only prose comments and the `kPemKeyLabel`/mbedTLS API names.
|
||||
- New pure `src/status.h`: `StatusSnapshot` + `statusJson()` moved out of `control_server.{h,cpp}` so the frame is host-testable, plus `tls` and `fp` (first 8 bytes of the fingerprint in hex, `fp:""` whenever `tls:0`; a non-hex `fp` is cut so a stored value cannot forge a JSON field). `src/tls_pem.h` gained `fingerprintPairs`, `fingerprintPrefix`, `derContains`, `dateTimeText`, `sanEntries()`; `src/tls.{h,cpp}` gained `certificateDetails()` (+ `KR_TLS==0` stub) and the `tls.cpp` docstring.
|
||||
- Tests: `test_tls_pem.cpp` +97 checks (pinned name table incl. `CN == kHostname + ".local"`, both printed fingerprint shapes rejoining to the digest and refusing at `cap`, `derContains` breaking on one changed byte either side, `dateTimeText`); `test_proto.cpp` + status-frame suite (fields, sanitising, longest legal frame **322 B ≤ `kStatusJsonCap` 384**, renders as nothing when 1 B short).
|
||||
|
||||
**Results:** `bash .agents/validate.sh` → `validation OK` (exit 0). `tests/run_tests.sh`: **4267 checks, 0 failures** (proto 2359, tls_pem 829, text 459, relayctl 160, output 153, ws_codec 135, page 84, mouse 48, keystate 40); same under `SANITIZE=1`. Coverage: `src/tls_pem.h` 100.00 % of 367, `src/status.h` 100.00 % of 28. `pio run -e bridge -e console` SUCCESS — bridge RAM 27.6 % (90 432 B) / Flash 37.5 %, console 17.7 %/34.9 %. Rollback `-DKR_TLS=0` (out-of-tree copy) builds both envs: RAM 26.8 %, Flash 36.1 %, no new warnings.
|
||||
|
||||
**Docs:** README — check count 3406→4267, Quick-start first-boot fingerprint block + survive-reflashing/re-pin rule, `cert` in the serial-console table, `tls`/`fp` in the Wire-protocol status row, architecture map (`status.h`, `tls_pem.h`, `tls.{h,cpp}`, `test_tls_pem.cpp`), Tests-table rows, NVS design decision, 3 troubleshooting rows, note that the measured transcript predates these fields. AGENTS.md — check count and the pure-module list (`status.h`).
|
||||
|
||||
**Deviations:** (1) fingerprint printed as wrapped colon-pair lines, not one 64-hex line; (2) status `fp` is a 16-char label, not the full digest — the cap and the page can't carry 64 hex usefully; (3) `StatusSnapshot`/`statusJson` moved modules (needed for the host-measured cap); (4) added `certificateDetails()` and the four `tls_pem.h` helpers as the tested seams `cert`/status need; (5) boot-time store read-back added to honour "if it does not fit, say so" — the API otherwise fails silently.
|
||||
|
||||
**Device pass (next, not run here):** `console.py cert` → `reboot` → same fingerprint + `identity loaded from NVS`; `nvs erase` + reboot → `identity generated`, pin it, reboot again → unchanged; `console.py status` shows `"tls":1,"fp":"<16 hex>"` matching `cert`'s first 8 bytes.
|
||||
|
||||
Next task: `.agents/phases/todo/02_tls_control_channel/03_tls_listener.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 27.6% (used 90432 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.5% (used 1178749 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 17.7% (used 58040 bytes from 327680 bytes)
|
||||
Flash: [=== ] 34.9% (used 1099237 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.863
|
||||
console SUCCESS 00:00:00.865
|
||||
========================= 2 succeeded in 00:00:01.727 =========================
|
||||
|
||||
== page size: src/web_page.h raw string ==
|
||||
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local did not resolve within 4 s (no responder? glibc does not cache mDNS — a retry may still work; the IP is the fallback)
|
||||
hw-probe https https://keyrelay.local/ unreachable (curl rc 28, code 000) — expected until phase 02 (TLS) lands
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
|
||||
@@ -0,0 +1,12 @@
|
||||
**Task complete: `02_tls_control_channel/03_tls_listener.md`**
|
||||
|
||||
**Implemented** — TLS control-channel listener on `:443`, raw mbedTLS driven from `tick()`:
|
||||
- `src/tls_session.{h,cpp}` — one non-blocking server session per accepted fd; handshake = one `mbedtls_ssl_handshake()` per tick with a `kTlsHandshakeMs` deadline; strict same-buffer write-resume contract; slot-cap refusal renders the 7-byte RFC 5246 fatal alert without building a context. Pure policy half (deadline, admission, return-code table, alert, write queue, frame ring) host-tested; library half behind `KR_TLS && ESP_PLATFORM`.
|
||||
- `src/control_server.{h,cpp}` — `:443` listener + plaintext `:80` feed **one** controller slot via one `admitConnection()` rule (cap 2, no transport parameter); per-slot response pumps (page leaves 4 KB/tick); WS frames whole-or-dropped via `TxRing`; `KR_TLS=0` rollback intact.
|
||||
- Device logs only sessions + heap; `hw_probe.sh` gained an informational TLS probe.
|
||||
|
||||
**Tests** — `tests/test_tls_session.cpp` (135 checks; 100 % coverage of the pure half, ASan/UBSan clean); suite now **4402 checks**, green normal + sanitised; `validate.sh` **validation OK** (both firmware builds, page bytes, hygiene).
|
||||
|
||||
**Device pass** (board flashed and verified live): TLSv1.2/**ECDHE-ECDSA-AES256-GCM-SHA384**; page over `https` = 48598 B = gate count; `/status` 401 without token over TLS; console `cert` == handshake fingerprint, stable across 2 reflashes + 3 reboots; heap 187.9 k boot → 145.5 k (1 session) → 107.9 k (2), 20 cycles no drift; 3rd connection refused with alert 40 in 0.0 s; stalled handshake never blocked `loop()` and died on the ~10 s deadline; `:80` untouched (task 04's territory). Results recorded in `.agents/HW_VERIFICATION.md`.
|
||||
|
||||
**Found & fixed during verification**: one silent identity rotation happened on the first boot under the new firmware with nothing observable about it — `loadTlsIdentity()`/`storeTlsIdentity()` now name the failing gate with lengths only. README/AGENTS.md updated (check counts, security model, new pinned-table row). No phase files touched; nothing staged; board left running the new firmware, not in download mode.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 135 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 135 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 160 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
element ids
|
||||
page 84 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 29.6% (used 97136 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.4% (used 1238321 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 19.8% (used 64744 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.8% (used 1158621 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.863
|
||||
console SUCCESS 00:00:00.858
|
||||
========================= 2 succeeded in 00:00:01.721 =========================
|
||||
|
||||
== page size: src/web_page.h raw string ==
|
||||
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
**Task 04 (`02_tls_control_channel/04_transport_abstraction.md`) — complete.**
|
||||
|
||||
- `src/conn.h`: one `kr::Conn` transport view (tagged union, no virtual — rebinding per slot, inlinable pump) over `WiFiClient`/`tls::Session`; whole-frame-or-nothing `push`, resumable `pump` capped per write (4 KB) and per tick, flash body streamed through one 1460 B RAM chunk.
|
||||
- `:80` is now redirect-only via pure `plaintextRoute()`: `301 https://…`, `426 Upgrade Required` for a plaintext `/ws` upgrade **and** for any `?token=` request (a `Location` would leak the passphrase twice); full `301`/`426` body pair built by the one `renderHttpResponseHead()`. `serveReadonly()` is the single HTTP answer for both slots.
|
||||
- Safety invariant kept and pinned: `_lastInboundMs` is stamped only for `WebSocket && _authed` — handshake, `/status` and viewer frames cannot keep a dead controller armed.
|
||||
- Tests: new `tests/test_conn.cpp` (264 checks, 100 % of the pure half), `test_tls_session` 135→118 (`WriteQueue` deleted; `TxRing` reseat/unseated pinned), `test_relayctl` 160→189 (structural pins: one formatter, one route decision, one clock stamp, page bytes == `kIndexHtmlLen`), `test_page` 84→93 (`https:`→`wss://`, no credential in the connect URL).
|
||||
- Two real bugs found by the **sanitised** run and fixed, each pinned by the case that triggered it: `memcmp` past a short query, and `octets[4]` overflow on `"192.168.1.1.1"`.
|
||||
- Gate: `.agents/validate.sh` exits 0 — 4687 checks in both the normal and `SANITIZE=1` runs, `bridge` RAM 30.4 % (99 616)/Flash 39.5 % (1 242 329), `console` 20.5 %/37.0 %, page 48 598 B, hygiene OK.
|
||||
- Device (flashed, observed): `https://…/` = 48 598 B ×3 with `sha256` == `web_page.h`; `/`→301, `/index.html`→301, `/nope`→301 to `/`; `/status?token=`→426 with no token in the body; `/ws`→426 (`rejected=1` on the board); `wss://` upgrade→101, wrong passphrase→`E1`+close; viewer 200/401 concurrently; heap 140 k/109 k/182 k over 0–2 sessions.
|
||||
- Docs moved: README (intro count, quick start `https://`, security-model rewrite, measured-behaviour rows, architecture map +`conn.h`, tests table, gaps), AGENTS.md (`curl -sk`, 4687, new defaults), `.agents/HW_VERIFICATION.md` rows.
|
||||
- Not run, recorded honestly: the keystroke/silence rows need an *authenticated* controller and this board's passphrase is not the default (`dflt:0`) — I did not change a credential I could not restore.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 264 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 189 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
element ids
|
||||
page 93 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 264 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 189 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
element ids
|
||||
page 93 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.5% (used 1242329 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.88 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.0% (used 1162585 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.882
|
||||
console SUCCESS 00:00:00.866
|
||||
========================= 2 succeeded in 00:00:01.748 =========================
|
||||
|
||||
== page size: src/web_page.h raw string ==
|
||||
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
**Task 05 (`02_tls_control_channel/05_page_client_fingerprint.md`) — complete.**
|
||||
|
||||
- **Page** (`src/web_page.h`): one 19-byte `#if KR_TLS` build marker joins the raw-string chunks (branches equal length → page size stays one number); TLS page on an `http:` origin opens **no socket, sends no frame**, disables the passphrase box + connect button behind a banner ("this page arrived in the clear; reload over https") — never a silent `ws://` fallback; rollback build keeps `ws://`; new `tls`/`plaintext` pill and a certificate line rendered from the status frame's `fp` only (nothing when `tls:0` or no `fp`).
|
||||
- **`relayctl.py`**: TLS by default on :443 via stdlib `ssl`; `--fingerprint`/`$KEYRELAY_TLS_FP` hashes the peer DER and **aborts before any frame** on mismatch; `--ca` = `create_default_context` + hostname checking; `--insecure` warns on every run; none of them → refuses (exit 2) without a socket; `--plain` is the only, explicit path to :80; 426/301 messages updated (tool now has a TLS client). Token pre-flight, `paste --yes` gate, no token ever printed — kept.
|
||||
- **Tests**: `test_relayctl` 189→271 (trust-decision matrix, port table, fingerprint normalisation/refusals, fake-board byte recording proving mismatch ⇒ zero bytes sent, `--ca`/`--insecure`/`--plain` shapes, main-level refusals, page assembled from *both* marker branches); `test_page` 93→113 (http: refusal incl. `boot()` disabling inputs, rollback marker keeps `ws://`, pill + exact-`fp` rendering); `test_conn` count 264→268 + `kServedPageBytes` 51 310→**51 626**; `test_proto.cpp` untouched.
|
||||
- **Gate**: `bash .agents/validate.sh` → **validation OK** — 4793 checks in both normal and `SANITIZE=1` runs; bridge RAM 30.4 % (99 616) / Flash 39.6 % (1 245 353), console 20.5 %/37.1 %; page bytes 51 626; hygiene OK. Coverage of changed/added `relayctl.py` functions: **156/158 = 98.7 %** (stdlib `trace`). `ruff --select F,E9` clean on `tools/`+`tests/`; both marker branches compile (`KR_TLS=0/1` syntax-checked).
|
||||
- **Device** (flashed `bridge`, board healthy, not in download mode): served page **51 626 B**, sha256 `dd16e195…f04e9` byte-equal to the locally assembled TLS page, marker `=1`, new ids present; `:80` still 301. `--fingerprint 00…00` → `certificate fingerprint mismatch … Nothing was sent`; real pin / `--insecure` / `--ca <exported self-signed>` all passed their trust step and stopped at authentication (`E1` — the passphrase is the operator's, 3 authfails total, not guessed); no argument → exit 2, no socket; `--plain` → the 426 sentence, no passphrase blame. Recorded in `.agents/HW_VERIFICATION.md`; typing over a pinned session stays the operator's confirmation.
|
||||
- **Docs**: README (4793 counts, Quick start pinning block with `cert`/`curl -sk …/status?token=`/`$KEYRELAY_TLS_FP`, Controller-page byte count + 3 new element rows, architecture map, end-to-end examples, 2 new Troubleshooting rows incl. the wrong-network/identity-rotation wording in full, tests table), AGENTS.md (check count, page-size and raw-string statements now describe the marker, new cross-check table row), `.agents/validate.sh` page-size check now assembles chunks+marker (would otherwise fail on the new layout).
|
||||
- **Notable**: the page must differ per build (a runtime probe would *guess* whether the board does TLS — invariant 5), hence the equal-length build marker; the pin hash deliberately runs post-handshake on the presented DER so self-signed pinning needs no CA ceremony, while `--ca` keeps OpenSSL's full verification.
|
||||
- **Next pending task**: `02_tls_control_channel/06_tls_docs_rollback.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.85 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.862
|
||||
console SUCCESS 00:00:00.854
|
||||
========================= 2 succeeded in 00:00:01.716 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
**Implemented**
|
||||
- `platformio.ini`: new `[env:rollback]` (`extends env:bridge`, unflags `-DKR_TLS=1`, sets `-DKR_TLS=0`) — `pio run -e rollback` is now the documented, reproducible rollback; `pio run` has no `--build-flags`, so an env is the only unambiguous spelling.
|
||||
- `.agents/validate.sh`: builds all three envs, plus new check 5 that pins the docs to measurements — check count in README+AGENTS, **every Tests-table row against that suite's own line**, the page byte count, Backlog numbered 1..N, and every `ws://` line must name the rollback on the same line; fixed its stale `curl -s http://` comment.
|
||||
- README: TLS intro bullet; Safety model "TLS is transport, not an arming input"; trust table gains "who can see the traffic" (with the unpinned window stated) and "anyone with a copy of this firmware"; WiFi-owner row corrected; new "What TLS does not buy" + "the certificate you did not check is the one thing to check"; Wire-protocol transport paragraph + the non-`E<code>` refusals (401/503/301/**426**); new **"The rollback — building it, and what it costs"**; Measured-behaviour rows (footprint vs baseline, TLS cost, handshake time, session heap); Troubleshooting rollback row; 5 Design decisions (challenge–response, on-board identity, raw mbedTLS vs `esp_tls`, 426 vs 301, rollback-as-build); Backlog TLS item removed, renumbered 1–9, narrower "a certificate a browser already trusts" added.
|
||||
- Docstrings: "what it does NOT protect / must not reverse" added to `tls.h`, `tls_pem.h`, `tls_session.h`, `conn.h`, `relayctl.py`. AGENTS.md: corrected facts only (3-env gate, `conn.h` pure half, gate checks doc numbers, TLS identity defaults, "Three tables"→"Tables", "last row"→"evdev row").
|
||||
- Fixed earlier-task defects: stale `test_page | 93`→113; two Tests rows missing a closing pipe + a blank line splitting that table; HW_VERIFICATION duplicate blank `/ws` row and a heap row wrapped over 6 lines (broke its table).
|
||||
|
||||
**Tests/lint**: `bash .agents/validate.sh` → **exit 0, validation OK**; 4793 checks / 0 failures in both the plain and `SANITIZE=1` runs; page bytes 51 626; RAM/Flash — bridge 30.4 %/99 616 · 39.6 %/1 245 353, console 20.5 %/67 224 · 37.1 %/1 165 609, **rollback 28.5 %/93 456 · 36.4 %/1 146 137**; `ruff --select F,E9 tools/ tests/` clean; `bash -n` clean. No new executable code, so coverage is unchanged (pure modules already 100 %).
|
||||
|
||||
**Docs**: README.md and AGENTS.md updated as above.
|
||||
|
||||
**Deviations**: rollback proven by build + binary evidence, not by reflashing — `nm` gives 0 `mbedtls_ssl_*`, 0 `mbedtls_x509write`, 0 identity strings in the rollback image vs 115/15/1 in `bridge`; flashing the rollback and back would put a live HID keyboard into download mode on the host running this pipeline, so the device leg is recorded `skipped (not flashed)` in `.agents/HW_VERIFICATION.md` with the exact commands. Handshake time measured this task (0.349–0.423 s warm ×7, 3.94 s cold, page whole in 0.53–0.62 s); heap quoted from the recorded task 03/04 serial readouts, not re-measured (opening the CDC port resets the board).
|
||||
|
||||
**Next pending task**: this was phase 02's last task; after the harness moves it, next is `03_session_takeover/01_pending_claim_socket.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 829 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.862
|
||||
console SUCCESS 00:00:00.860
|
||||
rollback SUCCESS 00:00:00.867
|
||||
========================= 3 succeeded in 00:00:02.589 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (4 793 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
@@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea
|
||||
This file is the operating manual: what must not break, how to build and verify, and where
|
||||
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
|
||||
drifts, and two paths that can disagree about arming is the exact failure this project keeps
|
||||
having to fix (see README, "Ten bugs").
|
||||
having to fix (see README, "Eleven bugs").
|
||||
|
||||
## Phased execution
|
||||
|
||||
@@ -34,7 +34,7 @@ This repository runs work through `.agents/phases/`:
|
||||
button, or the serial console — nothing else may gain the ability. The mouse is captured,
|
||||
never grabbed: Pointer Lock, so `Esc` (the browser's own, never forwarded) always frees
|
||||
it. `mouse off` over serial removes the pointer interface at the descriptor level, which
|
||||
needs a reboot and is the rollback.
|
||||
needs a reboot and is the pointer's rollback (the transport rollback is `-D KR_TLS=0`).
|
||||
3. **Every fail-safe cancels work in flight; nothing "pauses"** — and **no fail-safe may be
|
||||
gated on the absence of the thing it detects.** `ControlServer::linked()` is false when a
|
||||
controller is gone, so gating the silence-disarm on it made the backstop for a vanished
|
||||
@@ -61,7 +61,7 @@ This repository runs work through `.agents/phases/`:
|
||||
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
|
||||
through `serveReadonly()` so they cannot drift apart.
|
||||
|
||||
## Three tables that must agree
|
||||
## Tables that must agree
|
||||
|
||||
| table | where | names | pinned by |
|
||||
|---|---|---|---|
|
||||
@@ -70,8 +70,11 @@ This repository runs work through `.agents/phases/`:
|
||||
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
|
||||
| mouse button bits | `src/mouse.h`, `src/web_page.h` (`MOUSE_BTN`), `tools/relayctl.py` (`MOUSE_BUTTONS`) | HID bit → DOM `MouseEvent.button` **and** name | `test_proto.cpp` (page↔config), `test_relayctl.py` (tool↔page↔config) |
|
||||
| evdev axis codes | `tools/watch_target.py` (`REL_*`) | kernel code → label, for the *verifier* | `test_relayctl.py` — pinned to `<linux/input-event-codes.h>`, **not** to the report's byte order |
|
||||
| certificate names | `src/tls_pem.h` (`sanEntries`, `kSanOidTlv`, `sanExtensionFullDer`) | the CN + SAN the certificate carries, **and the OID that labels the SAN**, and the same list rendered for the operator | `test_tls_pem.cpp` — the CN must be `kHostname` + `.local`, the 34 DER bytes were cross-checked against what OpenSSL writes for those three names, and so were the 43 bytes of the whole extension: the writer takes the OID as **DER content bytes** (`55 1D 11`), and handing it the dotted text produces a valid certificate with no SAN (README bug 11) |
|
||||
| mbed TLS return codes + the refusal alert | `src/tls_session.h` (`kMbed*`, `mapIo`, `renderFatalAlert`) | "would block" vs "peer gone" vs "fatal" for every session call; the 7-byte RFC 5246 alert record a slot-cap refusal sends | `test_tls_session.cpp` walks the tables; `static_assert`s in `tls_session.cpp` weld the three copied error-code literals to the shipped library's macros — the code values differ between mbed TLS 2.x and 3.x, which is exactly how a confident transplant goes wrong |
|
||||
| page build marker | `src/web_page.h` (`#if KR_TLS` between the raw-string chunks) | which build served the page — the TLS page refuses an `http:` origin outright; only the rollback page may speak plaintext | `test_page.js`, `test_relayctl.py` and `validate.sh` each assemble the served bytes from the chunks, and the two marker branches are asserted **equal length**, so one page byte count covers both builds |
|
||||
|
||||
The last row exists because it was guessed from the HID report's field order and was wrong
|
||||
The evdev row exists because it was guessed from the HID report's field order and was wrong
|
||||
(`REL_WHEEL` is 8, not 2): a working wheel looked dead and the summary said no scroll
|
||||
happened. Codes the verifier has no name for are now counted and printed rather than
|
||||
dropped — the same fix as giving F13–F24 names. **When you add a table that decodes
|
||||
@@ -87,11 +90,13 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
|
||||
## Build, test, flash
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh # 3406 checks, no hardware
|
||||
tests/run_tests.sh # 4826 checks, no hardware
|
||||
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
|
||||
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
|
||||
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
|
||||
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console
|
||||
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console -e rollback
|
||||
# `rollback` is [env:bridge] with -DKR_TLS=0: the plaintext way back. The gate builds it on
|
||||
# every run, because a way back that stopped compiling is not a way back.
|
||||
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
|
||||
uv run --python 3.14 --with platformio python -m platformio run -e bridge -t upload
|
||||
```
|
||||
@@ -113,8 +118,13 @@ Environment facts that will otherwise cost you a cycle:
|
||||
thing that checks the no-HID `#else` branches.
|
||||
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
|
||||
until the board is reflashed — and a tab that is already open keeps the old page in memory
|
||||
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
|
||||
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
|
||||
until it is reloaded. Confirm with `curl -sk https://keyrelay.local/ | wc -c` against the
|
||||
`page bytes:` line the gate prints (the raw-string chunks plus the 19-byte build marker;
|
||||
equal-length branches make it one number for both builds), and grep the served bytes for
|
||||
the element you added. Plain `http://` returns a 301 and no page bytes at all (task 04), so a `curl -s
|
||||
http://…` that prints a redirect body means the transport is working, not that the page is
|
||||
stale — fetch over `https://`, and `curl -s -L` is not a substitute: it follows to the same
|
||||
bytes whether or not the flash changed.
|
||||
- **This directory is a git repository** with a short history that predates most of the
|
||||
current tree. Use it: `git diff` before and after any sweeping edit, and remember that
|
||||
`git checkout <file>` reverts far more than you asked for — restore a *region*, not a
|
||||
@@ -148,12 +158,23 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
|
||||
## Editing rules
|
||||
|
||||
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `text.h`, `ws_codec.h`,
|
||||
`sha1.h`) include no Arduino or ESP-IDF headers. That is what lets the identical code run
|
||||
on a host under sanitisers. Keep it.
|
||||
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
|
||||
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
|
||||
that every element id the script reaches for exists in the markup).
|
||||
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `status.h`, `text.h`,
|
||||
`ws_codec.h`, `sha1.h`, `tls_pem.h`) include no Arduino or ESP-IDF headers. That is what lets the
|
||||
identical code run on a host under sanitisers. Keep it — and keep the *crypto library*
|
||||
out too: `tls_pem.h` handles the identity's strings, `tls.h`/`tls.cpp` handle the
|
||||
library, and the host suite includes the first and never the second. `tls_session.h`
|
||||
follows the same split inside one file: its policy half (deadline, admission,
|
||||
library-return table, alert record, queues) is pure and host-tested, and its
|
||||
`kr::tls::Session` class hides behind `KR_TLS && ESP_PLATFORM` — with the mbed TLS
|
||||
includes **outside** the namespace, because inside it they declare `::FILE` as
|
||||
`kr::tls::FILE` and every later stdio user in the translation unit dies. `conn.h` splits the
|
||||
same way and for the same reason: `plaintextRoute()`, the HTTP response head, the redirect
|
||||
builder and the page send cursor are pure and walked by `test_conn.cpp`; only `kr::Conn`
|
||||
itself needs `WiFiClient`.
|
||||
- **`web_page.h`** is C++ raw string (plural since task 05: chunks joined by one
|
||||
`#if KR_TLS` build marker of equal-length branches): no compiler looks at the HTML or JS.
|
||||
Run `tests/test_page.js` after touching it (it executes the assembled page against a DOM
|
||||
shim and checks that every element id the script reaches for exists in the markup).
|
||||
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
|
||||
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
|
||||
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
|
||||
@@ -161,12 +182,19 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
|
||||
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
|
||||
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
|
||||
restate the behaviour.
|
||||
restate the behaviour. `.agents/validate.sh` checks the mechanical half so nobody has to
|
||||
remember it: the check count and the page byte count the docs quote must equal what the run
|
||||
measured, the Backlog must be numbered 1..N with nothing closed left inside it, and every line
|
||||
that mentions a plaintext `ws://` session must name the rollback build on the same line (wrap
|
||||
the sentence so it does — the point is that no sentence can read as if plaintext were default).
|
||||
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
|
||||
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
|
||||
the point of that endpoint — do not add the token to any other URL.
|
||||
the point of that endpoint — do not add the token to any other URL. The TLS private key is
|
||||
the same rule with more teeth: it is generated on the board and lives only in NVS, so
|
||||
nothing may print it, hash-and-print it in a way that reads as the key, or be added to the
|
||||
tree as a `.pem`. Fingerprint yes, key never.
|
||||
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
|
||||
README: a fixed bug belongs in "Ten bugs this verification caught" with its root cause and
|
||||
README: a fixed bug belongs in "Eleven bugs this verification caught" with its root cause and
|
||||
how it was proven, a new capability in the section that describes it, and a rule worth
|
||||
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
|
||||
what is left to do.
|
||||
@@ -174,6 +202,15 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
## Current defaults worth knowing
|
||||
|
||||
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
|
||||
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
|
||||
`keyrelay-ctl` / `keyrelay-ctl-pass`; **`https://keyrelay.local`** or `https://192.168.4.1`
|
||||
(port 443 — `:80` answers a 301 to it, a 426 to a plaintext `/ws` upgrade or to any request
|
||||
carrying `?token=`, and nothing else; plaintext only in the `-D KR_TLS=0` rollback, which is
|
||||
`pio run -e rollback`); the TLS identity is an ECDSA secp256r1 keypair and self-signed
|
||||
certificate generated on the board's first boot and stored in NVS (`tlscrt`, `tlskey`,
|
||||
`tlscrtfp`) so it survives reflashing — rotated only by `nvs erase`, printed once at that first
|
||||
boot and again on demand by the serial `cert` command, which prints the SHA-256 fingerprint, the
|
||||
validity window and the names, and has no argument form that yields the key; at most 2 live TLS
|
||||
sessions (`kTlsMaxSessions`, RAM-sized, a third is refused with an alert) and a 10 s handshake
|
||||
deadline (`kTlsHandshakeMs`); paste pace
|
||||
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
|
||||
station credentials and passphrase and survives reflashing.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ lib_deps =
|
||||
build_flags =
|
||||
${usb.fs_include}
|
||||
-DARDUINO_USB_MODE=0
|
||||
; TLS on: the page and the WebSocket are served over HTTPS/wss with an identity the
|
||||
; board generated itself (src/tls.h). The rollback value is KR_TLS=0 -- plain http://
|
||||
; and ws:// only, exactly the pre-phase-02 behaviour, same standing as `mouse off`.
|
||||
; It is a compile-time switch because a device that cannot be reached over TLS after a
|
||||
; bad provisioning change needs a way back that is a reflash, not a repair.
|
||||
-DKR_TLS=1
|
||||
; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1,
|
||||
; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the
|
||||
; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every
|
||||
@@ -79,3 +85,31 @@ build_flags =
|
||||
${usb.fs_include}
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
; Same TLS default as bridge: the control plane is what this environment exists to
|
||||
; diagnose, so it must behave identically. Roll back with -DKR_TLS=0 (see env:rollback).
|
||||
-DKR_TLS=1
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Rollback: the bridge with TLS compiled out, i.e. the pre-phase-02 build -- plain
|
||||
; http:// page and ws:// socket on :80, no :443, no identity in NVS. PLAN.md locks this
|
||||
; in as the way back from a provisioning change that left a board unreachable over TLS:
|
||||
; the fix is a reflash, not a repair, and a way back only counts if it still builds.
|
||||
; That is why it is an environment and not a remembered command line: `pio run` has no
|
||||
; --build-flags option in this PlatformIO, and a second -DKR_TLS on the command line
|
||||
; beside the -DKR_TLS=1 above is a macro redefinition whose winner depends on flag order.
|
||||
; Unflagging the default and setting 0 is the only spelling with no ambiguity -- and
|
||||
; .agents/validate.sh builds this env on every run, so the rollback cannot quietly rot.
|
||||
;
|
||||
; Nothing about arming moves here: KR_TLS changes which port carries bytes, never who may
|
||||
; type (src/config.h, README "The rollback"). The page this image serves is the KR_TLS=0
|
||||
; branch of the marker in src/web_page.h; it is the only build where a plaintext ws://
|
||||
; session is the normal case, and the only one the README permits it in.
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:rollback]
|
||||
extends = env:bridge
|
||||
build_flags =
|
||||
${env:bridge.build_flags}
|
||||
-DKR_TLS=0
|
||||
build_unflags =
|
||||
${env:bridge.build_unflags}
|
||||
-DKR_TLS=1
|
||||
|
||||
+139
-1
@@ -7,7 +7,11 @@
|
||||
*
|
||||
* Nothing secret belongs in this file. The default passphrase is deliberately
|
||||
* weak and loudly announced as such on the serial console and in the web page,
|
||||
* because a keyboard that anyone on the LAN can arm is an open door.
|
||||
* because a keyboard that anyone on the LAN can arm is an open door. The TLS
|
||||
* private key is the extreme case of the same rule: it is not here, not in a
|
||||
* PEM in the tree, not in a build flag -- it is generated on the board and kept
|
||||
* in NVS (src/tls.h). Only the *public* facts about the identity, the names it
|
||||
* is issued for and how long it lasts, are below.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
@@ -24,7 +28,16 @@ constexpr const char kFirmwareVersion[] = "1.0.0";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control plane only: the keystrokes travel WiFi -> board -> USB. The target
|
||||
// machine provides nothing but 5 V and a USB port, and never sees a network.
|
||||
//
|
||||
// One number with two names, because in a KR_TLS build the socket on it has one job left:
|
||||
// telling people where to go. `kHttpPort` is the port (80, what a browser dials when the
|
||||
// operator types a bare name); `kHttpRedirectPort` is what this board does with it -- 301
|
||||
// to https://, and 426 to anything that must not be answered in plaintext (a `/ws` upgrade,
|
||||
// a request carrying `?token=`). They are the same socket by design: two listeners on 80
|
||||
// would be two answers that can disagree. In the `-D KR_TLS=0` rollback the port carries
|
||||
// content again and the second name is only the honest description of what it used to be.
|
||||
constexpr uint16_t kHttpPort = 80;
|
||||
constexpr uint16_t kHttpRedirectPort = kHttpPort;
|
||||
constexpr char kHostname[] = "keyrelay"; // http://keyrelay.local
|
||||
|
||||
// Fallback access point, started whenever the station interface is not up, so
|
||||
@@ -46,6 +59,106 @@ constexpr char kApGw[] = "192.168.4.1";
|
||||
constexpr uint32_t kStaTimeoutMs = 8000; // give the association attempt this long at boot
|
||||
constexpr uint32_t kStaRetryMs = 20000; // ... then retry in the background at this rate
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS: the encrypted control channel
|
||||
// ---------------------------------------------------------------------------
|
||||
// The board serves the page and the WebSocket over TLS, with an identity it generated
|
||||
// itself at first boot (src/tls.h). One switch, one home:
|
||||
//
|
||||
// KR_TLS=1 the default. HTTPS on 443, wss://, and :80 as a redirect only.
|
||||
// KR_TLS=0 the rollback. The pre-TLS build: plain http:// and ws:// only, exactly the
|
||||
// behaviour the README described before phase 02. Same standing as `mouse
|
||||
// off`: a way back that is a reflash, not a repair.
|
||||
//
|
||||
// Nothing about arming changes with this flag. It moves bytes, not permissions.
|
||||
#ifndef KR_TLS
|
||||
#define KR_TLS 1
|
||||
#endif
|
||||
|
||||
// The name the certificate is issued to, and the name every client connects to. It is
|
||||
// kHostname plus the mDNS domain -- tests/test_tls_pem.cpp asserts that, because a
|
||||
// certificate for a name nobody types is a warning screen, and a CN that drifted from
|
||||
// the hostname the page tells you to use is the same failure wearing a different hat.
|
||||
constexpr char kCertCommonName[] = "keyrelay.local";
|
||||
|
||||
// How long the self-signed certificate stays valid. Ten years outlives the firmware,
|
||||
// the browser support and probably the project; re-generating an identity means the
|
||||
// operator re-pins a fingerprint, so doing it on a timer would be a support burden with
|
||||
// no security gain -- the key never leaves the board and there is no revocation path to
|
||||
// use. An NVS erase regenerates it, and that is the documented rotation.
|
||||
constexpr int kCertValidityYears = 10;
|
||||
|
||||
// The encrypted listener's port. Until task 04 makes :80 redirect-only, both serve the
|
||||
// page; the controller slot is shared, so a TLS controller and a plaintext controller can
|
||||
// never both hold the keyboard no matter which port they arrived on.
|
||||
constexpr uint16_t kHttpsPort = 443;
|
||||
|
||||
// A socket that opens on 443 and never finishes its handshake is the same kind of scan as
|
||||
// one that opens on :80 and never sends `T<token>` -- it gets the same treatment: closed,
|
||||
// not waited on. Ten seconds is generous for an ECDHE-ECDSA handshake on this core; the
|
||||
// deadline is checked once per tick inside Session::poll(), so a stalled handshake cannot
|
||||
// cost more than one library call that already returned WANT_READ.
|
||||
constexpr uint32_t kTlsHandshakeMs = 10000;
|
||||
|
||||
// Live sessions at most: the controller's, and one spare -- a short-lived read-only
|
||||
// request destroyed the moment its response is flushed, or (phase 03) a pending takeover
|
||||
// claim. A third connection is refused explicitly (a TLS alert record, then close, plus a
|
||||
// counter); never queued, never half-opened.
|
||||
//
|
||||
// This is a RAM decision, not a policy one, and the arithmetic is the core's:
|
||||
// CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN is 16384 in this core's sdkconfig (verified with grep
|
||||
// against `framework-arduinoespressif32-esp32s3-libs/*/include/sdkconfig.h`), so one
|
||||
// session's mbed TLS I/O buffers are ~2x16 KB once it has read and written -- roughly
|
||||
// 64 KB of heap at the full two sessions. mbedtls_ssl_conf_max_frag_len(4096) was NOT
|
||||
// applied: the measured free heap with two live sessions (see .agents/HW_VERIFICATION.md,
|
||||
// phase 02 task 03) fits with margin, so the smaller-fragments saving is not worth the
|
||||
// smaller record ceiling on the 48 KB page. Re-measure before changing that answer.
|
||||
constexpr size_t kTlsMaxSessions = 2;
|
||||
|
||||
// Bytes staged between the server's writes and a transport that may not accept one this
|
||||
// tick. A frame is pushed whole or dropped whole -- the ring is what keeps
|
||||
// `mbedtls_ssl_write`'s "call again with the same data" contract satisfiable while the
|
||||
// producer keeps running, and it is the same ring on the plaintext side now, where it fixes
|
||||
// a worse bug: NetworkClient::write() can return a *partial* write, and the code that
|
||||
// ignored the count used to truncate a WebSocket frame in half and desynchronise the stream.
|
||||
//
|
||||
// Sized for the largest single thing the controller slot writes: a status frame plus its
|
||||
// WebSocket header (kStatusJsonCap + 16 = 400), with room for two of them plus an HTTP head
|
||||
// so a heartbeat and a response can be queued in the same tick. Measured, not guessed: the
|
||||
// page never enters this ring (it is a flash body, see kPageChunkBytes below).
|
||||
constexpr size_t kConnTxCap = 2048;
|
||||
|
||||
// One transport write is one chunk: for TLS it is one record, below the 16 KB content limit
|
||||
// of this core, so the library never fragments and a call either accepts the whole chunk or
|
||||
// returns WANT_* with nothing consumed and the retry hands back byte-identical arguments.
|
||||
// The plaintext side takes the same cap because it is the stricter of the two rules and one
|
||||
// number is one fewer way for the transports to drift.
|
||||
constexpr size_t kConnWriteChunk = 4096;
|
||||
|
||||
// How much of a flash-resident body (the controller page) is copied into RAM per transport
|
||||
// write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one
|
||||
// segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The
|
||||
// cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase
|
||||
// report against the phase 01 baseline. The served page is 51 626 B (the count the gate
|
||||
// prints), so a page is 36 chunks.
|
||||
constexpr size_t kPageChunkBytes = 1460;
|
||||
|
||||
// The per-tick budget for draining a slot's queue: 4 chunks, so the 51 626-byte page leaves
|
||||
// in 9 ticks of pump() and no single loop() iteration can spend more than four transport
|
||||
// writes on one socket -- whatever that socket's peer is doing. loop() runs continuously
|
||||
// (nothing in it delays), so the page is out in milliseconds; the budget is not about
|
||||
// throughput, it is the ceiling on how long one iteration can be busy sending.
|
||||
constexpr size_t kPageBytesPerTick = 4 * kPageChunkBytes;
|
||||
|
||||
// Room for one PEM block. Measured on a P-256 identity carrying exactly the names in
|
||||
// sanEntries(): the certificate 603 bytes armoured (about 430 of DER inside it) and the
|
||||
// SEC1 private key 227 -- so nothing this board can be asked to armour is over 700
|
||||
// bytes, and 1024 is that with room to spare. One number in one place because a PEM
|
||||
// buffer exactly the size of today's certificate is an outage the day a name is added:
|
||||
// wrapPem() and the key exporter both refuse rather than truncate, so too small fails
|
||||
// loudly at boot instead of shipping a half-written certificate.
|
||||
constexpr size_t kPemMax = 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control passphrase
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,8 +227,33 @@ constexpr uint32_t kStatusIntervalMs = 1000;
|
||||
// The status JSON, sized for every field in StatusSnapshot plus headroom. Sized in
|
||||
// one place because three call sites build it and a truncated status lies about state,
|
||||
// which is worse than no status.
|
||||
//
|
||||
// The arithmetic, with every field at its widest legal value -- a 32-character SSID
|
||||
// (staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each of the five
|
||||
// counters, UINT16_MAX in `typing`, and the whole 16-hex `fp`: 322 bytes, measured by the
|
||||
// longest-legal-snapshot check in tests/test_proto.cpp and asserted below this cap, not
|
||||
// believed. `tls` and `fp` together cost 32 of those 322 -- `,"tls":1` is 8 bytes and
|
||||
// `,"fp":"0123456789abcdef"` is 24 -- which is why the cap did not have to move when they
|
||||
// were added, and why the measurement exists: the next field added without re-measuring is
|
||||
// how this constant stops meaning anything. The 62 bytes left over are one more field's
|
||||
// worth of headroom, asserted in the test, not slack to be spent by accident.
|
||||
constexpr size_t kStatusJsonCap = 384;
|
||||
|
||||
// The widest HTTP response head the board can build, in bytes: one formatter
|
||||
// (kr::renderHttpResponseHead, conn.h) for every route, port and transport, and this is
|
||||
// the sizing its answer is tested against -- with a full-length authority and the longest
|
||||
// path it carries over, so "it fits" is a measurement and not a hope. A head that would
|
||||
// not fit is refused (length 0), never truncated: half a header block desynchronises a
|
||||
// stream while looking like an answer.
|
||||
constexpr size_t kHttpResponseHeadCap = 320;
|
||||
|
||||
// The read-only slot only ever sends ONE response and then dies, so its queue is sized for
|
||||
// exactly that: the widest response head plus a whole status JSON, queued together (head and
|
||||
// body are accepted as a pair or refused together, so the ring has to hold the pair).
|
||||
// Smaller than the controller slot's ring on purpose -- this is static RAM on a board with
|
||||
// no PSRAM, and a spare slot must never be the reason the controller runs out.
|
||||
constexpr size_t kConnViewTxCap = kHttpResponseHeadCap + kStatusJsonCap;
|
||||
|
||||
constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger than this
|
||||
constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload
|
||||
constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast
|
||||
|
||||
+663
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* keyrelay -- the transport view: one byte pipe, two implementations, and the tables that
|
||||
* decide what the *plaintext* port is allowed to say.
|
||||
*
|
||||
* Why this file exists
|
||||
* --------------------
|
||||
* `ControlServer` used to carry two HTTP paths: one that wrote straight to a `WiFiClient`
|
||||
* and one that staged bytes for an mbed TLS session and pumped them across ticks. Two
|
||||
* paths that can disagree about a route is the failure mode this project keeps having to
|
||||
* fix (AGENTS.md, invariant 8: both read-only routes go through `serveReadonly()` "so they
|
||||
* cannot drift apart"), and a half-encrypted server is the shape that drifts. So the
|
||||
* transport stops being a routing question: the routing code asks a `Conn` to read, write
|
||||
* and drain, and does not know -- and must not care -- which of the two it is holding.
|
||||
*
|
||||
* What is pure, and where each half is proven
|
||||
* -------------------------------------------
|
||||
* The PURE half is everything that decides rather than I/Os: the HTTP response head, the
|
||||
* `:80` answer table (`plaintextRoute`), the redirect target builder (which is the one
|
||||
* place a client-supplied `Host:` and request path become bytes a browser will navigate
|
||||
* to), and the flash-body send cursor. It includes no Arduino, no IDF and no socket
|
||||
* header, so tests/test_conn.cpp walks it under sanitisers exactly like tls_pem.h.
|
||||
*
|
||||
* The LIBRARY half is `Conn` itself, compiled only where `WiFiClient` exists. It is proven
|
||||
* the way tls_session.h is: both firmware environments link, and .agents/HW_VERIFICATION.md
|
||||
* records what a real client did with it (page byte-exact over TLS, `:80` redirecting, a
|
||||
* plaintext `/ws` upgrade refused with 426).
|
||||
*
|
||||
* Tagged union, not virtual -- measured, then chosen
|
||||
* --------------------------------------------------
|
||||
* `Conn` is a kind tag plus two pointers, not a base class with two derived classes.
|
||||
* Measured on the target toolchain (`xtensa-esp32s3-elf-g++ -Os`, two probe translation
|
||||
* units performing the same dispatch): the virtual form emits `l32i` + `l32i` + `callx8`
|
||||
* -- two dependent loads and an indirect call -- and came out *smaller* at the call site,
|
||||
* 82 B against the tag switch's 98 B. So this choice is not about the cost of one dispatch,
|
||||
* and pretending it were would be a comment rationalising a decision instead of recording
|
||||
* it. It is about three other things:
|
||||
*
|
||||
* - a Conn is RE-BOUND between transports as slots come and go (`bindPlain` on an accept
|
||||
* at :80, `bindTls` on an accept at :443, the same slot object either way). A vtable
|
||||
* expresses that only by placement-new over a live object, or by holding both derived
|
||||
* objects and a pointer to the live one -- which is this union, plus an indirection and
|
||||
* a lifetime question nobody has to answer;
|
||||
* - `callx8` is not inlinable. `writeTextFrame()` -> `push()` -> `pump()` -> `writeRaw()`
|
||||
* is the road a keystroke frame leaves on, and every hop of a virtual chain there is a
|
||||
* call, while a branch on a value that never changes between a bind and a reset is one
|
||||
* the predictor sees coming and the inliner can see through;
|
||||
* - no vptr per object and no vtable to place: static RAM is the scarce resource on this
|
||||
* board (no PSRAM) and `Conn` lives inside an object that is already ~11.7 kB of it.
|
||||
*
|
||||
* The write contract
|
||||
* ------------------
|
||||
* `pump()` is resumable on every transport and never blocks `loop()`:
|
||||
* - plaintext asks lwIP whether the socket will take bytes *this instant* (zero-timeout
|
||||
* select) and returns "nothing written" rather than entering the Arduino write helper,
|
||||
* which retries a blocking select ten times with a one-second timeout each;
|
||||
* - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which
|
||||
* is that library's documented resume rule and is enforced in tls::Session;
|
||||
* - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and
|
||||
* a body that lives in flash is copied through a bounded RAM chunk, so a 48 KB page
|
||||
* leaves in bounded pieces across ticks instead of in one call that can wait.
|
||||
* Nothing here ever waits. A peer that stops reading is dropped by the caller's stall
|
||||
* deadline, which is the same rule that already applied to a stalled *request*.
|
||||
*
|
||||
* What this file must never become
|
||||
* --------------------------------
|
||||
* A Conn is a byte pipe plus one opinion: what the plaintext port is allowed to say. It
|
||||
* protects nothing else, and it must not grow. No route, no credential check, no arming
|
||||
* decision and no "but this one is over TLS" may land here -- the transport is not an authority
|
||||
* (README, Safety model), which is the same sentence that keeps a `wss://` controller from being
|
||||
* privileged and a `:80` socket from being trusted. Two specific decisions are load-bearing and
|
||||
* have tests: a token-carrying request and a `/ws` upgrade on the plaintext port are answered
|
||||
* 426 rather than 301, because a `Location:` that echoes `?token=` leaks the passphrase twice on
|
||||
* the wire the redirect is escaping; and exactly one formatter in `src/` writes an HTTP status
|
||||
* line, so the encrypted answer and the plaintext answer cannot disagree about anything except
|
||||
* which of them they are (AGENTS.md invariant 8).
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h> // snprintf, for the one response-head formatter
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "tls_session.h" // tls::TxRing, tls::Handshake (+ Session behind KR_TLS && ESP_PLATFORM)
|
||||
|
||||
// WiFiClient is a typedef of NetworkClient in this core, so it cannot be forward-declared;
|
||||
// including it is the only way to name it. Guarded, exactly like the mbed TLS includes in
|
||||
// tls_session.h, so a host translation unit (tests/test_conn.cpp) can include THIS header
|
||||
// and walk the pure half with no Arduino installed.
|
||||
#if defined(ESP_PLATFORM)
|
||||
#include <WiFi.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/time.h>
|
||||
#endif
|
||||
|
||||
namespace kr {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: the HTTP response head
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The two content paths, named once. The redirect builder below and the route table in
|
||||
// control_server.cpp both need them, and a second spelling of "/index.html" is a second
|
||||
// answer to "what does this board serve" -- which is the drift this phase exists to remove.
|
||||
constexpr char kPathIndex[] = "/";
|
||||
constexpr char kPathIndexHtml[] = "/index.html";
|
||||
|
||||
/**
|
||||
* One response formatter for every route, on every transport, in every build. It lives
|
||||
* here (pure) rather than in the server because a header block is a wire format whose
|
||||
* failure mode is silent: `snprintf` truncating it used to mean `headLen = 0`, which meant
|
||||
* the client got a closed socket and no explanation. The cap it is given,
|
||||
* `kHttpResponseHeadCap` (config.h, with the arithmetic), is asserted in
|
||||
* tests/test_conn.cpp against the *widest legal* response -- a 63-character authority and
|
||||
* the longest path a redirect carries over -- not believed.
|
||||
*
|
||||
* Returns the length written, or 0 when `cap` cannot hold the whole block (never a partial
|
||||
* head: half an HTTP response is a desynchronised stream).
|
||||
*/
|
||||
inline size_t renderHttpResponseHead(char *out, size_t cap, const char *status,
|
||||
const char *contentType, size_t bodyLen,
|
||||
const char *extra = nullptr) {
|
||||
if (!out || cap < kHttpResponseHeadCap) return 0;
|
||||
const int n = snprintf(out, cap,
|
||||
"HTTP/1.1 %s\r\n"
|
||||
"Content-Type: %s\r\n"
|
||||
"Content-Length: %u\r\n"
|
||||
"Cache-Control: no-store\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n"
|
||||
"%s"
|
||||
"Connection: close\r\n\r\n",
|
||||
status, contentType, (unsigned)bodyLen, extra ? extra : "");
|
||||
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: what :80 is allowed to answer when this build serves TLS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `Serve` is the pre-TLS (and `-D KR_TLS=0`) behaviour: answer the request here.
|
||||
* `Redirect` is a 301 to the same path over `https://`.
|
||||
* `UpgradeRequired` is 426: the client is asking for something this port refuses to do at
|
||||
* all, and the truthful answer is "use TLS", not a redirect.
|
||||
*
|
||||
* Why 426 and not a redirect for two of the shapes (the assumption this task was written
|
||||
* against, and the reason is a leak): a `Location:` that echoes `?token=<passphrase>`
|
||||
* exposes the passphrase TWICE -- once in the plaintext request and once in the plaintext
|
||||
* redirect the browser dutifully re-issues -- and would also train operators to paste the
|
||||
* passphrase into a `http://` bar. A plaintext `/ws` upgrade gets the same code because
|
||||
* silently accepting the weaker thing is never the default (invariant 5): the browser must
|
||||
* fail, read the answer, and reconnect to `https://`.
|
||||
*/
|
||||
enum class PlainRoute : uint8_t { Serve, Redirect, UpgradeRequired };
|
||||
|
||||
/**
|
||||
* `token=` present as a *key* in the query string. The value is never copied -- this
|
||||
* answers "is the passphrase in this URL", and copying it here would put the thing the
|
||||
* whole response exists to protect into a buffer for no reason. Same boundary rule as
|
||||
* ws::tokenFromQuery(): at the start of the query or straight after a '&', so a path like
|
||||
* `/status?mytoken=x` is not a token-carrying request.
|
||||
*/
|
||||
inline bool queryCarriesToken(const char *query) {
|
||||
static const char kKey[] = "token=";
|
||||
const size_t kl = sizeof(kKey) - 1;
|
||||
if (!query) return false;
|
||||
// Bounded by the length, not by the NUL: memcmp() of a 6-byte key against the last two
|
||||
// bytes of "a=1" reads four bytes past the buffer. The sanitiser found exactly that on
|
||||
// the first run of this file, which is why the boundary is written as `i + kl <= len`
|
||||
// (the same shape ws::tokenFromQuery uses) and why tests/test_conn.cpp feeds it every
|
||||
// short prefix of the key.
|
||||
const size_t len = strlen(query);
|
||||
for (size_t i = 0; i + kl <= len; i++) {
|
||||
if (i != 0 && query[i - 1] != '&') continue;
|
||||
if (memcmp(query + i, kKey, kl) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline PlainRoute plaintextRoute(bool upgradeWebsocket, const char *query, bool tlsBuild) {
|
||||
if (!tlsBuild) return PlainRoute::Serve; // the rollback build IS the plaintext server
|
||||
if (upgradeWebsocket) return PlainRoute::UpgradeRequired;
|
||||
if (queryCarriesToken(query)) return PlainRoute::UpgradeRequired;
|
||||
return PlainRoute::Redirect;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: the redirect target
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The authority for a `Location:` header, from the client's own `Host:`.
|
||||
*
|
||||
* Not a cosmetic decision: `Location` is bytes a browser navigates to. `Host` is attacker
|
||||
* text on an attacker-watched port, and `http://keyrelay.local@evil.com/` puts `evil.com`
|
||||
* in it -- which would make this board an open redirect onto a phishing page, on the one
|
||||
* port whose whole purpose is to say "go here instead". So the Host is accepted only when
|
||||
* it is a name this board can actually be reached at: the certificate's name, the bare
|
||||
* hostname, or a private-use dotted quad (the README's "IP if the name fails" path, which
|
||||
* is why a bare IP has to keep working). Anything else falls back to `kCertCommonName`,
|
||||
* which is always right about this board.
|
||||
*
|
||||
* A `:port` suffix is stripped (a redirect keeps the scheme's default port). Returns false
|
||||
* only when even the fallback would not fit `cap`, which cannot happen with the names in
|
||||
* config.h -- and is still checked, because that is how a name change stays honest.
|
||||
*/
|
||||
inline bool dottedQuadPrivate(const char *h, size_t n) {
|
||||
// Every index into octets[] is guarded BEFORE the write, not at the end: the first
|
||||
// version validated the octet count when a dot arrived, so "192.168.1.1.1" wrote
|
||||
// octets[4] -- a stack overflow on the way to answering "is this a private address?".
|
||||
// ASan found it on the first run; tests/test_conn.cpp keeps it found.
|
||||
unsigned octets[4] = {0, 0, 0, 0};
|
||||
size_t oi = 0, digits = 0;
|
||||
for (size_t i = 0; i <= n; i++) {
|
||||
if (i == n || h[i] == '.') {
|
||||
if (digits == 0 || oi >= 4) return false;
|
||||
oi++;
|
||||
digits = 0;
|
||||
continue;
|
||||
}
|
||||
if (h[i] < '0' || h[i] > '9' || oi >= 4) return false;
|
||||
octets[oi] = octets[oi] * 10u + static_cast<unsigned>(h[i] - '0');
|
||||
if (octets[oi] > 255u) return false; // bound it where it is built, not later
|
||||
digits++;
|
||||
}
|
||||
if (oi != 4) return false;
|
||||
// 10/8, 172.16/12, 192.168/16, link-local, loopback. Nothing else is a name this board
|
||||
// is plausibly reached at by number, and an attacker-supplied public IP never is.
|
||||
return octets[0] == 10 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
||||
(octets[0] == 192 && octets[1] == 168) || octets[0] == 169 || octets[0] == 127;
|
||||
}
|
||||
|
||||
inline bool ciEqN(const char *a, const char *b, size_t n) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const char ca = (a[i] >= 'A' && a[i] <= 'Z') ? static_cast<char>(a[i] + 32) : a[i];
|
||||
const char cb = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast<char>(b[i] + 32) : b[i];
|
||||
if (ca != cb || ca == '\0') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline size_t strLenN(const char *s, size_t max) {
|
||||
size_t n = 0;
|
||||
while (n < max && s[n]) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
inline bool redirectAuthority(const char *headerHost, char *out, size_t cap) {
|
||||
const char *fallback = kCertCommonName;
|
||||
char cand[80];
|
||||
size_t n = headerHost ? strLenN(headerHost, sizeof(cand) - 1) : 0;
|
||||
if (headerHost && n) {
|
||||
memcpy(cand, headerHost, n);
|
||||
cand[n] = '\0';
|
||||
// strip :port -- but a second colon means an IPv6 literal or garbage, not a name
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (cand[i] != ':') continue;
|
||||
bool digits = i + 1 < n;
|
||||
for (size_t j = i + 1; j < n; j++)
|
||||
if (cand[j] < '0' || cand[j] > '9') digits = false;
|
||||
if (!digits) {
|
||||
n = 0;
|
||||
break;
|
||||
}
|
||||
cand[i] = '\0';
|
||||
n = i;
|
||||
break;
|
||||
}
|
||||
bool ok = n > 0 && n < sizeof(cand);
|
||||
for (size_t i = 0; ok && i < n; i++) {
|
||||
const char c = cand[i];
|
||||
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '-';
|
||||
}
|
||||
ok = ok && cand[0] != '.' && cand[0] != '-' && cand[n - 1] != '.' && cand[n - 1] != '-';
|
||||
const size_t flen = strlen(kCertCommonName);
|
||||
const size_t hlen = strlen(kHostname);
|
||||
const bool isCertName = n == flen && ciEqN(cand, kCertCommonName, n);
|
||||
const bool isHostname = n == hlen && ciEqN(cand, kHostname, n);
|
||||
ok = ok && (isCertName || isHostname || dottedQuadPrivate(cand, n));
|
||||
// Accepted: emit the canonical spelling, not the caller's. A DNS name is
|
||||
// case-insensitive, so `KEYRELAY.LOCAL` matched -- and echoing a client's mixed case
|
||||
// into a header this board is authoring buys nothing worth the ambiguity.
|
||||
if (isCertName) fallback = kCertCommonName;
|
||||
else if (isHostname) fallback = kHostname;
|
||||
else if (ok) fallback = cand;
|
||||
else n = 0;
|
||||
}
|
||||
const size_t len = strlen(fallback);
|
||||
if (!out || cap < len + 1) return false;
|
||||
memcpy(out, fallback, len + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `https://<authority><path>` -- the whole `Location` value.
|
||||
*
|
||||
* Only the two paths this board actually has are carried over (`/` and `/index.html`);
|
||||
* anything else redirects to `/`. That is both the honest answer -- the board has two
|
||||
* content paths and everything else is a 404 on the encrypted port too -- and the safe
|
||||
* one: the request target is client text, and a bare CR or LF in it would inject headers
|
||||
* into the response this board is building. The character check below stays in as defence
|
||||
* in depth for whoever reuses this function with a longer path. The query is NEVER carried
|
||||
* over: the only query this board answers is `/status?token=`, and that request gets 426
|
||||
* precisely so its query does not travel twice.
|
||||
*
|
||||
* Returns the length written (the caller puts it in a body or a log), 0 if it would not fit.
|
||||
*/
|
||||
inline size_t renderRedirectTarget(const char *headerHost, const char *path, char *out,
|
||||
size_t cap) {
|
||||
if (!out || cap == 0) return 0;
|
||||
char authority[80];
|
||||
if (!redirectAuthority(headerHost, authority, sizeof(authority))) return 0;
|
||||
const char *safe = "/";
|
||||
if (path && path[0] == '/' && path[1]) {
|
||||
bool ok = true;
|
||||
for (size_t i = 1; path[i]; i++) {
|
||||
const char c = path[i];
|
||||
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '-' || c == '_' || c == '~' || c == '/' || c == '%' ||
|
||||
c == '+' || c == '=';
|
||||
if (!ok) break;
|
||||
}
|
||||
// ... and one of the two paths this board serves. Both halves are load-bearing: the
|
||||
// character check stops injection, this one bounds the head and refuses to echo an
|
||||
// arbitrary target back as a promise.
|
||||
if (ok && (strcmp(path, kPathIndexHtml) == 0 || strcmp(path, kPathIndex) == 0))
|
||||
safe = path;
|
||||
}
|
||||
const int n = snprintf(out, cap, "https://%s%s", authority, safe);
|
||||
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure: a body that lives in flash, drained in bounded pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The controller page is a PROGMEM raw string of several tens of kilobytes and no socket
|
||||
* call here may be handed all of it at once. This is the cursor that decides what leaves
|
||||
* next: at most `kPageChunkBytes` per transport write (the copy that bounds one call), and
|
||||
* at most `kPageBytesPerTick` per tick (the budget that bounds one loop() iteration).
|
||||
*
|
||||
* The bytes are COPIED into a bounded RAM buffer rather than passed through, even though on
|
||||
* ESP32 flash is memory-mapped and readable in place: the same code serves a transport that
|
||||
* reads the pointer directly (plaintext lwIP) and one that encrypts it (mbed TLS), and a
|
||||
* copy of one MSS is cheaper than a rule about what a transport may dereference. It also
|
||||
* keeps the resume arguments -- the bytes a partial write re-hands -- stable and small.
|
||||
*
|
||||
* `sent`/`total` are the per-connection send cursor the routing code keeps, and they stay
|
||||
* readable after the send completes so a caller can report "the page left intact".
|
||||
*/
|
||||
class FlashSend {
|
||||
public:
|
||||
void start(const char *src, size_t len) {
|
||||
_src = (src && len) ? src : nullptr;
|
||||
_total = _src ? len : 0;
|
||||
_sent = 0;
|
||||
}
|
||||
void stop() {
|
||||
_src = nullptr;
|
||||
_total = 0;
|
||||
_sent = 0;
|
||||
}
|
||||
|
||||
bool armed() const { return _src != nullptr; } // more bytes to copy
|
||||
bool done() const { return _sent >= _total; } // everything has been handed over
|
||||
size_t sent() const { return _sent; }
|
||||
size_t total() const { return _total; }
|
||||
size_t remaining() const { return _total - _sent; }
|
||||
|
||||
// How many bytes to copy for the next write: 0 when finished, when no budget is left, or
|
||||
// when the chunk would be empty. Never more than one chunk, never more than the budget.
|
||||
size_t next(size_t chunkCap, size_t budgetLeft) const {
|
||||
if (!armed() || chunkCap == 0 || budgetLeft == 0) return 0;
|
||||
size_t n = remaining();
|
||||
if (n > chunkCap) n = chunkCap;
|
||||
if (n > budgetLeft) n = budgetLeft;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Copy `n` bytes at the cursor. On ESP32 `_src` is in flash and a plain memcpy reads it
|
||||
// (there is no Harvard split to mind, unlike AVR's pgm_read_*); on the host it is memory.
|
||||
void copyTo(char *dst, size_t n) const {
|
||||
if (!armed() || !dst) return;
|
||||
if (n > remaining()) n = remaining();
|
||||
memcpy(dst, _src + _sent, n);
|
||||
}
|
||||
|
||||
// Called with what the transport actually accepted. Completing the send clears the source
|
||||
// (so armed() goes false and the caller can close the link) but keeps sent()/total().
|
||||
void advance(size_t n) {
|
||||
if (!armed()) return;
|
||||
const size_t left = remaining();
|
||||
_sent += (n < left) ? n : left;
|
||||
if (_sent >= _total) _src = nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
const char *_src = nullptr;
|
||||
size_t _total = 0;
|
||||
size_t _sent = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The transport itself (ESP only -- this is the half that touches sockets)
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(ESP_PLATFORM)
|
||||
|
||||
/**
|
||||
* One control link, either transport, seen as a byte pipe with a resumable outbound queue.
|
||||
*
|
||||
* Not a base class (see the header comment). Not copyable: it is a *view* of a socket that
|
||||
* somebody else owns -- `ControlServer` owns the `WiFiClient` and the `tls::Session`
|
||||
* objects, and a copy of the view would be a second owner of the same close().
|
||||
*
|
||||
* Outbound bytes are queued, never written from the caller's stack frame: `push()` takes a
|
||||
* whole frame or nothing (a half-queued WebSocket frame corrupts the stream; a dropped
|
||||
* status frame is one second of staleness), and `pump()` drains what the transport will
|
||||
* take this tick. Inbound is a straight `read()` -- the caller's buffer IS the framing
|
||||
* state, which is why requests need no queue.
|
||||
*/
|
||||
class Conn {
|
||||
public:
|
||||
Conn() = default;
|
||||
Conn(const Conn &) = delete;
|
||||
Conn &operator=(const Conn &) = delete;
|
||||
|
||||
void bindPlain(WiFiClient *client, uint8_t *ringBuf, size_t ringCap) {
|
||||
resetOut();
|
||||
_kind = Kind::Plain;
|
||||
_plain = client;
|
||||
_ring.reseat(ringBuf, ringCap);
|
||||
}
|
||||
#if KR_TLS
|
||||
void bindTls(tls::Session *session, uint8_t *ringBuf, size_t ringCap) {
|
||||
resetOut();
|
||||
_kind = Kind::Tls;
|
||||
_tls = session;
|
||||
_ring.reseat(ringBuf, ringCap);
|
||||
}
|
||||
#endif
|
||||
// Forget the transport without closing it: the slot is being handed to a different socket
|
||||
// object, and whatever was queued for the old one is not coming.
|
||||
void unbind() {
|
||||
resetOut();
|
||||
_kind = Kind::None;
|
||||
_plain = nullptr;
|
||||
#if KR_TLS
|
||||
_tls = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isTls() const { return _kind == Kind::Tls; }
|
||||
bool bound() const { return _kind != Kind::None; }
|
||||
|
||||
/** A socket is held, handshake pending or not. */
|
||||
bool attached() const {
|
||||
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain);
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->alive();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** ... and the peer is still there. This is the question a controller link asks. */
|
||||
bool alive() const {
|
||||
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain) && _plain->connected();
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->alive();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Application bytes may flow. Plaintext is ready the moment it is attached; TLS not
|
||||
* until the handshake completes. */
|
||||
bool ready() const {
|
||||
if (_kind == Kind::Plain) return attached();
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls) return _tls && _tls->ready();
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One non-blocking step of the handshake. Plaintext has none, and says so. */
|
||||
tls::Handshake poll() {
|
||||
if (_kind == Kind::Plain) return tls::Handshake::Ready;
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->poll();
|
||||
#endif
|
||||
return tls::Handshake::Failed;
|
||||
}
|
||||
|
||||
/** Application bytes in. 0 means "nothing right now" (or "gone"), never "spin". */
|
||||
size_t read(uint8_t *dst, size_t cap) {
|
||||
if (!dst || cap == 0) return 0;
|
||||
if (_kind == Kind::Plain) {
|
||||
if (!_plain || !static_cast<bool>(*_plain) || !_plain->available()) return 0;
|
||||
const int n = _plain->read(dst, cap);
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->read(dst, cap);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Close the transport. Never waits; the queue goes with it. */
|
||||
void stop() {
|
||||
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->stop();
|
||||
#if KR_TLS
|
||||
else if (_kind == Kind::Tls && _tls) _tls->close();
|
||||
#endif
|
||||
resetOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-chance flush before a close, plaintext only: without it the FIN that follows a
|
||||
* WebSocket close frame often arrives as an RST and the peer never sees the goodbye.
|
||||
* Over TLS there is nothing to flush -- the close_notify rides the same queue, and
|
||||
* tls::Session::close() sends what it can and moves on.
|
||||
*/
|
||||
void flushBeforeClose() {
|
||||
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->flush();
|
||||
}
|
||||
|
||||
// ---- outbound -------------------------------------------------------------
|
||||
|
||||
/** Whole frame or nothing: false leaves the queue untouched. */
|
||||
bool push(const uint8_t *src, size_t len) {
|
||||
if (!src || len == 0) return len == 0;
|
||||
return _ring.push(src, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two segments queued atomically -- an HTTP head followed by a body that lives in RAM
|
||||
* (a status JSON, a one-line error). Either both fit or neither is queued, because a
|
||||
* head without its body is a response that lies about Content-Length.
|
||||
*/
|
||||
bool pushPair(const uint8_t *a, size_t alen, const uint8_t *b, size_t blen) {
|
||||
if (alen + blen > _ring.capacity() - _ring.count()) return false;
|
||||
if (alen && !_ring.push(a, alen)) return false;
|
||||
if (blen && !_ring.push(b, blen)) return false; // cannot happen: sized above
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A head in RAM followed by a body in flash: the page, and nothing else is this big.
|
||||
* The head is queued, the body is armed here and copied through the chunk buffer as the
|
||||
* pump gets to it.
|
||||
*/
|
||||
bool pushFlashBody(const uint8_t *head, size_t headLen, const char *body, size_t bodyLen) {
|
||||
if (!body || bodyLen == 0) return push(head, headLen) || headLen == 0;
|
||||
if (headLen && headLen > _ring.capacity() - _ring.count()) return false;
|
||||
if (headLen && !_ring.push(head, headLen)) return false;
|
||||
_flash.start(body, bodyLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool busyOut() const {
|
||||
return _ring.count() != 0 || _pendLen != 0 || _flash.armed();
|
||||
}
|
||||
size_t queuedOut() const { return _ring.count() + _pendLen + _flash.remaining(); }
|
||||
const FlashSend &flashSend() const { return _flash; }
|
||||
|
||||
/**
|
||||
* Hand the transport as much as it will take, up to `budgetBytes` this call, one write of
|
||||
* at most `kConnWriteChunk` at a time. True means the queue is empty; false means "not
|
||||
* yet" -- including "the peer is not reading", which the caller owns as a stall deadline.
|
||||
* Never waits, never spins, and a partial write resumes from the identical bytes.
|
||||
*/
|
||||
bool pump(size_t budgetBytes) {
|
||||
while (budgetBytes > 0) {
|
||||
if (_pendLen == 0) {
|
||||
size_t n = 0;
|
||||
const size_t cap = budgetBytes < kConnWriteChunk ? budgetBytes : kConnWriteChunk;
|
||||
const uint8_t *p = _ring.head(cap, &n);
|
||||
if (p && n) {
|
||||
_pendPtr = p;
|
||||
_pendIsChunk = false;
|
||||
_pendLen = n;
|
||||
} else if (_flash.armed()) {
|
||||
// The ring first, always: the head precedes the body it describes.
|
||||
const size_t c = _flash.next(kPageChunkBytes, budgetBytes);
|
||||
if (c == 0) return false; // budget spent; the rest goes next tick
|
||||
_flash.copyTo(_chunk, c);
|
||||
_pendPtr = reinterpret_cast<const uint8_t *>(_chunk);
|
||||
_pendIsChunk = true;
|
||||
_pendLen = c;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const size_t acc = writeRaw(_pendPtr, _pendLen);
|
||||
if (acc == 0) return false;
|
||||
if (_pendIsChunk) _flash.advance(acc);
|
||||
else _ring.advance(acc);
|
||||
_pendPtr += acc;
|
||||
_pendLen -= acc;
|
||||
budgetBytes -= (acc < budgetBytes) ? acc : budgetBytes;
|
||||
}
|
||||
return !busyOut();
|
||||
}
|
||||
|
||||
/** Drop everything queued. Used when the link is going away, never mid-response. */
|
||||
void resetOut() {
|
||||
_ring.reset();
|
||||
_pendPtr = nullptr;
|
||||
_pendIsChunk = false;
|
||||
_pendLen = 0;
|
||||
_flash.stop();
|
||||
}
|
||||
|
||||
private:
|
||||
/** What the transport accepted this call: whole, partial or nothing. Never waits. */
|
||||
size_t writeRaw(const uint8_t *p, size_t len) {
|
||||
if (!p || len == 0) return 0;
|
||||
if (_kind == Kind::Plain) {
|
||||
if (!_plain || !static_cast<bool>(*_plain)) return 0;
|
||||
const int fd = _plain->fd();
|
||||
if (fd < 0) return 0;
|
||||
// Never enter NetworkClient::write() unless the socket will take bytes THIS instant.
|
||||
// That helper retries a blocking select() up to WIFI_CLIENT_MAX_WRITE_RETRY (10) times
|
||||
// with WIFI_CLIENT_SELECT_TIMEOUT_US (1 s) each, which is how one laptop that stopped
|
||||
// reading -- locked tab, asleep phone, closed lid -- could hold loop(), and therefore
|
||||
// the keystroke path, for ten seconds inside a single page write. A zero-timeout
|
||||
// select turns "not now" into a 0 return and a retry on the next tick, which is what
|
||||
// pump() already handles for TLS.
|
||||
fd_set wfds;
|
||||
FD_ZERO(&wfds);
|
||||
FD_SET(fd, &wfds);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 0;
|
||||
if (select(fd + 1, nullptr, &wfds, nullptr, &tv) <= 0) return 0;
|
||||
if (!FD_ISSET(fd, &wfds)) return 0;
|
||||
return _plain->write(p, len); // may be partial; the caller keeps the cursor
|
||||
}
|
||||
#if KR_TLS
|
||||
if (_kind == Kind::Tls && _tls) return _tls->write(p, len);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
enum class Kind : uint8_t { None, Plain, Tls };
|
||||
|
||||
Kind _kind = Kind::None;
|
||||
WiFiClient *_plain = nullptr; // owned by ControlServer, outlives every use here
|
||||
#if KR_TLS
|
||||
tls::Session *_tls = nullptr; // likewise
|
||||
#endif
|
||||
tls::TxRing _ring{nullptr, 1};
|
||||
const uint8_t *_pendPtr = nullptr;
|
||||
bool _pendIsChunk = false;
|
||||
size_t _pendLen = 0;
|
||||
FlashSend _flash;
|
||||
char _chunk[kPageChunkBytes]; // the bounded RAM copy of the flash body
|
||||
};
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
} // namespace kr
|
||||
+547
-154
@@ -1,10 +1,28 @@
|
||||
/**
|
||||
* keyrelay -- control server implementation. See control_server.h for the shape
|
||||
* and the reasoning; this file is the socket mechanics.
|
||||
* and the reasoning; this file is the routing, framing and lifecycle on top of the
|
||||
* transport view in conn.h.
|
||||
*
|
||||
* The rule this file lives or dies by: one controller slot, ONE routing table, ONE
|
||||
* read-only answer, and a transport the routing code barely notices. acceptNew() asks
|
||||
* tls::admitConnection() the same question for both listeners; serveHttp() and
|
||||
* serveViewer() read a request through a kr::Conn and route it without looking at which
|
||||
* transport answered; every response is built by buildResponse()/serveReadonly() and
|
||||
* queued by respondAndClose()/respondViewer(). The transport is visible in exactly three
|
||||
* places: which Conn a slot was bound to, the `plaintext` argument that selects the :80
|
||||
* policy in serveReadonly(), and the handshake question Conn::ready()/poll() answers.
|
||||
* A phase that adds a third transport adds one bind, not a second state machine.
|
||||
*/
|
||||
#include "control_server.h"
|
||||
|
||||
#include <ESPmDNS.h>
|
||||
#include <string.h>
|
||||
|
||||
#if KR_TLS
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <lwip/sockets.h>
|
||||
#endif
|
||||
|
||||
#include "web_page.h"
|
||||
|
||||
@@ -12,64 +30,43 @@ namespace kr {
|
||||
|
||||
namespace {
|
||||
|
||||
// HTTP responses are built by hand: one page, one JSON endpoint, one upgrade. A web
|
||||
// framework would add a request object, a route table and a buffer pool to serve
|
||||
// four URLs.
|
||||
void httpResponse(WiFiClient &c, const char *status, const char *contentType,
|
||||
const char *body, size_t len) {
|
||||
char head[192];
|
||||
const int n = snprintf(head, sizeof(head),
|
||||
"HTTP/1.1 %s\r\n"
|
||||
"Content-Type: %s\r\n"
|
||||
"Content-Length: %u\r\n"
|
||||
"Cache-Control: no-store\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n"
|
||||
"Connection: close\r\n\r\n",
|
||||
status, contentType, (unsigned)len);
|
||||
if (n > 0) c.write((const uint8_t *)head, (size_t)n);
|
||||
if (body && len) c.write((const uint8_t *)body, len);
|
||||
}
|
||||
// The status frame itself -- StatusSnapshot, jsonSafe() and statusJson() -- is in
|
||||
// status.h, so that the host suite can render it. Only the sockets live here.
|
||||
|
||||
void httpError(WiFiClient &c, const char *status, const char *text) {
|
||||
httpResponse(c, status, "text/plain; charset=utf-8", text, strlen(text));
|
||||
}
|
||||
|
||||
// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather
|
||||
// than trusting whatever a neighbouring access point is called.
|
||||
void jsonSafe(char *dst, size_t cap, const char *src) {
|
||||
size_t o = 0;
|
||||
for (; src && *src && o + 1 < cap; src++) {
|
||||
const char ch = *src;
|
||||
if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue;
|
||||
dst[o++] = ch;
|
||||
}
|
||||
dst[o] = '\0';
|
||||
}
|
||||
|
||||
constexpr char kPathIndex[] = "/";
|
||||
constexpr char kPathIndexHtml[] = "/index.html";
|
||||
// kPathIndex and kPathIndexHtml come from conn.h, where the redirect builder needs them
|
||||
// too; these two are routing-only.
|
||||
constexpr char kPathStatus[] = "/status";
|
||||
constexpr char kPathWs[] = "/ws";
|
||||
|
||||
} // namespace
|
||||
// A socket that opened, sent a partial request and went silent gets this long before
|
||||
// it is dropped -- the pre-TLS value, applied unchanged to every request path (plain,
|
||||
// TLS controller, TLS spare) so "stalled" means one number everywhere. A TLS *handshake*
|
||||
// stall has its own deadline (kTlsHandshakeMs, enforced inside the session) because a
|
||||
// stalled handshake never reaches "request". And since the page became a resumable send,
|
||||
// the same clock covers a peer that stopped *reading*: a response half-delivered is a
|
||||
// client that will render garbage, and a link that is not taking bytes is a link gone.
|
||||
constexpr uint32_t kRequestStallMs = 10000;
|
||||
|
||||
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) {
|
||||
char ssid[40];
|
||||
jsonSafe(ssid, sizeof(ssid), s.ssid);
|
||||
const int n = snprintf(
|
||||
out, cap,
|
||||
"{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\","
|
||||
"\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu,"
|
||||
"\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u,"
|
||||
"\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu}",
|
||||
s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u,
|
||||
s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent,
|
||||
(unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState,
|
||||
s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u,
|
||||
(unsigned long)s.typedChars, (unsigned)s.typing,
|
||||
s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames);
|
||||
return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n;
|
||||
}
|
||||
// The :80 answers, in a KR_TLS build. Bodies are flash literals with no client text in
|
||||
// them -- the one piece of client-supplied data in either response is the Location header,
|
||||
// and that goes through kr::renderRedirectTarget(), which refuses anything that is not a
|
||||
// name or path this board can actually be reached at.
|
||||
//
|
||||
// 426 says "the thing you asked for needs a protocol this port does not speak", and RFC
|
||||
// 5246/4266 want an Upgrade field with it. We cannot upgrade in place -- there is no
|
||||
// in-place TLS upgrade for HTTP/1.1 in this stack, and pretending otherwise would be a
|
||||
// second protocol -- so the connection closes and the field names what to come back as.
|
||||
constexpr char kUpgradeHeader[] = "Upgrade: TLS/1.2, HTTP/1.1\r\n";
|
||||
constexpr char kBodyPlaintextWs[] =
|
||||
"keyrelay: this controller channel is TLS-only -- reconnect over wss://, the page is"
|
||||
" on https://\n";
|
||||
constexpr char kBodyPlaintextToken[] =
|
||||
"keyrelay: 426 -- the passphrase must not cross this port in plaintext. Use"
|
||||
" https:// on this host (curl -k https://<board>/status?token=...)\n";
|
||||
constexpr char kBodyRedirect[] =
|
||||
"keyrelay: this port redirects. Follow Location, or use https:// on this host\n";
|
||||
|
||||
} // namespace
|
||||
|
||||
void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange,
|
||||
FillStatus fillStatus, GetToken getToken, void *ctx) {
|
||||
@@ -81,11 +78,15 @@ void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange,
|
||||
_server.begin(kHttpPort);
|
||||
_server.setNoDelay(true);
|
||||
MDNS.addService("http", "tcp", kHttpPort);
|
||||
#if KR_TLS
|
||||
startTlsListener();
|
||||
if (_tlsListen >= 0) MDNS.addService("https", "tcp", kHttpsPort);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ControlServer::tick(uint32_t nowMs) {
|
||||
acceptNew(nowMs);
|
||||
if (_viewer) serveViewer(nowMs);
|
||||
if (_viewOpen) serveViewer(nowMs);
|
||||
|
||||
switch (_phase) {
|
||||
case Phase::Http: serveHttp(nowMs); break;
|
||||
@@ -93,6 +94,11 @@ void ControlServer::tick(uint32_t nowMs) {
|
||||
default: break;
|
||||
}
|
||||
|
||||
// One drain per tick, after everything that could have produced bytes. pump() stops at
|
||||
// the first transport that will not take more, so an unreadable peer costs this loop one
|
||||
// syscall (plaintext: one zero-timeout select), never a wait.
|
||||
pumpSlots(nowMs);
|
||||
|
||||
// A socket that opens and never speaks is a scan, not a controller.
|
||||
if (_phase == Phase::WebSocket && !_authed &&
|
||||
nowMs - _connectedAtMs > kAuthTimeoutMs) {
|
||||
@@ -110,17 +116,27 @@ void ControlServer::tick(uint32_t nowMs) {
|
||||
_lastPingMs = nowMs;
|
||||
sendPing();
|
||||
}
|
||||
// The heartbeat and the ping produced bytes this tick; they leave in the drain below,
|
||||
// which is now the only place any slot's queue is pushed at the transport.
|
||||
pumpSlots(nowMs);
|
||||
}
|
||||
|
||||
if (_client && !_client.connected() && _phase != Phase::None) closeLink("socket closed");
|
||||
// The link died -- peer reset, peer gone, or (TLS) a fatal record that already
|
||||
// released the session. Conn::alive() is the same question for both transports, and this
|
||||
// check is deliberately the LAST thing tick() does with the slot: everything above got
|
||||
// its chance to notice and clean up first.
|
||||
if (_phase != Phase::None && !_ctl.alive()) closeLink("socket closed");
|
||||
}
|
||||
|
||||
void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
#if KR_TLS
|
||||
if (_tlsListen >= 0) admitTls(nowMs);
|
||||
#endif
|
||||
if (!_server.hasClient()) return;
|
||||
WiFiClient fresh = _server.accept();
|
||||
if (!fresh) return;
|
||||
|
||||
if (_phase != Phase::None && _client && !_client.connected()) {
|
||||
if (_phase != Phase::None && !_ctl.alive()) {
|
||||
// The incumbent controller is gone and its socket says so. Taking over without
|
||||
// settling it would inherit its armed state and whatever keys it left down: the
|
||||
// release funnel runs on a link *change*, and a silent handover to an unrelated
|
||||
@@ -128,13 +144,30 @@ void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
closeLink("controller socket closed");
|
||||
}
|
||||
|
||||
if (_phase != Phase::None && _client && _client.connected()) {
|
||||
const bool ctlBusy = _phase != Phase::None;
|
||||
const bool spare = spareBusy();
|
||||
const tls::Admit admit = tls::admitConnection(
|
||||
ctlBusy, (ctlBusy ? 1u : 0u) + (spare ? 1u : 0u), kTlsMaxSessions);
|
||||
|
||||
if (admit == tls::Admit::Refused) {
|
||||
// Two slots full. The answer this socket gets depends on its transport only in
|
||||
// HOW it is refused (TLS: alert record then close; plaintext: close), never in
|
||||
// WHETHER -- the decision above did not ask which port it came in on.
|
||||
_slotRefused++;
|
||||
fresh.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (admit == tls::Admit::Spare) {
|
||||
// The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket
|
||||
// is still allowed to look at the board. Only a second /ws upgrade -- a controller
|
||||
// claim -- is refused, and serveViewer() is what decides that.
|
||||
if (_viewer) stopViewer();
|
||||
// is still allowed to look at the board. Only a /ws upgrade -- a controller claim --
|
||||
// is refused, and serveViewer() is what decides that.
|
||||
if (_viewOpen) stopViewer(); // the spare is dead-but-flagged; settle it first
|
||||
_viewer = fresh;
|
||||
_viewer.setNoDelay(true);
|
||||
_view.bindPlain(&_viewer, _viewTx, sizeof(_viewTx));
|
||||
_viewOpen = true;
|
||||
_viewAfter = AfterDrain::Nothing;
|
||||
_viewerLen = 0;
|
||||
_viewerHttp[0] = '\0';
|
||||
_viewerAtMs = nowMs;
|
||||
@@ -142,6 +175,8 @@ void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
}
|
||||
fresh.setNoDelay(true);
|
||||
_client = fresh;
|
||||
_ctl.bindPlain(&_client, _ctlTx, sizeof(_ctlTx));
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
_phase = Phase::Http;
|
||||
_httpLen = 0;
|
||||
_authed = false;
|
||||
@@ -155,33 +190,170 @@ void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
}
|
||||
|
||||
size_t ControlServer::readSome(uint8_t *dst, size_t cap) {
|
||||
if (!_client || !_client.available()) return 0;
|
||||
const int n = _client.read(dst, cap);
|
||||
if (n > 0) {
|
||||
// The controller slot only, and only its Conn: the spare slot reads through _view
|
||||
// directly so that a read-only viewer can never be mistaken for the operator.
|
||||
const size_t got = _ctl.read(dst, cap);
|
||||
if (got) {
|
||||
// "The controller is alive" means the *authenticated controller on the WebSocket* is
|
||||
// sending. Bytes from a half-read HTTP request, an unauthenticated socket or a
|
||||
// read-only /status poll must not count, or the fail-safe that is supposed to fire 8 s
|
||||
// after a controller vanishes can be held open by anything else on the network.
|
||||
// sending. Bytes from a half-read HTTP request, an unauthenticated socket, a TLS
|
||||
// handshake or a read-only /status poll must not count, or the fail-safe that is
|
||||
// supposed to fire 8 s after a controller vanishes can be held open by anything else
|
||||
// on the network. That is why this is the only place a read stamps the clock, and why
|
||||
// the condition is the phase and the authentication and not merely "bytes arrived":
|
||||
// over TLS a handshake and over :443 a /status poll look exactly like a controller to
|
||||
// a transport that does not know the difference. tests/test_relayctl.py pins it.
|
||||
if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();
|
||||
return (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
return got;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responses: built once, queued on whichever transport the slot holds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ControlServer::buildResponse(BuiltResponse &rsp, const char *status,
|
||||
const char *contentType, BuiltResponse::Body body,
|
||||
const char *text, size_t textLen, const char *extra,
|
||||
bool textInFlash) {
|
||||
rsp.body = body;
|
||||
rsp.text = nullptr;
|
||||
rsp.textLen = 0;
|
||||
rsp.textInFlash = false;
|
||||
if (body == BuiltResponse::Body::Text) {
|
||||
rsp.text = text;
|
||||
rsp.textLen = textLen;
|
||||
rsp.textInFlash = textInFlash;
|
||||
}
|
||||
// A "JSON" body that failed to render (statusJson returns 0 when the frame would not
|
||||
// fit -- it never sends a truncated status) degrades to no body, and the content
|
||||
// length says so. Half a status frame is a lie; an empty one is an instrument panel
|
||||
// that admits it is dark.
|
||||
if (body == BuiltResponse::Body::Json && rsp.jsonLen == 0) rsp.body = BuiltResponse::Body::None;
|
||||
const size_t len = rsp.body == BuiltResponse::Body::Json
|
||||
? rsp.jsonLen
|
||||
: (rsp.body == BuiltResponse::Body::Text ? rsp.textLen : 0);
|
||||
// One formatter, in conn.h, host-tested for the widest legal block (including a
|
||||
// Location). It returns 0 rather than a truncated head: half a header block desynchronises
|
||||
// the stream, and a response with no head is a closed socket the client can read as
|
||||
// "no answer" instead of "a corrupt answer".
|
||||
rsp.headLen = renderHttpResponseHead(rsp.head, sizeof(rsp.head), status, contentType, len,
|
||||
extra);
|
||||
}
|
||||
|
||||
bool ControlServer::queueResponse(Conn &conn, AfterDrain &after, AfterDrain done,
|
||||
const BuiltResponse &rsp) {
|
||||
if (!rsp.headLen) return false; // nothing safe to send; the caller closes the link
|
||||
bool ok = false;
|
||||
if (rsp.body == BuiltResponse::Body::Text && rsp.textInFlash && rsp.textLen) {
|
||||
ok = conn.pushFlashBody(reinterpret_cast<const uint8_t *>(rsp.head), rsp.headLen,
|
||||
rsp.text, rsp.textLen);
|
||||
} else {
|
||||
const uint8_t *body = nullptr;
|
||||
size_t bodyLen = 0;
|
||||
if (rsp.body == BuiltResponse::Body::Text && rsp.textLen) {
|
||||
body = reinterpret_cast<const uint8_t *>(rsp.text);
|
||||
bodyLen = rsp.textLen;
|
||||
} else if (rsp.body == BuiltResponse::Body::Json && rsp.jsonLen) {
|
||||
body = reinterpret_cast<const uint8_t *>(rsp.json);
|
||||
bodyLen = rsp.jsonLen;
|
||||
}
|
||||
ok = conn.pushPair(reinterpret_cast<const uint8_t *>(rsp.head), rsp.headLen, body,
|
||||
bodyLen);
|
||||
}
|
||||
if (!ok) return false;
|
||||
after = done; // the link (or the spare) goes when pumpSlots() says it has all left
|
||||
return true;
|
||||
}
|
||||
|
||||
void ControlServer::respondAndClose(const BuiltResponse &rsp) {
|
||||
if (!queueResponse(_ctl, _ctlAfter, AfterDrain::CloseLink, rsp))
|
||||
closeLink("response would not fit");
|
||||
}
|
||||
|
||||
void ControlServer::respondViewer(const BuiltResponse &rsp) {
|
||||
if (!queueResponse(_view, _viewAfter, AfterDrain::StopViewer, rsp)) stopViewer();
|
||||
}
|
||||
|
||||
void ControlServer::pumpSlots(uint32_t nowMs) {
|
||||
if (_phase != Phase::None && (_ctl.busyOut() || _ctlAfter != AfterDrain::Nothing)) {
|
||||
if (_ctl.pump(kPageBytesPerTick)) {
|
||||
const AfterDrain after = _ctlAfter;
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
if (after == AfterDrain::CloseLink) {
|
||||
closeLink("served");
|
||||
return;
|
||||
}
|
||||
} else if (_phase == Phase::Http &&
|
||||
tls::deadlinePassed(nowMs, _connectedAtMs, kRequestStallMs)) {
|
||||
// Same stall rule that always applied to a stalled *request*, now covering a stalled
|
||||
// *send*: a peer that will not take the page is not waiting for it. Bounded by
|
||||
// kRequestStallMs, never by however long the peer takes.
|
||||
closeLink("http stalled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_viewOpen && (_view.busyOut() || _viewAfter != AfterDrain::Nothing)) {
|
||||
if (_view.pump(kPageBytesPerTick)) {
|
||||
const AfterDrain after = _viewAfter;
|
||||
_viewAfter = AfterDrain::Nothing;
|
||||
if (after == AfterDrain::StopViewer) {
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
} else if (tls::deadlinePassed(nowMs, _viewerAtMs, kRequestStallMs)) {
|
||||
stopViewer(); // stalled; drop it, no reply owed (the rule the plain viewer always had)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP on the controller slot: routes, then one queueing path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ControlServer::serveHttp(uint32_t nowMs) {
|
||||
// Read until the header block is complete, then route. A request line larger
|
||||
// than the buffer is refused instead of buffered, so the board cannot be pushed
|
||||
// into allocating around an attacker's header size.
|
||||
if (!_ctl.bound()) { closeLink("no transport"); return; }
|
||||
|
||||
if (!_ctl.ready()) {
|
||||
// Not carrying application bytes yet, which for a TLS slot means the handshake is
|
||||
// still in flight: ONE library call per tick (see Session::poll), bounded by
|
||||
// kTlsHandshakeMs inside the session, and Failed means the socket is already
|
||||
// released. A plaintext slot is ready the moment it is attached, so this is where a
|
||||
// plaintext socket that vanished between accept() and here gets noticed.
|
||||
const tls::Handshake h = _ctl.poll();
|
||||
if (h == tls::Handshake::Failed) {
|
||||
closeLink("tls handshake failed");
|
||||
return;
|
||||
}
|
||||
if (h == tls::Handshake::Ready) {
|
||||
// The HTTP stall clock starts here, not at accept: a handshake that legitimately
|
||||
// took most of kTlsHandshakeMs should not arrive already in timeout on its first
|
||||
// request, and the handshake itself was already bounded by its own deadline.
|
||||
_connectedAtMs = nowMs;
|
||||
_httpLen = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ctlAfter != AfterDrain::Nothing) return; // a response is draining; pumpSlots owns it
|
||||
|
||||
// Read until the header block is complete, then route. A request line larger than the
|
||||
// buffer is refused instead of buffered, so the board cannot be pushed into allocating
|
||||
// around an attacker's header size.
|
||||
for (;;) {
|
||||
const size_t space = sizeof(_http) - _httpLen - 1;
|
||||
if (space == 0) {
|
||||
httpError(_client, "431 Request Header Fields Too Large", "header block too large\n");
|
||||
closeLink("header too large");
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "431 Request Header Fields Too Large", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "header block too large\n",
|
||||
strlen("header block too large\n"));
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
const size_t got = readSome((uint8_t *)_http + _httpLen, space);
|
||||
if (got == 0) {
|
||||
if (nowMs - _connectedAtMs > 10000) closeLink("http stalled");
|
||||
if (tls::deadlinePassed(nowMs, _connectedAtMs, kRequestStallMs))
|
||||
closeLink("http stalled");
|
||||
return;
|
||||
}
|
||||
_httpLen += got;
|
||||
@@ -191,17 +363,48 @@ void ControlServer::serveHttp(uint32_t nowMs) {
|
||||
const int r = ws::parseRequest(_http, _httpLen, req);
|
||||
if (r == 0) continue;
|
||||
if (r < 0) {
|
||||
httpError(_client, "400 Bad Request", "malformed request\n");
|
||||
closeLink("malformed request");
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "malformed request\n",
|
||||
strlen("malformed request\n"));
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- routes ---------------------------------------------------------------
|
||||
if (req.upgradeWebsocket && strcmp(req.path, kPathWs) == 0 && req.wsKey[0]) {
|
||||
if (req.upgradeWebsocket) {
|
||||
#if KR_TLS
|
||||
if (!_ctl.isTls()) {
|
||||
// A controller claim on the plaintext port. Refused, not downgraded (invariant 5:
|
||||
// never quietly accept the weaker thing), and not redirected either: a 301 would
|
||||
// answer a WebSocket question with a page navigation. Counted with the other
|
||||
// refused claims, because that is exactly what it is.
|
||||
_rejected++;
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyPlaintextWs,
|
||||
strlen(kBodyPlaintextWs), kUpgradeHeader);
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (strcmp(req.path, kPathWs) != 0 || !req.wsKey[0]) {
|
||||
// An upgrade for anything other than a well-formed /ws is not a controller and
|
||||
// not a page either; there is nothing to route it to.
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "malformed websocket request\n",
|
||||
strlen("malformed websocket request\n"));
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
char accept[64];
|
||||
if (!ws::acceptFor(req.wsKey, strlen(req.wsKey), accept, sizeof(accept))) {
|
||||
httpError(_client, "400 Bad Request", "bad websocket key\n");
|
||||
closeLink("bad ws key");
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "bad websocket key\n",
|
||||
strlen("bad websocket key\n"));
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
char head[224];
|
||||
@@ -211,8 +414,10 @@ void ControlServer::serveHttp(uint32_t nowMs) {
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Accept: %s\r\n\r\n",
|
||||
accept);
|
||||
if (n <= 0) { closeLink("handshake failed"); return; }
|
||||
_client.write((const uint8_t *)head, (size_t)n);
|
||||
if (n <= 0 || !_ctl.push(reinterpret_cast<const uint8_t *>(head), (size_t)n)) {
|
||||
closeLink("handshake failed");
|
||||
return;
|
||||
}
|
||||
_phase = Phase::WebSocket;
|
||||
_reader.reset();
|
||||
_connectedAtMs = nowMs;
|
||||
@@ -221,41 +426,69 @@ void ControlServer::serveHttp(uint32_t nowMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.upgradeWebsocket) {
|
||||
// An upgrade for anything other than a well-formed /ws is not a controller and
|
||||
// not a page either; there is nothing to route it to.
|
||||
httpError(_client, "400 Bad Request", "malformed websocket request\n");
|
||||
closeLink("bad ws request");
|
||||
return;
|
||||
}
|
||||
|
||||
serveReadonly(_client, req);
|
||||
closeLink("served");
|
||||
BuiltResponse rsp;
|
||||
serveReadonly(req, rsp, !_ctl.isTls());
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The read-only routes: the controller page, the JSON status, favicon, 404. Shared by
|
||||
// the ordinary path above and the viewer path below on purpose -- "who may drive the
|
||||
// keyboard" and "who may see this board" are different questions, and the answer to the
|
||||
// second one must not be able to drift from the first.
|
||||
void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) {
|
||||
// The read-only routes: the controller page, the JSON status, favicon, 404 -- and, in a
|
||||
// KR_TLS build on the plaintext port, the redirect and the two 426s. Shared by the
|
||||
// ordinary path above and the viewer path below on purpose: "who may drive the keyboard"
|
||||
// and "who may see this board" are different questions, and the answer to the second one
|
||||
// must not be able to drift from the first. Nor may the two ports drift: this is the only
|
||||
// place either port answers, so "what does :80 serve" has exactly one answer, and it is
|
||||
// "nothing but the way to :443". It BUILDS the response rather than writing it, because
|
||||
// the write is the one part that differs per transport; the decision lives here, once.
|
||||
void ControlServer::serveReadonly(const ws::Request &req, BuiltResponse &rsp,
|
||||
bool plaintext) {
|
||||
#if KR_TLS
|
||||
if (plaintext) {
|
||||
// The table is pure (conn.h) so the whole policy is host-tested; `true` is this build's
|
||||
// answer to "do I serve TLS?", and in the -D KR_TLS=0 rollback this block does not
|
||||
// exist at all -- which is what makes that rollback today's server exactly, rather than
|
||||
// a reimplementation of it.
|
||||
const PlainRoute route = plaintextRoute(false, req.query, true);
|
||||
if (route == PlainRoute::UpgradeRequired) {
|
||||
// Only one shape reaches here from serveReadonly(): a query carrying `token=`. The
|
||||
// body names the fix without repeating the value, and there is no Location header --
|
||||
// a redirect would echo the query and leak the passphrase a second time.
|
||||
buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyPlaintextToken,
|
||||
strlen(kBodyPlaintextToken), kUpgradeHeader);
|
||||
return;
|
||||
}
|
||||
if (route == PlainRoute::Redirect) {
|
||||
char loc[128];
|
||||
const size_t locLen = renderRedirectTarget(req.host, req.path, loc, sizeof(loc));
|
||||
char extra[160];
|
||||
if (locLen == 0 ||
|
||||
snprintf(extra, sizeof(extra), "Location: %s\r\n", loc) < 0) {
|
||||
buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyPlaintextToken,
|
||||
strlen(kBodyPlaintextToken), kUpgradeHeader);
|
||||
return;
|
||||
}
|
||||
buildResponse(rsp, "301 Moved Permanently", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyRedirect, strlen(kBodyRedirect), extra);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (strcmp(req.method, "GET") != 0) {
|
||||
httpError(c, "405 Method Not Allowed", "GET only\n");
|
||||
buildResponse(rsp, "405 Method Not Allowed", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "GET only\n", strlen("GET only\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) {
|
||||
char head[192];
|
||||
const int n = snprintf(head, sizeof(head),
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: text/html; charset=utf-8\r\n"
|
||||
"Content-Length: %u\r\n"
|
||||
"Cache-Control: no-store\r\n"
|
||||
"Connection: close\r\n\r\n",
|
||||
(unsigned)kIndexHtmlLen);
|
||||
if (n > 0) c.write((const uint8_t *)head, (size_t)n);
|
||||
c.write_P(kIndexHtml, kIndexHtmlLen);
|
||||
// The page is the one body too big to queue: it is streamed out of flash through a
|
||||
// bounded chunk (config.h kPageChunkBytes / kPageBytesPerTick), which is what lets a
|
||||
// 48 KB response leave across ticks on either transport without one blocking write.
|
||||
buildResponse(rsp, "200 OK", "text/html; charset=utf-8", BuiltResponse::Body::Text,
|
||||
kIndexHtml, kIndexHtmlLen, nullptr, /*textInFlash=*/true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,80 +499,123 @@ void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) {
|
||||
const char *want = _getToken ? _getToken(_ctx) : "";
|
||||
if (!ws::tokenFromQuery(req.query, strlen(req.query), token, sizeof(token)) ||
|
||||
!proto::constTimeEq(token, strlen(token), want, strlen(want))) {
|
||||
httpError(c, "401 Unauthorized", "token required\n");
|
||||
buildResponse(rsp, "401 Unauthorized", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "token required\n",
|
||||
strlen("token required\n"));
|
||||
return;
|
||||
}
|
||||
StatusSnapshot s;
|
||||
if (_fillStatus) _fillStatus(_ctx, s);
|
||||
char json[kStatusJsonCap];
|
||||
const size_t len = statusJson(s, json, sizeof(json));
|
||||
httpResponse(c, "200 OK", "application/json", json, len);
|
||||
rsp.jsonLen = statusJson(s, rsp.json, sizeof(rsp.json));
|
||||
buildResponse(rsp, "200 OK", "application/json", BuiltResponse::Body::Json,
|
||||
nullptr, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(req.path, "/favicon.ico") == 0) {
|
||||
httpResponse(c, "204 No Content", "text/plain", nullptr, 0);
|
||||
buildResponse(rsp, "204 No Content", "text/plain", BuiltResponse::Body::None,
|
||||
nullptr, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
httpError(c, "404 Not Found", "keyrelay: / (page), /ws (websocket), "
|
||||
"/status?token=... (json)\n");
|
||||
const char *missing =
|
||||
"keyrelay: / (page), /ws (websocket), /status?token=... (json)\n";
|
||||
buildResponse(rsp, "404 Not Found", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, missing, strlen(missing));
|
||||
}
|
||||
|
||||
// A connection that arrived while the controller slot was occupied: serve it read-only
|
||||
// and close. This exists because the first version refused everything, which turned the
|
||||
// most ordinary gesture -- pressing reload on the page that already holds the bridge --
|
||||
// into a wall of text, and left a running controller with no way to be inspected.
|
||||
// Still refused here: a second /ws upgrade, because that is a controller claim.
|
||||
// Still refused here: a /ws upgrade, because that is a controller claim -- and on the
|
||||
// plaintext port it is refused as a transport question (426) before it is refused as a
|
||||
// slot question (503), so a client never learns "busy" when the answer is "TLS only".
|
||||
void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
if (!_viewer) {
|
||||
stopViewer();
|
||||
if (!_view.attached()) { stopViewer(); return; }
|
||||
|
||||
if (!_view.ready()) {
|
||||
// The spare slot's handshake is paced exactly like the controller's: one call per
|
||||
// tick, its own deadline inside the session, Failed means already-released.
|
||||
if (_view.poll() == tls::Handshake::Failed) stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
while (_viewer.available() && _viewerLen + 1 < sizeof(_viewerHttp)) {
|
||||
const int b = _viewer.read();
|
||||
if (b <= 0) break;
|
||||
_viewerHttp[_viewerLen++] = (char)b;
|
||||
_viewerHttp[_viewerLen] = '\0';
|
||||
if (_viewAfter != AfterDrain::Nothing) return; // draining; pumpSlots owns it
|
||||
|
||||
for (;;) {
|
||||
const size_t space = sizeof(_viewerHttp) - _viewerLen - 1;
|
||||
if (space == 0) {
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "431 Request Header Fields Too Large", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "header block too large\n",
|
||||
strlen("header block too large\n"));
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
// Directly on the Conn, never readSome(): whoever is watching is not whoever is
|
||||
// typing, and the controller-silence fail-safe must not be held open by a viewer
|
||||
// refreshing /status.
|
||||
const size_t got = _view.read(reinterpret_cast<uint8_t *>(_viewerHttp) + _viewerLen,
|
||||
space);
|
||||
if (got == 0) {
|
||||
if (tls::deadlinePassed(nowMs, _viewerAtMs, kRequestStallMs)) stopViewer();
|
||||
return;
|
||||
}
|
||||
_viewerLen += got;
|
||||
_viewerHttp[_viewerLen] = '\0';
|
||||
|
||||
ws::Request req;
|
||||
const int r = ws::parseRequest(_viewerHttp, _viewerLen, req);
|
||||
if (r == 0) {
|
||||
if (_viewerLen + 1 >= sizeof(_viewerHttp)) {
|
||||
httpError(_viewer, "431 Request Header Fields Too Large", "header block too large\n");
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
if (nowMs - _viewerAtMs > 10000) stopViewer(); // stalled; drop it, no reply owed
|
||||
return;
|
||||
}
|
||||
if (r == 0) continue;
|
||||
if (r < 0) {
|
||||
httpError(_viewer, "400 Bad Request", "malformed request\n");
|
||||
stopViewer();
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "400 Bad Request", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, "malformed request\n",
|
||||
strlen("malformed request\n"));
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.upgradeWebsocket) {
|
||||
// One controller. The incumbent keeps the bridge; the newcomer is told why.
|
||||
httpError(_viewer, "503 Service Unavailable",
|
||||
"keyrelay: another controller already holds this bridge.\n");
|
||||
_rejected++;
|
||||
stopViewer();
|
||||
#if KR_TLS
|
||||
if (!_view.isTls()) {
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyPlaintextWs,
|
||||
strlen(kBodyPlaintextWs), kUpgradeHeader);
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// One controller. The incumbent keeps the bridge; the newcomer is told why -- over
|
||||
// either transport with the same 503, because the refusal to hand over the keyboard
|
||||
// is not a transport question either.
|
||||
BuiltResponse rsp;
|
||||
const char *busy = "keyrelay: another controller already holds this bridge.\n";
|
||||
buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, busy, strlen(busy));
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
|
||||
serveReadonly(_viewer, req);
|
||||
stopViewer();
|
||||
BuiltResponse rsp;
|
||||
serveReadonly(req, rsp, !_view.isTls());
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ControlServer::stopViewer() {
|
||||
if (_viewer) {
|
||||
_viewer.flush();
|
||||
_viewer.stop();
|
||||
if (_view.bound()) {
|
||||
_view.flushBeforeClose();
|
||||
_view.stop();
|
||||
}
|
||||
_viewer = WiFiClient();
|
||||
_view.unbind();
|
||||
_viewAfter = AfterDrain::Nothing;
|
||||
_viewer = WiFiClient(); // the shared handle closes the fd when this last copy dies
|
||||
_viewOpen = false;
|
||||
_viewerLen = 0;
|
||||
}
|
||||
|
||||
@@ -376,7 +652,7 @@ bool ControlServer::handleWsFrame(uint32_t nowMs) {
|
||||
memcpy(payload, _reader.payload(), plen);
|
||||
_reader.reset();
|
||||
const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame));
|
||||
if (n && _client && _client.connected()) _client.write(frame, n);
|
||||
if (n && _ctl.alive() && !_ctl.push(frame, n)) _txDropped++;
|
||||
return true;
|
||||
}
|
||||
if (opcode == ws::kOpPong) { _reader.reset(); return true; }
|
||||
@@ -430,18 +706,23 @@ bool ControlServer::handleWsFrame(uint32_t nowMs) {
|
||||
}
|
||||
|
||||
void ControlServer::sendPing() {
|
||||
if (!_client || !_client.connected()) return;
|
||||
if (!_ctl.alive()) return;
|
||||
uint8_t frame[8];
|
||||
const size_t n = ws::encodeFrame(ws::kOpPing, nullptr, 0, frame, sizeof(frame));
|
||||
if (n) _client.write(frame, n);
|
||||
if (n && !_ctl.push(frame, n)) _txDropped++;
|
||||
}
|
||||
|
||||
void ControlServer::writeTextFrame(const char *text, size_t len) {
|
||||
uint8_t frame[kWsMessageCap + 8];
|
||||
const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame));
|
||||
if (n == 0) return;
|
||||
if (!_client || !_client.connected()) return;
|
||||
_client.write(frame, n);
|
||||
if (!_ctl.alive()) return;
|
||||
// Whole frame or nothing (Conn::push): a dropped periodic status frame is one second of
|
||||
// staleness, a half-sent frame is a corrupted stream, and the plaintext transport could
|
||||
// previously deliver exactly the second one -- NetworkClient::write() returns the number
|
||||
// of bytes it managed, and the count used to be thrown away. The counter keeps drops
|
||||
// countable rather than silent.
|
||||
if (!_ctl.push(frame, n)) _txDropped++;
|
||||
}
|
||||
|
||||
void ControlServer::sendStatus() {
|
||||
@@ -474,15 +755,25 @@ void ControlServer::closeLink(const char *why) {
|
||||
// and linked() is exactly what stops being true when a controller goes away.
|
||||
// disarm() is a no-op when already disarmed, so this cannot over-release.
|
||||
const bool wasLinked = _phase == Phase::WebSocket;
|
||||
if (_client && _client.connected()) {
|
||||
if (_ctl.alive()) {
|
||||
if (_phase == Phase::WebSocket) {
|
||||
uint8_t frame[16];
|
||||
const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame));
|
||||
if (n && _phase == Phase::WebSocket) {
|
||||
_client.write(frame, n);
|
||||
_client.flush(); // without this the close frame is often RST'd instead
|
||||
if (n && _ctl.push(frame, n)) {
|
||||
// One bounded shove, then goodbye. Over TLS Session::close() adds the
|
||||
// close_notify on its way out. Neither is waited on -- a peer that is not reading
|
||||
// gets a torn-down connection, which is a truthful goodbye.
|
||||
_ctl.pump(kPageChunkBytes);
|
||||
_ctl.flushBeforeClose();
|
||||
}
|
||||
_client.stop();
|
||||
}
|
||||
}
|
||||
// The release runs whether or not the goodbye got through, and whether or not the
|
||||
// transport thought it was alive: this is the funnel (AGENTS.md invariant 3), and a
|
||||
// teardown failure must never skip it. Both transports' close paths are idempotent.
|
||||
if (_ctl.bound()) _ctl.stop();
|
||||
_ctl.unbind();
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
_phase = Phase::None;
|
||||
_authed = false;
|
||||
_httpLen = 0;
|
||||
@@ -499,4 +790,106 @@ void ControlServer::closeLink(const char *why) {
|
||||
if (wasLinked && _onLinkChange) _onLinkChange(_ctx, false);
|
||||
}
|
||||
|
||||
#if KR_TLS
|
||||
// ---------------------------------------------------------------------------
|
||||
// The encrypted listener. Everything after accept() is Conn's job: the handshake, the
|
||||
// queue, the pumps and the close are the same code the plaintext slot runs, which is the
|
||||
// entire point of task 04.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ControlServer::startTlsListener() {
|
||||
// No identity, no listener. A :443 that fails every handshake is worse than a
|
||||
// refused connection: it advertises encryption it cannot do. main.cpp generates
|
||||
// the identity at first boot and only reaches begin() after that attempt.
|
||||
if (!_identity || !_identity->cert[0] || !_identity->key[0]) return;
|
||||
|
||||
// Raw LWIP listen socket rather than WiFiServer: NetworkClient hands the accepted
|
||||
// fd to a shared handle that closes it when the last copy dies, and tls::Session
|
||||
// must own the fd for exactly its own lifetime (closed on every path, here). See
|
||||
// control_server.h; this is the ownership decision, restated where it is executed.
|
||||
_tlsListen = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (_tlsListen < 0) return;
|
||||
const int on = 1;
|
||||
setsockopt(_tlsListen, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(kHttpsPort);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
if (::bind(_tlsListen, (struct sockaddr *)&addr, sizeof(addr)) != 0 ||
|
||||
::listen(_tlsListen, static_cast<int>(kTlsMaxSessions) + 2) != 0) {
|
||||
::close(_tlsListen);
|
||||
_tlsListen = -1;
|
||||
return;
|
||||
}
|
||||
// Non-blocking accept: "nothing waiting" must be an instant answer, because this
|
||||
// runs once per loop() beside the keystroke path.
|
||||
fcntl(_tlsListen, F_SETFL, O_NONBLOCK);
|
||||
}
|
||||
|
||||
void ControlServer::admitTls(uint32_t nowMs) {
|
||||
// At most one accept per tick, the same pace rule the plaintext listener lives by:
|
||||
// a backlog is picked up on following ticks, and a flood costs the loop one syscall
|
||||
// per tick -- it cannot starve the keystroke path.
|
||||
const int fd = ::accept(_tlsListen, nullptr, nullptr);
|
||||
if (fd < 0) return; // EWOULDBLOCK -- nothing waiting -- or a transient error
|
||||
|
||||
if (_phase != Phase::None && !_ctl.alive()) closeLink("controller socket closed");
|
||||
|
||||
const bool ctlBusy = _phase != Phase::None;
|
||||
const bool spare = spareBusy();
|
||||
const tls::Admit admit = tls::admitConnection(
|
||||
ctlBusy, (ctlBusy ? 1u : 0u) + (spare ? 1u : 0u), kTlsMaxSessions);
|
||||
|
||||
if (admit == tls::Admit::Refused) {
|
||||
// The third connection. Refused explicitly -- a TLS alert record, then close --
|
||||
// never queued and never half-opened: opening an mbed TLS context here would
|
||||
// spend ~32 KB of heap to say "there is no heap", which is the entire thing this
|
||||
// refusal protects. RAM, not policy; same answer a plaintext socket gets above,
|
||||
// delivered the way TLS speaks.
|
||||
_slotRefused++;
|
||||
tls::refuseTls(fd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_identity) { // defensive: the listener only exists with an identity
|
||||
tls::refuseTls(fd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (admit == tls::Admit::Spare) {
|
||||
// The spare slot is one slot: whatever the last occupant left (a pump mid-response
|
||||
// to a peer that died) must be gone before this socket inherits the shared
|
||||
// _viewerHttp/_viewerLen state, or the previous viewer's half-written response
|
||||
// would be pumped to the new one.
|
||||
stopViewer();
|
||||
if (!_tlsView.open(fd, *_identity)) { // open consumed the fd either way
|
||||
_slotRefused++;
|
||||
return;
|
||||
}
|
||||
_view.bindTls(&_tlsView, _viewTx, sizeof(_viewTx));
|
||||
_viewOpen = true;
|
||||
_viewerLen = 0;
|
||||
_viewerHttp[0] = '\0';
|
||||
_viewerAtMs = nowMs;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_tlsCtl.open(fd, *_identity)) { // open consumed the fd either way
|
||||
_slotRefused++;
|
||||
return;
|
||||
}
|
||||
_ctl.bindTls(&_tlsCtl, _ctlTx, sizeof(_ctlTx));
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
_authed = false;
|
||||
_httpLen = 0;
|
||||
_reader.reset();
|
||||
_phase = Phase::Http;
|
||||
_connectedAtMs = nowMs;
|
||||
// Again deliberately NOT _lastInboundMs: a TLS handshake in flight is exactly the
|
||||
// "someone connected is not someone being alive" case readSome's comment is about.
|
||||
}
|
||||
|
||||
#endif // KR_TLS
|
||||
|
||||
} // namespace kr
|
||||
|
||||
+135
-41
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes.
|
||||
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes,
|
||||
* over a plaintext socket or a TLS session -- into one controller slot.
|
||||
*
|
||||
* Shape of it:
|
||||
* - one controller at a time. A second WebSocket upgrade is refused outright rather
|
||||
@@ -8,14 +9,28 @@
|
||||
* Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board.
|
||||
* Refusing everything meant pressing reload on your own tab failed, and a live
|
||||
* controller could not be inspected by anything else (serveViewer).
|
||||
* - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both
|
||||
* feed the same accept decision -- tls::admitConnection -- the same serveHttp() /
|
||||
* serveWebSocket() routing and the same serveReadonly(); the only thing a socket's
|
||||
* origin changes is which Conn it got bound to, and the only place the difference is
|
||||
* visible at all is the plaintext policy in serveReadonly() (:80 answers a redirect or
|
||||
* 426 instead of content, compiled out entirely by -D KR_TLS=0). A socket is HTTP,
|
||||
* WebSocket or none regardless of port, so a TLS controller and a plaintext controller
|
||||
* can never both hold the keyboard.
|
||||
* - non-blocking throughout: tick() runs once per loop() and consumes only what is
|
||||
* already buffered. The keystroke path must not wait on the network stack.
|
||||
* already buffered. The keystroke path must not wait on the network stack -- under
|
||||
* TLS that includes the handshake (Conn::poll(), one library call per tick) and every
|
||||
* response write (queued, pumped under kPageBytesPerTick, never waited on); in
|
||||
* plaintext it includes the write that used to be able to sit in a one-second select
|
||||
* ten times over (see Conn::writeRaw).
|
||||
* - nothing is trusted before the passphrase. The socket opens, must present T<token>
|
||||
* inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the
|
||||
* evaluation out for kTokenLockMs.
|
||||
* evaluation out for kTokenLockMs. TLS is transport, never an arming input: a
|
||||
* TLS session that authenticates but does not arm types nothing.
|
||||
*
|
||||
* Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse
|
||||
* (host-tested). What is left here is routing, framing and lifecycle -- the part that
|
||||
* (host-tested); the transport and the plaintext-port tables live in conn.h (its pure
|
||||
* half host-tested). What is left here is routing, framing and lifecycle -- the part that
|
||||
* genuinely needs a socket to be worth testing.
|
||||
*/
|
||||
#pragma once
|
||||
@@ -23,40 +38,18 @@
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "conn.h"
|
||||
#include "proto.h"
|
||||
#include "status.h"
|
||||
#include "tls_session.h"
|
||||
#include "ws_codec.h"
|
||||
|
||||
namespace kr {
|
||||
|
||||
// What the board knows about itself, as shown to the controller. The JSON field
|
||||
// names in StatusJson below are the contract the page in web_page.h reads.
|
||||
struct StatusSnapshot {
|
||||
const char *version = "0";
|
||||
uint32_t uptimeS = 0;
|
||||
bool usbHost = false; // TinyUSB is attached to a target
|
||||
bool armed = false;
|
||||
bool netUp = false;
|
||||
const char *net = "down"; // "sta" | "ap" | "down"
|
||||
const char *ssid = "";
|
||||
const char *ip = "";
|
||||
uint32_t keysSent = 0;
|
||||
uint32_t chordsSent = 0;
|
||||
uint32_t dropped = 0;
|
||||
uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text)
|
||||
uint16_t typing = 0; // characters still queued in the paste in progress
|
||||
bool rollover = false;
|
||||
// The mouse: whether it can report at all, which buttons the board believes are
|
||||
// down, and how many reports have gone out. `mouse` is false when the interface is
|
||||
// switched off on the board or when the target has forced boot protocol -- in both
|
||||
// cases the keyboard is untouched and M= frames are refused, not ignored.
|
||||
bool mouse = false;
|
||||
uint8_t mouseButtons = 0;
|
||||
uint32_t mouseFrames = 0;
|
||||
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
|
||||
bool defaultToken = false;
|
||||
bool apForced = false;
|
||||
};
|
||||
|
||||
// StatusSnapshot and statusJson() live in status.h, which includes nothing but stdio and
|
||||
// config.h. That is not tidiness: it is what lets tests/test_proto.cpp render the real
|
||||
// frame and measure it against kStatusJsonCap instead of taking that on trust from a
|
||||
// device. Include status.h rather than re-declaring anything here.
|
||||
// Error codes, named. The wire form is `E<code>:<text>`; see proto.h for the list.
|
||||
enum ErrorCode : uint8_t {
|
||||
kErrNotAuthed = 1,
|
||||
@@ -72,12 +65,6 @@ enum ErrorCode : uint8_t {
|
||||
kErrMouseBootProtocol = 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the status frame. Split out of the class so it is callable from the serial
|
||||
* console too, and so the field names stay in one place.
|
||||
*/
|
||||
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap);
|
||||
|
||||
class ControlServer {
|
||||
public:
|
||||
// ctx-first function pointers rather than std::function: no allocation, and the
|
||||
@@ -109,12 +96,77 @@ class ControlServer {
|
||||
uint32_t rejected() const { return _rejected; }
|
||||
uint32_t authFails() const { return _authFails; }
|
||||
|
||||
// Frames the transport could not take (its queue was full) -- a periodic status frame
|
||||
// loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either
|
||||
// transport: the queue is the transport view's, so the count means one thing everywhere.
|
||||
uint32_t txDropped() const { return _txDropped; }
|
||||
|
||||
// Connections refused because the slot table was full -- either listener, either
|
||||
// transport. A number, about a refusal; the refusal itself is visible to the peer
|
||||
// (TLS alert then close on :443, immediate close on :80).
|
||||
uint32_t slotRefused() const { return _slotRefused; }
|
||||
|
||||
#if KR_TLS
|
||||
// The identity the TLS listener serves. Must be set before begin(), and to nullptr
|
||||
// (or left unset) when there is no usable identity: begin() then does not open :443
|
||||
// at all, rather than open a listener that fails every handshake. The PemPair lives
|
||||
// in main.cpp next to the NVS code that loaded it; the server only reads it, at
|
||||
// session-open time -- copying 2 kB into every server object would be silly.
|
||||
void setIdentity(const tls::PemPair *identity) { _identity = identity; }
|
||||
|
||||
// Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this
|
||||
// changes. A number, about numbers; nothing about content (invariant 4).
|
||||
uint32_t tlsLiveSessions() const {
|
||||
return (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u);
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
// No separate "handshake in progress" phase: the handshake is what Conn::ready() and
|
||||
// Conn::poll() are for, and a slot in Phase::Http that is not ready yet is a slot still
|
||||
// handshaking. A phase for it would be a second place to remember the same fact, which
|
||||
// is how the plaintext and TLS paths used to drift.
|
||||
enum class Phase : uint8_t { None, Http, WebSocket };
|
||||
|
||||
// What the slot does when its outbound queue finally drains: the response is finished,
|
||||
// so the link goes. Nothing else closes a link on a *write* boundary -- and a response
|
||||
// that is still draining must never be cut short, or the page arrives truncated and
|
||||
// parses as garbage.
|
||||
enum class AfterDrain : uint8_t { Nothing, CloseLink, StopViewer };
|
||||
|
||||
// A response built but not yet written: the status line and headers, plus a body that
|
||||
// lives somewhere else -- a string literal in flash, the rendered status JSON, or the
|
||||
// 48 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of
|
||||
// the response (literals and kIndexHtml live forever; the JSON lives in this struct,
|
||||
// which the caller keeps until the response is queued), which is what makes "build once,
|
||||
// queue on either transport" safe.
|
||||
struct BuiltResponse {
|
||||
enum class Body : uint8_t { None, Text, Json };
|
||||
char head[kHttpResponseHeadCap];
|
||||
size_t headLen = 0;
|
||||
Body body = Body::None;
|
||||
const char *text = nullptr; // Body::Text: a literal, or kIndexHtml in PROGMEM
|
||||
size_t textLen = 0;
|
||||
bool textInFlash = false; // true for the page: streamed through a bounded chunk
|
||||
char json[kStatusJsonCap]; // Body::Json: rendered BEFORE buildResponse is called
|
||||
size_t jsonLen = 0;
|
||||
};
|
||||
|
||||
// One response formatter for every route, every transport and both ports; the header
|
||||
// block itself is rendered by the pure kr::renderHttpResponseHead() in conn.h, which is
|
||||
// what makes its sizing host-testable.
|
||||
static void buildResponse(BuiltResponse &rsp, const char *status,
|
||||
const char *contentType, BuiltResponse::Body body,
|
||||
const char *text, size_t textLen,
|
||||
const char *extra = nullptr, bool textInFlash = false);
|
||||
|
||||
void acceptNew(uint32_t nowMs);
|
||||
void serveHttp(uint32_t nowMs);
|
||||
void serveReadonly(WiFiClient &c, const ws::Request &req);
|
||||
// The read-only routes, built rather than written: the routing answer must have exactly
|
||||
// one source (invariant 8) while the transport answer differs only in which Conn the
|
||||
// built response is queued on. `plaintext` is what selects the :80 policy (redirect /
|
||||
// 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback.
|
||||
void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext);
|
||||
void serveViewer(uint32_t nowMs);
|
||||
void stopViewer();
|
||||
void serveWebSocket(uint32_t nowMs);
|
||||
@@ -124,15 +176,43 @@ class ControlServer {
|
||||
void writeTextFrame(const char *text, size_t len);
|
||||
size_t readSome(uint8_t *dst, size_t cap);
|
||||
|
||||
// Queue a built response on a slot's transport. One response writer for both slots and
|
||||
// both transports -- the grep for a second HTTP response writer in src/ is part of this
|
||||
// task's completion criteria -- so `done` says what that slot does when the bytes have
|
||||
// all left, and the two callers differ only in that answer.
|
||||
bool queueResponse(Conn &conn, AfterDrain &after, AfterDrain done,
|
||||
const BuiltResponse &rsp);
|
||||
void respondAndClose(const BuiltResponse &rsp); // controller slot
|
||||
void respondViewer(const BuiltResponse &rsp); // read-only slot
|
||||
// Drain both slots' queues under the per-tick budget, and run the AfterDrain decisions.
|
||||
void pumpSlots(uint32_t nowMs);
|
||||
bool spareBusy() const { return _viewOpen; } // the read-only slot, either transport
|
||||
|
||||
#if KR_TLS
|
||||
void startTlsListener();
|
||||
void admitTls(uint32_t nowMs); // accept at most one TLS socket per tick
|
||||
#endif
|
||||
|
||||
WiFiServer _server;
|
||||
// The controller slot: a Conn over one of these two, plus the slot's request buffer.
|
||||
// The WiFiClient and tls::Session objects are the owners; Conn is a view of one of them.
|
||||
WiFiClient _client;
|
||||
Conn _ctl;
|
||||
uint8_t _ctlTx[kConnTxCap];
|
||||
AfterDrain _ctlAfter = AfterDrain::Nothing;
|
||||
Phase _phase = Phase::None;
|
||||
|
||||
char _http[kHttpHeaderCap];
|
||||
size_t _httpLen = 0;
|
||||
|
||||
// A second socket, read-only, for when the controller slot is taken.
|
||||
// A second socket, read-only, for when the controller slot is taken. Same shape as the
|
||||
// controller slot -- a Conn over whichever transport answered -- because the spare slot
|
||||
// is one slot and a second set of route handling would be a second answer to give.
|
||||
WiFiClient _viewer;
|
||||
Conn _view;
|
||||
uint8_t _viewTx[kConnViewTxCap];
|
||||
AfterDrain _viewAfter = AfterDrain::Nothing;
|
||||
bool _viewOpen = false;
|
||||
char _viewerHttp[kHttpHeaderCap];
|
||||
size_t _viewerLen = 0;
|
||||
uint32_t _viewerAtMs = 0;
|
||||
@@ -149,6 +229,20 @@ class ControlServer {
|
||||
uint32_t _lockUntilMs = 0;
|
||||
uint32_t _authFails = 0;
|
||||
uint32_t _rejected = 0;
|
||||
uint32_t _txDropped = 0;
|
||||
uint32_t _slotRefused = 0;
|
||||
|
||||
#if KR_TLS
|
||||
// The encrypted listener: a raw LWIP socket rather than WiFiServer, because the
|
||||
// session must own the accepted fd -- NetworkClient closes its fd through a shared
|
||||
// handle the moment the last copy dies, which is the double-close tls::Session is
|
||||
// written to make impossible. bind/listen happen in startTlsListener().
|
||||
int _tlsListen = -1;
|
||||
const tls::PemPair *_identity = nullptr;
|
||||
|
||||
tls::Session _tlsCtl; // the controller slot, when _ctl.isTls()
|
||||
tls::Session _tlsView; // the spare slot, when _view.isTls()
|
||||
#endif
|
||||
|
||||
OnMessage _onMessage = nullptr;
|
||||
OnLinkChange _onLinkChange = nullptr;
|
||||
|
||||
+301
-1
@@ -97,7 +97,9 @@
|
||||
#include "mouse.h"
|
||||
#include "output.h"
|
||||
#include "proto.h"
|
||||
#include "status.h"
|
||||
#include "text.h"
|
||||
#include "tls.h"
|
||||
|
||||
#if KR_HAVE_HID
|
||||
// TinyUSB's per-instance readiness check is a plain C function in the linked stack.
|
||||
@@ -171,6 +173,9 @@ constexpr char kKeyStaPass[] = "sta_pass";
|
||||
constexpr char kKeyToken[] = "token";
|
||||
constexpr char kKeyApMode[] = "ap_mode"; // 0 auto, 1 force on, 2 off
|
||||
constexpr char kKeyMouse[] = "mouse"; // 1 = advertise the mouse collection
|
||||
constexpr char kKeyTlsCert[] = "tlscrt"; // the certificate, PEM
|
||||
constexpr char kKeyTlsKey[] = "tlskey"; // the private key, PEM. Never printed.
|
||||
constexpr char kKeyTlsFp[] = "tlscrtfp"; // its SHA-256, hex: public, and printed freely
|
||||
|
||||
char staSsid[33] = "";
|
||||
char staPass[65] = "";
|
||||
@@ -235,6 +240,191 @@ void saveMouseEnabled(bool on) {
|
||||
mouseEnabled = on;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The TLS identity (NVS)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Made on the board, once, and kept in the same namespace as the SSID and the
|
||||
// passphrase: per-board, never in this repo, and it survives reflashing because NVS does.
|
||||
// Three records -- certificate, key, fingerprint -- and the fingerprint is *stored* rather
|
||||
// than recomputed so that `cert` and the status frame cost no hashing at all. It is public
|
||||
// data; storing it raises no secret-at-rest question that storing the certificate does not
|
||||
// already raise, and a stale stored copy is caught the same way a stale certificate is:
|
||||
// selfCheck() at boot.
|
||||
//
|
||||
// What may never be printed is the key or the certificate body, in any form, including a
|
||||
// length that identifies which text is which. `token` already refuses to echo a passphrase
|
||||
// into a serial log for the same reason; the fingerprint and mbed TLS's error code are the
|
||||
// only facts about this identity that reach a log.
|
||||
//
|
||||
// A record that will not pass selfCheck() is regenerated rather than used. That costs the
|
||||
// operator a re-pin and the board says so loudly, once. The alternative -- starting a
|
||||
// listener on a key that may not match its certificate -- produces a device that answers
|
||||
// every handshake with a fatal alert and nothing to print about it.
|
||||
|
||||
constexpr size_t kFingerprintPrintPairs = 16; // 16 pairs is 47 characters: one safe line
|
||||
|
||||
// The label the status frame carries, and the flag beside it. Both exist in every build --
|
||||
// the rollback leaves them empty and false -- because `fillStatus()` is one function and the
|
||||
// frame always has both fields; the certificate itself does not exist at all in a KR_TLS=0
|
||||
// build, which is what the #if below is for.
|
||||
char tlsFingerprint[kr::tls::kFingerprintCap] = "";
|
||||
bool tlsIdentityReady = false;
|
||||
|
||||
#if KR_TLS
|
||||
|
||||
// 2 kB of buffers, so they live here rather than in any function's automatic storage
|
||||
// (src/tls.h: "not in an auto variable on the 8 kB loop stack").
|
||||
kr::tls::PemPair tlsIdentity;
|
||||
|
||||
// An NVS String into a PEM buffer, refusing rather than truncating: half a certificate is
|
||||
// not a shorter certificate, it is a handshake failure with no explanation.
|
||||
bool copyPemField(const String &from, char *to, size_t cap) {
|
||||
if (from.length() == 0 || from.length() >= cap) return false;
|
||||
memcpy(to, from.c_str(), from.length());
|
||||
to[from.length()] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
void clearTlsIdentity() {
|
||||
tlsIdentity.cert[0] = '\0';
|
||||
tlsIdentity.key[0] = '\0';
|
||||
tlsFingerprint[0] = '\0';
|
||||
tlsIdentityReady = false;
|
||||
}
|
||||
|
||||
// The whole fingerprint, 16 colon-separated pairs to a line. The wrapping is not styling:
|
||||
// the global CDC transmit timeout is 0, so a line longer than the 128-byte TX ring is
|
||||
// dropped silently in the middle of the value somebody is trying to copy (README bug 1).
|
||||
void printTlsFingerprint() {
|
||||
char line[kr::tls::kSha256Len * 3];
|
||||
for (size_t first = 0; first < kr::tls::kSha256Len; first += kFingerprintPrintPairs) {
|
||||
const size_t left = kr::tls::kSha256Len - first;
|
||||
const size_t count = left < kFingerprintPrintPairs ? left : kFingerprintPrintPairs;
|
||||
if (kr::tls::fingerprintPairs(tlsFingerprint, first, count, line, sizeof(line)) == 0) {
|
||||
Serial.println(F(" (the stored fingerprint is not hex -- run `cert`)"));
|
||||
return;
|
||||
}
|
||||
Serial.printf(" %s\n", line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the identity and read it back.
|
||||
*
|
||||
* The read-back is what turns the assumption into an observation: ~900 bytes of PEM added to
|
||||
* the SSID, the passphrase and the token has to fit the 20 KB NVS partition of
|
||||
* huge_app.csv, and `putString()` returning 0 is the only other signal the API gives. If it
|
||||
* does not fit, that is said out loud, in as many words, rather than discovered later as a
|
||||
* fingerprint that changes on every boot.
|
||||
*
|
||||
* Lengths are compared; contents never are, and nothing about either buffer is printed.
|
||||
*/
|
||||
bool storeTlsIdentity() {
|
||||
prefs.begin(kNvsNamespace, false);
|
||||
const size_t wroteCert = prefs.putString(kKeyTlsCert, tlsIdentity.cert);
|
||||
const size_t wroteKey = prefs.putString(kKeyTlsKey, tlsIdentity.key);
|
||||
const size_t wroteFp = prefs.putString(kKeyTlsFp, tlsFingerprint);
|
||||
prefs.end();
|
||||
if (wroteCert == 0 || wroteKey == 0 || wroteFp == 0) {
|
||||
// A 0 from putString is the whole API's signal that a value did not go in. The three
|
||||
// numbers here are what distinguishes "the partition is full" from "one record was
|
||||
// rejected", and a length identifies a record, never a secret.
|
||||
Serial.printf("[tls] NVS put refused: cert=%u key=%u fp=%u\n", (unsigned)wroteCert,
|
||||
(unsigned)wroteKey, (unsigned)wroteFp);
|
||||
return false;
|
||||
}
|
||||
|
||||
prefs.begin(kNvsNamespace, true);
|
||||
const size_t backCert = prefs.getString(kKeyTlsCert, "").length();
|
||||
const size_t backKey = prefs.getString(kKeyTlsKey, "").length();
|
||||
const String backFp = prefs.getString(kKeyTlsFp, "");
|
||||
prefs.end();
|
||||
if (backCert != strlen(tlsIdentity.cert) || backKey != strlen(tlsIdentity.key) ||
|
||||
!backFp.equals(tlsFingerprint)) {
|
||||
Serial.printf("[tls] NVS read-back mismatch: cert %u->%u key %u->%u fp %d\n",
|
||||
(unsigned)strlen(tlsIdentity.cert), (unsigned)backCert,
|
||||
(unsigned)strlen(tlsIdentity.key), (unsigned)backKey, backFp.length());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the identity, or make one.
|
||||
*
|
||||
* Runs at boot, once, and prints the fingerprint only when it just generated it -- the value
|
||||
* is meant to be copied once, and a boot log that repeats it trains whoever reads it to
|
||||
* ignore it. `cert` reprints it on demand; that pairing is what makes printing once safe.
|
||||
*/
|
||||
void loadTlsIdentity() {
|
||||
prefs.begin(kNvsNamespace, true);
|
||||
const String nvCert = prefs.getString(kKeyTlsCert, "");
|
||||
const String nvKey = prefs.getString(kKeyTlsKey, "");
|
||||
const String nvFp = prefs.getString(kKeyTlsFp, "");
|
||||
prefs.end();
|
||||
|
||||
const bool hadSomething = nvCert.length() || nvKey.length() || nvFp.length();
|
||||
const bool shaped =
|
||||
copyPemField(nvCert, tlsIdentity.cert, sizeof(tlsIdentity.cert)) &&
|
||||
copyPemField(nvKey, tlsIdentity.key, sizeof(tlsIdentity.key)) &&
|
||||
nvFp.length() == kr::tls::kFingerprintHexChars &&
|
||||
kr::tls::fingerprintPrefix(nvFp.c_str(), kr::tls::kFingerprintHexChars, tlsFingerprint,
|
||||
sizeof(tlsFingerprint));
|
||||
if (shaped && kr::tls::selfCheck(tlsIdentity)) {
|
||||
tlsIdentityReady = true;
|
||||
// No fingerprint on this line, on purpose: it prints on every boot, and the number the
|
||||
// operator is meant to write down is printed when it changes.
|
||||
Serial.println(F("[tls] identity loaded from NVS (reprint with `cert`)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hadSomething) {
|
||||
// Loud, once, and *before* the new fingerprint, so the lines read as cause and effect.
|
||||
// An identity that changed quietly is a pinned fingerprint that stopped meaning anything
|
||||
// quietly, which is the one failure mode of self-signed TLS worth engineering against.
|
||||
// The cause line says WHICH gate refused -- record absent, wrong shape, or whole but
|
||||
// failing to verify -- with lengths only, because a length identifies a record and
|
||||
// never a secret. Without it, "unusable" is the only fact the board offers, and a
|
||||
// fingerprint that changes every boot is exactly the bug that needs a real diagnosis.
|
||||
Serial.printf("[tls] stored records cert=%u key=%u fp=%u -- %s\n", (unsigned)nvCert.length(),
|
||||
(unsigned)nvKey.length(), (unsigned)nvFp.length(),
|
||||
shaped ? "selfCheck FAILED" : "shape refused (empty/oversized/bad fp)");
|
||||
Serial.println(F("[tls] !! THE STORED TLS IDENTITY IS UNUSABLE -- generating a new one"));
|
||||
Serial.println(F("[tls] !! any fingerprint you pinned for this board is now wrong"));
|
||||
}
|
||||
clearTlsIdentity();
|
||||
|
||||
if (!kr::tls::generateIdentity(tlsIdentity, tlsFingerprint, sizeof(tlsFingerprint))) {
|
||||
// The error code is the only thing here that identifies the failure, and it identifies a
|
||||
// library condition, not the key. Nothing about a failed generation is a secret.
|
||||
Serial.printf("[tls] IDENTITY GENERATION FAILED (mbed TLS error %d)\n",
|
||||
kr::tls::lastError());
|
||||
Serial.println(F("[tls] this board has no TLS identity; arming is unchanged either way"));
|
||||
clearTlsIdentity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storeTlsIdentity()) {
|
||||
Serial.println(F("[tls] IDENTITY NOT STORED -- NVS refused it or truncated it"));
|
||||
Serial.println(F("[tls] this boot can use it; the next one will generate another, so"));
|
||||
Serial.println(F("[tls] do not pin this fingerprint unless you must. Check the partition."));
|
||||
}
|
||||
tlsIdentityReady = true;
|
||||
Serial.println(F("[tls] identity generated -- pin this certificate:"));
|
||||
printTlsFingerprint();
|
||||
}
|
||||
|
||||
#else // KR_TLS == 0 -- the plaintext rollback
|
||||
|
||||
void loadTlsIdentity() {
|
||||
// No identity, no listener, no NVS records: :80 serves exactly what it served before
|
||||
// phase 02. Said once so a board that quietly grew a KR_TLS=0 is not a mystery later --
|
||||
// `cert` says the same thing on demand, and `status` answers `"tls":0,"fp":""`.
|
||||
Serial.println(F("[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel"));
|
||||
}
|
||||
|
||||
#endif // KR_TLS
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The emit side
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -487,6 +677,7 @@ const char *netMode() {
|
||||
// point at them -- these buffers outlive the call.
|
||||
char ssidBuf[40];
|
||||
char ipBuf[20];
|
||||
char tlsFpBuf[kr::kStatusFingerprintHex + 1];
|
||||
|
||||
void fillStatus(void *, kr::StatusSnapshot &s) {
|
||||
s.version = kr::kFirmwareVersion;
|
||||
@@ -521,6 +712,18 @@ void fillStatus(void *, kr::StatusSnapshot &s) {
|
||||
s.mouseFrames = mouseFrames;
|
||||
s.defaultToken = strcmp(token, kr::kDefaultToken) == 0;
|
||||
s.apForced = apMode == 1;
|
||||
// The TLS half. `tls` describes the identity this board holds, not the link this frame is
|
||||
// travelling on -- the listener is later in phase 02, and a controller running over
|
||||
// plaintext must not be told otherwise by a field in the frame it received in clear.
|
||||
// `fp` is the first bytes of the fingerprint so the page can show the same label `cert`
|
||||
// prints; the full value stays on the console (src/status.h).
|
||||
s.tls = tlsIdentityReady;
|
||||
if (!tlsIdentityReady ||
|
||||
!kr::tls::fingerprintPrefix(tlsFingerprint, kr::kStatusFingerprintHex, tlsFpBuf,
|
||||
sizeof(tlsFpBuf))) {
|
||||
tlsFpBuf[0] = '\0';
|
||||
}
|
||||
s.fp = tlsFpBuf;
|
||||
}
|
||||
|
||||
void linkChanged(void *, bool linked) {
|
||||
@@ -894,6 +1097,8 @@ void printHelp() {
|
||||
" wifi clear forget the station network\n"
|
||||
" ap auto|force|off access point policy (auto = when station is down)\n"
|
||||
" token <passphrase> control passphrase, max 32 chars\n"
|
||||
" cert TLS certificate: fingerprint, validity, names (never\n"
|
||||
" the key, and nothing here writes one)\n"
|
||||
" arm | disarm same as the button, from the console\n"
|
||||
" mouse on|off offer the target a pointer (needs a reboot)\n"
|
||||
" reboot\n"
|
||||
@@ -926,6 +1131,53 @@ void printStatus() {
|
||||
(unsigned)stage.mouseQ.count());
|
||||
}
|
||||
|
||||
// The `cert` command: what the operator needs in order to decide whether to trust what the
|
||||
// browser is about to accept. There is deliberately no argument form and no subcommand --
|
||||
// nothing here can write a key, and nothing here prints one. The PEM body is not offered on
|
||||
// request either: the fingerprint is the thing to compare, and a terminal with the key in
|
||||
// its scrollback is a private key on somebody's laptop.
|
||||
void printCert() {
|
||||
#if KR_TLS
|
||||
if (!tlsIdentityReady) {
|
||||
Serial.println(F("cert no usable TLS identity on this board (see the boot log)"));
|
||||
return;
|
||||
}
|
||||
Serial.println(F("cert SHA-256 of this board's certificate -- pin this:"));
|
||||
printTlsFingerprint();
|
||||
|
||||
kr::tls::CertDetails d;
|
||||
if (!kr::tls::certificateDetails(tlsIdentity.cert, d)) {
|
||||
Serial.println(F(" validity: the stored certificate did not parse"));
|
||||
return;
|
||||
}
|
||||
Serial.printf(" valid %s\n", d.validFrom);
|
||||
Serial.printf(" .. %s\n", d.validTo);
|
||||
|
||||
char names[128];
|
||||
if (kr::tls::sanList(names, sizeof(names)) == 0) {
|
||||
Serial.println(F(" names: this build's name table would not render"));
|
||||
return;
|
||||
}
|
||||
Serial.printf(" names %s\n", names);
|
||||
if (!d.namesAsPrinted) {
|
||||
// The stored certificate does not contain the extension these names come from, so the
|
||||
// list above is this build's table and not the certificate's. Say which one it is, and
|
||||
// what the consequence is: without that extension a client checks the CN instead, which
|
||||
// browsers stopped accepting as a name years ago, so the certificate can be pinned by
|
||||
// fingerprint but never recognised by name. `nvs erase` is the remedy and it is loud --
|
||||
// it reissues the identity, prints a new fingerprint once, and also forgets the
|
||||
// passphrase and the network, so it is the operator's call and not the board's.
|
||||
Serial.println(F(" !! the stored certificate does NOT carry that name list --"));
|
||||
Serial.println(F(" it was issued by a different build. Treat it as unknown:"));
|
||||
Serial.println(F(" no client can check the name, only the fingerprint above."));
|
||||
Serial.println(F(" `nvs erase` reissues it (new fingerprint, re-pin, and it"));
|
||||
Serial.println(F(" also forgets the token and the Wi-Fi: operator's call)."));
|
||||
}
|
||||
#else
|
||||
Serial.println(F("cert this build has no TLS identity (KR_TLS=0: plaintext rollback)"));
|
||||
#endif
|
||||
}
|
||||
|
||||
// Split a line into `verb` and the rest of the line, trimmed. Returns false if the
|
||||
// line is empty.
|
||||
bool splitLine(char *buf, char *&verb, char *&rest) {
|
||||
@@ -947,6 +1199,7 @@ void handleConsoleLine(char *buf) {
|
||||
if (strcmp(verb, "status") == 0) { printStatus(); return; }
|
||||
if (strcmp(verb, "reboot") == 0) { Serial.println(F("[boot] rebooting")); delay(50);
|
||||
esp_restart(); return; }
|
||||
if (strcmp(verb, "cert") == 0) { printCert(); return; }
|
||||
if (strcmp(verb, "arm") == 0) { setArmed(true, "console"); return; }
|
||||
if (strcmp(verb, "disarm") == 0) { setArmed(false, "console"); return; }
|
||||
|
||||
@@ -1113,13 +1366,45 @@ void setup() {
|
||||
: "CONTROL PLANE ONLY (this build cannot type)");
|
||||
wifiBringUp();
|
||||
|
||||
// The identity before the server that would serve it. Generating one costs a few hundred
|
||||
// milliseconds of DRBG and ECDSA on this board (no PSRAM, so it is heap the loop needs);
|
||||
// it happens here rather than next to loadSettings() so a first boot spends it after the
|
||||
// USB descriptors are fixed and the network has started, and so the fingerprint lands in
|
||||
// the log where somebody reading the boot output is already looking.
|
||||
loadTlsIdentity();
|
||||
|
||||
#if KR_TLS
|
||||
// The identity before the listener that would serve it: control.begin() opens :443
|
||||
// only when it is handed a usable pair, and opens nothing at all rather than a port
|
||||
// that fails every handshake (control_server.cpp::startTlsListener).
|
||||
if (tlsIdentityReady) control.setIdentity(&tlsIdentity);
|
||||
#endif
|
||||
control.begin(onMessage, linkChanged, fillStatus, getToken, nullptr);
|
||||
|
||||
#if KR_TLS
|
||||
// Numbers about the TLS listener and the heap it starts on -- no content, no key,
|
||||
// nothing but sizes (invariant 4). This is the boot line the phase's heap numbers are
|
||||
// read from, next to the sessions= lines loop() prints whenever a session opens or
|
||||
// closes; .agents/HW_VERIFICATION.md's phase 02 rows say which lines to collect.
|
||||
Serial.printf("[tls] https :%u %s heap=%u\n", (unsigned)kr::kHttpsPort,
|
||||
tlsIdentityReady ? "listening" : "OFF (no identity)",
|
||||
(unsigned)ESP.getFreeHeap());
|
||||
#endif
|
||||
|
||||
if (strcmp(token, kr::kDefaultToken) == 0) {
|
||||
Serial.println(F("!! the control passphrase is still the default -- set one with"
|
||||
" `token <passphrase>` before plugging into a target"));
|
||||
}
|
||||
Serial.print(F("url http://"));
|
||||
Serial.print(F("url "));
|
||||
// The URL the operator is told to type is the URL this build answers. With TLS on, :80
|
||||
// carries a redirect and two 426s and nothing else, so printing http:// here would point
|
||||
// at the one port that no longer has the page. The IP is the printed form on purpose --
|
||||
// mDNS is the first thing to fail on a strange network (README, Troubleshooting).
|
||||
#if KR_TLS
|
||||
Serial.print(F("https://"));
|
||||
#else
|
||||
Serial.print(F("http://"));
|
||||
#endif
|
||||
Serial.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString()
|
||||
: String(kr::kApGw));
|
||||
Serial.println(F("button short press = arm / disarm. Console: `help`"));
|
||||
@@ -1138,6 +1423,21 @@ void loop() {
|
||||
pollConsole();
|
||||
wifiMaintain(now);
|
||||
|
||||
#if KR_TLS
|
||||
// Heap cost of the TLS sessions, logged once per change and as numbers only
|
||||
// (invariant 4): boot, first session, second session, and after both close, is
|
||||
// exactly the sequence .agents/HW_VERIFICATION.md asks phase 02 to read off the
|
||||
// serial line. A session open or close is a state change, not content; there is
|
||||
// nothing here for the transcript to remember about who connected.
|
||||
static uint32_t lastTlsSessions = 0xFFFFFFFF;
|
||||
const uint32_t tlsSessions = control.tlsLiveSessions();
|
||||
if (tlsSessions != lastTlsSessions) {
|
||||
lastTlsSessions = tlsSessions;
|
||||
Serial.printf("[tls] sessions=%u heap=%u\n", (unsigned)tlsSessions,
|
||||
(unsigned)ESP.getFreeHeap());
|
||||
}
|
||||
#endif
|
||||
|
||||
// Silence disarms (config.h kNoClientTimeoutMs). A live controller says something
|
||||
// every couple of seconds -- the page's keepalive, or its automatic ping replies --
|
||||
// so this only ever fires when the controller is gone without having said goodbye:
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* keyrelay -- the status frame: what the board tells its controller about itself.
|
||||
*
|
||||
* Pure: no Arduino include, no ESP-IDF include, so the frame the board puts on the wire is
|
||||
* the frame `tests/test_proto.cpp` renders and measures here. That is why this file exists
|
||||
* apart from control_server.h: the struct and its renderer used to sit behind `#include
|
||||
* <WiFi.h>`, which made every claim about the status frame untestable on a host -- including
|
||||
* the one that matters most, that the frame still fits `kStatusJsonCap`. A status that does
|
||||
* not fit is a status that renders as nothing (`statusJson` returns 0 rather than a
|
||||
* truncated lie), and the controller is then flying blind with no indication that the
|
||||
* instrument panel is what failed. Adding a field used to be the sort of edit that could
|
||||
* only be noticed on the device.
|
||||
*
|
||||
* Rules every field has to follow (AGENTS.md invariants 4 and 5):
|
||||
* - counters and state, never content. `typed` and `typing` count characters; the
|
||||
* characters themselves are nowhere in here, and no new field may carry any.
|
||||
* - `fp` is the operator-visible label of a public value -- the leading bytes of the
|
||||
* certificate's SHA-256 -- and never anything about the key.
|
||||
* - a field is rendered from a value the board actually measured or holds; nothing here
|
||||
* guesses. Absent means empty string or 0, and the page treats those as unknown.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
namespace kr {
|
||||
|
||||
// The operator-visible slice of the certificate's SHA-256 fingerprint: its first
|
||||
// kStatusFingerprintBytes bytes, rendered as hex.
|
||||
//
|
||||
// Eight bytes because it is long enough to distinguish two boards at a glance, short enough
|
||||
// to read aloud over a shoulder, and short enough to sit next to a button on a phone. It is
|
||||
// a label, not the pin: `cert` on the serial console prints all 64, and that is the value
|
||||
// to compare against `openssl s_client … | x509 -fingerprint -sha256`.
|
||||
constexpr size_t kStatusFingerprintBytes = 8;
|
||||
constexpr size_t kStatusFingerprintHex = kStatusFingerprintBytes * 2; // 16 characters
|
||||
|
||||
// What the board knows about itself, as shown to the controller. The JSON field
|
||||
// names below are the contract the page in web_page.h reads.
|
||||
struct StatusSnapshot {
|
||||
const char *version = "0";
|
||||
uint32_t uptimeS = 0;
|
||||
bool usbHost = false; // TinyUSB is attached to a target
|
||||
bool armed = false;
|
||||
bool netUp = false;
|
||||
const char *net = "down"; // "sta" | "ap" | "down"
|
||||
const char *ssid = "";
|
||||
const char *ip = "";
|
||||
uint32_t keysSent = 0;
|
||||
uint32_t chordsSent = 0;
|
||||
uint32_t dropped = 0;
|
||||
uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text)
|
||||
uint16_t typing = 0; // characters still queued in the paste in progress
|
||||
bool rollover = false;
|
||||
// The mouse: whether it can report at all, which buttons the board believes are
|
||||
// down, and how many reports have gone out. `mouse` is false when the interface is
|
||||
// switched off on the board or when the target has forced boot protocol -- in both
|
||||
// cases the keyboard is untouched and M= frames are refused, not ignored.
|
||||
bool mouse = false;
|
||||
uint8_t mouseButtons = 0;
|
||||
uint32_t mouseFrames = 0;
|
||||
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
|
||||
bool defaultToken = false;
|
||||
bool apForced = false;
|
||||
// The TLS identity: `tls` is true when this build compiled TLS in *and* the board holds
|
||||
// a keypair-plus-certificate that passed its boot-time check -- the identity the :443
|
||||
// listener serves. It is still a statement about the identity, not about the transport
|
||||
// of THIS frame: a status arriving over plaintext :80 with tls:1 means "this board does
|
||||
// TLS", and only the URL the controller loaded on says whether the bytes in hand are
|
||||
// encrypted. `fp` is the label above, "" when there is no identity to label (the
|
||||
// KR_TLS=0 rollback, or a generation that failed).
|
||||
bool tls = false;
|
||||
const char *fp = "";
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather than
|
||||
// trusting whatever a neighbouring access point is called.
|
||||
inline void jsonSafe(char *dst, size_t cap, const char *src) {
|
||||
size_t o = 0;
|
||||
for (; src && *src && o + 1 < cap; src++) {
|
||||
const char ch = *src;
|
||||
if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue;
|
||||
dst[o++] = ch;
|
||||
}
|
||||
dst[o] = '\0';
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* Renders the status frame.
|
||||
*
|
||||
* Returns the length written, or 0 when `cap` could not hold it -- in which case nothing
|
||||
* usable was written and the caller must not send the buffer. Never a truncated frame: the
|
||||
* page reads `armed` out of this, and half a status is a status that lies.
|
||||
*
|
||||
* Callable from the serial console as well as the socket, which is the reason it is a free
|
||||
* function on the snapshot rather than a method of the server.
|
||||
*/
|
||||
inline size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) {
|
||||
char ssid[40];
|
||||
detail::jsonSafe(ssid, sizeof(ssid), s.ssid);
|
||||
char fp[kStatusFingerprintHex + 1];
|
||||
// Two refusals, both deliberate. An identity that is not there has no label: a snapshot
|
||||
// with tls:0 renders fp:"" however the caller filled it, so the page can never show a
|
||||
// fingerprint for a board that has no certificate. And this is the JSON boundary -- a
|
||||
// stored value that had somehow gone non-hex is cut where it stops being hex rather than
|
||||
// pasted in, because a stray quote in a field is a field forgery.
|
||||
size_t fplen = 0;
|
||||
if (s.tls) {
|
||||
for (; s.fp && s.fp[fplen] && fplen < kStatusFingerprintHex; fplen++) {
|
||||
const char ch = s.fp[fplen];
|
||||
const bool hexish = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') ||
|
||||
(ch >= 'A' && ch <= 'F');
|
||||
if (!hexish) break;
|
||||
}
|
||||
memcpy(fp, s.fp, fplen);
|
||||
}
|
||||
fp[fplen] = '\0';
|
||||
|
||||
const int n = snprintf(
|
||||
out, cap,
|
||||
"{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\","
|
||||
"\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu,"
|
||||
"\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u,"
|
||||
"\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"tls\":%u,\"fp\":\"%s\"}",
|
||||
s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u,
|
||||
s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent,
|
||||
(unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState,
|
||||
s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u,
|
||||
(unsigned long)s.typedChars, (unsigned)s.typing,
|
||||
s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames,
|
||||
s.tls ? 1u : 0u, fp);
|
||||
return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n;
|
||||
}
|
||||
|
||||
} // namespace kr
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* keyrelay -- the mbed TLS half of the board's identity. See tls.h for the reasoning
|
||||
* (why on-board, why ECDSA) and for the evidence that these symbols link; see tls_pem.h
|
||||
* for everything that is strings, which is host-tested.
|
||||
*
|
||||
* The shape of every function here is the same: acquire contexts through the RAII
|
||||
* wrappers below and return as soon as anything fails. mbed TLS contexts own heap
|
||||
* allocations, so an early `return` past a `*_free()` is a leak that repeats on every
|
||||
* boot retry -- and on a board with no PSRAM, a leak on the error path is how a device
|
||||
* becomes unbootable one failed provisioning attempt at a time. The wrappers make the
|
||||
* error path and the success path free the same things.
|
||||
*/
|
||||
#include "tls.h"
|
||||
|
||||
#if KR_TLS
|
||||
|
||||
#include "mbedtls/ctr_drbg.h"
|
||||
#include "mbedtls/entropy.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "mbedtls/pk.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "mbedtls/version.h"
|
||||
#include "mbedtls/x509_crt.h"
|
||||
|
||||
// The configuration the core ships has to have these or this file cannot do its job.
|
||||
// Naming the missing one beats a wall of undefined references at the end of the link.
|
||||
#if !defined(MBEDTLS_ECP_C)
|
||||
#error "KR_TLS needs MBEDTLS_ECP_C (CONFIG_MBEDTLS_ECP_C)"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_ECDSA_C)
|
||||
#error "KR_TLS needs MBEDTLS_ECDSA_C (CONFIG_MBEDTLS_ECDSA_C)"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_ECP_DP_SECP256R1_ENABLED)
|
||||
#error "KR_TLS needs MBEDTLS_ECP_DP_SECP256R1_ENABLED"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_X509_CRT_WRITE_C) || !defined(MBEDTLS_X509_CREATE_C)
|
||||
#error "KR_TLS needs MBEDTLS_X509_CRT_WRITE_C and MBEDTLS_X509_CREATE_C"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_PK_WRITE_C) || !defined(MBEDTLS_PK_PARSE_C)
|
||||
#error "KR_TLS needs MBEDTLS_PK_WRITE_C and MBEDTLS_PK_PARSE_C"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_ENTROPY_C) || !defined(MBEDTLS_CTR_DRBG_C)
|
||||
#error "KR_TLS needs MBEDTLS_ENTROPY_C and MBEDTLS_CTR_DRBG_C"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_MD_C) || !defined(MBEDTLS_SHA256_C)
|
||||
#error "KR_TLS needs MBEDTLS_MD_C and MBEDTLS_SHA256_C for the SHA-256 signature"
|
||||
#endif
|
||||
#if !defined(MBEDTLS_X509_CRT_PARSE_C)
|
||||
#error "KR_TLS needs MBEDTLS_X509_CRT_PARSE_C for selfCheck()"
|
||||
#endif
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
namespace {
|
||||
|
||||
// Contexts, RAII-wrapped. Constructed on the stack of whoever called us; freed on every
|
||||
// exit path including the ones that return from the middle of a sequence.
|
||||
struct Entropy {
|
||||
mbedtls_entropy_context ctx;
|
||||
Entropy() { mbedtls_entropy_init(&ctx); }
|
||||
~Entropy() { mbedtls_entropy_free(&ctx); }
|
||||
};
|
||||
|
||||
struct Drbg {
|
||||
mbedtls_ctr_drbg_context ctx;
|
||||
Drbg() { mbedtls_ctr_drbg_init(&ctx); }
|
||||
~Drbg() { mbedtls_ctr_drbg_free(&ctx); }
|
||||
};
|
||||
|
||||
struct Key {
|
||||
mbedtls_pk_context ctx;
|
||||
Key() { mbedtls_pk_init(&ctx); }
|
||||
~Key() { mbedtls_pk_free(&ctx); }
|
||||
};
|
||||
|
||||
struct CertWriter {
|
||||
mbedtls_x509write_cert ctx;
|
||||
CertWriter() { mbedtls_x509write_crt_init(&ctx); }
|
||||
~CertWriter() { mbedtls_x509write_crt_free(&ctx); }
|
||||
};
|
||||
|
||||
struct Cert {
|
||||
mbedtls_x509_crt ctx;
|
||||
Cert() { mbedtls_x509_crt_init(&ctx); }
|
||||
~Cert() { mbedtls_x509_crt_free(&ctx); }
|
||||
};
|
||||
|
||||
int g_lastError = 0;
|
||||
|
||||
// Records the failure and returns it, so a call site reads `if (fail(rc = ...))`.
|
||||
inline int note(int code) {
|
||||
if (code != 0) g_lastError = code;
|
||||
return code;
|
||||
}
|
||||
|
||||
// Distinguishes "the library said no" from "this file said no" for the steps that are
|
||||
// ours alone (a PEM that would not fit, a build date that would not parse). Those are
|
||||
// bugs in this tree, not library conditions, and they must not read as 0 in the log.
|
||||
constexpr int kErrArmourTooSmall = -1;
|
||||
constexpr int kErrBuildDate = -2;
|
||||
constexpr int kErrNoBuffer = -3;
|
||||
|
||||
// Personalises the DRBG so a stream taken from this generator can never be mistaken for
|
||||
// one from another device with the same seed source. It is not a secret and carries no
|
||||
// entropy; it exists so the bytes have a named purpose.
|
||||
const unsigned char kPersonalisation[] = "keyrelay";
|
||||
|
||||
// RFC 5280 4.2.1.6. The writer takes the OID as the *content bytes* of the DER OBJECT
|
||||
// IDENTIFIER, not as a dotted string -- see kr::tls::kSanOidTlv in tls_pem.h for what
|
||||
// passing the dotted string does (it is not an error: it is a well-formed certificate with
|
||||
// the SAN under an OID no client recognises). The constant is host-tested; this call site
|
||||
// only hands it over.
|
||||
//
|
||||
// Welded at compile time as well, the way tls_session.cpp welds the three copied error codes
|
||||
// to the library's macros. There is no library macro to compare against here -- the SAN OID is
|
||||
// spelled out inside x509write.c rather than in a public header -- so this pins the bytes the
|
||||
// writer must receive, and the certificate a board produced pins the framing around them. If it
|
||||
// ever stops compiling, somebody put the dotted text back where the DER content belongs, and
|
||||
// that mistake does not fail a handshake: it produces a valid certificate whose SAN no client
|
||||
// can find (README bug 11).
|
||||
static_assert(kSanOidTlvLen == 5, "subjectAltName is one OBJECT IDENTIFIER with 3 content bytes");
|
||||
static_assert(kSanOidTlv[0] == 0x06 && kSanOidTlv[1] == 0x03, "tag 6, length 3");
|
||||
static_assert(kSanOidTlv[2] == 0x55 && kSanOidTlv[3] == 0x1D && kSanOidTlv[4] == 0x11,
|
||||
"OID 2.5.29.17 must go to the writer as the DER content bytes 55 1D 11, never as text");
|
||||
|
||||
// 16 bytes of serial: under the 20-byte RFC 5280 cap, and 64 bits of randomness would be
|
||||
// plenty but a collision between two boards is the sort of thing worth not thinking
|
||||
// about. The high bit is cleared because a DER INTEGER with it set is negative and some
|
||||
// verifiers reject a negative serial outright; the low bit is set so an all-zero draw
|
||||
// (which no self-respecting DRBG produces, and which no verifier accepts) cannot happen.
|
||||
constexpr size_t kSerialBytes = 16;
|
||||
|
||||
// Scratch for the certificate DER. mbed TLS writes it backwards from the end of the
|
||||
// buffer, so the buffer has to be at least as large as the finished certificate:
|
||||
// measured ~430 bytes for this identity, 704 here.
|
||||
//
|
||||
// It is static rather than local on purpose. This runs once, at boot, before loop() has
|
||||
// anything to interleave with -- and 704 bytes is most of a spare 8 kB task stack that
|
||||
// the WebSocket path is going to want back. If a second caller ever appears, this
|
||||
// becomes a parameter, not a silent second static.
|
||||
constexpr size_t kDerScratchMax = 704;
|
||||
uint8_t gDerScratch[kDerScratchMax];
|
||||
|
||||
// The validity window, from the build date. See tls_pem.h: the arithmetic is pure and
|
||||
// tested; this is where it is applied.
|
||||
bool validityWindow(char *notBefore, size_t beforeCap, char *notAfter, size_t afterCap) {
|
||||
int y = 0;
|
||||
int mo = 0;
|
||||
int d = 0;
|
||||
if (!parseBuildDate(__DATE__, y, mo, d)) {
|
||||
g_lastError = kErrBuildDate;
|
||||
return false;
|
||||
}
|
||||
// One day back: __DATE__ is the build machine's local date, and a controller whose
|
||||
// clock is a few hours behind it would otherwise see "not yet valid" on a certificate
|
||||
// generated minutes ago.
|
||||
addDays(y, mo, d, -1);
|
||||
if (utcTime(notBefore, beforeCap, y, mo, d, 0, 0, 0) == 0) {
|
||||
g_lastError = kErrBuildDate;
|
||||
return false;
|
||||
}
|
||||
addYears(y, mo, d, kCertValidityYears);
|
||||
if (utcTime(notAfter, afterCap, y, mo, d, 23, 59, 59) == 0) {
|
||||
g_lastError = kErrBuildDate;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Everything generateIdentity() does, without the all-or-nothing guarantee the public
|
||||
// entry point adds. The two-PEM buffer pair is only ever handed to the caller complete.
|
||||
bool generateInto(PemPair &out, char *fpHex, size_t fpCap) {
|
||||
out.cert[0] = '\0';
|
||||
out.key[0] = '\0';
|
||||
if (fpHex && fpCap) fpHex[0] = '\0';
|
||||
|
||||
Entropy entropy;
|
||||
Drbg drbg;
|
||||
if (note(mbedtls_ctr_drbg_seed(&drbg.ctx, mbedtls_entropy_func, &entropy.ctx,
|
||||
kPersonalisation, sizeof(kPersonalisation) - 1)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Key key;
|
||||
if (note(mbedtls_pk_setup(&key.ctx, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY))) != 0) {
|
||||
return false;
|
||||
}
|
||||
// The task says what happens on failure and it is not "silently retry with RSA": a
|
||||
// board that cannot draw a key has an entropy problem, and the caller reporting a
|
||||
// failed identity is the correct end of the story.
|
||||
if (note(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key.ctx),
|
||||
mbedtls_ctr_drbg_random, &drbg.ctx)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The key first. A key this context cannot export is a key no certificate should be
|
||||
// issued for, and the exporter is the step with a hard buffer requirement.
|
||||
if (note(mbedtls_pk_write_key_pem(
|
||||
&key.ctx, reinterpret_cast<unsigned char *>(out.key), sizeof(out.key))) != 0) {
|
||||
out.key[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
char subject[64];
|
||||
if (subjectLine(subject, sizeof(subject)) == 0) {
|
||||
g_lastError = kErrArmourTooSmall;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t san[kSanDerMax];
|
||||
const size_t sanLen = sanExtensionDer(san, sizeof(san));
|
||||
if (sanLen == 0) {
|
||||
g_lastError = kErrArmourTooSmall;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t serial[kSerialBytes];
|
||||
if (note(mbedtls_ctr_drbg_random(&drbg.ctx, serial, sizeof(serial))) != 0) return false;
|
||||
serial[0] = static_cast<uint8_t>(serial[0] & 0x7F);
|
||||
serial[sizeof(serial) - 1] = static_cast<uint8_t>(serial[sizeof(serial) - 1] | 0x01);
|
||||
|
||||
char notBefore[16];
|
||||
char notAfter[16];
|
||||
if (!validityWindow(notBefore, sizeof(notBefore), notAfter, sizeof(notAfter))) return false;
|
||||
|
||||
CertWriter crt;
|
||||
mbedtls_x509write_crt_set_version(&crt.ctx, MBEDTLS_X509_CRT_VERSION_3);
|
||||
if (note(mbedtls_x509write_crt_set_serial_raw(&crt.ctx, serial, sizeof(serial))) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (note(mbedtls_x509write_crt_set_validity(&crt.ctx, notBefore, notAfter)) != 0) {
|
||||
return false;
|
||||
}
|
||||
// Self-signed: the board is its own one-member CA, so issuer and subject are the same
|
||||
// string and the same key signs. That is what makes the fingerprint the whole trust
|
||||
// story -- there is no chain to check it against, which is exactly why the operator
|
||||
// pins the fingerprint once instead of trusting a name.
|
||||
if (note(mbedtls_x509write_crt_set_subject_name(&crt.ctx, subject)) != 0) return false;
|
||||
if (note(mbedtls_x509write_crt_set_issuer_name(&crt.ctx, subject)) != 0) return false;
|
||||
mbedtls_x509write_crt_set_subject_key(&crt.ctx, &key.ctx);
|
||||
mbedtls_x509write_crt_set_issuer_key(&crt.ctx, &key.ctx);
|
||||
mbedtls_x509write_crt_set_md_alg(&crt.ctx, MBEDTLS_MD_SHA256);
|
||||
// A CA, no path-length constraint, with the key identifier so a pinned fingerprint can
|
||||
// be matched against the certificate a handshake actually presented.
|
||||
if (note(mbedtls_x509write_crt_set_basic_constraints(&crt.ctx, 1, -1)) != 0) return false;
|
||||
if (note(mbedtls_x509write_crt_set_subject_key_identifier(&crt.ctx)) != 0) return false;
|
||||
// The extension browsers check. Without it every modern client reports the name as
|
||||
// mismatched no matter what the CN says -- CN-only matching was removed from
|
||||
// browsers years ago.
|
||||
size_t oidLen = 0;
|
||||
const uint8_t *oid = sanOidDer(&oidLen);
|
||||
if (oidLen != 3 ||
|
||||
note(mbedtls_x509write_crt_set_extension(&crt.ctx, reinterpret_cast<const char *>(oid),
|
||||
oidLen, 0, san, sanLen)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int written = mbedtls_x509write_crt_der(&crt.ctx, gDerScratch, sizeof(gDerScratch),
|
||||
mbedtls_ctr_drbg_random, &drbg.ctx);
|
||||
if (written <= 0) {
|
||||
note(written);
|
||||
out.key[0] = '\0'; // no certificate means this key is not an identity either
|
||||
return false;
|
||||
}
|
||||
const uint8_t *der = gDerScratch + sizeof(gDerScratch) - static_cast<size_t>(written);
|
||||
const size_t derLen = static_cast<size_t>(written);
|
||||
|
||||
if (wrapPem(kPemCertLabel, der, derLen, out.cert, sizeof(out.cert)) == 0) {
|
||||
g_lastError = kErrArmourTooSmall;
|
||||
out.key[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// The fingerprint is taken over the DER that went into the armour, which is what every
|
||||
// client hashes too -- not over the PEM text, and not over the public key.
|
||||
if (fpHex && fpCap) {
|
||||
uint8_t digest[kSha256Len];
|
||||
if (note(mbedtls_sha256(der, derLen, digest, 0)) != 0) {
|
||||
out.cert[0] = '\0';
|
||||
out.key[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
if (!fingerprintHex(digest, sizeof(digest), fpHex, fpCap)) {
|
||||
g_lastError = kErrNoBuffer;
|
||||
out.cert[0] = '\0';
|
||||
out.key[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap) {
|
||||
g_lastError = 0;
|
||||
const bool ok = generateInto(out, fpHex, fpCap);
|
||||
if (!ok) {
|
||||
// Never half an identity: whatever the caller stores is either complete or empty.
|
||||
out.cert[0] = '\0';
|
||||
out.key[0] = '\0';
|
||||
if (fpHex && fpCap) fpHex[0] = '\0';
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool selfCheck(const PemPair &pair) {
|
||||
g_lastError = 0;
|
||||
if (!pair.cert[0] || !pair.key[0]) {
|
||||
g_lastError = kErrNoBuffer;
|
||||
return false;
|
||||
}
|
||||
|
||||
Entropy entropy;
|
||||
Drbg drbg;
|
||||
if (note(mbedtls_ctr_drbg_seed(&drbg.ctx, mbedtls_entropy_func, &entropy.ctx,
|
||||
kPersonalisation, sizeof(kPersonalisation) - 1)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The length passed includes the terminating NUL: that is how the parser tells PEM
|
||||
// from DER, and a length one short is a parse failure that looks like corruption.
|
||||
Cert cert;
|
||||
if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast<const unsigned char *>(pair.cert),
|
||||
strlen(pair.cert) + 1)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Key key;
|
||||
if (note(mbedtls_pk_parse_key(&key.ctx, reinterpret_cast<const unsigned char *>(pair.key),
|
||||
strlen(pair.key) + 1, nullptr, 0, mbedtls_ctr_drbg_random,
|
||||
&drbg.ctx)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not just "both parsed": the point of the boot check is that this certificate's
|
||||
// public key is the one this private key signs with. A cert from one generation and a
|
||||
// key from another is exactly what a torn NVS write can produce, and both parse fine.
|
||||
return note(mbedtls_pk_check_pair(&cert.ctx.pk, &key.ctx, mbedtls_ctr_drbg_random,
|
||||
&drbg.ctx)) == 0;
|
||||
}
|
||||
|
||||
bool certificateDetails(const char *certPem, CertDetails &out) {
|
||||
g_lastError = 0;
|
||||
out.validFrom[0] = '\0';
|
||||
out.validTo[0] = '\0';
|
||||
out.namesAsPrinted = false;
|
||||
if (!certPem || !certPem[0]) {
|
||||
g_lastError = kErrNoBuffer;
|
||||
return false;
|
||||
}
|
||||
|
||||
Cert cert;
|
||||
if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast<const unsigned char *>(certPem),
|
||||
strlen(certPem) + 1)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The window as the certificate states it. dateTimeText() has already range-checked every
|
||||
// component, so a stored certificate that parses but carries a date this tree would never
|
||||
// have written is reported as unreadable rather than printed as something merely odd.
|
||||
const mbedtls_x509_time &from = cert.ctx.valid_from;
|
||||
const mbedtls_x509_time &to = cert.ctx.valid_to;
|
||||
if (dateTimeText(out.validFrom, sizeof(out.validFrom), from.year, from.mon, from.day,
|
||||
from.hour, from.min, from.sec) == 0 ||
|
||||
dateTimeText(out.validTo, sizeof(out.validTo), to.year, to.mon, to.day, to.hour,
|
||||
to.min, to.sec) == 0) {
|
||||
g_lastError = kErrBuildDate;
|
||||
out.validFrom[0] = '\0';
|
||||
out.validTo[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// The names, checked rather than assumed. Deliberately not a parse of the parsed
|
||||
// certificate: the library hands the SAN back as a per-entry sequence whose exact span its
|
||||
// own documentation describes differently from version to version, and a decoder that is
|
||||
// wrong about that prints a name list the board never issued. Searching the certificate's
|
||||
// raw DER for the extension this build writes is version-independent, and can only ever
|
||||
// answer "these exact names, under that OID: yes or no". The OID is part of the search
|
||||
// because it is part of the question -- a certificate whose SAN value sits under a
|
||||
// mislabelled OID carries no SAN to a client, and must not be reported as carrying one.
|
||||
out.namesAsPrinted = derCarriesSan(cert.ctx.raw.p, cert.ctx.raw.len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap) {
|
||||
g_lastError = 0;
|
||||
if (!certPem || !certPem[0] || !fpHex || !fpCap) {
|
||||
g_lastError = kErrNoBuffer;
|
||||
return false;
|
||||
}
|
||||
|
||||
Cert cert;
|
||||
if (note(mbedtls_x509_crt_parse(&cert.ctx, reinterpret_cast<const unsigned char *>(certPem),
|
||||
strlen(certPem) + 1)) != 0) {
|
||||
return false;
|
||||
}
|
||||
uint8_t digest[kSha256Len];
|
||||
if (note(mbedtls_sha256(cert.ctx.raw.p, cert.ctx.raw.len, digest, 0)) != 0) return false;
|
||||
if (!fingerprintHex(digest, sizeof(digest), fpHex, fpCap)) {
|
||||
g_lastError = kErrNoBuffer;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int lastError() { return g_lastError; }
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
|
||||
#else // KR_TLS == 0 -- the plaintext rollback. See README "Rolling TLS back".
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
// The rollback build compiles the identity away rather than deleting it: the callers in
|
||||
// main.cpp and control_server.cpp keep linking, the 2 kB PemPair stays a definition and
|
||||
// not a bug, and the only thing that changes on the wire is that nothing is encrypted.
|
||||
// A caller that asks for an identity in this build gets a refusal, never a fake one.
|
||||
bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap) {
|
||||
(void)fpCap;
|
||||
out.cert[0] = '\0';
|
||||
out.key[0] = '\0';
|
||||
if (fpHex) fpHex[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
bool selfCheck(const PemPair &pair) {
|
||||
(void)pair;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap) {
|
||||
(void)certPem;
|
||||
(void)fpHex;
|
||||
(void)fpCap;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool certificateDetails(const char *certPem, CertDetails &out) {
|
||||
(void)certPem;
|
||||
out.validFrom[0] = '\0';
|
||||
out.validTo[0] = '\0';
|
||||
out.namesAsPrinted = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
int lastError() { return 0; }
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
|
||||
#endif // KR_TLS
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* keyrelay -- the board's TLS identity: an ECDSA secp256r1 keypair and a self-signed
|
||||
* certificate for the names in config.h, both as PEM, plus the certificate's SHA-256
|
||||
* fingerprint.
|
||||
*
|
||||
* This is the half that needs the crypto library. Everything that is *strings* -- PEM
|
||||
* armour, the fingerprint's hex, the subject and SAN text, the extension's DER, the
|
||||
* validity arithmetic -- lives in tls_pem.h, which is pure and runs on a host under
|
||||
* sanitisers (tests/test_tls_pem.cpp). This file deliberately has no host suite: it is
|
||||
* a sequence of library calls whose failure modes are error codes, and pretending
|
||||
* otherwise would mean either a fake library or a test that asserts nothing.
|
||||
*
|
||||
* Why the key is made here rather than shipped
|
||||
* --------------------------------------------
|
||||
* A key in the tree is a key on every board forever, and a key the person building this
|
||||
* has to generate by hand is a first build that fails for everyone who does not. So the
|
||||
* board makes one on first boot: per-board, never leaves this device, survives
|
||||
* reflashing because NVS does, and dies only with an NVS erase -- which is the
|
||||
* documented rotation, and prints a new fingerprint. See README "Security model and
|
||||
* trust boundaries".
|
||||
*
|
||||
* Why ECDSA secp256r1 rather than RSA 2048
|
||||
* ----------------------------------------
|
||||
* This board has no PSRAM. An EC keypair costs a fraction of the RAM and flash of RSA
|
||||
* during both generation and handshake, and is the stronger key at that size. Every
|
||||
* browser and every `openssl s_client` on the platforms the README targets accepts a
|
||||
* self-signed EC certificate; nothing here needs anything rarer.
|
||||
*
|
||||
* What this identity does NOT protect
|
||||
* -----------------------------------
|
||||
* It is not a credential and nothing checks it as one. Client certification is deliberately
|
||||
* off (tls_session.h): the passphrase is the controller's authority, the board's button is the
|
||||
* operator's, and no code path anywhere may treat "the handshake completed" as permission to
|
||||
* type. A stolen key buys an attacker a convincing certificate against controllers that never
|
||||
* compared a fingerprint -- and still not one keystroke past the passphrase, the 5-try lockout
|
||||
* or the armed state. Two rules follow, and both outlive this file: the key is written exactly
|
||||
* once, by main.cpp, into NVS, and never printed, hashed-in-a-way-that-reads-as-the-key, or
|
||||
* committed; the fingerprint (a digest of the public certificate) is the only identity string
|
||||
* that is ever shown to anybody.
|
||||
*
|
||||
* How this was verified to LINK, not assumed to
|
||||
* ---------------------------------------------
|
||||
* The core ships mbed TLS prebuilt, so the question is whether the symbols and the
|
||||
* build-time configuration are actually there. They are, in
|
||||
* `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`:
|
||||
*
|
||||
* flags/ld_libs -lmbedtls_2 -lmbedcrypto -lmbedx509 -lesp-tls -llwip
|
||||
* flags/includes mbedtls/mbedtls/include, esp-tls, tcp_transport/include
|
||||
* sdkconfig.h CONFIG_MBEDTLS_ECP_C, CONFIG_MBEDTLS_ECDSA_C,
|
||||
* CONFIG_MBEDTLS_ECP_DP_SECP256R1_ENABLED,
|
||||
* CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA,
|
||||
* CONFIG_MBEDTLS_PEM_WRITE_C, CONFIG_MBEDTLS_TLS_SERVER
|
||||
* esp_config.h MBEDTLS_X509_CRT_WRITE_C, MBEDTLS_X509_CREATE_C,
|
||||
* MBEDTLS_PK_WRITE_C, MBEDTLS_ASN1_WRITE_C, MBEDTLS_BASE64_C,
|
||||
* MBEDTLS_ENTROPY_C, MBEDTLS_CTR_DRBG_C, MBEDTLS_MD_C,
|
||||
* MBEDTLS_SHA256_C, MBEDTLS_X509_CRT_PARSE_C, MBEDTLS_PK_PARSE_C
|
||||
*
|
||||
* and every call below was checked against the archives with the toolchain's nm:
|
||||
* mbedtls_x509write_crt_der / _init / _set_extension / _set_serial_raw / _set_validity
|
||||
* / _set_subject_name / _set_issuer_name / _set_md_alg / _set_basic_constraints
|
||||
* / _set_subject_key_identifier in libmbedx509.a; mbedtls_pk_setup, mbedtls_pk_ec,
|
||||
* mbedtls_ecp_gen_key, mbedtls_pk_write_key_pem, mbedtls_pk_parse_key,
|
||||
* mbedtls_pk_check_pair, mbedtls_ctr_drbg_seed, mbedtls_ctr_drbg_random,
|
||||
* mbedtls_entropy_init, mbedtls_sha256 in libmbedcrypto.a.
|
||||
*
|
||||
* The static asserts below turn "the symbol was not there" from a linker error at the
|
||||
* end of a four-minute build into a compile error naming the feature, which is the
|
||||
* difference between a two-second diagnosis and a confusing one.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "tls_pem.h"
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
/**
|
||||
* One identity, both halves armoured. Two kPemMax buffers, so a PemPair is ~2 kB: keep
|
||||
* it at namespace scope or in whatever long-lived buffer reads it out of NVS, not in an
|
||||
* auto variable on the 8 kB loop stack.
|
||||
*/
|
||||
struct PemPair {
|
||||
char cert[kPemMax];
|
||||
char key[kPemMax];
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a fresh keypair and self-sign a certificate for sanEntries().
|
||||
*
|
||||
* secp256r1 keypair -> out.key, "EC PRIVATE KEY" PEM
|
||||
* CN == issuer == subjectLine() -> out.cert, "CERTIFICATE" PEM
|
||||
* SHA-256 of the certificate DER -> fpHex, lowercase hex (pass fpHex == nullptr to skip)
|
||||
*
|
||||
* Signed with SHA-256, serial from the board's own DRBG, valid from the build date
|
||||
* (backdated one day, so a controller whose clock is slightly behind the build machine
|
||||
* does not see "not yet valid") for kCertValidityYears, marked as a CA with a subject
|
||||
* key identifier, and carrying the subjectAlternativeName extension built by
|
||||
* tls_pem.h's sanExtensionDer() under the OID from sanOidDer() -- because the browser
|
||||
* checks the SAN, not the CN, and it checks it *by OID*: the value alone, labelled with
|
||||
* anything else, is a certificate with no SAN at all (README bug 11).
|
||||
*
|
||||
* Returns false, with both PEM buffers and the fingerprint left empty, if any step
|
||||
* fails. Nothing is written to NVS here; whoever calls this owns storage, and a
|
||||
* half-written identity is what the boot-time selfCheck() exists to catch.
|
||||
*/
|
||||
bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap);
|
||||
|
||||
/**
|
||||
* Reparse the certificate and the key and prove they are a pair.
|
||||
*
|
||||
* Called at boot over whatever NVS returned. A record that survived a power loss in the
|
||||
* middle of a write parses as *something* often enough to be worse than nothing: it
|
||||
* produces a listener that answers every handshake with a fatal alert, and a browser
|
||||
* that says only "wrong certificate". Reporting it is the whole point.
|
||||
*/
|
||||
bool selfCheck(const PemPair &pair);
|
||||
|
||||
/**
|
||||
* SHA-256 fingerprint of an armoured certificate, as lowercase hex -- the value the
|
||||
* operator pins and the value `openssl s_client ... | x509 -fingerprint -sha256` prints.
|
||||
* Works on a certificate loaded from NVS, so the fingerprint can be re-derived after a
|
||||
* reflash instead of trusted from a stale stored copy.
|
||||
*/
|
||||
bool certificateFingerprint(const char *certPem, char *fpHex, size_t fpCap);
|
||||
|
||||
/**
|
||||
* What a stored certificate says about its own lifetime, and whether it matches the
|
||||
* names this build issues certificates for. `cert` on the serial console prints exactly
|
||||
* this, which is the whole reason it exists: the operator deciding whether to trust a
|
||||
* pinned fingerprint should read it off the certificate, not off a build-time constant.
|
||||
*
|
||||
* The two date strings are rendered by tls_pem.h's dateTimeText() from the parsed
|
||||
* certificate, so they are what the certificate carries -- including for a certificate
|
||||
* generated by an older firmware and re-read from NVS, whose window came from that build's
|
||||
* date and nothing to do with this one.
|
||||
*/
|
||||
constexpr size_t kCertTimeTextCap = 24; // "2026-11-08 00:00:00 UTC" plus the NUL
|
||||
|
||||
struct CertDetails {
|
||||
char validFrom[kCertTimeTextCap];
|
||||
char validTo[kCertTimeTextCap];
|
||||
// True when the certificate's own bytes contain exactly the SAN extension that
|
||||
// sanEntries() + sanExtensionDer() + sanOidDer() produce -- OID included, because that
|
||||
// is what makes it the extension. False does not mean the certificate is bad;
|
||||
// it means the name list printed next to it came from this build's table and the
|
||||
// certificate did not confirm it -- so the console says so rather than implying it did.
|
||||
bool namesAsPrinted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a stored certificate and fill the above. False means it did not parse at all --
|
||||
* the same verdict selfCheck() reaches by a longer route -- and `out` is then empty.
|
||||
*
|
||||
* Nothing here consults the board's clock. There is no time source on this device: an
|
||||
* "expired" verdict computed against a clock that resets to 1970 on every boot would be a
|
||||
* guess dressed up as a measurement, so the window is printed and the operator, who has a
|
||||
* wristwatch, decides.
|
||||
*/
|
||||
bool certificateDetails(const char *certPem, CertDetails &out);
|
||||
|
||||
/**
|
||||
* The library's error code from the last call that failed, 0 when the last call
|
||||
* succeeded. Diagnostic only, and never any part of the identity: mbed TLS returns
|
||||
* negative bit-coded errno values, which is the only thing about a failed generation a
|
||||
* log is allowed to print.
|
||||
*/
|
||||
int lastError();
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
+876
@@ -0,0 +1,876 @@
|
||||
/**
|
||||
* keyrelay -- the string half of the board's TLS identity.
|
||||
*
|
||||
* Pure: no Arduino include, no IDF include, no crypto-library include, so the exact code
|
||||
* that runs on the board is compiled and exercised on a host under ASan/UBSan
|
||||
* (tests/test_tls_pem.cpp). That rule (AGENTS.md "Editing rules") is the only reason
|
||||
* this file is separate from tls.h: every way an identity can go silently wrong goes
|
||||
* wrong here, and none of them is visible from a failed handshake. A PEM one byte short
|
||||
* of its buffer, a fingerprint off by one nibble, a base64 body that decodes to
|
||||
* *nearly* the right bytes -- each of those produces a certificate that parses, pins to
|
||||
* the wrong value, and fails closed with no clue what was wrong.
|
||||
*
|
||||
* What lives here:
|
||||
* - PEM armour in both directions (wrapPem / pemFind)
|
||||
* - the operator-facing rendering of a certificate digest (fingerprintHex, and the two
|
||||
* shapes the console and the status frame need: fingerprintPairs / fingerprintPrefix)
|
||||
* - derContains(), so a claim about what a certificate carries can be checked against the
|
||||
* certificate instead of against the table that was supposed to have gone into it
|
||||
* - the ONE table of names the certificate is issued for (sanEntries), and the two
|
||||
* renderings built from it: subjectLine() for the subject string the certificate
|
||||
* carries, sanList() for the human-readable list the operator is told to expect.
|
||||
* The certificate and the strings a human compares it against come from the same
|
||||
* three entries, which is the only reason they cannot drift apart.
|
||||
* - the DER value of the subjectAlternativeName extension (sanExtensionDer), so the
|
||||
* bytes that end up inside the certificate are the host-tested ones rather than a
|
||||
* library call whose IP-address encoding differs between versions; the OID that labels
|
||||
* it (sanOidDer, as DER content bytes because that is what the writer takes); the whole
|
||||
* Extension SEQUENCE as it goes on the wire (sanExtensionFullDer); and
|
||||
* derCarriesSan(), which asks whether a stored certificate carries those bytes under
|
||||
* that OID -- the question a client asks, rather than the easier one that a
|
||||
* mislabelled extension still passes.
|
||||
* - the validity-window arithmetic (parseBuildDate / addDays / addYears / utcTime) and
|
||||
* its human-readable twin, dateTimeText(): "2026-11-08 00:00:00 UTC".
|
||||
*
|
||||
* What does NOT live here: keys, curve arithmetic, key or certificate parsing. That is
|
||||
* tls.h / tls.cpp, which this suite must never include -- including it would take the
|
||||
* host build with it and the pure-module rule would be gone.
|
||||
*
|
||||
* What these strings do NOT protect
|
||||
* ---------------------------------
|
||||
* Rendering an identity is not authorising anything. No decision about arming, about who may
|
||||
* type, or about whether a link is alive may ever be taken from a value in this file: the
|
||||
* fingerprint is a public digest that a human compares, not a token a machine trusts. What this
|
||||
* module owes the operator is the opposite of cleverness -- refuse rather than truncate, refuse
|
||||
* rather than decode *nearly* the right bytes, never sanitise a malformed armour block into
|
||||
* something that parses (AGENTS.md invariant 5). Nothing in this file prints anything -- it
|
||||
* returns lengths and bools and lets the caller speak -- and the caller's rule is the one in
|
||||
* AGENTS.md: the key's PEM goes to NVS and nowhere else, so `cert` prints a fingerprint, a
|
||||
* validity window and a name list, never a body. The only identity string that ever leaves the
|
||||
* board is a digest of the certificate, which is public by construction; that asymmetry is
|
||||
* deliberate, and it is why a log line from this code can be trusted not to be a leak.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PEM armour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The label the certificate is armoured under, and the label a SEC1 EC private key is
|
||||
// exported under. Both are wire formats, not free text: whatever reads the PEM looks for
|
||||
// these exact strings.
|
||||
constexpr char kPemCertLabel[] = "CERTIFICATE";
|
||||
constexpr char kPemKeyLabel[] = "EC PRIVATE KEY";
|
||||
|
||||
constexpr size_t kPemLineChars = 64; // characters of base64 per armour line
|
||||
constexpr size_t kPemLabelMax = 32; // sanity bound: a longer label is a bug, not a label
|
||||
constexpr char kPemBeginMark[] = "-----BEGIN ";
|
||||
constexpr char kPemEndMark[] = "-----END ";
|
||||
constexpr char kPemMarkTail[] = "-----";
|
||||
constexpr size_t kPemMarkTailLen = 5;
|
||||
|
||||
namespace detail {
|
||||
|
||||
constexpr char kB64Alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
// -1 for anything that is not a base64 digit, including whitespace and padding, so the
|
||||
// callers can test one thing.
|
||||
inline int b64Value(char c) {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
}
|
||||
|
||||
inline bool isArmourSpace(char c) {
|
||||
return c == '\r' || c == '\n' || c == ' ' || c == '\t';
|
||||
}
|
||||
|
||||
// How many '\n'-terminated lines a base64 body of b64Len characters occupies.
|
||||
inline size_t armourLineCount(size_t b64Len) {
|
||||
return b64Len == 0 ? 0 : (b64Len + kPemLineChars - 1) / kPemLineChars;
|
||||
}
|
||||
|
||||
// base64 with a newline after every 64 characters, and after the last short line.
|
||||
// Returns the characters written (including newlines); does not NUL-terminate.
|
||||
//
|
||||
// 64 is a multiple of 4 and every input group emits exactly 4 characters, so a group
|
||||
// never straddles a line boundary -- that is what makes the wrap above correct rather
|
||||
// than almost correct.
|
||||
inline size_t base64Wrapped(const uint8_t *in, size_t len, char *out) {
|
||||
size_t o = 0;
|
||||
size_t i = 0;
|
||||
size_t col = 0;
|
||||
for (; i + 3 <= len; i += 3) {
|
||||
const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8) | in[i + 2];
|
||||
out[o++] = kB64Alphabet[(v >> 18) & 63];
|
||||
out[o++] = kB64Alphabet[(v >> 12) & 63];
|
||||
out[o++] = kB64Alphabet[(v >> 6) & 63];
|
||||
out[o++] = kB64Alphabet[v & 63];
|
||||
col += 4;
|
||||
if (col == kPemLineChars) {
|
||||
out[o++] = '\n';
|
||||
col = 0;
|
||||
}
|
||||
}
|
||||
const size_t rem = len - i;
|
||||
if (rem) {
|
||||
uint32_t v = uint32_t(in[i]) << 16;
|
||||
if (rem == 2) v |= uint32_t(in[i + 1]) << 8;
|
||||
out[o++] = kB64Alphabet[(v >> 18) & 63];
|
||||
out[o++] = kB64Alphabet[(v >> 12) & 63];
|
||||
out[o++] = rem == 2 ? kB64Alphabet[(v >> 6) & 63] : '=';
|
||||
out[o++] = '=';
|
||||
// The padded group is four characters like any other, and it has to be counted or a
|
||||
// body whose last line is only that group (1 or 2 bytes mod 48, which is exactly
|
||||
// where a key or a certificate often lands) gets written with no terminator and runs
|
||||
// straight into the END marker. Round-tripping still works -- pemFind ignores
|
||||
// whitespace, including its absence -- so nothing but this count and the cross-check
|
||||
// against an independent base64 would ever have noticed.
|
||||
col += 4;
|
||||
}
|
||||
if (col) out[o++] = '\n';
|
||||
return o;
|
||||
}
|
||||
|
||||
// "-----BEGIN <label>-----" / "-----END <label>-----", no newline.
|
||||
inline bool armourMarker(const char *prefix, const char *label, size_t labelLen, char *out,
|
||||
size_t cap) {
|
||||
const size_t prefixLen = strlen(prefix);
|
||||
const size_t need = prefixLen + labelLen + kPemMarkTailLen + 1;
|
||||
if (cap < need) return false;
|
||||
memcpy(out, prefix, prefixLen);
|
||||
memcpy(out + prefixLen, label, labelLen);
|
||||
memcpy(out + prefixLen + labelLen, kPemMarkTail, kPemMarkTailLen);
|
||||
out[need - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* Wrap `derLen` bytes in PEM armour:
|
||||
*
|
||||
* -----BEGIN <label>-----\n
|
||||
* <base64, 64 characters a line, every line terminated by \n>
|
||||
* -----END <label>-----\n
|
||||
*
|
||||
* An empty input produces the two armour lines with no body between them, which
|
||||
* pemFind() reads back as zero bytes. An empty certificate is not a usable identity,
|
||||
* but it is a valid round trip, and a caller that measured zero should report the
|
||||
* measurement rather than have the armour module invent a format for it.
|
||||
*
|
||||
* Returns the number of characters written, excluding the NUL, or 0 -- writing NOTHING
|
||||
* -- when `cap` cannot hold the whole block. A PEM truncated by one byte fails to parse
|
||||
* at the far end with nothing to say but "malformed", so partial output is never the
|
||||
* answer (AGENTS.md invariant 5: reject, never guess).
|
||||
*/
|
||||
inline size_t wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out,
|
||||
size_t cap) {
|
||||
if (!label || !out || (derLen && !der)) return 0;
|
||||
const size_t labelLen = strlen(label);
|
||||
if (labelLen == 0 || labelLen > kPemLabelMax) return 0;
|
||||
|
||||
const size_t b64Len = ((derLen + 2) / 3) * 4;
|
||||
const size_t bodyLen = b64Len + detail::armourLineCount(b64Len);
|
||||
const size_t headLen = strlen(kPemBeginMark) + labelLen + kPemMarkTailLen + 1;
|
||||
const size_t tailLen = strlen(kPemEndMark) + labelLen + kPemMarkTailLen + 1;
|
||||
const size_t total = headLen + bodyLen + tailLen;
|
||||
if (cap < total + 1) return 0;
|
||||
|
||||
size_t o = 0;
|
||||
memcpy(out + o, kPemBeginMark, sizeof(kPemBeginMark) - 1);
|
||||
o += sizeof(kPemBeginMark) - 1;
|
||||
memcpy(out + o, label, labelLen);
|
||||
o += labelLen;
|
||||
memcpy(out + o, kPemMarkTail, kPemMarkTailLen);
|
||||
o += kPemMarkTailLen;
|
||||
out[o++] = '\n';
|
||||
|
||||
o += detail::base64Wrapped(der, derLen, out + o);
|
||||
|
||||
memcpy(out + o, kPemEndMark, sizeof(kPemEndMark) - 1);
|
||||
o += sizeof(kPemEndMark) - 1;
|
||||
memcpy(out + o, label, labelLen);
|
||||
o += labelLen;
|
||||
memcpy(out + o, kPemMarkTail, kPemMarkTailLen);
|
||||
o += kPemMarkTailLen;
|
||||
out[o++] = '\n';
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the DER back out of a PEM armour block carrying `label`.
|
||||
*
|
||||
* Tolerated, because every tool that ever wrote a PEM writes one of these: CRLF line
|
||||
* endings, a missing trailing newline, spaces and tabs inside the body, and the first
|
||||
* block of that label in a haystack that holds several.
|
||||
*
|
||||
* Returns false -- having written nothing -- when there is no such block, when the body
|
||||
* holds a character that is not base64, when its length is not a multiple of four, when
|
||||
* padding is followed by data, or when the decoded bytes do not fit in `cap`. Callers
|
||||
* read these blocks out of NVS: a record that half-survived a failed write must be
|
||||
* reported as broken, not decoded into something almost right.
|
||||
*/
|
||||
inline bool pemFind(const char *label, const char *haystack, uint8_t *out, size_t cap,
|
||||
size_t *derLen = nullptr) {
|
||||
if (derLen) *derLen = 0;
|
||||
if (!label || !haystack || !out) return false;
|
||||
const size_t labelLen = strlen(label);
|
||||
if (labelLen == 0 || labelLen > kPemLabelMax) return false;
|
||||
|
||||
char begin[sizeof(kPemBeginMark) + kPemLabelMax + sizeof(kPemMarkTail)];
|
||||
char end[sizeof(kPemEndMark) + kPemLabelMax + sizeof(kPemMarkTail)];
|
||||
if (!detail::armourMarker(kPemBeginMark, label, labelLen, begin, sizeof(begin))) return false;
|
||||
if (!detail::armourMarker(kPemEndMark, label, labelLen, end, sizeof(end))) return false;
|
||||
|
||||
const char *body = strstr(haystack, begin);
|
||||
if (!body) return false;
|
||||
body += strlen(begin);
|
||||
const char *tail = strstr(body, end);
|
||||
if (!tail) return false;
|
||||
|
||||
// Pass 1: validate and measure. Nothing is written until the whole body is known to
|
||||
// be well formed AND known to fit, so a refusal never leaves a partial decode behind.
|
||||
size_t digits = 0;
|
||||
size_t pad = 0;
|
||||
for (const char *p = body; p < tail; p++) {
|
||||
const char c = *p;
|
||||
if (detail::isArmourSpace(c)) continue;
|
||||
if (c == '=') {
|
||||
pad++;
|
||||
continue;
|
||||
}
|
||||
if (detail::b64Value(c) < 0) return false; // '-' from a torn block, '<', anything
|
||||
if (pad) return false; // data after padding
|
||||
digits++;
|
||||
}
|
||||
if (pad > 2) return false;
|
||||
if ((digits + pad) % 4 != 0) return false;
|
||||
const size_t decoded = ((digits + pad) / 4) * 3 - pad;
|
||||
if (decoded > cap) return false;
|
||||
|
||||
// Pass 2: decode. Nothing here can fail: the alphabet, the length and the room are all
|
||||
// established above.
|
||||
size_t o = 0;
|
||||
uint32_t acc = 0;
|
||||
int held = 0;
|
||||
for (const char *p = body; p < tail; p++) {
|
||||
const int v = detail::b64Value(*p);
|
||||
if (v < 0) continue; // whitespace and padding carry no bits
|
||||
acc = (acc << 6) | uint32_t(v);
|
||||
if (++held == 4) {
|
||||
out[o++] = static_cast<uint8_t>(acc >> 16);
|
||||
out[o++] = static_cast<uint8_t>(acc >> 8);
|
||||
out[o++] = static_cast<uint8_t>(acc);
|
||||
acc = 0;
|
||||
held = 0;
|
||||
}
|
||||
}
|
||||
if (held == 2) {
|
||||
out[o++] = static_cast<uint8_t>(acc >> 4);
|
||||
} else if (held == 3) {
|
||||
out[o++] = static_cast<uint8_t>(acc >> 10);
|
||||
out[o++] = static_cast<uint8_t>(acc >> 2);
|
||||
}
|
||||
|
||||
if (derLen) *derLen = o;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fingerprints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
constexpr size_t kSha256Len = 32;
|
||||
constexpr size_t kFingerprintCap = kSha256Len * 2 + 1; // 64 hex characters + NUL
|
||||
constexpr size_t kFingerprintHexChars = kSha256Len * 2; // 64, without the NUL
|
||||
|
||||
inline size_t fingerprintHexCap(size_t digestLen) { return digestLen * 2 + 1; }
|
||||
|
||||
namespace detail {
|
||||
|
||||
// A hex digit is a hex digit. A stored fingerprint holding anything else is a corrupt
|
||||
// record, not a fingerprint written in another notation, and the callers below refuse it
|
||||
// rather than rendering half of it.
|
||||
inline bool isHexDigit(char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* Lowercase hex of a digest, NUL-terminated: the form `openssl x509 -fingerprint -sha256`
|
||||
* prints (minus its colons) and therefore the form the operator compares against.
|
||||
*
|
||||
* Returns false and writes nothing when `cap` cannot hold 2*digestLen + 1. A fingerprint
|
||||
* is the one value the operator uses to decide whether to trust the board; a truncated
|
||||
* one that still looks like a fingerprint is the worst thing this could return.
|
||||
*/
|
||||
inline bool fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap) {
|
||||
if (!digest || !out || cap < digestLen * 2 + 1) return false;
|
||||
static const char kHex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < digestLen; i++) {
|
||||
out[2 * i] = kHex[digest[i] >> 4];
|
||||
out[2 * i + 1] = kHex[digest[i] & 0xF];
|
||||
}
|
||||
out[digestLen * 2] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `hexChars` characters of a fingerprint hex string, NUL-terminated -- the
|
||||
* short form the status frame carries so the page can show *something* to compare without
|
||||
* putting 64 characters on a screen next to a button.
|
||||
*
|
||||
* Every character read must actually be a hex digit and the string must be at least that
|
||||
* long, so a truncated or corrupt stored fingerprint is refused rather than rendered as a
|
||||
* shorter-looking-but-different one. The full fingerprint stays the authority; this is a
|
||||
* label, and it is the reason `cert` prints all 64.
|
||||
*/
|
||||
inline bool fingerprintPrefix(const char *fpHex, size_t hexChars, char *out, size_t cap) {
|
||||
if (!fpHex || !out || cap < hexChars + 1) return false;
|
||||
for (size_t i = 0; i < hexChars; i++) {
|
||||
if (!detail::isHexDigit(fpHex[i])) return false;
|
||||
}
|
||||
memcpy(out, fpHex, hexChars);
|
||||
out[hexChars] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `pairCount` colon-separated pairs of a fingerprint, starting at pair index `firstPair`:
|
||||
* `"aa:bb:cc:…"`. Two calls with `firstPair` 0 and 16 give the whole SHA-256 in two lines
|
||||
* of 47 characters, which is the shape the serial console prints.
|
||||
*
|
||||
* The wrapping is not decoration. A 64-character run on one line plus a prefix goes over
|
||||
* the 128-byte TinyUSB CDC transmit ring, and with the global transmit timeout at 0 that
|
||||
* drops silently in the middle of the value the operator is supposed to copy (README bug
|
||||
* 1 was exactly this, mid-word). Colon pairs are also what
|
||||
* `openssl x509 -noout -fingerprint -sha256` prints, so the two can be compared pair by
|
||||
* pair instead of character by character.
|
||||
*
|
||||
* Returns the length written (3*pairCount - 1), 0 and nothing written when `cap` is too
|
||||
* small, the range is empty or past the end, or the source is not hex.
|
||||
*/
|
||||
inline size_t fingerprintPairs(const char *fpHex, size_t firstPair, size_t pairCount,
|
||||
char *out, size_t cap) {
|
||||
if (!fpHex || !out || pairCount == 0) return 0;
|
||||
if (firstPair + pairCount > kSha256Len) return 0;
|
||||
const size_t need = pairCount * 3; // 3*pairCount - 1 characters + NUL
|
||||
if (cap < need) return 0;
|
||||
|
||||
// Validated across the whole range before anything is written, so a refusal cannot leave
|
||||
// half a fingerprint in the buffer for a caller that ignored the return value to print.
|
||||
for (size_t i = 0; i < pairCount; i++) {
|
||||
const size_t base = (firstPair + i) * 2;
|
||||
if (!detail::isHexDigit(fpHex[base]) || !detail::isHexDigit(fpHex[base + 1])) return 0;
|
||||
}
|
||||
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < pairCount; i++) {
|
||||
const size_t base = (firstPair + i) * 2;
|
||||
if (i) out[o++] = ':';
|
||||
out[o++] = fpHex[base];
|
||||
out[o++] = fpHex[base + 1];
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The names this board's certificate is issued for
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// One table, three consumers: the subject string the certificate carries, the SAN
|
||||
// extension it carries, and the human-readable list the README and the serial console
|
||||
// tell the operator to expect. tests/test_tls_pem.cpp pins all three against
|
||||
// src/config.h, because a certificate issued for a name nobody connects to, or a banner
|
||||
// that promises a name the certificate does not hold, both look exactly like a working
|
||||
// board right up until the browser complains.
|
||||
|
||||
enum class SanType : uint8_t { Dns, Ip };
|
||||
|
||||
struct SanEntry {
|
||||
SanType type;
|
||||
const char *value;
|
||||
};
|
||||
|
||||
inline const SanEntry *sanEntries(size_t *count) {
|
||||
static const SanEntry kEntries[] = {
|
||||
{SanType::Dns, kCertCommonName}, // what the operator types: https://keyrelay.local
|
||||
{SanType::Dns, kHostname}, // the bare mDNS instance name
|
||||
{SanType::Ip, kApGw}, // the address that works with no DNS at all
|
||||
};
|
||||
if (count) *count = sizeof(kEntries) / sizeof(kEntries[0]);
|
||||
return kEntries;
|
||||
}
|
||||
|
||||
inline const char *sanTypeName(SanType type) { return type == SanType::Dns ? "DNS:" : "IP:"; }
|
||||
|
||||
/**
|
||||
* The subject the certificate is issued to and self-signed by: "CN=keyrelay.local".
|
||||
* This exact string goes into the certificate writer, which parses it as a
|
||||
* comma-separated list of type=value pairs -- so the common name must never contain a
|
||||
* comma. It is built from kCertCommonName, which the test pins to kHostname + ".local".
|
||||
*
|
||||
* Returns the length written, 0 when it does not fit (nothing written).
|
||||
*/
|
||||
inline size_t subjectLine(char *out, size_t cap) {
|
||||
if (!out) return 0;
|
||||
static const char kPrefix[] = "CN=";
|
||||
const size_t need = strlen(kPrefix) + strlen(kCertCommonName) + 1;
|
||||
if (cap < need) return 0;
|
||||
const size_t n = strlen(kPrefix);
|
||||
memcpy(out, kPrefix, n);
|
||||
memcpy(out + n, kCertCommonName, strlen(kCertCommonName));
|
||||
out[need - 1] = '\0';
|
||||
return need - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SAN as the operator reads it: "DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1"
|
||||
* -- the same rendering `openssl x509 -text` prints, so a pinned fingerprint can be
|
||||
* checked against a name-by-name list rather than a blob.
|
||||
*
|
||||
* Returns the length written, 0 when it does not fit (nothing written).
|
||||
*/
|
||||
inline size_t sanList(char *out, size_t cap) {
|
||||
if (!out || cap == 0) return 0;
|
||||
|
||||
size_t count = 0;
|
||||
const SanEntry *entries = sanEntries(&count);
|
||||
size_t total = 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (i) total += 2; // ", "
|
||||
total += strlen(sanTypeName(entries[i].type)) + strlen(entries[i].value);
|
||||
}
|
||||
if (cap < total + 1) return 0;
|
||||
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (i) {
|
||||
out[o++] = ',';
|
||||
out[o++] = ' ';
|
||||
}
|
||||
const char *prefix = sanTypeName(entries[i].type);
|
||||
const size_t plen = strlen(prefix);
|
||||
memcpy(out + o, prefix, plen);
|
||||
o += plen;
|
||||
const size_t vlen = strlen(entries[i].value);
|
||||
memcpy(out + o, entries[i].value, vlen);
|
||||
o += vlen;
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DER for one extension, written by hand and tested by hand
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The alternative was the certificate library's own SAN helper. Two reasons not to: its
|
||||
// IP-address member has taken both a dotted string and four raw bytes across versions,
|
||||
// and getting that wrong produces a certificate the browser rejects for a reason the
|
||||
// board cannot print. The encoding below is ~30 bytes of DER with no degrees of freedom,
|
||||
// it is the module's own code so the host suite walks it, and the expected bytes were
|
||||
// cross-checked against what OpenSSL writes for the same three names (see the test).
|
||||
|
||||
constexpr size_t kSanDerMax = 128;
|
||||
constexpr size_t kIpv4Len = 4;
|
||||
|
||||
inline size_t asn1LengthBytes(size_t len) {
|
||||
if (len < 128) return 1;
|
||||
size_t n = 0;
|
||||
while (len >> (8 * n)) n++;
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// DER length: short form below 128, otherwise 0x80|n followed by n big-endian bytes.
|
||||
// Returns the bytes written. DER forbids the long form for small lengths and forbids
|
||||
// leading zeros in it, and a verifier that disagrees is a handshake that fails.
|
||||
inline size_t asn1WriteLength(size_t len, uint8_t *out) {
|
||||
if (len < 128) {
|
||||
out[0] = static_cast<uint8_t>(len);
|
||||
return 1;
|
||||
}
|
||||
size_t n = 0;
|
||||
while (len >> (8 * n)) n++;
|
||||
out[0] = static_cast<uint8_t>(0x80 | n);
|
||||
for (size_t i = 0; i < n; i++) out[1 + i] = static_cast<uint8_t>(len >> (8 * (n - 1 - i)));
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict dotted-quad parse: four decimal octets, 0..255, nothing else. No leading '+',
|
||||
* no trailing junk, no "192.168.4" (which some resolvers would expand). Returns false
|
||||
* rather than producing an address the operator did not ask for.
|
||||
*/
|
||||
inline bool ipv4Bytes(const char *text, uint8_t out[kIpv4Len]) {
|
||||
if (!text || !out) return false;
|
||||
const char *p = text;
|
||||
for (size_t i = 0; i < kIpv4Len; i++) {
|
||||
if (*p < '0' || *p > '9') return false;
|
||||
unsigned v = 0;
|
||||
size_t digits = 0;
|
||||
while (*p >= '0' && *p <= '9') {
|
||||
v = v * 10 + unsigned(*p - '0');
|
||||
p++;
|
||||
digits++;
|
||||
if (digits > 3) return false;
|
||||
}
|
||||
if (v > 255) return false;
|
||||
out[i] = static_cast<uint8_t>(v);
|
||||
if (i + 1 < kIpv4Len) {
|
||||
if (*p != '.') return false;
|
||||
p++;
|
||||
}
|
||||
}
|
||||
return *p == '\0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a DER blob contain these exact bytes?
|
||||
*
|
||||
* This exists for one question the serial console has to answer honestly: the names it
|
||||
* prints come from `sanEntries()`, and the certificate the board is actually serving was
|
||||
* built from that same table by `sanExtensionDer()`. A self-signed certificate carries its
|
||||
* extensions verbatim, so if the SAN bytes this build would write do not appear inside the
|
||||
* stored certificate, the certificate is not from this table -- say so instead of printing
|
||||
* a name list the board never issued. An empty needle is a refusal, not a match: "contains
|
||||
* nothing" is true of every buffer and therefore tells the operator nothing.
|
||||
*/
|
||||
inline bool derContains(const uint8_t *haystack, size_t haystackLen, const uint8_t *needle,
|
||||
size_t needleLen) {
|
||||
if (!haystack || !needle || needleLen == 0 || needleLen > haystackLen) return false;
|
||||
for (size_t i = 0; i + needleLen <= haystackLen; i++) {
|
||||
if (memcmp(haystack + i, needle, needleLen) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DER *value* of the subjectAlternativeName extension (RFC 5280 4.2.1.6): a
|
||||
* GeneralNames SEQUENCE of context-tagged entries, for sanEntries() and nothing else.
|
||||
* The caller passes the result, with OID 2.5.29.17, to the certificate writer, which
|
||||
* wraps it in the extension's OCTET STRING itself.
|
||||
*
|
||||
* SEQUENCE { [2] IA5String keyrelay.local
|
||||
* [2] IA5String keyrelay
|
||||
* [7] OCTET STRING C0 A8 04 01 } -- 192.168.4.1
|
||||
*
|
||||
* Returns the length written, 0 when it does not fit or when an IP entry is not a valid
|
||||
* dotted quad (nothing written in either case).
|
||||
*/
|
||||
inline size_t sanExtensionDer(uint8_t *out, size_t cap) {
|
||||
if (!out) return 0;
|
||||
|
||||
size_t count = 0;
|
||||
const SanEntry *entries = sanEntries(&count);
|
||||
if (count > 4) return 0; // the scratch below is sized for the table, not for a guess
|
||||
|
||||
// Measure and validate first, keeping the parsed addresses: by the time a byte is
|
||||
// written, every length is known and nothing can fail part-way through the buffer.
|
||||
uint8_t ip[4][kIpv4Len];
|
||||
size_t bodyLen = 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
size_t vlen;
|
||||
if (entries[i].type == SanType::Dns) {
|
||||
vlen = strlen(entries[i].value);
|
||||
} else {
|
||||
if (!ipv4Bytes(entries[i].value, ip[i])) return 0;
|
||||
vlen = kIpv4Len;
|
||||
}
|
||||
bodyLen += 1 + asn1LengthBytes(vlen) + vlen;
|
||||
}
|
||||
const size_t total = 1 + asn1LengthBytes(bodyLen) + bodyLen;
|
||||
if (cap < total) return 0;
|
||||
|
||||
size_t o = 0;
|
||||
out[o++] = 0x30; // SEQUENCE
|
||||
o += asn1WriteLength(bodyLen, out + o);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const uint8_t *value;
|
||||
size_t vlen;
|
||||
if (entries[i].type == SanType::Dns) {
|
||||
out[o++] = 0x82; // context [2], primitive: dNSName (IA5String)
|
||||
value = reinterpret_cast<const uint8_t *>(entries[i].value);
|
||||
vlen = strlen(entries[i].value);
|
||||
} else {
|
||||
out[o++] = 0x87; // context [7], primitive: iPAddress (4 or 16 raw bytes)
|
||||
value = ip[i];
|
||||
vlen = kIpv4Len;
|
||||
}
|
||||
o += asn1WriteLength(vlen, out + o);
|
||||
memcpy(out + o, value, vlen);
|
||||
o += vlen;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The OID that turns those bytes into a subjectAlternativeName
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The complete OBJECT IDENTIFIER TLV of RFC 5280 4.2.1.6: tag 06, length 03, content
|
||||
// 55 1D 11 (2.5.29.17). It lives here, next to the value it labels, for the same reason
|
||||
// the value does: the host suite walks it and the board writes it.
|
||||
//
|
||||
// The trap it exists to keep shut is a documented one that reads harmlessly. The
|
||||
// certificate writer takes an extension as {OID *content* bytes, OCTET STRING value} --
|
||||
// the content of the DER OBJECT IDENTIFIER, not its dotted text. Handing it "2.5.29.17"
|
||||
// is not an error the library reports: it writes nine bytes of ASCII where three bytes of
|
||||
// OID belong, which is perfectly well-formed DER for the OID
|
||||
// 1.10.46.53.46.50.57.46.49.55, and the certificate comes back carrying the right SAN
|
||||
// value under an OID nothing recognises. No client sees a subjectAlternativeName; every
|
||||
// one of them then falls back to the CN, which browsers stopped honouring years ago, so
|
||||
// the name check fails with a message about the *name* and nothing on the board says the
|
||||
// extension is the problem. That is how this shipped once: `openssl x509 -text` on the
|
||||
// live board's certificate showed `1.10.46.53.46.50.57.46.49.55` where `X509v3 Subject
|
||||
// Alternative Name` should have been, and every host test stayed green because they walked
|
||||
// the value and the name table and never the label. See README "Eleven bugs", item 11.
|
||||
constexpr uint8_t kSanOidTlv[] = {0x06, 0x03, 0x55, 0x1D, 0x11};
|
||||
constexpr size_t kSanOidTlvLen = sizeof(kSanOidTlv);
|
||||
|
||||
// The OID as the certificate writer wants it: the three content bytes, `55 1D 11`.
|
||||
inline const uint8_t *sanOidDer(size_t *count) {
|
||||
if (count) *count = kSanOidTlvLen - 2;
|
||||
return kSanOidTlv + 2;
|
||||
}
|
||||
|
||||
// The SAN extension the way it is written on the wire: SEQUENCE { OID, OCTET STRING value }
|
||||
// (critical is false, so no BOOLEAN -- confirmed against the DER a board produced, not
|
||||
// against a reading of the writer).
|
||||
constexpr size_t kSanExtensionFullDerMax = kSanDerMax + 16;
|
||||
|
||||
inline size_t sanExtensionFullDer(uint8_t *out, size_t cap) {
|
||||
if (!out) return 0;
|
||||
uint8_t value[kSanDerMax];
|
||||
const size_t valueLen = sanExtensionDer(value, sizeof(value));
|
||||
if (valueLen == 0) return 0;
|
||||
const size_t octetLen = 1 + asn1LengthBytes(valueLen) + valueLen;
|
||||
const size_t body = kSanOidTlvLen + octetLen;
|
||||
const size_t total = 1 + asn1LengthBytes(body) + body;
|
||||
if (cap < total) return 0;
|
||||
size_t o = 0;
|
||||
out[o++] = 0x30; // SEQUENCE (one Extension)
|
||||
o += asn1WriteLength(body, out + o);
|
||||
memcpy(out + o, kSanOidTlv, kSanOidTlvLen);
|
||||
o += kSanOidTlvLen;
|
||||
out[o++] = 0x04; // OCTET STRING, wrapping the extension value
|
||||
o += asn1WriteLength(valueLen, out + o);
|
||||
memcpy(out + o, value, valueLen);
|
||||
return o + valueLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this DER carry the subjectAlternativeName extension this build writes?
|
||||
*
|
||||
* Not "does it contain the SAN value" -- that was the first version of this question and
|
||||
* it is the wrong one. A certificate with the right GeneralNames under the wrong OID
|
||||
* answers yes to it and has no SAN as far as any client is concerned, which is the exact
|
||||
* defect this predicate now catches (see the block above). Matching the whole Extension
|
||||
* SEQUENCE asks the question a browser asks: these bytes, under this OID.
|
||||
*/
|
||||
inline bool derCarriesSan(const uint8_t *der, size_t derLen) {
|
||||
uint8_t ext[kSanExtensionFullDerMax];
|
||||
const size_t len = sanExtensionFullDer(ext, sizeof(ext));
|
||||
if (len == 0) return false;
|
||||
return derContains(der, derLen, ext, len);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validity window
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The certificate is written at build time +10 years, so its two date strings are
|
||||
// produced by arithmetic on `__DATE__` rather than copied in. Getting that arithmetic
|
||||
// wrong is invisible until every handshake fails: a year written as three digits, a
|
||||
// month of 13, a February 29th in a year that has none. Hence: pure, and walked.
|
||||
|
||||
inline bool leapYear(int y) {
|
||||
return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
|
||||
}
|
||||
|
||||
inline int daysInMonth(int y, int m) {
|
||||
static const signed char kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||||
if (m < 1 || m > 12) return 0;
|
||||
if (m == 2 && leapYear(y)) return 29;
|
||||
return kDays[m - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 11-character `__DATE__` macro ("Nov 8 2026": the day is space padded, so
|
||||
* the field widths lie). Returns false for anything that is not exactly
|
||||
* "<Mon> <d[ 1..31]> <yyyy>", because a silently wrong build date is a certificate that
|
||||
* is not yet valid or no longer is.
|
||||
*/
|
||||
inline bool parseBuildDate(const char *s, int &year, int &month, int &day) {
|
||||
year = 0;
|
||||
month = 0;
|
||||
day = 0;
|
||||
if (!s) return false;
|
||||
static const char kMonths[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
|
||||
for (int i = 0; i < 12; i++) {
|
||||
if (s[0] == kMonths[i * 3] && s[1] == kMonths[i * 3 + 1] && s[2] == kMonths[i * 3 + 2]) {
|
||||
month = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!month) return false;
|
||||
|
||||
const char *p = s + 3;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
int d = 0;
|
||||
int digits = 0;
|
||||
while (*p >= '0' && *p <= '9' && digits < 2) {
|
||||
d = d * 10 + (*p - '0');
|
||||
p++;
|
||||
digits++;
|
||||
}
|
||||
if (digits == 0 || d < 1 || d > 31) return false;
|
||||
day = d;
|
||||
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
int y = 0;
|
||||
digits = 0;
|
||||
while (*p >= '0' && *p <= '9' && digits < 4) {
|
||||
y = y * 10 + (*p - '0');
|
||||
p++;
|
||||
digits++;
|
||||
}
|
||||
if (digits != 4 || *p != '\0') return false;
|
||||
year = y;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Day-scale calendar arithmetic only (a few days either way): it steps one day at a
|
||||
// time, which is the right cost for backdating a certificate and the wrong cost for
|
||||
// walking a year.
|
||||
inline void addDays(int &y, int &m, int &d, int days) {
|
||||
while (days > 0) {
|
||||
if (++d > daysInMonth(y, m)) {
|
||||
d = 1;
|
||||
if (++m > 12) {
|
||||
m = 1;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
days--;
|
||||
}
|
||||
while (days < 0) {
|
||||
if (--d < 1) {
|
||||
if (--m < 1) {
|
||||
m = 12;
|
||||
y--;
|
||||
}
|
||||
d = daysInMonth(y, m);
|
||||
}
|
||||
days++;
|
||||
}
|
||||
}
|
||||
|
||||
// Adding years keeps the day-of-month when the target month has one, and lands on the
|
||||
// last day when it does not: 2028-02-29 + 1 year is 2029-02-28, never 2029-02-31, which
|
||||
// is a date the certificate writer would reject at build time and nobody would trace
|
||||
// back to a leap year.
|
||||
inline void addYears(int &y, int &m, int &d, int years) {
|
||||
y += years;
|
||||
const int dim = daysInMonth(y, m);
|
||||
if (dim && d > dim) d = dim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format "YYYYMMDDhhmmss" -- the UTC string the certificate writer takes (RFC 5280
|
||||
* UTCTime). Returns 14, or 0 without writing when the window is outside what UTCTime can
|
||||
* express (1950..2049 inclusive) or a component is out of range. No printf: this runs in
|
||||
* a header that the sanitiser suite compiles, and digits do not need a formatter.
|
||||
*/
|
||||
inline size_t utcTime(char *out, size_t cap, int y, int mo, int d, int hh, int mi, int ss) {
|
||||
if (!out || cap < 15) return 0;
|
||||
if (y < 1950 || y > 2049) return 0;
|
||||
if (mo < 1 || mo > 12) return 0;
|
||||
if (d < 1 || d > daysInMonth(y, mo)) return 0;
|
||||
if (hh < 0 || hh > 23 || mi < 0 || mi > 59 || ss < 0 || ss > 60) return 0;
|
||||
|
||||
size_t o = 0;
|
||||
out[o++] = char('0' + (y / 1000) % 10);
|
||||
out[o++] = char('0' + (y / 100) % 10);
|
||||
out[o++] = char('0' + (y / 10) % 10);
|
||||
out[o++] = char('0' + y % 10);
|
||||
out[o++] = char('0' + (mo / 10) % 10);
|
||||
out[o++] = char('0' + mo % 10);
|
||||
out[o++] = char('0' + (d / 10) % 10);
|
||||
out[o++] = char('0' + d % 10);
|
||||
out[o++] = char('0' + (hh / 10) % 10);
|
||||
out[o++] = char('0' + hh % 10);
|
||||
out[o++] = char('0' + (mi / 10) % 10);
|
||||
out[o++] = char('0' + mi % 10);
|
||||
out[o++] = char('0' + (ss / 10) % 10);
|
||||
out[o++] = char('0' + ss % 10);
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same moment as a human reads it: "2026-11-08 00:00:00 UTC", 23 characters.
|
||||
*
|
||||
* `utcTime()` writes what the certificate carries; this renders what the serial console
|
||||
* prints for it, so the two date strings in `cert` are produced by the same range checks
|
||||
* that decide whether a certificate may be written at all -- a month of 13 cannot reach the
|
||||
* operator as "month 13" without this file having first refused to encode it. Unlike
|
||||
* `utcTime()` it is not limited to UTCTime's 1950..2049 window: reading a date is not the
|
||||
* same permission as writing one, and a certificate outliving the notation is a problem for
|
||||
* the writer, not for the reader.
|
||||
*/
|
||||
inline size_t dateTimeText(char *out, size_t cap, int y, int mo, int d, int hh, int mi,
|
||||
int ss) {
|
||||
if (!out || cap < 24) return 0;
|
||||
if (y < 0 || y > 9999) return 0;
|
||||
if (mo < 1 || mo > 12) return 0;
|
||||
if (d < 1 || d > daysInMonth(y, mo)) return 0;
|
||||
if (hh < 0 || hh > 23 || mi < 0 || mi > 59 || ss < 0 || ss > 60) return 0;
|
||||
|
||||
size_t o = 0;
|
||||
out[o++] = char('0' + (y / 1000) % 10);
|
||||
out[o++] = char('0' + (y / 100) % 10);
|
||||
out[o++] = char('0' + (y / 10) % 10);
|
||||
out[o++] = char('0' + y % 10);
|
||||
out[o++] = '-';
|
||||
out[o++] = char('0' + (mo / 10) % 10);
|
||||
out[o++] = char('0' + mo % 10);
|
||||
out[o++] = '-';
|
||||
out[o++] = char('0' + (d / 10) % 10);
|
||||
out[o++] = char('0' + d % 10);
|
||||
out[o++] = ' ';
|
||||
out[o++] = char('0' + (hh / 10) % 10);
|
||||
out[o++] = char('0' + hh % 10);
|
||||
out[o++] = ':';
|
||||
out[o++] = char('0' + (mi / 10) % 10);
|
||||
out[o++] = char('0' + mi % 10);
|
||||
out[o++] = ':';
|
||||
out[o++] = char('0' + (ss / 10) % 10);
|
||||
out[o++] = char('0' + ss % 10);
|
||||
out[o++] = ' ';
|
||||
out[o++] = 'U';
|
||||
out[o++] = 'T';
|
||||
out[o++] = 'C';
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* keyrelay -- the mbed TLS half of the session layer. See tls_session.h for the shape,
|
||||
* the esp_tls decision and the write contract; this file is the library calls.
|
||||
*
|
||||
* Every failure path here ends the session and releases the socket -- an mbed TLS
|
||||
* context owns heap allocations, and on a board with no PSRAM a session that leaks on
|
||||
* its error path is how a scan turns into an out-of-memory reboot. tls.cpp makes the
|
||||
* same argument about the identity; the fd plus four contexts per live session makes
|
||||
* it louder here.
|
||||
*/
|
||||
#include "tls_session.h"
|
||||
|
||||
#if KR_TLS
|
||||
|
||||
// Arduino.h BEFORE the POSIX headers, and that order is not style: this core's
|
||||
// platform_include/sys/fcntl.h expands newlib's sys/stat.h, which needs the base
|
||||
// typedefs (dev_t, mode_t, ...) that Arduino's own include chain has already pulled
|
||||
// in by then. Including <fcntl.h> first -- the C-idiomatic order -- fails with
|
||||
// "'dev_t' does not name a type" deep inside newlib. millis() is the one Arduino
|
||||
// thing this file needs; this ordering is the price of every other call here.
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <lwip/sockets.h>
|
||||
#include <netinet/tcp.h>
|
||||
|
||||
// Ordered includes: mbedtls headers assume build_info.h has set the feature macros
|
||||
// first (tls.cpp orders them the same way); starting at ctr_drbg.h pulls in
|
||||
// aes.h/entropy.h with mbedtls_aes_context and mbedtls_md_context_t still unseen.
|
||||
#include "mbedtls/build_info.h"
|
||||
#include "mbedtls/ctr_drbg.h"
|
||||
#include "mbedtls/entropy.h"
|
||||
#include "mbedtls/net_sockets.h"
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
// The pure half branches on these three NUMBERS without including the library; this is
|
||||
// where the two get welded together, and where a core update that renumbers an error
|
||||
// code stops the build instead of silently reclassifying "would block" as "fatal".
|
||||
// AGENTS.md: "when you add a table that decodes something somebody else defined, test
|
||||
// the table, not your confidence in it" -- the static_asserts are the table's pin.
|
||||
static_assert(kMbedWantRead == MBEDTLS_ERR_SSL_WANT_READ,
|
||||
"tls_session.h kMbedWantRead drifted from the shipped mbed TLS");
|
||||
static_assert(kMbedWantWrite == MBEDTLS_ERR_SSL_WANT_WRITE,
|
||||
"tls_session.h kMbedWantWrite drifted from the shipped mbed TLS");
|
||||
static_assert(kMbedPeerCloseNotify == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY,
|
||||
"tls_session.h kMbedPeerCloseNotify drifted from the shipped mbed TLS");
|
||||
|
||||
namespace {
|
||||
|
||||
// One entropy source and one DRBG for the whole device, seeded on first use.
|
||||
// mbedtls_ctr_drbg is explicitly not thread-safe; that is fine here and load-bearing
|
||||
// to say: every Session call happens from the loop() task, and the alternative -- a
|
||||
// per-session entropy context -- costs ~2 kB of heap per connection on the exact path
|
||||
// the session cap exists to keep bounded. Personalised (like tls.cpp's generator) so
|
||||
// these bytes have a named purpose and cannot be confused with identity keystream.
|
||||
mbedtls_entropy_context g_entropy;
|
||||
mbedtls_ctr_drbg_context g_drbg;
|
||||
bool g_drbgReady = false;
|
||||
const char kDrbgPersonalisation[] = "keyrelay-session";
|
||||
|
||||
bool ensureDrbg() {
|
||||
if (g_drbgReady) return true;
|
||||
mbedtls_entropy_init(&g_entropy);
|
||||
mbedtls_ctr_drbg_init(&g_drbg);
|
||||
if (mbedtls_ctr_drbg_seed(&g_drbg, mbedtls_entropy_func, &g_entropy,
|
||||
reinterpret_cast<const unsigned char *>(kDrbgPersonalisation),
|
||||
sizeof(kDrbgPersonalisation) - 1) != 0) {
|
||||
return false;
|
||||
}
|
||||
g_drbgReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The socket bridge. mbed TLS calls these with whatever state set_bio was given --
|
||||
// here the fd itself, carried as a void*; they must map EWOULDBLOCK to the library's
|
||||
// WANT_* codes, which is the entire mechanism by which the handshake stays one-call-
|
||||
// per-tick instead of blocking loop(). A hard socket error returns a net error, which
|
||||
// mapIo() classifies as fatal -- the session closes rather than retrying a dead fd.
|
||||
int bioRecv(void *bio, unsigned char *buf, size_t len) {
|
||||
const int fd = static_cast<int>(reinterpret_cast<intptr_t>(bio));
|
||||
const ssize_t n = ::recv(fd, buf, len, 0);
|
||||
if (n > 0) return static_cast<int>(n);
|
||||
if (n == 0) return MBEDTLS_ERR_NET_CONN_RESET; // peer vanished without a record
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
|
||||
return MBEDTLS_ERR_SSL_WANT_READ;
|
||||
}
|
||||
return MBEDTLS_ERR_NET_RECV_FAILED;
|
||||
}
|
||||
|
||||
int bioSend(void *bio, const unsigned char *buf, size_t len) {
|
||||
const int fd = static_cast<int>(reinterpret_cast<intptr_t>(bio));
|
||||
const ssize_t n = ::send(fd, buf, len, 0);
|
||||
if (n > 0) return static_cast<int>(n);
|
||||
if (n == 0) return static_cast<int>(len) ? MBEDTLS_ERR_SSL_WANT_WRITE : 0;
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
|
||||
return MBEDTLS_ERR_SSL_WANT_WRITE;
|
||||
}
|
||||
return MBEDTLS_ERR_NET_SEND_FAILED;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Session::open(int fd, const PemPair &id) {
|
||||
// One socket per Session, always. (Reopening means close() first; the argument fd is
|
||||
// still consumed, because open() consuming the fd unconditionally is the rule.)
|
||||
if (_fd >= 0) {
|
||||
::close(fd);
|
||||
return false;
|
||||
}
|
||||
_lastError = 0;
|
||||
if (!id.cert[0] || !id.key[0]) {
|
||||
::close(fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-blocking BEFORE anything can read it: a blocking fd here would hand the whole
|
||||
// "loop() never waits" guarantee back. TCP_NODELAY matches what the plaintext path
|
||||
// does with setNoDelay(true) -- small WebSocket frames, latency over efficiency.
|
||||
fcntl(fd, F_SETFL, O_NONBLOCK);
|
||||
const int one = 1;
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||
|
||||
mbedtls_ssl_init(&_ssl);
|
||||
mbedtls_ssl_config_init(&_conf);
|
||||
mbedtls_x509_crt_init(&_crt);
|
||||
mbedtls_pk_init(&_key);
|
||||
_cryptoInit = true;
|
||||
_fd = fd; // ownership taken here; every return-false below runs through close()
|
||||
|
||||
if (!ensureDrbg()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
if (mbedtls_ssl_config_defaults(&_conf, MBEDTLS_SSL_IS_SERVER,
|
||||
MBEDTLS_SSL_TRANSPORT_STREAM,
|
||||
MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
mbedtls_ssl_conf_endpoint(&_conf, MBEDTLS_SSL_IS_SERVER);
|
||||
mbedtls_ssl_conf_rng(&_conf, mbedtls_ctr_drbg_random, &g_drbg);
|
||||
// No client certificate: see tls_session.h. The passphrase is the credential.
|
||||
mbedtls_ssl_conf_authmode(&_conf, MBEDTLS_SSL_VERIFY_NONE);
|
||||
|
||||
// +1 length: how the parsers tell PEM from DER (the same rule tls.cpp's selfCheck
|
||||
// documents). A parse failure here is an identity problem, not a client problem --
|
||||
// which is why boot self-checks the pair instead of waiting for every handshake to
|
||||
// fail in the same way.
|
||||
if (mbedtls_x509_crt_parse(&_crt, reinterpret_cast<const unsigned char *>(id.cert),
|
||||
strlen(id.cert) + 1) != 0) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
if (mbedtls_pk_parse_key(&_key, reinterpret_cast<const unsigned char *>(id.key),
|
||||
strlen(id.key) + 1, nullptr, 0, mbedtls_ctr_drbg_random,
|
||||
&g_drbg) != 0) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
if (mbedtls_ssl_conf_own_cert(&_conf, &_crt, &_key) != 0) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
if (mbedtls_ssl_setup(&_ssl, &_conf) != 0) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
mbedtls_ssl_set_bio(&_ssl, reinterpret_cast<void *>(static_cast<intptr_t>(fd)),
|
||||
bioSend, bioRecv, nullptr);
|
||||
_handshaked = false;
|
||||
_pendPtr = nullptr;
|
||||
_pendLen = 0;
|
||||
_startedAtMs = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
Handshake Session::poll() {
|
||||
if (_fd < 0) return Handshake::Failed;
|
||||
if (_handshaked) return Handshake::Ready;
|
||||
// The deadline is checked here and nowhere else, so the session that opens and never
|
||||
// finishes a handshake -- the scan this timeout exists for -- costs one comparison
|
||||
// per tick, and dies with the socket released like every other failure.
|
||||
if (deadlinePassed(static_cast<uint32_t>(millis()), _startedAtMs, kTlsHandshakeMs)) {
|
||||
close();
|
||||
return Handshake::Failed;
|
||||
}
|
||||
const int rc = mbedtls_ssl_handshake(&_ssl);
|
||||
if (rc == 0) {
|
||||
_handshaked = true;
|
||||
return Handshake::Ready;
|
||||
}
|
||||
const Io io = mapIo(rc);
|
||||
if (io == Io::Want) return Handshake::InProgress;
|
||||
_lastError = rc;
|
||||
close();
|
||||
return Handshake::Failed;
|
||||
}
|
||||
|
||||
size_t Session::read(uint8_t *dst, size_t cap) {
|
||||
if (!ready() || !dst || cap == 0) return 0;
|
||||
const int rc = mbedtls_ssl_read(&_ssl, dst, cap);
|
||||
const Io io = mapIo(rc);
|
||||
if (io == Io::Bytes) return static_cast<size_t>(rc);
|
||||
if (io == Io::Want) return 0; // nothing buffered; the caller stops, never spins
|
||||
if (rc != 0) _lastError = rc;
|
||||
close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t Session::write(const uint8_t *src, size_t len) {
|
||||
if (!ready() || !src || len == 0) return 0;
|
||||
// The resume contract: while a write is in flight, only the identical call proceeds.
|
||||
// A different (ptr, len) mid-record is a bug in a caller, and the honest answer to
|
||||
// that bug is "0, nothing happened" -- not a corrupted stream, and not an abort that
|
||||
// would take an authenticated controller down over a programming error.
|
||||
if (_pendLen != 0 && (src != _pendPtr || len != _pendLen)) return 0;
|
||||
const int rc = mbedtls_ssl_write(&_ssl, src, len);
|
||||
const Io io = mapIo(rc);
|
||||
if (io == Io::Bytes) {
|
||||
_pendPtr = nullptr;
|
||||
_pendLen = 0;
|
||||
return static_cast<size_t>(rc);
|
||||
}
|
||||
if (io == Io::Want) {
|
||||
_pendPtr = src;
|
||||
_pendLen = len;
|
||||
return 0;
|
||||
}
|
||||
if (rc != 0) _lastError = rc;
|
||||
_pendPtr = nullptr;
|
||||
_pendLen = 0;
|
||||
close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void Session::close() {
|
||||
if (_fd >= 0) {
|
||||
if (_handshaked) {
|
||||
// Best effort, exactly once: close_notify can return WANT_WRITE on a stalled peer,
|
||||
// and waiting for it to drain is precisely the blocking this class forbids. The fd
|
||||
// closes either way; a peer that missed the alert sees the TCP close.
|
||||
mbedtls_ssl_close_notify(&_ssl);
|
||||
}
|
||||
// _cryptoInit is true whenever _fd is open -- open() inits the contexts before it
|
||||
// claims the fd -- so reset() here only ever sees initialised contexts.
|
||||
mbedtls_ssl_session_reset(&_ssl);
|
||||
::close(_fd);
|
||||
_fd = -1;
|
||||
}
|
||||
releaseCrypto(); // no-op on a never-opened session; that is what the flag is for
|
||||
_handshaked = false;
|
||||
_pendPtr = nullptr;
|
||||
_pendLen = 0;
|
||||
}
|
||||
|
||||
void Session::releaseCrypto() {
|
||||
if (!_cryptoInit) return;
|
||||
_cryptoInit = false;
|
||||
// One set for every path, the way tls.cpp wraps them in RAII objects: contexts that
|
||||
// malloc must be freed as a set or not at all, or the error paths leak per attempt.
|
||||
mbedtls_x509_crt_free(&_crt);
|
||||
mbedtls_pk_free(&_key);
|
||||
mbedtls_ssl_free(&_ssl);
|
||||
mbedtls_ssl_config_free(&_conf);
|
||||
}
|
||||
|
||||
void refuseTls(int fd) {
|
||||
uint8_t alert[kFatalAlertLen];
|
||||
const size_t n = renderFatalAlert(alert, sizeof(alert));
|
||||
// MSG_DONTWAIT: this path answers a scan, and even 7 bytes may not go out to a peer
|
||||
// that has stopped reading. Whether the alert arrives or not, the refusal is visible:
|
||||
// the connection closes immediately instead of joining a queue.
|
||||
if (n) (void)::send(fd, alert, n, MSG_DONTWAIT);
|
||||
::close(fd);
|
||||
}
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
|
||||
#else // KR_TLS == 0 -- the plaintext rollback.
|
||||
|
||||
// Nothing to translate: the pure policy helpers in tls_session.h (the accept table, the
|
||||
// deadline test) still compile and still govern the plaintext sockets, and the session
|
||||
// class simply does not exist in this build. The listener in control_server.cpp is
|
||||
// compiled out by the same flag, which is what makes the rollback "the pre-phase-02
|
||||
// behaviour" rather than "half of it".
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
// A translation unit needs at least one declaration; this one says so on purpose.
|
||||
static_assert(kTlsMaxSessions == 2, "rollback build still pins the session ceiling");
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
|
||||
#endif // KR_TLS
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* keyrelay -- the TLS session: one encrypted connection over an already-accepted socket,
|
||||
* driven entirely from loop() and never allowed to block it.
|
||||
*
|
||||
* The shape, and why it is this shape
|
||||
* -----------------------------------
|
||||
* "The keystroke path must not wait on the network stack" is why tick() has consumed only
|
||||
* what was already buffered since before this project had TLS. That sentence survives TLS
|
||||
* only if the handshake is paced the same way, so Session is exactly:
|
||||
*
|
||||
* open(fd, identity) take ownership of fd, configure mbed TLS, start the clock
|
||||
* poll() advance the handshake ONE library call -- never blocks
|
||||
* read() / write() application bytes; 0 means "nothing right now", never "spin"
|
||||
* close() release everything, on every path, including open()'s failures
|
||||
*
|
||||
* A session that has not completed its handshake within kTlsHandshakeMs is closed from
|
||||
* poll(): a socket that opens and never finishes a handshake is the same kind of scan as
|
||||
* one that opens and never sends `T<token>`, and it gets the same treatment.
|
||||
*
|
||||
* Why raw mbed TLS rather than esp_tls
|
||||
* ------------------------------------
|
||||
* esp_tls.h (in `framework-arduinoespressif32-esp32s3-libs/include/esp-tls/`) was read
|
||||
* before choosing, and what it offers on the server side is:
|
||||
* `esp_tls_server_session_create(cfg, sockfd, tls)`, whose documented behaviour is "creates
|
||||
* a TLS/SSL server context for already accepted client connection AND PERFORMS TLS/SSL
|
||||
* HANDSHAKE" -- inside the call; the asynchronous spelling is `esp_tls_server_session_init`
|
||||
* plus `esp_tls_server_session_continue_async`, and either way the esp_tls object then
|
||||
* owns the session and its teardown (`esp_tls_server_session_delete` /
|
||||
* `esp_tls_conn_destroy`), layers its own error-code translation over the mbedtls one, and
|
||||
* reads and writes through `esp_tls_conn_read/write` with its own fd bookkeeping.
|
||||
* `tick()` needs the opposite of all three: the handshake driven by exactly one call per
|
||||
* tick, the fd's lifetime identical to the Session object's (closed on every path, here,
|
||||
* visibly), and WANT_READ distinguishable from a real failure by the same table that read()
|
||||
* and write() use. So this file calls mbed TLS directly. If a future reader is tempted to
|
||||
* "simplify" this back onto esp_tls: the blocking create() is documented above, and the
|
||||
* async pair still owns the socket -- the reason is in the header, not in the diff.
|
||||
*
|
||||
* The two halves of this file, and where each is proven
|
||||
* ----------------------------------------------------
|
||||
* The POLICY half is pure -- the deadline comparison, the accept table, the library-return
|
||||
* table, the fatal-alert record, the write queue and the transmit ring. It includes no
|
||||
* Arduino, no IDF, no mbed TLS header, and tests/test_tls_session.cpp walks it under
|
||||
* sanitisers, exactly like tls_pem.h/tls.cpp split strings from library calls.
|
||||
*
|
||||
* The LIBRARY half is the Session class, compiled only where mbed TLS and LWIP exist
|
||||
* (`KR_TLS && ESP_PLATFORM`) -- which is what lets a host suite include this header at
|
||||
* all. It is proven the way tls.cpp is: both firmware environments link, and
|
||||
* .agents/HW_VERIFICATION.md records what a real handshake, a stalled handshake and a
|
||||
* second concurrent connection did on a real board.
|
||||
*
|
||||
* The write contract, because it is the one place TLS can corrupt a stream
|
||||
* ------------------------------------------------------------------------
|
||||
* mbed TLS's documented rule for a non-blocking write is that after WANT_WRITE the caller
|
||||
* must call again "with the same data" -- the half-sent record lives in the context's own
|
||||
* output buffer and the library resumes it. Session therefore remembers the (ptr, len) of
|
||||
* the write currently in flight and refuses -- returns 0, changes nothing -- any write with
|
||||
* different arguments until that one completes. Callers (control_server.cpp: the frame
|
||||
* ring and the read-only response pump) satisfy it by keeping the bytes put until the
|
||||
* write returns their full length. Every chunk handed to mbedtls_ssl_write is capped at
|
||||
* kTlsWriteChunk, below the 16 KB content limit of this core, so the library never
|
||||
* fragments a write Session handed and "accepted" means "all of it" -- the one fact the
|
||||
* resume logic in both callers assumes.
|
||||
*
|
||||
* What a session does NOT protect, and what it must never decide
|
||||
* --------------------------------------------------------------
|
||||
* A completed handshake is not authority. The passphrase still gates every frame, and the
|
||||
* controller-silence clock is moved only by an authenticated controller's bytes -- TLS bytes
|
||||
* are not "the operator is alive" (control_server.cpp: readSome(), README bug 10). A session
|
||||
* that dies here is a lost link: the release funnel runs and the board disarms, exactly as a
|
||||
* plain socket's death did, and that is the ONLY way TLS is allowed to affect the keystroke
|
||||
* path. So nothing in this file may know that arming exists, and no failure mode of mbed TLS
|
||||
* may resolve to "leave the keys down" -- Failed is terminal precisely so the caller closes and
|
||||
* the funnel runs. The slot cap is a RAM rule, not a policy: kTlsMaxSessions is what the record
|
||||
* buffers fit, a third connection is refused with an alert rather than queued, and changing it
|
||||
* means re-measuring heap, not re-arguing permission.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "tls.h" // PemPair -- tls.h itself pulls in no library header (see its comment)
|
||||
|
||||
// The library half. These are the headers whose symbols task 01 proved resolve in
|
||||
// libmbedtls_2.a; the same evidence is recorded in tls.h. ESP_PLATFORM is what keeps a
|
||||
// host translation unit that includes this header -- tests/test_tls_session.cpp --
|
||||
// compiling with no mbed TLS installed, which is the whole point of the pure/library
|
||||
// split AGENTS.md defends.
|
||||
//
|
||||
// OUTSIDE the namespace, and that is load-bearing, not layout: mbed TLS headers
|
||||
// transitively include <stdio.h>/<stdlib.h> through mbedtls/platform.h, and including
|
||||
// them inside namespace kr::tls declares ::FILE as kr::tls::FILE and every later
|
||||
// consumer of stdio in the translation unit fails to compile.
|
||||
#if KR_TLS && defined(ESP_PLATFORM)
|
||||
#include <mbedtls/pk.h>
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
#endif
|
||||
|
||||
namespace kr {
|
||||
namespace tls {
|
||||
|
||||
/**
|
||||
* The handshake verdict of one Session::poll().
|
||||
* Failed is terminal: the session has already released the socket when it is returned.
|
||||
*/
|
||||
enum class Handshake : uint8_t { InProgress, Ready, Failed };
|
||||
|
||||
/**
|
||||
* Where the next accepted socket goes. One controller slot, one spare, then refusal --
|
||||
* the same table for every listener, which is the point of it living here: a third
|
||||
* connection is refused because the board is out of session slots, never because of
|
||||
* which port it knocked on.
|
||||
*
|
||||
* The cap is checked BEFORE the free-slot question so that a caller that has somehow
|
||||
* reached maxSessions without a controller is refused rather than admitted: the cap is
|
||||
* the promise memory was made against, and the slot layout is how it is spent.
|
||||
*/
|
||||
enum class Admit : uint8_t { Controller, Spare, Refused };
|
||||
|
||||
inline Admit admitConnection(bool controllerBusy, uint32_t liveSessions,
|
||||
uint32_t maxSessions) {
|
||||
if (liveSessions >= maxSessions) return Admit::Refused;
|
||||
return controllerBusy ? Admit::Spare : Admit::Controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap-safe deadline test, in the same `int32_t(now - start) > limit` shape the rest of
|
||||
* the project's timers use (it stays correct across the uint32 rollover at 49.7 days).
|
||||
* Strictly greater: reaching the limit exactly has not passed it, so a handshake that
|
||||
* finishes in exactly kTlsHandshakeMs is a handshake that finished.
|
||||
*/
|
||||
inline bool deadlinePassed(uint32_t nowMs, uint32_t startedAtMs, uint32_t limitMs) {
|
||||
return static_cast<int32_t>(nowMs - startedAtMs) > static_cast<int32_t>(limitMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The three mbed TLS return codes whose NUMBERS this file branches on. Copied rather
|
||||
* than included, because the pure half must compile where mbed TLS does not -- which is
|
||||
* precisely the drift risk AGENTS.md's "three tables that must agree" section exists to
|
||||
* name. tls_session.cpp static_asserts each of these against the library's own macro at
|
||||
* compile time in both firmware environments, so a core update that renumbers an error
|
||||
* code stops the BUILD instead of silently reclassifying "would block" as "fatal".
|
||||
* (Values from mbedtls 3.6.6 as shipped: ssl.h -0x6900 WANT_READ, -0x6880 WANT_WRITE,
|
||||
* -0x7880 PEER_CLOSE_NOTIFY.)
|
||||
*/
|
||||
constexpr int kMbedWantRead = -0x6900;
|
||||
constexpr int kMbedWantWrite = -0x6880;
|
||||
constexpr int kMbedPeerCloseNotify = -0x7880;
|
||||
|
||||
/**
|
||||
* What one mbedtls_ssl_read()/mbedtls_ssl_write()/mbedtls_ssl_handshake() return means
|
||||
* to this server. One table for all three, because the verdicts are the same:
|
||||
*
|
||||
* Bytes rc > 0 -- rc bytes; a short WRITE is still a short write, the
|
||||
* caller keeps its framing state exactly as with WiFiClient
|
||||
* Want WANT_READ/WRITE -- nothing happened; return 0, do not spin, come back
|
||||
* Closed rc == 0 or peer -- the peer is gone (mbed TLS 3.6: rc == 0 means the read
|
||||
* close_notify end of the transport closed, with or without a
|
||||
* CloseNotify); the session releases the socket
|
||||
* Error anything negative -- same release, with the code kept for `lastError`
|
||||
*
|
||||
* A client-side-only code (NEW_SESSION_TICKET) is not mapped: this is a server, and an
|
||||
* unmapped code lands in Error, which closes the session -- visible, never a silent
|
||||
* half-truth.
|
||||
*/
|
||||
enum class Io : uint8_t { Bytes, Want, Closed, Error };
|
||||
|
||||
inline Io mapIo(int rc) {
|
||||
if (rc > 0) return Io::Bytes;
|
||||
if (rc == 0) return Io::Closed;
|
||||
if (rc == kMbedWantRead || rc == kMbedWantWrite) return Io::Want;
|
||||
if (rc == kMbedPeerCloseNotify) return Io::Closed;
|
||||
return Io::Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of the TLS record this board sends when it refuses a connection: a plaintext
|
||||
* fatal alert, handshake_failure (RFC 5246 §7.2: type 21, version 3.1, length 2, level 2
|
||||
* fatal, description 40). Built here as bytes -- and tested here rather than eyeballed --
|
||||
* for two reasons. One: it is a wire format somebody else defined, which is the exact
|
||||
* category AGENTS.md says to pin. Two: NOT using mbedtls_ssl_close_notify for it is
|
||||
* deliberate -- close_notify needs the library's ~16 KB output buffer, so an mbed TLS
|
||||
* setup per refused connection would make the session-cap refusal itself an allocation
|
||||
* churn, which is the resource the refusal exists to protect.
|
||||
*
|
||||
* Returns the length written, 0 when cap cannot hold all 7 bytes (never a partial record).
|
||||
*/
|
||||
constexpr size_t kFatalAlertLen = 7;
|
||||
|
||||
inline size_t renderFatalAlert(uint8_t *out, size_t cap) {
|
||||
static const uint8_t kAlert[kFatalAlertLen] = {0x15, 0x03, 0x01, 0x00,
|
||||
0x02, 0x02, 0x28};
|
||||
if (!out || cap < kFatalAlertLen) return 0;
|
||||
for (size_t i = 0; i < kFatalAlertLen; i++) out[i] = kAlert[i];
|
||||
return kFatalAlertLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte ring between the server's frame writes and a TLS session that may not accept
|
||||
* a write this tick. Frames are pushed WHOLE or not at all: a dropped status frame costs
|
||||
* one second until the next one, a half-pushed WebSocket frame corrupts the stream, and
|
||||
* those are the only two options this class makes possible.
|
||||
*
|
||||
* The head never moves except through advance(), and advance() is only called with what
|
||||
* the transport actually accepted -- so a resume after WANT_WRITE re-hands the identical
|
||||
* bytes, which is the contract mbedtls_ssl_write documents and Session enforces.
|
||||
*
|
||||
* Same init shape as ws::Reader{_buf, cap}: the storage belongs to the caller.
|
||||
*
|
||||
* Default-constructible on a 1-byte dummy so a holder that binds later (kr::Conn, which is
|
||||
* rebound between the two transports as slots come and go) can exist before it has storage.
|
||||
* An unseated ring refuses everything: cap 1, count 0, and no modulo by zero anywhere.
|
||||
*/
|
||||
class TxRing {
|
||||
public:
|
||||
TxRing(uint8_t *buf = nullptr, size_t cap = 1) : _buf(buf), _cap(cap ? cap : 1) {}
|
||||
|
||||
// Move the ring onto different storage. Only legal with nothing queued, which is the
|
||||
// situation Conn::bind*() is called in (a slot being (re)assigned to a socket).
|
||||
void reseat(uint8_t *buf, size_t cap) {
|
||||
_buf = buf;
|
||||
_cap = cap ? cap : 1;
|
||||
reset();
|
||||
}
|
||||
|
||||
// True if the whole frame fits; false leaves the ring untouched (no partial frame).
|
||||
bool push(const uint8_t *src, size_t len) {
|
||||
if (!src || len > _cap - _count) return false;
|
||||
for (size_t i = 0; i < len; i++) _buf[(_head + _count + i) % _cap] = src[i];
|
||||
_count += len;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t count() const { return _count; }
|
||||
size_t capacity() const { return _cap; }
|
||||
|
||||
// The contiguous span at the head, at most `cap` bytes (a wrapped remainder reads as
|
||||
// two writes -- both re-hand from the same stable head, so the resume rule holds).
|
||||
const uint8_t *head(size_t cap, size_t *len) const {
|
||||
const size_t untilWrap = _cap - _head;
|
||||
size_t n = _count < untilWrap ? _count : untilWrap;
|
||||
if (n > cap) n = cap;
|
||||
*len = n;
|
||||
return n ? _buf + _head : nullptr;
|
||||
}
|
||||
|
||||
void advance(size_t n) {
|
||||
if (n > _count) n = _count;
|
||||
_head = (_head + n) % _cap;
|
||||
_count -= n;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
private:
|
||||
uint8_t *_buf;
|
||||
size_t _cap;
|
||||
size_t _head = 0;
|
||||
size_t _count = 0;
|
||||
};
|
||||
|
||||
#if KR_TLS && defined(ESP_PLATFORM)
|
||||
/**
|
||||
* One TLS connection over an accepted fd, server-side, non-blocking throughout.
|
||||
*
|
||||
* open() ALWAYS consumes the fd: it takes it on success and ::close()s it on every
|
||||
* failure path, so whoever called open() never has to know whether the fd is theirs to
|
||||
* clean up -- the answer is always "it was, until open() returned, and after that it
|
||||
* was never yours".
|
||||
*
|
||||
* Client certification is NOT requested (MBEDTLS_SSL_VERIFY_NONE): the passphrase is
|
||||
* still the controller's credential, not the certificate. The certificate proves which
|
||||
* board you are talking to only to whoever pinned its fingerprint; asking the client for
|
||||
* a certificate we have no way to vet would authenticate nothing we would believe.
|
||||
*
|
||||
* Not copyable: the fd and the four library contexts have exactly one owner, and the
|
||||
* destructor is the last-resort close().
|
||||
*/
|
||||
class Session {
|
||||
public:
|
||||
Session() = default;
|
||||
~Session() { close(); }
|
||||
Session(const Session &) = delete;
|
||||
Session &operator=(const Session &) = delete;
|
||||
|
||||
// Configure and take the fd; see above for the ownership rule. false means the
|
||||
// session holds nothing -- including no fd.
|
||||
bool open(int fd, const PemPair &id);
|
||||
|
||||
// One step of the handshake. InProgress means "call again when the socket wakes or
|
||||
// on the next tick"; Ready means read()/write() are now valid; Failed is terminal
|
||||
// and the socket is already released, deadline included.
|
||||
Handshake poll();
|
||||
|
||||
// Socket open -- handshaking or established. This is what the controller slot asks:
|
||||
// "is the link I handed this slot still a link at all".
|
||||
bool alive() const { return _fd >= 0; }
|
||||
// Handshake complete and socket open: application bytes may flow.
|
||||
bool ready() const { return _fd >= 0 && _handshaked; }
|
||||
|
||||
// Application bytes. Both return 0 for "nothing now" (would block) and for "the
|
||||
// session died while you asked" -- alive()/ready() distinguish, and the callers in
|
||||
// control_server.cpp re-check the slot every tick either way. read() never returns
|
||||
// a partial verdict that needs retrying: it returns whole bytes or 0.
|
||||
//
|
||||
// write(): len must be <= kTlsWriteChunk (one record, no library fragmentation) and
|
||||
// src/len must be the SAME arguments as the last write that returned 0 until a write
|
||||
// returns their full length -- the resume contract of mbedtls_ssl_write, enforced here
|
||||
// by returning 0 to mismatched arguments rather than corrupting the stream.
|
||||
size_t read(uint8_t *dst, size_t cap);
|
||||
size_t write(const uint8_t *src, size_t len);
|
||||
|
||||
// close_notify (established sessions only), then reset/free every context and
|
||||
// ::close(fd). Idempotent -- safe from the destructor and from any error path.
|
||||
void close();
|
||||
|
||||
// The library code of the last failure this session hit; diagnostic, never identity.
|
||||
int lastError() const { return _lastError; }
|
||||
|
||||
private:
|
||||
void releaseCrypto();
|
||||
|
||||
int _fd = -1;
|
||||
bool _handshaked = false;
|
||||
// True between the four init() calls in open() and the matching free() set in
|
||||
// releaseCrypto(). A never-opened Session holds plain zeroed structs, and free()ing
|
||||
// a context that was never init()ed is reading garbage pointers -- this boolean is
|
||||
// why the destructor can call close() unconditionally.
|
||||
bool _cryptoInit = false;
|
||||
uint32_t _startedAtMs = 0;
|
||||
int _lastError = 0;
|
||||
const uint8_t *_pendPtr = nullptr;
|
||||
size_t _pendLen = 0;
|
||||
// Contexts are init()ed at the top of open() and freed together in releaseCrypto(),
|
||||
// so the success path and every failure path free the same set -- the same RAII-free
|
||||
// discipline as tls.cpp, expressed as one function instead of wrappers because these
|
||||
// four outlive a single call.
|
||||
mbedtls_ssl_context _ssl;
|
||||
mbedtls_ssl_config _conf;
|
||||
mbedtls_x509_crt _crt;
|
||||
mbedtls_pk_context _key;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refuse a connection the session cap cannot pay for: hand the peer a real TLS alert
|
||||
* record (renderFatalAlert above, sent with MSG_DONTWAIT), then close. No mbed TLS
|
||||
* context is built -- see renderFatalAlert's comment for why that would be the wrong
|
||||
* economy on the one path that exists to avoid spending.
|
||||
*/
|
||||
void refuseTls(int fd);
|
||||
|
||||
#endif // KR_TLS && ESP_PLATFORM
|
||||
|
||||
} // namespace tls
|
||||
} // namespace kr
|
||||
+83
-1
@@ -50,10 +50,13 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "config.h" // KR_TLS -- the build marker embedded in the script below
|
||||
|
||||
namespace kr {
|
||||
|
||||
// Raw string, so the HTML below is literally what goes on the wire.
|
||||
static const char kIndexHtml[] PROGMEM = R"HTML(<!DOCTYPE html>
|
||||
static const char kIndexHtml[] PROGMEM =
|
||||
R"HTML(<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -144,6 +147,7 @@ body.off{opacity:.92}
|
||||
<span class="pill" id="pArmed">disarmed</span>
|
||||
<span class="pill" id="pLeds" title="reported by the target">caps num</span>
|
||||
<span class="pill off" id="pMouse" title="is this tab holding your mouse pointer?">mouse free</span>
|
||||
<span class="pill off" id="pTls" title="is this channel encrypted? the certificate fingerprint is in the Connection card">tls ?</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -207,6 +211,14 @@ body.off{opacity:.92}
|
||||
|
||||
<div class="card">
|
||||
<h2>Connection</h2>
|
||||
<div class="banner" id="clearWarn" hidden>
|
||||
<b style="color:var(--hot)">This page arrived in the clear.</b> A TLS board
|
||||
answers plaintext requests with a redirect and never with these bytes, so an
|
||||
<code>http://</code> copy of this page is a stale cache, a downgrade, or
|
||||
somebody else’s server — and it will not take a passphrase. Reload
|
||||
over <b>https://</b> and confirm the certificate fingerprint shown below
|
||||
against the <code>cert</code> command on the board’s serial console.
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="password" id="token" placeholder="passphrase" autocomplete="off">
|
||||
<button class="k" id="connect">connect</button>
|
||||
@@ -219,6 +231,7 @@ body.off{opacity:.92}
|
||||
The passphrase is checked by the board on every connection. Without it nothing
|
||||
can be armed, so anyone else on the network gets a page that does nothing.
|
||||
</p>
|
||||
<p class="hint" id="certFp" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,6 +306,23 @@ body.off{opacity:.92}
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
)HTML"
|
||||
/* Build marker: the only part of the served page that differs between the
|
||||
* KR_TLS=1 and KR_TLS=0 builds, and both spellings are exactly 19 bytes, so
|
||||
* the page byte count the gate prints (and every `wc -c` device check against
|
||||
* it) stays one number. It cannot be a runtime probe: the page must know,
|
||||
* before any socket exists and before a passphrase can leave the tab, whether
|
||||
* the board that served it can answer https: at all -- deciding that from
|
||||
* network behaviour is guessing, and guessing wrong here means typing the
|
||||
* passphrase into an attacker. The rollback build keeps its ws:// (PLAN.md
|
||||
* "Plaintext rollback"); the TLS build's page refuses it. tests/test_page.js
|
||||
* and tests/test_relayctl.py assemble both builds' bytes from this block. */
|
||||
#if KR_TLS
|
||||
"var KR_TLS_BUILD=1;"
|
||||
#else
|
||||
"var KR_TLS_BUILD=0;"
|
||||
#endif
|
||||
R"HTML(
|
||||
/* ------------------------------------------------------------------ *
|
||||
* KeyboardEvent.code -> USB HID usage id (usage page 0x07).
|
||||
* code names a physical position, so this table is US-ANSI based; it is
|
||||
@@ -435,6 +465,17 @@ function describePress(code) {
|
||||
* ------------------------------------------------------------------ */
|
||||
let ws = null, armed = false, authed = false, reconnectAt = 0, status = null;
|
||||
|
||||
/* The TLS build's page on an http: origin is a page nobody legitimate served: a
|
||||
* KR_TLS=1 board answers :80 with a redirect and never with these bytes, so an
|
||||
* http: tab is a cache, a downgrade, or somebody else's server. Refuse before the
|
||||
* passphrase can leave it. This is the guard the other way round from the scheme
|
||||
* choice below -- and it never falls back to the plaintext scheme, because
|
||||
* silently taking the weaker option is the failure this transport exists to
|
||||
* prevent. */
|
||||
function servedInClear() {
|
||||
return KR_TLS_BUILD === 1 && location.protocol !== "https:";
|
||||
}
|
||||
|
||||
function tokenValue() {
|
||||
let t = $("token").value.trim();
|
||||
if (!t) t = sessionStorage.getItem("keyrelay.token") || "";
|
||||
@@ -442,6 +483,10 @@ function tokenValue() {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (servedInClear()) {
|
||||
log("this page arrived in the clear; reload over https", "e");
|
||||
return;
|
||||
}
|
||||
const tok = tokenValue();
|
||||
if (!tok) { log("enter the board's passphrase first", "w"); return; }
|
||||
sessionStorage.setItem("keyrelay.token", tok);
|
||||
@@ -490,8 +535,29 @@ function onFrame(raw) {
|
||||
log("board: " + raw);
|
||||
}
|
||||
|
||||
/* The channel and its certificate, rendered from the status frame only. `tls` is
|
||||
* what the board compiled in and holds; `fp` is the label of the certificate it
|
||||
* actually serves -- never assembled from anything this page invents, because the
|
||||
* operator compares these exact bytes against `cert` on the serial console. */
|
||||
function paintChannel(s) {
|
||||
const p = $("pTls");
|
||||
if (s.tls) { p.className = "pill on"; p.textContent = "tls"; }
|
||||
else { p.className = "pill bad"; p.textContent = "plaintext"; }
|
||||
const el = $("certFp");
|
||||
const fp = (s.tls && s.fp) ? String(s.fp) : "";
|
||||
if (fp) {
|
||||
el.hidden = false;
|
||||
el.textContent = "certificate SHA-256 " + fp + "\u2026 \u2014 confirm it against "
|
||||
+ "the `cert` command on the board's serial console";
|
||||
} else {
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatus(s) {
|
||||
status = s;
|
||||
paintChannel(s);
|
||||
$("ver").textContent = "v" + (s.v || "?");
|
||||
$("sMode").textContent = s.net === "sta" ? "station" : (s.net === "ap" ? "access point" : "down");
|
||||
$("sIp").textContent = s.ip || "\u2014";
|
||||
@@ -1114,9 +1180,25 @@ setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) send("K");
|
||||
}, 2000);
|
||||
|
||||
/* Boot, as one callable thing rather than top-level code: the http: branch below
|
||||
* is a safety property (a clear-served page must not even take a passphrase),
|
||||
* and tests/test_page.js has to be able to run it on both origins to prove it. */
|
||||
function boot() {
|
||||
if (servedInClear()) {
|
||||
/* Not cosmetic: the input is disabled because a passphrase typed into a page
|
||||
* that arrived in the clear goes to whoever sent the page, and the socket
|
||||
* refused in connect() above could not tell the difference. */
|
||||
$("clearWarn").hidden = false;
|
||||
$("token").disabled = true;
|
||||
$("connect").disabled = true;
|
||||
log("this page arrived in the clear; reload over https", "e");
|
||||
return;
|
||||
}
|
||||
$("token").value = sessionStorage.getItem("keyrelay.token") || "";
|
||||
if (sessionStorage.getItem("keyrelay.token")) connect();
|
||||
else log("enter the board's passphrase, then press connect");
|
||||
}
|
||||
boot();
|
||||
paintHeld();
|
||||
refreshSendBox();
|
||||
paintMouse();
|
||||
|
||||
+17
-3
@@ -312,13 +312,19 @@ struct Request {
|
||||
char method[8];
|
||||
char path[96];
|
||||
char query[96];
|
||||
// The Host: header, verbatim and unvalidated. It is only ever used to build the
|
||||
// `Location:` of the :80 -> https redirect, and that builder (kr::redirectAuthority,
|
||||
// conn.h) is what decides whether this string is a name this board may be reached at --
|
||||
// because Location is bytes a browser navigates to. Truncated, never rejected: a long
|
||||
// Host must not turn a legal GET into a 400.
|
||||
char host[72];
|
||||
char wsKey[64];
|
||||
bool upgradeWebsocket;
|
||||
bool complete; // the full header block (CRLF CRLF) was seen
|
||||
};
|
||||
|
||||
inline void initRequest(Request &r) {
|
||||
r.method[0] = r.path[0] = r.query[0] = r.wsKey[0] = '\0';
|
||||
r.method[0] = r.path[0] = r.query[0] = r.host[0] = r.wsKey[0] = '\0';
|
||||
r.upgradeWebsocket = false;
|
||||
r.complete = false;
|
||||
}
|
||||
@@ -347,7 +353,7 @@ inline bool containsCi(const char *hay, size_t hayLen, const char *needle) {
|
||||
* malformed or the header block is larger than `cap` (reject, don't buffer).
|
||||
*
|
||||
* `len` bytes of `buf` are inspected. Only the fields the bridge routes on are
|
||||
* extracted: method, path, query, the Upgrade token and Sec-WebSocket-Key.
|
||||
* extracted: method, path, query, Host, the Upgrade token and Sec-WebSocket-Key.
|
||||
*/
|
||||
inline int parseRequest(const char *buf, size_t len, Request &out) {
|
||||
initRequest(out);
|
||||
@@ -399,11 +405,19 @@ inline int parseRequest(const char *buf, size_t len, Request &out) {
|
||||
const size_t n = static_cast<size_t>(eol - ls);
|
||||
if (n >= 8 && ciEquals(ls, "upgrade:", 8)) {
|
||||
if (containsCi(ls + 8, n - 8, "websocket")) out.upgradeWebsocket = true;
|
||||
} else if (n >= 5 && ciEquals(ls, "host:", 5)) {
|
||||
const char *v = ls + 5;
|
||||
size_t vn = n - 5;
|
||||
while (vn && (*v == ' ' || *v == '\t')) { v++; vn--; }
|
||||
while (vn && (v[vn - 1] == ' ' || v[vn - 1] == '\t')) vn--;
|
||||
if (vn >= sizeof(out.host)) vn = sizeof(out.host) - 1; // truncate, do not reject
|
||||
memcpy(out.host, v, vn);
|
||||
out.host[vn] = '\0';
|
||||
} else if (n >= 18 && ciEquals(ls, "sec-websocket-key:", 18)) {
|
||||
const char *v = ls + 18;
|
||||
size_t vn = n - 18;
|
||||
while (vn && (*v == ' ' || *v == '\t')) { v++; vn--; }
|
||||
while (vn && (v[vn - 1] == ' ' || v[vn - 1] == ' ' || v[vn - 1] == '\t')) vn--;
|
||||
while (vn && (v[vn - 1] == ' ' || v[vn - 1] == '\t')) vn--;
|
||||
if (vn >= sizeof(out.wsKey)) return -1;
|
||||
memcpy(out.wsKey, v, vn);
|
||||
out.wsKey[vn] = '\0';
|
||||
|
||||
+8
-5
@@ -4,10 +4,12 @@
|
||||
# tests/run_tests.sh build and run every suite
|
||||
# tests/run_tests.sh clean remove the build directory
|
||||
#
|
||||
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/mouse.h,
|
||||
# src/output.h, src/ws_codec.h, src/sha1.h) have no Arduino or ESP-IDF dependency by
|
||||
# design, so the exact code that runs on the board is compiled and exercised here before
|
||||
# anything is flashed.
|
||||
# The modules under test (src/keystate.h, src/proto.h, src/status.h, src/text.h,
|
||||
# src/mouse.h, src/output.h, src/ws_codec.h, src/sha1.h, src/tls_pem.h, and the pure
|
||||
# policy halves of src/tls_session.h and src/conn.h) have no Arduino or
|
||||
# ESP-IDF
|
||||
# dependency by design, so the exact code that runs on the board is compiled and
|
||||
# exercised here before anything is flashed.
|
||||
#
|
||||
# A C++ compiler is not always installed on a workstation, so the runner picks one:
|
||||
# $CXX if you set it, it wins
|
||||
@@ -33,7 +35,8 @@ images=(
|
||||
"docker.io/library/gcc:14"
|
||||
)
|
||||
|
||||
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec)
|
||||
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
|
||||
test_tls_pem test_tls_session test_conn)
|
||||
|
||||
build_and_run() {
|
||||
local cxx="$1"
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* tests/test_conn.cpp -- the pure half of src/conn.h, walked on the host. No Arduino, no
|
||||
* WiFiClient, no socket: exactly like tls_pem.h and the policy half of tls_session.h, the
|
||||
* header keeps its deciding logic free of library includes so the identical code runs here
|
||||
* under -fsanitize=address,undefined. `kr::Conn` itself hides behind ESP_PLATFORM and is
|
||||
* proven the other way (both firmware environments link, plus the device rows in
|
||||
* .agents/HW_VERIFICATION.md: page byte-exact over TLS, :80 redirecting, plaintext /ws
|
||||
* refused with 426).
|
||||
*
|
||||
* What each check pins, and the failure it is defending:
|
||||
*
|
||||
* renderHttpResponseHead -- one header formatter for every route, port and transport.
|
||||
* snprintf truncation used to become `headLen = 0`, which became "the
|
||||
* board answered nothing". Here: the exact bytes, the Content-Length
|
||||
* agreeing with the body, an extra header landing in the right place,
|
||||
* and the widest legal response PROVABLY fitting kHttpResponseHeadCap.
|
||||
* queryCarriesToken -- the :80 rule "a URL carrying the passphrase is never redirected".
|
||||
* The boundary matters: a check that matched the substring would turn
|
||||
* /status?mytoken=x into a 426, and one that missed `&token=` would
|
||||
* redirect the real thing and leak the passphrase a second time.
|
||||
* plaintextRoute -- the whole :80 answer table, including that the -D KR_TLS=0
|
||||
* rollback answers everything (the rollback must stay buildable and
|
||||
* must stay today's server).
|
||||
* redirectAuthority -- an attacker-controlled `Host:` becoming a `Location:` a browser
|
||||
* navigates to. `http://keyrelay.local@evil.com/` is the shape that
|
||||
* makes this a real open redirect, not a theoretical one.
|
||||
* renderRedirectTarget -- the request path is client text too, and a CR in it would
|
||||
* inject headers into the board's own response.
|
||||
* FlashSend -- the page's send cursor. An off-by-one here is a page that arrives
|
||||
* a byte short and parses as garbage; a next() that ignores the budget
|
||||
* is one loop() iteration spending an unbounded time writing a socket.
|
||||
*
|
||||
* Two defects in this new code were found by the SANITISED run of this file, not by its
|
||||
* assertions, and both are pinned by the case that triggered them:
|
||||
* - queryCarriesToken() memcmp()ed its 6-byte key against the tail of any shorter query,
|
||||
* reading past the end of `"a=1"` (global-buffer-overflow);
|
||||
* - dottedQuadPrivate() checked the octet count when a dot arrived instead of before each
|
||||
* digit, so `"192.168.1.1.1"` wrote octets[4] (stack-buffer-overflow).
|
||||
* Neither changes an answer on a healthy input, and neither is visible on the board until
|
||||
* it isn't. That is the argument for `SANITIZE=1` being part of the gate rather than an
|
||||
* option (AGENTS.md: the sanitiser run found five of the ten bugs the README documents).
|
||||
*/
|
||||
#include <string.h>
|
||||
|
||||
#include "test.h"
|
||||
|
||||
#include "conn.h"
|
||||
|
||||
using kr::FlashSend;
|
||||
using kr::kConnTxCap;
|
||||
using kr::kConnViewTxCap;
|
||||
using kr::kConnWriteChunk;
|
||||
using kr::kHttpResponseHeadCap;
|
||||
using kr::kPageBytesPerTick;
|
||||
using kr::kPageChunkBytes;
|
||||
using kr::kStatusJsonCap;
|
||||
using kr::plaintextRoute;
|
||||
using kr::PlainRoute;
|
||||
using kr::queryCarriesToken;
|
||||
using kr::redirectAuthority;
|
||||
using kr::renderHttpResponseHead;
|
||||
using kr::renderRedirectTarget;
|
||||
|
||||
namespace {
|
||||
|
||||
// The served page's length, in bytes. Cross-checked against the real raw strings by
|
||||
// tests/test_relayctl.py ("the page the transport sends"). The page carries one
|
||||
// 19-byte #if KR_TLS build marker and both spellings are the same length, so this
|
||||
// number is build-independent -- a fact test_relayctl pins, not a hope.
|
||||
constexpr size_t kServedPageBytes = 51626;
|
||||
|
||||
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
|
||||
struct DrainLog {
|
||||
size_t pieces = 0;
|
||||
size_t bytes = 0;
|
||||
size_t maxPiece = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
char head[kHttpResponseHeadCap];
|
||||
|
||||
// ------------------------------------------------------ the response head
|
||||
CASE("head: the exact bytes of a plain 200");
|
||||
const size_t n = renderHttpResponseHead(head, sizeof(head), "200 OK",
|
||||
"text/plain; charset=utf-8", 5);
|
||||
CHECK_STR(head,
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\n"
|
||||
"Content-Length: 5\r\n"
|
||||
"Cache-Control: no-store\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\n"
|
||||
"Connection: close\r\n\r\n");
|
||||
CHECK_EQ(n, strlen(head));
|
||||
CHECK(n > 0 && n < kHttpResponseHeadCap);
|
||||
|
||||
CASE("head: the extra header sits before Connection");
|
||||
CHECK(renderHttpResponseHead(head, sizeof(head), "301 Moved Permanently",
|
||||
"text/html; charset=utf-8", 0,
|
||||
"Location: https://keyrelay.local/\r\n") > 0);
|
||||
CHECK(strstr(head, "Location: https://keyrelay.local/\r\nConnection: close") != nullptr);
|
||||
|
||||
CASE("head: 426 carries the Upgrade field RFC 4266 asks for");
|
||||
CHECK(renderHttpResponseHead(head, sizeof(head), "426 Upgrade Required",
|
||||
"text/plain; charset=utf-8", 40,
|
||||
"Upgrade: TLS/1.2, HTTP/1.1\r\n") > 0);
|
||||
CHECK(strstr(head, "Upgrade: TLS/1.2, HTTP/1.1\r\n") != nullptr);
|
||||
CHECK(strstr(head, "Content-Length: 40\r\n") != nullptr);
|
||||
|
||||
CASE("head: too small a buffer is refused, never truncated");
|
||||
char tiny[16] = "AAAAAAAAAAAAAAA";
|
||||
CHECK_EQ(renderHttpResponseHead(tiny, sizeof(tiny), "200 OK", "text/plain", 1), 0);
|
||||
CHECK_EQ(tiny[0], 'A'); // nothing written at all
|
||||
CHECK_EQ(renderHttpResponseHead(nullptr, 64, "200 OK", "text/plain", 1), 0);
|
||||
// The formatter's contract is the SHIPPED cap, so a caller passing anything else gets a
|
||||
// refusal rather than a head built to a size nobody sized.
|
||||
char almost[kHttpResponseHeadCap];
|
||||
CHECK_EQ(renderHttpResponseHead(almost, kHttpResponseHeadCap - 1, "200 OK", "text/plain",
|
||||
1), 0);
|
||||
|
||||
CASE("head: the widest legal response fits, with margin to spare");
|
||||
// The widest authority redirectAuthority() will actually accept is a dotted quad
|
||||
// (kCertCommonName is longer than any IP, and nothing else passes the name test); the
|
||||
// widest path it carries over is /index.html. So this is not a hypothetical worst case,
|
||||
// it is reachable -- and it must fit anyway, with room left for the next header.
|
||||
char authority[80];
|
||||
CHECK(redirectAuthority("192.168.123.234", authority, sizeof(authority)));
|
||||
char loc[128];
|
||||
const size_t locLen = renderRedirectTarget("192.168.123.234", "/index.html", loc,
|
||||
sizeof(loc));
|
||||
CHECK(locLen > 0);
|
||||
char extra[160];
|
||||
const int extraLen = snprintf(extra, sizeof(extra), "Location: %s\r\n", loc);
|
||||
CHECK(extraLen > 0);
|
||||
const size_t wide = renderHttpResponseHead(head, sizeof(head), "301 Moved Permanently",
|
||||
"text/plain; charset=utf-8", 128, extra);
|
||||
CHECK(wide > 0);
|
||||
CHECK(wide < kHttpResponseHeadCap);
|
||||
CHECK(kHttpResponseHeadCap - wide >= 32); // room for one more header, not slack
|
||||
|
||||
// -------------------------------------------------- the token-in-URL test
|
||||
CASE("query: token= is found where a key can start");
|
||||
CHECK(queryCarriesToken("token=secret"));
|
||||
CHECK(queryCarriesToken("a=1&token=secret"));
|
||||
CHECK(queryCarriesToken("a=1&b=2&token=x"));
|
||||
CHECK(queryCarriesToken("token=")); // an empty token is still a token carried
|
||||
|
||||
CASE("query: look-alikes are not token-carrying");
|
||||
CHECK(!queryCarriesToken(""));
|
||||
CHECK(!queryCarriesToken(nullptr));
|
||||
CHECK(!queryCarriesToken("mytoken=secret")); // not a key boundary
|
||||
CHECK(!queryCarriesToken("a=mytoken"));
|
||||
CHECK(!queryCarriesToken("status?token")); // no '='
|
||||
CHECK(!queryCarriesToken("xtoken=secret"));
|
||||
CHECK(!queryCarriesToken("a=1token=secret")); // '=' is not a key boundary
|
||||
|
||||
CASE("query: every short prefix is scanned without reading past the string");
|
||||
// ASan caught the first version doing exactly this: comparing a 6-byte key against the
|
||||
// tail of "a=1" reads four bytes that are not there. Nothing about the answer changes;
|
||||
// what changes is whether the board walks off the end of a buffer on a router's URL.
|
||||
static const char *const kShort[] = {"t", "to", "tok", "toke", "token", "=", "&", "a=1",
|
||||
"a=1&", "a=1&b", "x&token=", "&&token=x"};
|
||||
for (const char *const shortq : kShort) {
|
||||
const bool want = std::strcmp(shortq, "x&token=") == 0 ||
|
||||
std::strcmp(shortq, "&&token=x") == 0;
|
||||
CHECK_EQ(queryCarriesToken(shortq) ? 1 : 0, want ? 1 : 0);
|
||||
}
|
||||
|
||||
CASE("route: the :80 table, in one place, for both builds");
|
||||
// tlsBuild == false is the -D KR_TLS=0 rollback: every shape is served, exactly as
|
||||
// before phase 02. That is the rollback criterion, and it is a row of this table.
|
||||
CHECK(plaintextRoute(false, "token=x", false) == PlainRoute::Serve);
|
||||
CHECK(plaintextRoute(true, nullptr, false) == PlainRoute::Serve);
|
||||
CHECK(plaintextRoute(false, nullptr, false) == PlainRoute::Serve);
|
||||
// tlsBuild == true: nothing on :80 is served; an upgrade and a token both get 426; the
|
||||
// rest get the redirect. 426 before redirect, for the token, always.
|
||||
CHECK(plaintextRoute(false, nullptr, true) == PlainRoute::Redirect);
|
||||
CHECK(plaintextRoute(false, "a=1", true) == PlainRoute::Redirect);
|
||||
CHECK(plaintextRoute(false, "token=secret", true) == PlainRoute::UpgradeRequired);
|
||||
CHECK(plaintextRoute(true, nullptr, true) == PlainRoute::UpgradeRequired);
|
||||
// A /ws upgrade carrying a token is still an upgrade: the transport answer wins, and it
|
||||
// is the more fundamental one ("this port does not speak that at all").
|
||||
CHECK(plaintextRoute(true, "token=secret", true) == PlainRoute::UpgradeRequired);
|
||||
|
||||
// -------------------------------------------------- the redirect authority
|
||||
CASE("authority: the names this board is reached at survive");
|
||||
char auth[80];
|
||||
CHECK(redirectAuthority("keyrelay.local", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "keyrelay.local");
|
||||
CHECK(redirectAuthority("KEYRELAY.LOCAL", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "keyrelay.local");
|
||||
CHECK(redirectAuthority("keyrelay", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "keyrelay");
|
||||
// "IP if the name fails" is in the README; a redirect that threw the IP away would
|
||||
// break the documented fallback path.
|
||||
CHECK(redirectAuthority("192.168.4.1", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "192.168.4.1");
|
||||
CHECK(redirectAuthority("10.2.0.22:80", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "10.2.0.22"); // :port stripped, the scheme's own is implied
|
||||
CHECK(redirectAuthority("172.20.1.9", auth, sizeof(auth)));
|
||||
CHECK_STR(auth, "172.20.1.9");
|
||||
|
||||
CASE("authority: everything else falls back to the certificate's name");
|
||||
// Each of these would otherwise put attacker-chosen text into a Location a browser
|
||||
// follows. The @ one is the shape of a real open-redirect URL.
|
||||
static const char *const kRejected[] = {
|
||||
"evil.com", "keyrelay.local@evil.com", "keyrelay.local.evil.com", "evil.local",
|
||||
"1.2.3.4", "8.8.8.8:443", "keyrela", "keyrelay.too", "-bad", "bad-", ".local", "",
|
||||
"a b", "a\re", "::1", "192.168.1", "192.168.1.1.1", "300.1.1.1", "172.32.0.1",
|
||||
"host:80:90",
|
||||
};
|
||||
for (const char *const bad : kRejected) {
|
||||
CHECK(redirectAuthority(bad, auth, sizeof(auth)));
|
||||
CHECK_STR(auth, kr::kCertCommonName);
|
||||
}
|
||||
CHECK(redirectAuthority(nullptr, auth, sizeof(auth)));
|
||||
CHECK_STR(auth, kr::kCertCommonName);
|
||||
char longish[300];
|
||||
memset(longish, 'a', sizeof(longish) - 1);
|
||||
longish[sizeof(longish) - 1] = '\0';
|
||||
CHECK(redirectAuthority(longish, auth, sizeof(auth)));
|
||||
CHECK_STR(auth, kr::kCertCommonName); // truncated, then refused: never echoed
|
||||
CHECK(!redirectAuthority("keyrelay.local", auth, 4)); // cannot even hold the fallback
|
||||
|
||||
CASE("redirect target: only the two content paths travel");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "/", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "/index.html", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/index.html");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "/nope", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/"); // 404 awaits on the encrypted port
|
||||
// Header injection: the request target is client bytes, and Location is a header.
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "/a\r\nX-Evil: 1", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "/a\nX-Evil: 1", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", "", loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/");
|
||||
CHECK(renderRedirectTarget("keyrelay.local", nullptr, loc, sizeof(loc)) > 0);
|
||||
CHECK_STR(loc, "https://keyrelay.local/");
|
||||
CHECK_EQ(renderRedirectTarget("keyrelay.local", "/", loc, 4), 0); // would not fit
|
||||
CHECK_EQ(renderRedirectTarget("keyrelay.local", "/", nullptr, 16), 0);
|
||||
// and no redirect body ever contains a query
|
||||
CHECK(renderRedirectTarget("192.168.4.1", "/index.html", loc, sizeof(loc)) > 0);
|
||||
CHECK(strchr(loc, '?') == nullptr);
|
||||
|
||||
// ------------------------------------------------------ the flash send
|
||||
CASE("flash send: empty and null are inert");
|
||||
FlashSend fs;
|
||||
CHECK(!fs.armed());
|
||||
CHECK(fs.done());
|
||||
CHECK_EQ(fs.sent(), 0);
|
||||
CHECK_EQ(fs.total(), 0);
|
||||
CHECK_EQ(fs.next(100, 100), 0);
|
||||
fs.start(nullptr, 10);
|
||||
CHECK(!fs.armed());
|
||||
CHECK_EQ(fs.total(), 0);
|
||||
fs.start("abc", 0); // a zero-length body is nothing to send
|
||||
CHECK(!fs.armed());
|
||||
CHECK(fs.done());
|
||||
|
||||
CASE("flash send: one chunk at a time, in order, clamped");
|
||||
static const char page[] = "0123456789ABCDEFGHIJ"; // 20 bytes
|
||||
fs.start(page, sizeof(page) - 1);
|
||||
CHECK(fs.armed());
|
||||
CHECK(!fs.done());
|
||||
CHECK_EQ(fs.total(), 20);
|
||||
CHECK_EQ(fs.remaining(), 20);
|
||||
CHECK_EQ(fs.next(8, 100), 8); // chunk cap
|
||||
CHECK_EQ(fs.next(8, 5), 5); // budget cap, whichever is smaller
|
||||
CHECK_EQ(fs.next(8, 0), 0); // no budget: nothing to hand out
|
||||
CHECK_EQ(fs.next(0, 100), 0);
|
||||
char out[32];
|
||||
memset(out, '.', sizeof(out));
|
||||
fs.copyTo(out, 8);
|
||||
CHECK(memcmp(out, "01234567", 8) == 0);
|
||||
fs.copyTo(out, 999); // clamped to what is left, no overrun
|
||||
CHECK_EQ(out[20], '.');
|
||||
fs.advance(8);
|
||||
CHECK_EQ(fs.sent(), 8);
|
||||
CHECK_EQ(fs.remaining(), 12);
|
||||
fs.advance(0); // the retry path: identical bytes again
|
||||
CHECK_EQ(fs.sent(), 8);
|
||||
fs.copyTo(out, 4);
|
||||
CHECK(memcmp(out, "89AB", 4) == 0);
|
||||
|
||||
CASE("flash send: completing keeps the totals readable");
|
||||
DrainLog log;
|
||||
fs.start(page, sizeof(page) - 1);
|
||||
while (fs.armed()) {
|
||||
const size_t take = fs.next(8, 100);
|
||||
if (take == 0) break;
|
||||
fs.copyTo(out, take);
|
||||
// every byte of the piece is the page's, at the cursor: the whole claim of the pump
|
||||
for (size_t i = 0; i < take; i++)
|
||||
CHECK(out[i] == page[log.bytes + i]);
|
||||
log.bytes += take;
|
||||
log.pieces++;
|
||||
if (take > log.maxPiece) log.maxPiece = take;
|
||||
fs.advance(take);
|
||||
}
|
||||
CHECK(fs.done());
|
||||
CHECK(!fs.armed());
|
||||
CHECK_EQ(log.bytes, 20);
|
||||
CHECK_EQ(log.pieces, 3); // 8 + 8 + 4
|
||||
CHECK_EQ(log.maxPiece, 8);
|
||||
CHECK_EQ(fs.sent(), 20); // sent()/total() still describe the send
|
||||
CHECK_EQ(fs.remaining(), 0);
|
||||
fs.advance(5); // past the end: clamped, no wrap
|
||||
CHECK_EQ(fs.sent(), 20);
|
||||
|
||||
CASE("flash send: the real page, on the real budget");
|
||||
// 51 626 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
|
||||
// and kPageBytesPerTick per tick: the exact numbers the device check compares against.
|
||||
{
|
||||
// A stand-in of the served page's size (51 626 B, the `page bytes:` line the gate
|
||||
// prints). tests/test_relayctl.py pins this constant against the raw string in
|
||||
// src/web_page.h, so if the page changes and this number does not, a check fails --
|
||||
// the same "test the table" rule as the mouse bits and the evdev codes.
|
||||
static const char big[kServedPageBytes] = {0};
|
||||
FlashSend p;
|
||||
p.start(big, sizeof(big));
|
||||
size_t ticks = 0, total = 0, worstTick = 0, worstPiece = 0;
|
||||
while (p.armed() && ticks < 1000) {
|
||||
size_t thisTick = 0;
|
||||
while (p.armed()) {
|
||||
const size_t take = p.next(kPageChunkBytes, kPageBytesPerTick - thisTick);
|
||||
if (take == 0) break;
|
||||
CHECK(take <= kPageChunkBytes);
|
||||
thisTick += take;
|
||||
CHECK(thisTick <= kPageBytesPerTick);
|
||||
total += take;
|
||||
if (take > worstPiece) worstPiece = take;
|
||||
p.advance(take);
|
||||
}
|
||||
CHECK(thisTick <= kPageBytesPerTick);
|
||||
if (thisTick > worstTick) worstTick = thisTick;
|
||||
ticks++;
|
||||
}
|
||||
CHECK(p.done());
|
||||
CHECK_EQ(total, sizeof(big));
|
||||
CHECK(worstPiece <= kPageChunkBytes);
|
||||
CHECK(worstTick <= kPageBytesPerTick);
|
||||
// 51 626 / 1460 = 36 chunks; / 5840 per tick = 9 ticks. If either number changes in
|
||||
// config.h this line moves, and the report has to move with it.
|
||||
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 9);
|
||||
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 36);
|
||||
}
|
||||
|
||||
CASE("config: the sizing this layer promises");
|
||||
{
|
||||
// One transport write is never bigger than the transport's own record cap, and the
|
||||
// per-tick budget is a whole number of chunks -- otherwise the last write of a tick
|
||||
// would be a runt and the arithmetic above would have a special case in it.
|
||||
CHECK(kPageChunkBytes <= kConnWriteChunk);
|
||||
CHECK_EQ(kPageBytesPerTick % kPageChunkBytes, 0);
|
||||
CHECK(kPageBytesPerTick >= kPageChunkBytes);
|
||||
// The spare slot must be able to hold the widest response it can be asked to send:
|
||||
// a head and its body are queued as a PAIR, so the pair has to fit.
|
||||
CHECK_EQ(kConnViewTxCap, kHttpResponseHeadCap + kStatusJsonCap);
|
||||
CHECK(kConnTxCap >= kHttpResponseHeadCap + kStatusJsonCap);
|
||||
CHECK(kConnTxCap >= kStatusJsonCap + 16); // a whole status frame, as before
|
||||
}
|
||||
|
||||
return report("test_conn");
|
||||
}
|
||||
+154
-10
@@ -76,12 +76,11 @@ const byId = id => {
|
||||
return registry.get(id);
|
||||
};
|
||||
|
||||
const sentFrames = [];
|
||||
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
|
||||
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
|
||||
|
||||
|
||||
class FakeWebSocket {
|
||||
constructor() {
|
||||
constructor(url) {
|
||||
this.url = url; // what the page asked to connect to
|
||||
this.readyState = 1; // OPEN
|
||||
FakeWebSocket.instances.push(this);
|
||||
setTimeout(() => this.onopen && this.onopen(), 0);
|
||||
@@ -112,7 +111,11 @@ const doc = {
|
||||
const sandbox = {
|
||||
document: doc,
|
||||
window: { addEventListener: winListeners.add },
|
||||
location: { protocol: "http:", host: "keyrelay.local" },
|
||||
// A TLS board never hands its page an http: origin (:80 answers a redirect), so
|
||||
// the origin this suite boots on is https:, like the real first load. The
|
||||
// connect-path section switches it deliberately to walk both the refusal and
|
||||
// the -D KR_TLS=0 rollback.
|
||||
location: { protocol: "https:", host: "keyrelay.local" },
|
||||
sessionStorage: { getItem: () => null, setItem() {} },
|
||||
WebSocket: FakeWebSocket,
|
||||
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
|
||||
@@ -125,7 +128,9 @@ const sandbox = {
|
||||
const expose = `
|
||||
;globalThis.__x = {
|
||||
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
|
||||
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
|
||||
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect, boot,
|
||||
servedInClear,
|
||||
get tlsBuild() { return KR_TLS_BUILD; }, set tlsBuild(v) { KR_TLS_BUILD = v; },
|
||||
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
|
||||
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
|
||||
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
|
||||
@@ -140,19 +145,52 @@ const expose = `
|
||||
|
||||
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
|
||||
const headerText = fs.readFileSync(headerPath, "utf8");
|
||||
const from = headerText.indexOf("<script>");
|
||||
const to = headerText.indexOf("<\/script>", from);
|
||||
|
||||
// The page is a chain of R"HTML( ... )HTML" raw-string chunks joined by exactly one
|
||||
// #if KR_TLS build marker (task 05: the page has to know its own build before any
|
||||
// socket exists). Assemble the bytes one build serves, the same assembly
|
||||
// tests/test_relayctl.py and .agents/validate.sh perform -- three readers of one
|
||||
// layout, each failing if the layout drifts.
|
||||
function servedPage(text, tls) {
|
||||
const chunks = [...text.matchAll(/R"HTML\(([\s\S]*?)\)HTML"/g)].map(m => m[1]);
|
||||
const marks = [...text.matchAll(
|
||||
/#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif/g)];
|
||||
if (chunks.length === 0 || marks.length !== chunks.length - 1) {
|
||||
console.error("web_page.h: raw-string chunks and #if KR_TLS markers no longer " +
|
||||
"join up; fix this harness rather than shipping a guessed page");
|
||||
process.exit(1);
|
||||
}
|
||||
let out = "";
|
||||
chunks.forEach((c, i) => {
|
||||
out += c;
|
||||
if (i < marks.length) out += tls ? marks[i][1] : marks[i][2];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const served = servedPage(headerText, true); // the TLS build, by default
|
||||
const rollbackPage = servedPage(headerText, false);
|
||||
|
||||
const from = served.indexOf("<script>");
|
||||
const to = served.indexOf("<\/script>", from);
|
||||
if (from < 0 || to < 0) {
|
||||
console.error("no <script> block in " + headerPath);
|
||||
process.exit(1);
|
||||
}
|
||||
const pageScript = headerText.slice(from + "<script>".length, to);
|
||||
const pageScript = served.slice(from + "<script>".length, to);
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
vm.runInContext(pageScript + expose, context);
|
||||
const x = sandbox.__x;
|
||||
const logEl = x.logEl;
|
||||
|
||||
// Declared after FakeWebSocket's class body only because the class closes over
|
||||
// it: send() runs long after this line has, and a hoisted `let` would be a
|
||||
// temporal-dead-zone trap waiting for a socket that opens during page load.
|
||||
const sentFrames = [];
|
||||
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
|
||||
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
@@ -482,8 +520,114 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
|
||||
x.mCaptured = false;
|
||||
x.setArmed(false, "harness: done");
|
||||
|
||||
console.log("the connect path");
|
||||
// The board serves the page over https:// (and, in a -D KR_TLS=0 build, over http://).
|
||||
// The socket scheme follows the page's own origin: an https: page that opened ws://
|
||||
// would be blocked by the browser as mixed content, and the failure would look exactly
|
||||
// like a dead board. The other direction is the guard task 05 added: a TLS build's
|
||||
// page on an http: origin is bytes nobody legitimate served (a TLS board answers :80
|
||||
// with a redirect), so it refuses -- and the rollback build, whose marker is 0,
|
||||
// keeps its ws:// because that is the documented way back.
|
||||
checkEq(Buffer.byteLength(served), Buffer.byteLength(rollbackPage),
|
||||
"both builds' pages are the same byte count -- the marker branches are " +
|
||||
"equal length, so every pinned page count is one number");
|
||||
byId("token").value = "hunter2"; // connect() refuses without one
|
||||
sandbox.location.protocol = "https:";
|
||||
FakeWebSocket.instances.length = 0;
|
||||
x.connect();
|
||||
await wait(5);
|
||||
let inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
check(inst !== undefined, "connect() opened a socket on an https: origin");
|
||||
checkEq(inst.url, "wss://keyrelay.local/ws",
|
||||
"an https: origin connects over wss:// -- the encrypted controller channel");
|
||||
// The passphrase travels in the first frame, never in a URL: a query string lands in
|
||||
// proxy logs, browser history and the Referer of anything the page later loads.
|
||||
check(!/token|pass/i.test(inst.url), `the connect URL carries no credential (${inst.url})`);
|
||||
|
||||
// An http: origin on the TLS build: no socket, no frame, and the reason is said.
|
||||
FakeWebSocket.instances.length = 0;
|
||||
sentFrames.length = 0;
|
||||
sandbox.location.protocol = "http:";
|
||||
x.connect();
|
||||
await wait(5);
|
||||
checkEq(FakeWebSocket.instances.length, 0,
|
||||
"a clear-loaded TLS page opens no socket -- it does not fall back to plaintext");
|
||||
checkEq(sentFrames.length, 0,
|
||||
"and sends not one frame -- above all not the T<passphrase>");
|
||||
check(String(logEl.lastElementChild && logEl.lastElementChild.textContent)
|
||||
.includes("this page arrived in the clear; reload over https"),
|
||||
"and it says why, in the words the operator can act on");
|
||||
|
||||
// boot() on the same origin: the passphrase box itself comes disabled, with the
|
||||
// banner up -- refusing the socket while still accepting the passphrase would be
|
||||
// half a guard.
|
||||
byId("token").disabled = false;
|
||||
byId("connect").disabled = false;
|
||||
byId("clearWarn").hidden = true;
|
||||
x.boot();
|
||||
checkEq(byId("token").disabled, true, "a clear-loaded page will not take a passphrase");
|
||||
checkEq(byId("connect").disabled, true, "and its connect button is inert");
|
||||
checkEq(byId("clearWarn").hidden, false, "and the banner explains the refusal");
|
||||
checkEq(FakeWebSocket.instances.length, 0, "boot() on http: connects to nothing");
|
||||
|
||||
// The rollback build (-D KR_TLS=0) keeps today's behaviour on the same origin:
|
||||
// ws:// is its documented way back, opted into by the build flag itself.
|
||||
x.tlsBuild = 0;
|
||||
byId("token").disabled = false;
|
||||
byId("connect").disabled = false;
|
||||
byId("clearWarn").hidden = true;
|
||||
FakeWebSocket.instances.length = 0;
|
||||
x.connect();
|
||||
await wait(5);
|
||||
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
check(inst !== undefined, "the KR_TLS=0 page still connects on an http: origin");
|
||||
if (inst) checkEq(inst.url, "ws://keyrelay.local/ws",
|
||||
"the rollback page speaks plaintext ws:// because that is the rollback");
|
||||
x.tlsBuild = 1;
|
||||
sandbox.location.protocol = "https:"; // as the shim started
|
||||
|
||||
// The plaintext spelling exists exactly once in the served bytes -- the fallback
|
||||
// the rollback build reaches. A TLS board never hands a browser an http: origin
|
||||
// (port 80 answers a redirect and a 426, pinned in tests/test_relayctl.py and
|
||||
// walked in tests/test_conn.cpp), so one occurrence is the whole rollback and
|
||||
// two would be a second, ungated path.
|
||||
checkEq(served.split("ws://").length - 1, 1,
|
||||
"the served page contains exactly one plaintext ws:// -- the rollback's path");
|
||||
checkEq(served.split("wss://").length - 1, 1, "and exactly one wss://, its counterpart");
|
||||
checkEq(served.split("new WebSocket(").length - 1, 1,
|
||||
"one socket is opened in the whole page, and it opens the url just built");
|
||||
check(served.includes("var KR_TLS_BUILD=1;"),
|
||||
"the TLS build's assembled page carries its marker");
|
||||
check(rollbackPage.includes("var KR_TLS_BUILD=0;"),
|
||||
"and the assembled rollback page carries the other one");
|
||||
|
||||
console.log("the channel pill and the certificate line");
|
||||
// Both render from the status frame only -- the page has no other way to know,
|
||||
// and an operator compares these bytes against `cert` on the serial console.
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"0123abcd456789ef"}');
|
||||
checkEq(byId("pTls").textContent, "tls", "a TLS board's pill says tls");
|
||||
checkEq(byId("pTls").className, "pill on", "and it is the calm green state");
|
||||
checkEq(byId("certFp").hidden, false, "the certificate line is up");
|
||||
check(byId("certFp").textContent.includes("0123abcd456789ef"),
|
||||
"the line renders exactly the fp the status frame carried");
|
||||
check(byId("certFp").textContent.includes("cert"),
|
||||
"and names where to confirm it (the serial `cert` command)");
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":0,"fp":"0123abcd456789ef"}');
|
||||
checkEq(byId("pTls").textContent, "plaintext", "a board without TLS says plaintext");
|
||||
check(byId("pTls").className.includes("bad"),
|
||||
"and says it in the alarming colour -- unencrypted is the state to notice");
|
||||
checkEq(byId("certFp").hidden, true, "with tls:0 no fingerprint line renders, ...");
|
||||
checkEq(byId("certFp").textContent, "",
|
||||
"... even when the frame carried one anyway (the page does not launder it)");
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1}');
|
||||
checkEq(byId("certFp").hidden, true,
|
||||
"and a tls:1 frame with no fp renders no line either -- nothing is invented");
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"00aa11bb22cc33dd"}');
|
||||
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
|
||||
"a new status re-renders the new fingerprint");
|
||||
|
||||
console.log("element ids");
|
||||
const markup = fs.readFileSync(headerPath, "utf8");
|
||||
const markup = served;
|
||||
const present = new Set();
|
||||
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
|
||||
check(present.size > 15, `the markup's ids were parsed (${present.size})`);
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
#include "keystate.h"
|
||||
#include "proto.h"
|
||||
#include "status.h"
|
||||
#include "test.h"
|
||||
|
||||
using kr::proto::Cmd;
|
||||
using kr::proto::Message;
|
||||
using kr::StatusSnapshot; // the status frame, rendered by status.h
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -535,5 +537,125 @@ int main() {
|
||||
CHECK(out.find("typer.cancel()") != std::string::npos);
|
||||
}
|
||||
|
||||
CASE("the status frame: the TLS identity renders, and the frame fits its cap");
|
||||
{
|
||||
// statusJson() lives in status.h so that this can run at all. It used to sit behind
|
||||
// #include <WiFi.h>, which left the number that decides whether the controller's whole
|
||||
// picture of the board arrives intact -- kr::kStatusJsonCap -- unmeasured by anything on the
|
||||
// host. A frame that does not fit renders as *nothing* (the function returns 0 and the
|
||||
// caller sends nothing), so an added field would have looked like a dropped socket or a
|
||||
// stalled page, three commits away from the cause.
|
||||
StatusSnapshot s;
|
||||
char json[kr::kStatusJsonCap];
|
||||
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
|
||||
// A default snapshot: no TLS, and no label for an identity that is not there.
|
||||
size_t n = statusJson(s, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(n < kr::kStatusJsonCap);
|
||||
CHECK(strstr(json, "\"tls\":0") != nullptr);
|
||||
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
|
||||
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
|
||||
CHECK_EQ(kr::kStatusFingerprintHex, 16);
|
||||
|
||||
// The identity renders as `"tls":1` plus a `fp` of exactly 16 hex characters -- the
|
||||
// first kr::kStatusFingerprintBytes of the digest, not the whole 64: this field is a label to
|
||||
// eyeball against `cert`, and the full value is the console's job.
|
||||
s.tls = true;
|
||||
s.fp = fp;
|
||||
n = statusJson(s, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(strstr(json, "\"tls\":1") != nullptr);
|
||||
CHECK(strstr(json, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
|
||||
CHECK(strstr(json, "0de5dae2223") == nullptr);
|
||||
|
||||
// Upper case hex is as renderable as lower case: the board prints lower, but a value
|
||||
// that arrived upper case must survive the boundary unchanged rather than be cut to "".
|
||||
s.fp = "BA7816BF8F01CFEA0123456789abcdef";
|
||||
n = statusJson(s, json, sizeof(json));
|
||||
CHECK(strstr(json, "\"fp\":\"BA7816BF8F01CFEA\"") != nullptr);
|
||||
|
||||
// tls:0 wins over a filled-in label. A page that drew a fingerprint next to "no TLS" is
|
||||
// drawing an picture of security the board does not have.
|
||||
s.tls = false;
|
||||
n = statusJson(s, json, sizeof(json));
|
||||
CHECK(strstr(json, "\"tls\":0") != nullptr);
|
||||
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
|
||||
|
||||
// The JSON boundary: a stored value that had gone non-hex is cut where it stops being
|
||||
// hex instead of pasted in, because a quote inside a field is a field forgery -- the same
|
||||
// reason the SSID below is sanitised.
|
||||
s.tls = true;
|
||||
s.fp = "01234567" "\"armed\":0,\"x\":\"";
|
||||
n = statusJson(s, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(strstr(json, "\"fp\":\"01234567\"") != nullptr);
|
||||
int armedFields = 0;
|
||||
for (const char *p = json; (p = strstr(p, "\"armed\"")) != nullptr; p++) armedFields++;
|
||||
CHECK_EQ(armedFields, 1);
|
||||
|
||||
// An SSID is somebody else's bytes on that same boundary: quotes, backslashes, control
|
||||
// characters and 0x7F never reach the frame, and an over-long one is cut at the buffer
|
||||
// rather than at the frame. (Behaviour inherited from control_server.cpp; it is now
|
||||
// testable, so it is now tested.)
|
||||
StatusSnapshot hostile;
|
||||
hostile.ssid = "a\"b\\c" "\x01" "d" "\x7f" "e";
|
||||
n = statusJson(hostile, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(strstr(json, "\"ssid\":\"abcde\"") != nullptr);
|
||||
CHECK(strchr(json, '\\') == nullptr);
|
||||
hostile.ssid = "0123456789012345678901234567890123456789012345678901234567890123456789";
|
||||
n = statusJson(hostile, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
// 39 characters: jsonSafe() fills cap-1 of the 40-byte buffer it is given.
|
||||
CHECK(strstr(json, "\"ssid\":\"012345678901234567890123456789012345678\"") != nullptr);
|
||||
|
||||
// The longest frame the board can legally put on the wire: a 32-character SSID (the
|
||||
// 802.11 limit, and staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each
|
||||
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button, and
|
||||
// the whole 16-hex `fp`. This is the arithmetic config.h quotes -- measured here rather
|
||||
// than believed, with the headroom asserted as at least one more field's worth so the
|
||||
// next field is a decision and not an overflow nobody looked for.
|
||||
StatusSnapshot worst;
|
||||
worst.version = kr::kFirmwareVersion;
|
||||
worst.uptimeS = 0xFFFFFFFFu;
|
||||
worst.usbHost = true;
|
||||
worst.armed = true;
|
||||
worst.netUp = true;
|
||||
worst.net = "down"; // the longest of the three net modes
|
||||
worst.ssid = "01234567890123456789012345678901";
|
||||
worst.ip = "255.255.255.255";
|
||||
worst.keysSent = 0xFFFFFFFFu;
|
||||
worst.chordsSent = 0xFFFFFFFFu;
|
||||
worst.dropped = 0xFFFFFFFFu;
|
||||
worst.typedChars = 0xFFFFFFFFu;
|
||||
worst.typing = 0xFFFF;
|
||||
worst.rollover = true;
|
||||
worst.mouse = true;
|
||||
worst.mouseButtons = kr::kMouseButtonMask;
|
||||
worst.mouseFrames = 0xFFFFFFFFu;
|
||||
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
|
||||
worst.defaultToken = true;
|
||||
worst.apForced = true;
|
||||
worst.tls = true;
|
||||
worst.fp = fp;
|
||||
char big[kr::kStatusJsonCap + 16];
|
||||
const size_t longest = statusJson(worst, big, sizeof(big));
|
||||
CHECK(longest > 0);
|
||||
CHECK(longest <= kr::kStatusJsonCap);
|
||||
CHECK(kr::kStatusJsonCap - longest >= 32);
|
||||
CHECK_EQ(strlen(big), longest);
|
||||
CHECK(big[0] == '{');
|
||||
CHECK(big[longest - 1] == '}');
|
||||
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
|
||||
CHECK(strstr(big, "\"tls\":1") != nullptr);
|
||||
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
|
||||
|
||||
// And the cap means it: one byte short renders nothing at all, which is the difference
|
||||
// between a status that is missing and a status that is wrong.
|
||||
CHECK_EQ(statusJson(worst, big, longest), 0);
|
||||
CHECK_EQ(statusJson(worst, big, longest + 1), longest);
|
||||
}
|
||||
|
||||
return report("proto");
|
||||
}
|
||||
|
||||
@@ -382,6 +382,543 @@ check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
|
||||
"closeLink() treats anything that reached the WebSocket phase as able to leave "
|
||||
"keys down, not only what is still marked authenticated")
|
||||
|
||||
print("one routing path, two transports")
|
||||
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
|
||||
# claims are structural, so they are pinned as source sentences: what they defend is a
|
||||
# second path that can disagree, and nothing that runs on a host can observe two paths
|
||||
# agreeing until one of them is wrong.
|
||||
conn_src = (root / "src" / "conn.h").read_text()
|
||||
ws_src = (root / "src" / "ws_codec.h").read_text()
|
||||
|
||||
# readSome()'s rule, carried over from the TLS task and now the only clock-stamping read:
|
||||
# bytes count as "the operator is alive" only from an authenticated controller on the
|
||||
# WebSocket. Over TLS a handshake, a /status poll and a viewer refresh all look identical
|
||||
# to the transport, so if this condition ever loosens to "bytes arrived", anything on the
|
||||
# network can hold a dead controller's armed state open -- bug 10's shape again.
|
||||
read_fn = server_src[server_src.index("size_t ControlServer::readSome"):
|
||||
server_src.index("\n}", server_src.index("size_t ControlServer::readSome"))]
|
||||
check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in read_fn,
|
||||
"readSome() stamps the silence clock only for an authenticated controller on the "
|
||||
"WebSocket, whatever transport the bytes came over")
|
||||
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
|
||||
# ... and it is the ONLY inbound path that may. Three writers of that clock would mean a
|
||||
# route that can keep the board armed: readSome, and closeLink stamping on the way out.
|
||||
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
|
||||
check_eq(len(stamps), 3,
|
||||
"exactly three writers of the controller-silence clock: auth, readSome, closeLink")
|
||||
check(any("the controller exists from here" in l for l in stamps),
|
||||
"one of them is the authentication itself")
|
||||
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
|
||||
# directly, never through readSome().
|
||||
viewer_fn = server_src[server_src.index("void ControlServer::serveViewer"):
|
||||
server_src.index("void ControlServer::stopViewer")]
|
||||
# Code lines only: the function carries a comment explaining why it does NOT go through
|
||||
# readSome(), and a check that flagged the comment would be worse than no check at all.
|
||||
viewer_code = "\n".join(l for l in viewer_fn.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
check("readSome" not in viewer_code,
|
||||
"the read-only slot reads its Conn directly, so it cannot refresh the controller clock")
|
||||
|
||||
# One HTTP response writer. The task's completion criterion is a grep that comes up empty,
|
||||
# so this is that grep, as a check: exactly one formatter in src/, and the only literal
|
||||
# response status line outside it is the 101 protocol switch (which is not a response).
|
||||
writers = sorted(p.name for p in (root / "src").glob("*")
|
||||
if '"HTTP/1.1 ' in p.read_text(errors="replace"))
|
||||
check_eq(writers, ["conn.h", "control_server.cpp"],
|
||||
"only conn.h's formatter and the 101 upgrade line write an HTTP status line")
|
||||
check_eq(server_src.count('"HTTP/1.1 '), 1,
|
||||
"control_server.cpp holds exactly one, the WebSocket 101 -- not a second response writer")
|
||||
check("respondAndClose(rsp)" in server_src and "respondViewer(rsp)" in server_src,
|
||||
"both slots queue a built response through the same pair of writers")
|
||||
# serveReadonly is the single read-only answer, reachable from both listeners/slots.
|
||||
check_eq(server_src.count("serveReadonly(req, rsp,"), 2,
|
||||
"the page/status routing is reached from the controller slot and the spare, once each")
|
||||
check("void ControlServer::serveReadonly" in server_src,
|
||||
"and it is defined once")
|
||||
check_eq(server_src.count("plaintextRoute("), 1,
|
||||
"the :80 answer table is consulted in exactly one place, inside serveReadonly")
|
||||
# Both slots go through the transport view; nothing in the routing consults the port.
|
||||
check("_ctl.read(" in conn_src or "_ctl.read(" in server_src or "_plain->read" in conn_src,
|
||||
"the controller slot reads through kr::Conn")
|
||||
check("_view.read(" in server_src, "the spare slot reads through kr::Conn too")
|
||||
check("_server.hasClient()" in server_src and "::accept(_tlsListen" in server_src,
|
||||
"both listeners still feed one accept decision")
|
||||
|
||||
# The :80 policy itself is pure (kr::plaintextRoute, walked in tests/test_conn.cpp); these
|
||||
# pins keep the firmware from re-deciding it locally.
|
||||
check("PlainRoute::UpgradeRequired" in conn_src and "PlainRoute::Redirect" in conn_src,
|
||||
"conn.h spells out the redirect and the 426 as data, not as scattered ifs")
|
||||
check("namespace kr" in conn_src, "conn.h is in namespace kr, like every other module")
|
||||
check("token=" in conn_src and "The value is never copied" in conn_src,
|
||||
"the token-in-URL test looks for the key without copying the passphrase")
|
||||
# The Host header had to be parsed to build a Location that keeps the operator's address.
|
||||
check('ciEquals(ls, "host:", 5)' in ws_src, "ws_codec parses Host: for the redirect")
|
||||
check("char host[" in ws_src, "and carries it on Request")
|
||||
|
||||
# What the terminal client says when the board wants TLS. A refusal that reads like a
|
||||
# passphrase problem sends the operator re-typing a correct secret until the board locks it.
|
||||
check("board refuses plaintext" in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
|
||||
"426 is named as a TLS refusal")
|
||||
check("passphrase" not in relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required").lower(),
|
||||
"and the 426 wording does not blame the passphrase")
|
||||
check("passphrase" in relayctl.handshake_refusal("HTTP/1.1 401 Unauthorized"),
|
||||
"401 still says what it always said")
|
||||
check(relayctl.handshake_refusal("HTTP/1.1 503 Service Unavailable")
|
||||
!= relayctl.handshake_refusal("HTTP/1.1 426 Upgrade Required"),
|
||||
"'busy' and 'this board wants TLS' are never the same sentence")
|
||||
check("https" in relayctl.handshake_refusal("HTTP/1.1 301 Moved Permanently"),
|
||||
"a redirect on :80 is reported as the port policy it is")
|
||||
for line, want in (("HTTP/1.1 418 Teapot", "refused"), ("garbage", "refused")):
|
||||
check_eq(relayctl.handshake_refusal(line), want, f"unknown status {line!r}")
|
||||
|
||||
# The page the transport sends: the byte count tests/test_conn.cpp drains through the
|
||||
# chunk arithmetic has to be the page that is actually in the tree. The page is a
|
||||
# chain of raw-string chunks joined by one #if KR_TLS build marker (task 05), so
|
||||
# "the page" is the chunks plus exactly one marker branch -- and both branches are
|
||||
# asserted to be the same length, which is what keeps every pinned count (this one,
|
||||
# the gate's, the device's `wc -c`) build-independent. That is a test, not a hope.
|
||||
def served_page(src, tls=True):
|
||||
"""The bytes one build serves: the raw-string chunks concatenated around the
|
||||
marker branch that build takes. None when the layout is not chunks joined by
|
||||
markers, so a restructured page fails loudly instead of counting half of it."""
|
||||
chunks = re.findall(r'R"HTML\((.*?)\)HTML"', src, re.S)
|
||||
marks = re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif', src)
|
||||
if len(chunks) < 1 or len(marks) != len(chunks) - 1:
|
||||
return None
|
||||
out = ""
|
||||
for i, chunk in enumerate(chunks):
|
||||
out += chunk
|
||||
if i < len(marks):
|
||||
out += marks[i][0] if tls else marks[i][1]
|
||||
return out
|
||||
|
||||
|
||||
page_tls = served_page(page_src, True)
|
||||
page_plain = served_page(page_src, False)
|
||||
check(page_tls is not None and page_plain is not None,
|
||||
"every join between the page's raw-string chunks is one #if KR_TLS marker")
|
||||
check(all(len(a) == len(b) for a, b in
|
||||
re.findall(r'#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif',
|
||||
page_src)),
|
||||
"both branches of every build marker are the same length, so the served page "
|
||||
"is the same size whichever build flashes")
|
||||
check(page_tls is not None and "var KR_TLS_BUILD=1;" in page_tls,
|
||||
"the TLS build's page carries its marker")
|
||||
check("var KR_TLS_BUILD=0;" in page_plain,
|
||||
"and the -D KR_TLS=0 rollback page carries its own")
|
||||
check_eq(len(page_tls.encode("utf-8")), len(page_plain.encode("utf-8")),
|
||||
"the two builds serve byte-count-equal pages")
|
||||
pinned_page_bytes = int(re.search(r"kServedPageBytes = (\d+)",
|
||||
(root / "tests" / "test_conn.cpp").read_text()).group(1))
|
||||
check_eq(len(page_tls.encode("utf-8")), pinned_page_bytes,
|
||||
"the page the chunk arithmetic is tested against is the page in web_page.h")
|
||||
|
||||
# The page's only scheme choice is its own origin; a TLS board never has an http: origin
|
||||
# because :80 answers a redirect (pinned above, walked in test_conn).
|
||||
check(page_src.count('"wss://"') == 1 and page_src.count('"ws://"') == 1,
|
||||
"the page derives the socket scheme from location.protocol, in exactly one place")
|
||||
check('location.protocol === "https:" ? "wss://" : "ws://"' in page_src,
|
||||
"https: origin gives wss://, and ws:// is reachable only on a KR_TLS=0 build")
|
||||
# The guard the other way round (task 05): a TLS build's page on an http: origin
|
||||
# refuses to connect and refuses the passphrase. The behaviour is walked in
|
||||
# tests/test_page.js; these pins keep the refusal from being edited into a warning.
|
||||
check("function servedInClear()" in page_src and "if (servedInClear())" in page_src,
|
||||
"the page asks whether it arrived in the clear, in connect() and at load")
|
||||
check("this page arrived in the clear; reload over https" in page_src,
|
||||
"and it says why, in words the operator can act on")
|
||||
check('$("token").disabled = true' in page_src,
|
||||
"a clear-served page will not even take passphrase input")
|
||||
check("paintChannel(s)" in page_src and 'id="certFp"' in page_src
|
||||
and 'id="pTls"' in page_src,
|
||||
"the status frame's tls/fp pair is rendered: a pill and a certificate line")
|
||||
|
||||
print("the client side of pinning")
|
||||
# The half of phase 02 the operator types. None of this may guess: no TLS argument
|
||||
# means no connection, a wrong fingerprint means nothing was sent, and the only
|
||||
# path to the plaintext port is the flag that says it knows what it is doing.
|
||||
import contextlib # noqa: E402
|
||||
import hashlib # noqa: E402
|
||||
import io # noqa: E402
|
||||
import os # noqa: E402
|
||||
import socket as socketmod # noqa: E402
|
||||
|
||||
# --- the decision functions, walked directly ---------------------------------
|
||||
check_eq(relayctl.select_transport("a" * 64, "", False, False), "pin",
|
||||
"a fingerprint pins")
|
||||
check_eq(relayctl.select_transport("", "/tmp/ca.pem", False, False), "ca",
|
||||
"a CA file verifies a chain")
|
||||
check_eq(relayctl.select_transport("", "", True, False), "insecure",
|
||||
"--insecure is a decision, not a default")
|
||||
check_eq(relayctl.select_transport("", "", False, True), "plain",
|
||||
"--plain is the rollback path")
|
||||
for bad, why in ((("", "", False, False), "no trust argument at all"),
|
||||
(("a" * 64, "/tmp/ca.pem", False, False), "a pin and a CA at once"),
|
||||
(("", "", True, True), "--insecure together with --plain"),
|
||||
(("a" * 64, "", False, True), "a pin on the rollback path")):
|
||||
try:
|
||||
relayctl.select_transport(*bad)
|
||||
check(False, f"select_transport should refuse: {why}")
|
||||
except relayctl.RelayError as exc:
|
||||
check(True, f"select_transport refuses: {why}")
|
||||
if why == "no trust argument at all":
|
||||
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
|
||||
check(option in str(exc),
|
||||
f"the refusal names {option} as a way forward")
|
||||
|
||||
check_eq(relayctl.resolve_port("pin", None), 443, "a pinned run defaults to :443")
|
||||
check_eq(relayctl.resolve_port("ca", None), 443, "a verified run defaults to :443")
|
||||
check_eq(relayctl.resolve_port("insecure", None), 443,
|
||||
"even an unverified TLS run defaults to the TLS port")
|
||||
check_eq(relayctl.resolve_port("plain", None), 80,
|
||||
"--plain is the only path to :80")
|
||||
check_eq(relayctl.resolve_port("pin", 8443), 8443, "an explicit --port wins")
|
||||
|
||||
check_eq(relayctl.normalize_fingerprint("A" * 64), "a" * 64,
|
||||
"a bare hex fingerprint normalises to lowercase")
|
||||
check_eq(relayctl.normalize_fingerprint(":".join("a1" for _ in range(32))), "a1" * 32,
|
||||
"OpenSSL's colon pairs are accepted")
|
||||
check_eq(relayctl.normalize_fingerprint(" " + "ab" * 32 + "\n"), "ab" * 32,
|
||||
"and so is the whitespace a serial console wraps in")
|
||||
for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
|
||||
try:
|
||||
relayctl.normalize_fingerprint(bad)
|
||||
check(False, f"a malformed fingerprint is refused: {bad[:12]!r}")
|
||||
except relayctl.RelayError:
|
||||
check(True, f"malformed fingerprint refused: {bad[:12]!r}")
|
||||
|
||||
# --- the connection itself, against a fake board ------------------------------
|
||||
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
|
||||
|
||||
|
||||
class FakeBoardSock:
|
||||
"""Records every byte this side sends; answers the upgrade and `T<token>`.
|
||||
|
||||
The recorded frame list is the point: "the pin aborted before T<token>" can
|
||||
only be shown by what did NOT go out.
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
|
||||
self.host, self.port = host, port
|
||||
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
|
||||
self.flood = flood # answer with headers that never terminate
|
||||
self.sent = [] # every sendall payload, in order
|
||||
self.inbox = bytearray()
|
||||
self.upgraded = False
|
||||
self.post = bytearray() # bytes after the 101 (client WS frames)
|
||||
self.closed = False
|
||||
self.timeout = None
|
||||
self.close_early = close_early # answer the upgrade with a clean EOF
|
||||
|
||||
def settimeout(self, t):
|
||||
self.timeout = t
|
||||
|
||||
def sendall(self, data):
|
||||
data = bytes(data)
|
||||
self.sent.append(data)
|
||||
if self.close_early:
|
||||
return # received, but the board answers nothing
|
||||
if not self.upgraded:
|
||||
blob = b"".join(self.sent)
|
||||
if b"\r\n\r\n" in blob:
|
||||
m = re.search(rb"Sec-WebSocket-Key: (\S+)", blob)
|
||||
if self.flood:
|
||||
self.inbox += b"HTTP/1.1 101 " + b"x" * 20000
|
||||
self.upgraded = True
|
||||
return
|
||||
accept = (b"not-the-accept" if self.bad_accept
|
||||
else relayctl.expect_accept(m.group(1).decode()).encode())
|
||||
self.inbox += (b"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n")
|
||||
self.upgraded = True
|
||||
return
|
||||
self.post += data
|
||||
while len(self.post) >= 2:
|
||||
length = self.post[1] & 0x7F
|
||||
key = self.post[2:6]
|
||||
payload = bytes(b ^ key[i & 3] for i, b in enumerate(self.post[6:6 + length]))
|
||||
del self.post[:6 + length]
|
||||
if payload.startswith(b"T"):
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
||||
|
||||
def recv(self, n):
|
||||
if self.inbox:
|
||||
data = bytes(self.inbox[:n])
|
||||
del self.inbox[:n]
|
||||
return data
|
||||
if self.close_early:
|
||||
return b"" # clean EOF: the board hung up mid-handshake
|
||||
raise socketmod.timeout("fake board: no more bytes right now")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def relayctl_server_frame(opcode, payload):
|
||||
out = bytearray([0x80 | opcode, len(payload)])
|
||||
out += payload
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class FakeSsl:
|
||||
"""Stand-in for the ssl module: real policy code runs against these knobs, and
|
||||
wrap_socket returns the same socket with a getpeercert of our choosing."""
|
||||
|
||||
PROTOCOL_TLS_CLIENT = "tls-client"
|
||||
CERT_NONE = 0
|
||||
CERT_REQUIRED = 2
|
||||
|
||||
class SSLError(Exception):
|
||||
pass
|
||||
|
||||
def __init__(self, der=FAKE_DER, wrap_error=None, empty_der=False):
|
||||
self.der = der
|
||||
self.wrap_error = wrap_error # raised by wrap_socket, simulating a failed TLS
|
||||
self.empty_der = empty_der # peer completes TLS but shows no certificate
|
||||
self.contexts = [] # (kind, ctx) for every context built
|
||||
|
||||
def SSLContext(self, protocol):
|
||||
ctx = FakeSsl.Ctx(self, protocol)
|
||||
self.contexts.append(("manual", ctx))
|
||||
return ctx
|
||||
|
||||
def create_default_context(self, cafile=None):
|
||||
ctx = FakeSsl.Ctx(self, "default")
|
||||
ctx.cafile = cafile
|
||||
self.contexts.append(("default", ctx))
|
||||
return ctx
|
||||
|
||||
class Ctx:
|
||||
def __init__(self, owner, protocol):
|
||||
self.owner, self.protocol = owner, protocol
|
||||
self.check_hostname = None
|
||||
self.verify_mode = None
|
||||
self.cafile = None
|
||||
self.server_hostname = None
|
||||
|
||||
def wrap_socket(self, sock, server_hostname=None):
|
||||
self.server_hostname = server_hostname
|
||||
if self.owner.wrap_error is not None:
|
||||
raise self.owner.wrap_error
|
||||
der = b"" if self.owner.empty_der else self.owner.der
|
||||
sock.getpeercert = lambda binary_form=False: der if binary_form else {}
|
||||
return sock
|
||||
|
||||
|
||||
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
||||
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
|
||||
empty_der=False, bad_accept=False, flood=False,
|
||||
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
|
||||
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
|
||||
socks = []
|
||||
|
||||
def fake_create(addr, timeout):
|
||||
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
|
||||
bad_accept=bad_accept, flood=flood)
|
||||
socks.append(s)
|
||||
return s
|
||||
|
||||
relayctl.ssl = fake
|
||||
relayctl.socket.create_connection = fake_create
|
||||
stderr = io.StringIO()
|
||||
relay, err = None, None
|
||||
try:
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
|
||||
fingerprint=fingerprint, ca=ca,
|
||||
insecure=insecure, plain=plain)
|
||||
except relayctl.RelayError as exc:
|
||||
err = exc
|
||||
finally:
|
||||
relayctl.ssl = real_ssl
|
||||
relayctl.socket.create_connection = real_create
|
||||
return relay, err, socks, fake, stderr.getvalue()
|
||||
|
||||
|
||||
def client_frame_payloads(sock):
|
||||
"""Decode the masked client frames this side sent, skipping the HTTP upgrade."""
|
||||
blob = b"".join(sock.sent)
|
||||
tail = blob.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in blob else b""
|
||||
out = []
|
||||
while len(tail) >= 2:
|
||||
length = tail[1] & 0x7F
|
||||
key = tail[2:6]
|
||||
out.append(bytes(b ^ key[i & 3] for i, b in enumerate(tail[6:6 + length])))
|
||||
tail = tail[6 + length:]
|
||||
return out
|
||||
|
||||
# A wrong fingerprint aborts BEFORE a single frame -- above all before T<token>.
|
||||
_wrong = "0" * 64
|
||||
relay, err, socks, fake, warns = connect_with(fingerprint=_wrong)
|
||||
check(isinstance(err, relayctl.RelayError), "a wrong fingerprint raises")
|
||||
check(err is not None and "fingerprint mismatch" in str(err),
|
||||
"and says exactly what mismatched")
|
||||
check(socks and socks[0].sent == [],
|
||||
"nothing at all went out -- not the upgrade, and certainly not T<token>")
|
||||
|
||||
# The right fingerprint (in any sane spelling) connects, and the passphrase frame
|
||||
# is the first thing on the encrypted wire.
|
||||
_right = hashlib.sha256(FAKE_DER).hexdigest()
|
||||
relay, err, socks, fake, warns = connect_with(fingerprint=_right)
|
||||
check(err is None and relay is not None, f"the pinned fingerprint connects ({err})")
|
||||
check(relay is not None and relay.mode == "pin", "the mode is the pin")
|
||||
if socks:
|
||||
frames = client_frame_payloads(socks[0])
|
||||
check(frames and frames[0] == b"Tsekrit",
|
||||
"the first frame on a pinned session is the authentication")
|
||||
check(b"Tsekrit" not in b"".join(socks[0].sent),
|
||||
"the token never travels as plaintext bytes -- it is only in masked frames")
|
||||
check(socks and socks[0].port == 443, "pinning dials :443")
|
||||
relay2, err2, socks2, _, _ = connect_with(
|
||||
fingerprint=":".join(_right[i:i + 2] for i in range(0, 64, 2)).upper())
|
||||
check(err2 is None, "a colon-separated uppercase pin is the same pin")
|
||||
|
||||
# TLS failures, in the two shapes the operator can actually act on.
|
||||
relay, err, socks, fake, warns = connect_with(
|
||||
fingerprint=_right, wrap_error=FakeSsl.SSLError("handshake refused"))
|
||||
check(err is not None and "TLS handshake with keyrelay.local failed" in str(err),
|
||||
"a failed TLS handshake is named as that")
|
||||
check(err is not None and "426" in str(err),
|
||||
"and it names the plaintext-port mistake without ever proposing to make it")
|
||||
check(socks and socks[0].closed, "and the socket is closed on the way out")
|
||||
|
||||
relay, err, socks, fake, warns = connect_with(
|
||||
ca="/tmp/ca.pem", wrap_error=FakeSsl.SSLError("certificate verify failed"))
|
||||
check(err is not None and "failed verification" in str(err),
|
||||
"a --ca verification failure is not collapsed into 'handshake failed'")
|
||||
check(err is not None and "nvs erase" in str(err),
|
||||
"and it says the two things a verify failure can mean")
|
||||
|
||||
relay, err, socks, fake, warns = connect_with(fingerprint=_right, empty_der=True)
|
||||
check(err is not None and "no certificate" in str(err),
|
||||
"a TLS peer that shows no certificate is refused, not hashed as empty")
|
||||
check(socks and socks[0].closed, "and that socket is closed too")
|
||||
|
||||
# --plain against a board that hangs up instead of speaking WebSocket.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, close_early=True)
|
||||
check(err is not None and "server closed during the handshake" in str(err),
|
||||
"a closed handshake is reported for what it is")
|
||||
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, bad_accept=True)
|
||||
check(err is not None and "Sec-WebSocket-Accept" in str(err),
|
||||
"a wrong accept hash refuses -- the RFC 6455 check is not decoration")
|
||||
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, flood=True)
|
||||
check(err is not None and "handshake response too large" in str(err),
|
||||
"and a header block that never ends is bounded, not buffered forever")
|
||||
|
||||
# --ca verifies with OpenSSL's own machinery: hostname checking ON, chain REQUIRED.
|
||||
relay, err, socks, fake, warns = connect_with(ca="/tmp/board-ca.pem")
|
||||
check(err is None, f"--ca connects ({err})")
|
||||
kinds = [k for k, _ in fake.contexts]
|
||||
check(kinds == ["default"], "--ca builds its context with create_default_context")
|
||||
ctx = fake.contexts[0][1]
|
||||
check_eq(ctx.cafile, "/tmp/board-ca.pem", "and loads the file the operator named")
|
||||
check_eq(ctx.check_hostname, True, "--ca keeps hostname checking on")
|
||||
check_eq(ctx.verify_mode, 2, "and requires the chain")
|
||||
check(ctx.server_hostname == "keyrelay.local",
|
||||
"the hostname travels -- SNI and the check share one value")
|
||||
|
||||
# --insecure: connects, warns, and warns on EVERY run (it is stderr, not a memory).
|
||||
relay, err, socks, fake, warns = connect_with(insecure=True)
|
||||
check(err is None, "--insecure connects")
|
||||
check("--insecure" in warns and "not verifying" in warns,
|
||||
"--insecure is loud about what it skipped")
|
||||
check_eq(socks[0].port, 443, "and still uses the TLS port")
|
||||
_, _, _, fake_p, warns_p = connect_with(plain=True)
|
||||
check(fake_p.contexts == [], "--plain builds no TLS context at all")
|
||||
check(warns_p == "", "--plain warns about nothing it did not skip")
|
||||
|
||||
# --plain is the only way to :80, and it is explicit every time.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True)
|
||||
check(err is None and socks[0].port == 80, "--plain reaches :80")
|
||||
frames = client_frame_payloads(socks[0])
|
||||
check(frames and frames[0] == b"Tsekrit",
|
||||
"the rollback session authenticates the same way")
|
||||
|
||||
# --- main(): the refusals that need no board -----------------------------------
|
||||
|
||||
|
||||
def run_main(argv, fp_env=None, token_env="hunter2secret"):
|
||||
"""Run main() with the transport faked: create_connection records the address
|
||||
instead of dialing, and ssl is the fake from above. Any credential that
|
||||
reaches the returned text is a test failure, so the caller never has to
|
||||
trust this helper to scrub it."""
|
||||
calls = []
|
||||
|
||||
def record_create(addr, timeout):
|
||||
calls.append(addr)
|
||||
return FakeBoardSock(addr[0], addr[1])
|
||||
|
||||
old_argv = sys.argv
|
||||
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
|
||||
sys.argv = ["relayctl.py"] + argv
|
||||
if fp_env is None:
|
||||
os.environ.pop("KEYRELAY_TLS_FP", None)
|
||||
else:
|
||||
os.environ["KEYRELAY_TLS_FP"] = fp_env
|
||||
if token_env is None:
|
||||
os.environ.pop("KEYRELAY_TOKEN", None)
|
||||
else:
|
||||
os.environ["KEYRELAY_TOKEN"] = token_env
|
||||
real_ssl_main = relayctl.ssl
|
||||
relayctl.ssl = FakeSsl()
|
||||
relayctl.socket.create_connection = record_create
|
||||
out, err_stream = io.StringIO(), io.StringIO()
|
||||
code, exc = None, None
|
||||
try:
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err_stream):
|
||||
code = relayctl.main()
|
||||
except BaseException as e: # noqa: BLE001 -- reported, then re-raised below
|
||||
exc = e
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
for name, value in zip(("KEYRELAY_TLS_FP", "KEYRELAY_TOKEN"), old_env):
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
relayctl.socket.create_connection = real_create_main
|
||||
relayctl.ssl = real_ssl_main
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return code, out.getvalue(), err_stream.getvalue(), calls
|
||||
|
||||
|
||||
real_create_main = socketmod.create_connection
|
||||
|
||||
code, out, err_text, calls = run_main(["--host", "keyrelay.local", "status"])
|
||||
check_eq(code, 2, "no trust argument: main refuses with a usage error")
|
||||
check(calls == [], "and refuses without opening a socket")
|
||||
for option in ("--fingerprint", "--ca", "--insecure", "--plain"):
|
||||
check(option in err_text, f"the refusal names {option}")
|
||||
check("hunter2secret" not in err_text and "hunter2secret" not in out,
|
||||
"the refusal prints no credential")
|
||||
|
||||
code, out, err_text, calls = run_main(["--fingerprint", "nothex", "status"])
|
||||
check_eq(code, 2, "a malformed pin is refused before the network too")
|
||||
check(calls == [] and "SHA-256" in err_text, "with the shape of what a pin must be")
|
||||
|
||||
# $KEYRELAY_TLS_FP is honoured like $KEYRELAY_TOKEN: the gate passes and the
|
||||
# socket that follows is the board's. The fake's DER does not match the pin,
|
||||
# so the run ends at the mismatch -- proof the pin is wired through main, and
|
||||
# the token (never echoed) stays in the terminal.
|
||||
code, out, err_text, calls = run_main(["--timeout", "0.1", "status"],
|
||||
fp_env="dead" * 16)
|
||||
check(calls == [("keyrelay.local", 443)],
|
||||
"$KEYRELAY_TLS_FP satisfied the gate and selected :443")
|
||||
check(code == 1 and "fingerprint mismatch" in err_text,
|
||||
"and a pin that does not match still aborts the run, through main()")
|
||||
check("hunter2secret" not in err_text and "hunter2secret" not in out,
|
||||
"the passphrase is not printed on the abort path")
|
||||
|
||||
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32, token_env=None)
|
||||
check_eq(code, 2, "the token pre-flight still runs before anything else")
|
||||
check("passphrase" in err_text and calls == [],
|
||||
"no token: refused in the terminal, exactly as before TLS")
|
||||
code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
|
||||
check_eq(code, 4, "paste still needs --yes before any connection")
|
||||
check(calls == [], "and that check still does not require a reachable board")
|
||||
|
||||
print("the target-side verifier")
|
||||
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
|
||||
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
// tests for the pure half of the TLS identity: src/tls_pem.h
|
||||
//
|
||||
// This suite includes tls_pem.h and nothing that drags in a crypto library. If it ever
|
||||
// includes src/tls.h the host build breaks -- which is the point: that is the rule that
|
||||
// keeps the identical code running here and on the board (AGENTS.md "Editing rules").
|
||||
//
|
||||
// What is under test is the part a handshake cannot diagnose. A certificate that fails
|
||||
// to parse says "malformed"; it does not say "your PEM had 63 characters on line two",
|
||||
// "your fingerprint was truncated to 63 hex digits" or "your SAN wrote the IP address as
|
||||
// text". Each check below is named after the failure it would catch.
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "sha1.h" // base64Encode: a second, independent base64 to cross-check against
|
||||
#include "test.h"
|
||||
#include "tls_pem.h"
|
||||
|
||||
using namespace kr;
|
||||
using namespace kr::tls; // the module under test; kr alone does not reach into it
|
||||
|
||||
namespace {
|
||||
|
||||
// A pattern that cannot appear in any output, so "writes nothing" is an observation
|
||||
// rather than an assumption.
|
||||
constexpr char kSentinel = char(0x5A);
|
||||
|
||||
void prime(char *buf, size_t cap) {
|
||||
memset(buf, kSentinel, cap);
|
||||
buf[cap - 1] = '\0';
|
||||
}
|
||||
|
||||
bool untouched(const char *buf, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (buf[i] != kSentinel) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string hexOf(const uint8_t *p, size_t len) {
|
||||
static const char kHex[] = "0123456789abcdef";
|
||||
std::string s;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
s += kHex[p[i] >> 4];
|
||||
s += kHex[p[i] & 0xF];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// The lines strictly between the BEGIN line and the END line. An armour block with no
|
||||
// body has none, and a helper that reported the END marker as a body line would quietly
|
||||
// turn the empty-input case into a passing test of nothing.
|
||||
std::vector<std::string> bodyLines(const char *pem) {
|
||||
std::vector<std::string> all;
|
||||
const std::string s(pem);
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
const size_t q = s.find('\n', start);
|
||||
if (q == std::string::npos) {
|
||||
all.push_back(s.substr(start));
|
||||
break;
|
||||
}
|
||||
all.push_back(s.substr(start, q - start));
|
||||
start = q + 1;
|
||||
}
|
||||
if (!all.empty() && all.back().empty()) all.pop_back(); // the final terminator
|
||||
if (all.size() <= 2) return std::vector<std::string>();
|
||||
return std::vector<std::string>(all.begin() + 1, all.end() - 1);
|
||||
}
|
||||
|
||||
// A DER-shaped blob: every byte value walks somewhere and it is not all zeros, so a
|
||||
// base64 bug that only shows up on particular bit patterns cannot hide here.
|
||||
void fillBlob(uint8_t *buf, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
buf[i] = static_cast<uint8_t>((i * 37 + 11) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
// The sizes that straddle the 64-character armour line: 48 DER bytes are exactly 64
|
||||
// base64 characters, so 45/46/47 land on the last partial quantum of a full line and
|
||||
// 49/50/51 spill onto the next one. 704 is the worst case the generator can hand over.
|
||||
const size_t kLineBoundarySizes[] = {0, 1, 2, 3, 4, 45, 46, 47, 48, 49,
|
||||
50, 51, 94, 95, 96, 97, 142, 143, 144, 145,
|
||||
146, 299, 300, 301, 400, 704};
|
||||
|
||||
std::string toCrlf(const char *src) {
|
||||
std::string out;
|
||||
for (const char *p = src; *p; p++) {
|
||||
if (*p == '\n') out += "\r\n";
|
||||
out += *p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
char pem[2048];
|
||||
uint8_t der[1024];
|
||||
uint8_t back[1024];
|
||||
|
||||
CASE("wrapPem: one known vector, byte for byte");
|
||||
{
|
||||
// RFC 4648's own example, "foobar" -> "Zm9vYmFy". If the alphabet table is off by
|
||||
// one character anywhere, this is the check that says so.
|
||||
const uint8_t in[] = {'f', 'o', 'o', 'b', 'a', 'r'};
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t n = wrapPem("CERTIFICATE", in, sizeof(in), pem, sizeof(pem));
|
||||
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n");
|
||||
CHECK_EQ(n, strlen(pem));
|
||||
CHECK_EQ(n, 63); // 28 head + 9 body (8 characters and their newline) + 26 tail
|
||||
|
||||
// An empty input is the two armour lines and nothing between them.
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t z = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
|
||||
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n");
|
||||
CHECK_EQ(z, strlen(pem));
|
||||
|
||||
// The label a private key is armoured under is a wire format, not decoration.
|
||||
prime(pem, sizeof(pem));
|
||||
wrapPem(kPemKeyLabel, in, sizeof(in), pem, sizeof(pem));
|
||||
CHECK_STR(pem,
|
||||
"-----BEGIN EC PRIVATE KEY-----\nZm9vYmFy\n-----END EC PRIVATE KEY-----\n");
|
||||
}
|
||||
|
||||
CASE("wrapPem: 64 characters a line, boundary by boundary");
|
||||
{
|
||||
for (size_t s = 0; s < sizeof(kLineBoundarySizes) / sizeof(kLineBoundarySizes[0]); s++) {
|
||||
const size_t len = kLineBoundarySizes[s];
|
||||
fillBlob(der, len);
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
|
||||
CHECK(n > 0);
|
||||
CHECK_EQ(n, strlen(pem)); // no NUL in the middle, nothing after the end
|
||||
|
||||
// Every body line is exactly 64 characters except possibly the last, and a body
|
||||
// exists exactly when there are bytes to carry.
|
||||
const std::vector<std::string> lines = bodyLines(pem);
|
||||
const size_t b64Len = ((len + 2) / 3) * 4;
|
||||
const size_t wantLines = b64Len == 0 ? 0 : (b64Len + 63) / 64;
|
||||
CHECK_EQ(lines.size(), wantLines);
|
||||
for (size_t i = 0; i < lines.size(); i++) {
|
||||
const bool lastLine = i + 1 == lines.size();
|
||||
CHECK(lines[i].size() <= 64);
|
||||
CHECK(lines[i].size() >= 1);
|
||||
if (!lastLine) CHECK_EQ(lines[i].size(), 64);
|
||||
}
|
||||
|
||||
// Cross-checked against sha1.h's base64, which the WebSocket handshake already
|
||||
// trusts: two implementations written for different purposes that agree at every
|
||||
// length cannot both be wrong about the alphabet.
|
||||
char ref[1400];
|
||||
const size_t refLen = base64Encode(der, len, ref, sizeof(ref));
|
||||
CHECK_EQ(refLen, b64Len);
|
||||
std::string stripped;
|
||||
for (const std::string &l : lines) stripped += l;
|
||||
CHECK(stripped == std::string(ref, refLen));
|
||||
|
||||
// And back again, unchanged.
|
||||
size_t got = 0;
|
||||
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
|
||||
CHECK_EQ(got, len);
|
||||
CHECK(len == 0 || memcmp(der, back, len) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
CASE("wrapPem: a padded final group on a line of its own still gets its newline");
|
||||
{
|
||||
// 1, 2 and everything 1 or 2 mod 48: the base64 body ends with a padded group that
|
||||
// starts a fresh line, because 48 DER bytes fill exactly 64 characters. A writer that
|
||||
// counts only whole groups leaves that line unterminated, and the END marker glues
|
||||
// itself onto the body -- pemFind still decodes it, so the round trip passes and the
|
||||
// block is nonetheless not what any other tool on earth writes. This was a real bug
|
||||
// here; the independent-base64 cross-check above is what caught it.
|
||||
const size_t paddedGroupSizes[] = {1, 2, 49, 50, 97, 98, 145, 146};
|
||||
for (size_t s = 0; s < sizeof(paddedGroupSizes) / sizeof(paddedGroupSizes[0]); s++) {
|
||||
const size_t len = paddedGroupSizes[s];
|
||||
fillBlob(der, len);
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
|
||||
CHECK(n > 0);
|
||||
const char *endMark = strstr(pem, "-----END");
|
||||
CHECK(endMark != nullptr);
|
||||
CHECK(endMark > pem + 1);
|
||||
CHECK_EQ(endMark[-1], '\n'); // the body's last line is terminated
|
||||
CHECK_EQ(endMark[-2], '='); // ...and it really was the padded group
|
||||
// The body is exactly ceil(b64/64) lines, the last one 4 characters long.
|
||||
const std::vector<std::string> lines = bodyLines(pem);
|
||||
const size_t b64Len = ((len + 2) / 3) * 4;
|
||||
CHECK_EQ(lines.size(), (b64Len + 63) / 64);
|
||||
CHECK_EQ(lines.back().size(), 4);
|
||||
CHECK_EQ(lines.back()[3], '=');
|
||||
}
|
||||
}
|
||||
|
||||
CASE("wrapPem: refuses a buffer one byte short, and writes nothing at all");
|
||||
{
|
||||
fillBlob(der, 100);
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t exact = wrapPem("CERTIFICATE", der, 100, pem, sizeof(pem));
|
||||
CHECK_EQ(exact, strlen(pem));
|
||||
|
||||
// A buffer the size of the text: the NUL is the byte that does not fit, and a
|
||||
// caller who counted characters and not the terminator is the bug this catches.
|
||||
prime(pem, sizeof(pem));
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact), 0);
|
||||
CHECK(untouched(pem, exact));
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact + 1), exact);
|
||||
|
||||
// Same for the empty-body case, which has a size of its own.
|
||||
prime(pem, sizeof(pem));
|
||||
const size_t emptyNeed = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
|
||||
prime(pem, sizeof(pem));
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed), 0);
|
||||
CHECK(untouched(pem, emptyNeed));
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed + 1), emptyNeed);
|
||||
}
|
||||
|
||||
CASE("wrapPem: refuses input it cannot name");
|
||||
{
|
||||
fillBlob(der, 8);
|
||||
prime(pem, sizeof(pem));
|
||||
CHECK_EQ(wrapPem(nullptr, der, 8, pem, sizeof(pem)), 0);
|
||||
CHECK_EQ(wrapPem("", der, 8, pem, sizeof(pem)), 0);
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 8, pem, sizeof(pem)), 0); // bytes, no bytes
|
||||
CHECK_EQ(wrapPem("CERTIFICATE", der, 8, nullptr, sizeof(pem)), 0);
|
||||
|
||||
// A label over the bound is a caller bug; exactly at the bound still works, so the
|
||||
// refusal is about the bound and not about caution.
|
||||
char longLabel[kPemLabelMax + 2];
|
||||
memset(longLabel, 'X', sizeof(longLabel) - 1);
|
||||
longLabel[sizeof(longLabel) - 1] = '\0';
|
||||
CHECK_EQ(wrapPem(longLabel, der, 8, pem, sizeof(pem)), 0);
|
||||
longLabel[kPemLabelMax - 1] = '\0';
|
||||
CHECK(wrapPem(longLabel, der, 8, pem, sizeof(pem)) > 0);
|
||||
}
|
||||
|
||||
CASE("pemFind: reads back what a real tool wrote");
|
||||
{
|
||||
fillBlob(der, 47);
|
||||
prime(pem, sizeof(pem));
|
||||
CHECK(wrapPem("CERTIFICATE", der, 47, pem, sizeof(pem)) > 0);
|
||||
|
||||
size_t got = 0;
|
||||
|
||||
// LF, as wrapPem writes it.
|
||||
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 47);
|
||||
CHECK(memcmp(der, back, 47) == 0);
|
||||
|
||||
// CRLF: whatever passed through a Windows editor, and the shape a copy-pasted
|
||||
// certificate usually arrives in.
|
||||
const std::string crlf = toCrlf(pem);
|
||||
CHECK(pemFind("CERTIFICATE", crlf.c_str(), back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 47);
|
||||
CHECK(memcmp(der, back, 47) == 0);
|
||||
|
||||
// No trailing newline after the END marker.
|
||||
std::string trimmed(pem, strlen(pem) - 1);
|
||||
CHECK(pemFind("CERTIFICATE", trimmed.c_str(), back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 47);
|
||||
|
||||
// Spaces and tabs inside the body: "AAAA" is three zero bytes, "BBBB" is 04 10 41.
|
||||
const std::string spaced =
|
||||
"-----BEGIN CERTIFICATE-----\nAAAA \t\nBBBB\n-----END CERTIFICATE-----\n";
|
||||
CHECK(pemFind("CERTIFICATE", spaced.c_str(), back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 6);
|
||||
CHECK(memcmp(back, "\x00\x00\x00\x04\x10\x41", 6) == 0);
|
||||
|
||||
// An empty body is zero bytes -- not a failure, and not garbage.
|
||||
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 0);
|
||||
|
||||
// Two blocks in one haystack: the first of that label wins.
|
||||
fillBlob(der, 3);
|
||||
char second[256];
|
||||
wrapPem("CERTIFICATE", der, 3, second, sizeof(second));
|
||||
const std::string both = std::string(pem) + second;
|
||||
CHECK(pemFind("CERTIFICATE", both.c_str(), back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 47);
|
||||
// ...and a block stored under another label is not this one.
|
||||
CHECK(!pemFind("EC PRIVATE KEY", both.c_str(), back, sizeof(back), &got));
|
||||
|
||||
// The label matches exactly: a request for CERTIFICATE must not pick up a
|
||||
// CERTIFICATE REQUEST, or a key would be found where a certificate belongs.
|
||||
CHECK(!pemFind("CERTIFICATE",
|
||||
"-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n",
|
||||
back, sizeof(back), &got));
|
||||
}
|
||||
|
||||
CASE("pemFind: rejects a malformed block instead of decoding almost the right thing");
|
||||
{
|
||||
size_t got = 99;
|
||||
const char *good = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n";
|
||||
|
||||
CHECK(!pemFind("CERTIFICATE", "", back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", "not a pem at all", back, sizeof(back), &got));
|
||||
// BEGIN without END: a record torn by power loss in the middle of the write.
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA\n", back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", "-----END CERTIFICATE-----\n", back, sizeof(back), &got));
|
||||
// One character torn out of the body, so the length is no longer a multiple of four.
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
// A character from the armour inside the body -- what a block pasted inside itself
|
||||
// looks like, and what a buffer that held two writes looks like after a realloc.
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA-A\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA<>\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
// Padding is only legal in the last two positions of the last group.
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nA=AA\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n=AAA\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE",
|
||||
"-----BEGIN CERTIFICATE-----\nAAAA=A\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA====AAAA\n"
|
||||
"-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
|
||||
// Legal padding, for contrast: "QQ==" is one byte, "QUI=" is two.
|
||||
// "QQ==" is "A" and "QUI=" is "AB" -- RFC 4648's own padding examples.
|
||||
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQQ==\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 1);
|
||||
CHECK_EQ(back[0], 'A');
|
||||
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQUI=\n-----END CERTIFICATE-----\n",
|
||||
back, sizeof(back), &got));
|
||||
CHECK_EQ(got, 2);
|
||||
CHECK_EQ(back[0], 'A');
|
||||
CHECK_EQ(back[1], 'B');
|
||||
|
||||
// Does not fit: a refusal with nothing written, never a truncated decode.
|
||||
prime(reinterpret_cast<char *>(back), 8);
|
||||
CHECK(!pemFind("CERTIFICATE", good, back, 2, &got));
|
||||
CHECK(untouched(reinterpret_cast<const char *>(back), 7));
|
||||
CHECK(pemFind("CERTIFICATE", good, back, 3, &got));
|
||||
CHECK_EQ(got, 3);
|
||||
|
||||
// Degenerate arguments.
|
||||
CHECK(!pemFind(nullptr, good, back, sizeof(back), &got));
|
||||
CHECK(!pemFind("", good, back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", nullptr, back, sizeof(back), &got));
|
||||
CHECK(!pemFind("CERTIFICATE", good, nullptr, sizeof(back), &got));
|
||||
char longLabel[kPemLabelMax + 2];
|
||||
memset(longLabel, 'X', sizeof(longLabel) - 1);
|
||||
longLabel[sizeof(longLabel) - 1] = '\0';
|
||||
CHECK(!pemFind(longLabel, good, back, sizeof(back), &got));
|
||||
|
||||
// A caller that does not want the length still gets the right bytes.
|
||||
CHECK(pemFind("CERTIFICATE", good, back, sizeof(back)));
|
||||
CHECK_EQ(back[0], 0);
|
||||
}
|
||||
|
||||
CASE("fingerprintHex: the published SHA-256 of \"abc\"");
|
||||
{
|
||||
// FIPS 180-4. The digest arrives as bytes -- the board hashes with the platform
|
||||
// library -- so what is under test is the rendering the operator reads, which is
|
||||
// where a fingerprint goes wrong in practice: one nibble dropped, and the pinned
|
||||
// value never matches while every handshake succeeds.
|
||||
const uint8_t abc[32] = {
|
||||
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40,
|
||||
0xde, 0x5d, 0xae, 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17,
|
||||
0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00, 0x15, 0xad};
|
||||
char hex[128];
|
||||
|
||||
prime(hex, sizeof(hex));
|
||||
CHECK(fingerprintHex(abc, sizeof(abc), hex, 65));
|
||||
CHECK_STR(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
CHECK_EQ(strlen(hex), 64);
|
||||
CHECK_EQ(kFingerprintCap, 65);
|
||||
CHECK_EQ(fingerprintHexCap(32), 65);
|
||||
|
||||
// Exactly one byte short: no output, not a 63-character fingerprint.
|
||||
prime(hex, sizeof(hex));
|
||||
CHECK(!fingerprintHex(abc, sizeof(abc), hex, 64));
|
||||
CHECK(untouched(hex, 64));
|
||||
|
||||
// Lowercase always: the operator copies this out of `openssl x509 -fingerprint`.
|
||||
const uint8_t high[1] = {0xFF};
|
||||
CHECK(fingerprintHex(high, 1, hex, sizeof(hex)));
|
||||
CHECK_STR(hex, "ff");
|
||||
|
||||
// A zero-length digest is an empty string, and it still needs room for the NUL.
|
||||
CHECK(fingerprintHex(high, 0, hex, 1));
|
||||
CHECK_STR(hex, "");
|
||||
prime(hex, sizeof(hex));
|
||||
CHECK(!fingerprintHex(high, 0, hex, 0));
|
||||
|
||||
CHECK(!fingerprintHex(nullptr, 32, hex, sizeof(hex)));
|
||||
CHECK(!fingerprintHex(abc, 32, nullptr, sizeof(hex)));
|
||||
}
|
||||
|
||||
CASE("the names in the certificate are the names in config.h");
|
||||
{
|
||||
// kCertCommonName is the string the operator types into a browser. If it ever stops
|
||||
// being kHostname + ".local", the certificate and the mDNS name diverge and every
|
||||
// connection starts with a warning -- so this pins the config value, not just its
|
||||
// formatting.
|
||||
const std::string derived = std::string(kHostname) + ".local";
|
||||
CHECK_STR(kCertCommonName, derived.c_str());
|
||||
|
||||
char subject[64];
|
||||
prime(subject, sizeof(subject));
|
||||
const size_t n = subjectLine(subject, sizeof(subject));
|
||||
CHECK_STR(subject, "CN=keyrelay.local");
|
||||
CHECK_EQ(n, strlen(subject));
|
||||
CHECK_EQ(n, 17);
|
||||
// No comma: the certificate writer parses this as a comma-separated name list.
|
||||
CHECK(strchr(subject, ',') == nullptr);
|
||||
|
||||
prime(subject, sizeof(subject));
|
||||
CHECK_EQ(subjectLine(subject, n), 0);
|
||||
CHECK(untouched(subject, n));
|
||||
CHECK_EQ(subjectLine(nullptr, sizeof(subject)), 0);
|
||||
|
||||
char san[128];
|
||||
prime(san, sizeof(san));
|
||||
const size_t s = sanList(san, sizeof(san));
|
||||
CHECK_STR(san, "DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1");
|
||||
CHECK_EQ(s, strlen(san));
|
||||
|
||||
// Every name the README tells the operator to expect, taken from config.h rather
|
||||
// than restated here.
|
||||
CHECK(strstr(san, kCertCommonName) != nullptr);
|
||||
CHECK(strstr(san, kHostname) != nullptr);
|
||||
CHECK(strstr(san, kApGw) != nullptr);
|
||||
// The subject and the list agree on the common name, because a human is shown one
|
||||
// and the browser checks the other.
|
||||
CHECK(strstr(san, kCertCommonName) == san + strlen("DNS:"));
|
||||
|
||||
prime(san, sizeof(san));
|
||||
CHECK_EQ(sanList(san, s), 0);
|
||||
CHECK(untouched(san, s));
|
||||
CHECK_EQ(sanList(san, 0), 0);
|
||||
CHECK_EQ(sanList(nullptr, sizeof(san)), 0);
|
||||
|
||||
size_t count = 0;
|
||||
const SanEntry *entries = sanEntries(&count);
|
||||
CHECK_EQ(count, 3);
|
||||
CHECK(entries[0].type == SanType::Dns);
|
||||
CHECK_STR(entries[0].value, kCertCommonName);
|
||||
CHECK(entries[1].type == SanType::Dns);
|
||||
CHECK_STR(entries[1].value, kHostname);
|
||||
CHECK(entries[2].type == SanType::Ip);
|
||||
CHECK_STR(entries[2].value, kApGw);
|
||||
CHECK(sanEntries(nullptr) == entries); // one table, whoever asks
|
||||
CHECK_STR(sanTypeName(SanType::Dns), "DNS:");
|
||||
CHECK_STR(sanTypeName(SanType::Ip), "IP:");
|
||||
}
|
||||
|
||||
CASE("kPemMax holds the largest block the generator can hand it");
|
||||
{
|
||||
// config.h sizes kPemMax from a measured certificate of ~430 DER bytes armoured to
|
||||
// ~603; the DER scratch in tls.cpp is 704 bytes, so 704 is the worst case anything
|
||||
// can ask to be armoured. wrapPem refuses rather than truncates, so if kPemMax ever
|
||||
// falls below that, the board fails to get an identity at boot -- loudly, which is
|
||||
// the point, but only if a test says so first.
|
||||
fillBlob(der, 704);
|
||||
const size_t n = wrapPem(kPemCertLabel, der, 704, pem, kPemMax);
|
||||
CHECK(n > 0);
|
||||
CHECK(n == strlen(pem));
|
||||
CHECK(n < kPemMax);
|
||||
CHECK_EQ(kPemMax, 1024); // the number in config.h, pinned so the headroom is a fact
|
||||
}
|
||||
|
||||
CASE("sanExtensionDer: the GeneralNames a verifier actually parses");
|
||||
{
|
||||
uint8_t out[64];
|
||||
const size_t n = sanExtensionDer(out, sizeof(out));
|
||||
const std::string got = hexOf(out, n);
|
||||
// SEQUENCE(32) { [2] "keyrelay.local", [2] "keyrelay", [7] C0 A8 04 01 }
|
||||
CHECK_EQ(n, 34);
|
||||
CHECK_STR(got.c_str(),
|
||||
"3020820e6b657972656c61792e6c6f63616c82086b657972656c61798704c0a80401");
|
||||
// Cross-checked, not asserted: a certificate signed with
|
||||
// openssl req -x509 -key <P-256 key> -subj /CN=keyrelay.local
|
||||
// -sha256 -addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"
|
||||
// contains these exact 34 bytes, and `openssl asn1parse -inform DER` reads them back
|
||||
// as SEQUENCE { cont[2] 14, cont[2] 8, cont[7] 4 }. This module's encoder and
|
||||
// OpenSSL's agree byte for byte, which is what turns "the browser will accept it"
|
||||
// into a measured claim instead of a hope.
|
||||
|
||||
CHECK_EQ(sanExtensionDer(out, n - 1), 0);
|
||||
CHECK_EQ(sanExtensionDer(out, 1), 0);
|
||||
CHECK_EQ(sanExtensionDer(nullptr, sizeof(out)), 0);
|
||||
CHECK_EQ(sanExtensionDer(out, n), n); // an exact fit is a fit
|
||||
}
|
||||
|
||||
CASE("kSanOidTlv: the extension's label, as DER content bytes and not as dotted text");
|
||||
{
|
||||
// RFC 5280 4.2.1.6: subjectAltName is 2.5.29.17, whose OBJECT IDENTIFIER is
|
||||
// 06 03 55 1D 11 -- first arc 40*2 + 5 = 0x55, then 29 and 17. These five bytes are
|
||||
// what makes the 34 above a SAN; without them the certificate carries a name list no
|
||||
// client will read, and nothing on the board says so.
|
||||
CHECK_EQ(kSanOidTlvLen, 5);
|
||||
CHECK_EQ(kSanOidTlv[0], 0x06); // UNIVERSAL 6, OBJECT IDENTIFIER
|
||||
CHECK_EQ(kSanOidTlv[1], 0x03); // three content bytes
|
||||
CHECK_EQ(kSanOidTlv[2], 0x55); // 2.5
|
||||
CHECK_EQ(kSanOidTlv[3], 0x1D); // .29
|
||||
CHECK_EQ(kSanOidTlv[4], 0x11); // .17
|
||||
|
||||
size_t n = 0;
|
||||
const uint8_t *oid = sanOidDer(&n);
|
||||
CHECK_EQ(n, 3);
|
||||
CHECK_EQ(oid[0], 0x55);
|
||||
CHECK_EQ(oid[1], 0x1D);
|
||||
CHECK_EQ(oid[2], 0x11);
|
||||
CHECK_EQ(sanOidDer(nullptr), oid); // the count is optional, the bytes are not
|
||||
|
||||
// The failure this pins is the one that actually happened: the dotted string handed to
|
||||
// the writer as if it were the encoding. Nine ASCII bytes are a legal OID --
|
||||
// 1.10.46.53.46.50.57.46.49.55, as `openssl x509 -text` printed on a real board -- so
|
||||
// the library accepts them, the certificate signs, the handshake completes and only the
|
||||
// hostname check disagrees, in a sentence about the name. No byte of the text form may
|
||||
// appear in what this module hands over.
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
CHECK(oid[i] != '.' && !(oid[i] >= '0' && oid[i] <= '9'));
|
||||
}
|
||||
CHECK(!derContains(kSanOidTlv, kSanOidTlvLen, reinterpret_cast<const uint8_t *>("2.5"), 3));
|
||||
// And the cross-check, the same way the 34 value bytes were: OpenSSL 4.0.3 writes
|
||||
// 30 29 06 03 55 1d 11 04 22 <the 34 bytes>
|
||||
// for `-addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"`, which
|
||||
// is the sanExtensionFullDer() case below, byte for byte.
|
||||
}
|
||||
|
||||
CASE("ASN.1 lengths: short form, long form, never either by accident");
|
||||
{
|
||||
CHECK_EQ(asn1LengthBytes(0), 1);
|
||||
CHECK_EQ(asn1LengthBytes(127), 1);
|
||||
CHECK_EQ(asn1LengthBytes(128), 2);
|
||||
CHECK_EQ(asn1LengthBytes(255), 2);
|
||||
CHECK_EQ(asn1LengthBytes(256), 3);
|
||||
CHECK_EQ(asn1LengthBytes(65535), 3);
|
||||
CHECK_EQ(asn1LengthBytes(65536), 4);
|
||||
|
||||
uint8_t len[8];
|
||||
CHECK_EQ(asn1WriteLength(0, len), 1);
|
||||
CHECK_EQ(len[0], 0x00);
|
||||
CHECK_EQ(asn1WriteLength(127, len), 1);
|
||||
CHECK_EQ(len[0], 0x7F);
|
||||
CHECK_EQ(asn1WriteLength(128, len), 2);
|
||||
CHECK_EQ(len[0], 0x81);
|
||||
CHECK_EQ(len[1], 0x80);
|
||||
CHECK_EQ(asn1WriteLength(300, len), 3); // 0x82 + two value bytes
|
||||
CHECK_EQ(len[0], 0x82);
|
||||
CHECK_EQ(len[1], 0x01);
|
||||
CHECK_EQ(len[2], 0x2C); // 300 == 0x012C
|
||||
CHECK_EQ(asn1WriteLength(65536, len), 4);
|
||||
CHECK_EQ(len[0], 0x83);
|
||||
CHECK_EQ(len[1], 0x01);
|
||||
CHECK_EQ(len[2], 0x00);
|
||||
CHECK_EQ(len[3], 0x00);
|
||||
}
|
||||
|
||||
CASE("ipv4Bytes: a dotted quad or nothing");
|
||||
{
|
||||
uint8_t ip[4];
|
||||
CHECK(ipv4Bytes("192.168.4.1", ip));
|
||||
CHECK_EQ(ip[0], 192);
|
||||
CHECK_EQ(ip[1], 168);
|
||||
CHECK_EQ(ip[2], 4);
|
||||
CHECK_EQ(ip[3], 1);
|
||||
CHECK(ipv4Bytes("0.0.0.0", ip));
|
||||
CHECK(ipv4Bytes("255.255.255.255", ip));
|
||||
|
||||
CHECK(!ipv4Bytes("256.1.1.1", ip));
|
||||
CHECK(!ipv4Bytes("1.2.3.256", ip));
|
||||
CHECK(!ipv4Bytes("1.2.3", ip)); // an address a resolver would silently expand
|
||||
CHECK(!ipv4Bytes("1.2.3.", ip));
|
||||
CHECK(!ipv4Bytes(".1.2.3", ip));
|
||||
CHECK(!ipv4Bytes("1.2.3.4.5", ip));
|
||||
CHECK(!ipv4Bytes("1.2.3.4 ", ip));
|
||||
CHECK(!ipv4Bytes(" 1.2.3.4", ip));
|
||||
CHECK(!ipv4Bytes("a.b.c.d", ip));
|
||||
CHECK(!ipv4Bytes("", ip));
|
||||
CHECK(!ipv4Bytes("1.2.3.999", ip));
|
||||
CHECK(!ipv4Bytes(nullptr, ip));
|
||||
CHECK(!ipv4Bytes("1.2.3.4", nullptr));
|
||||
// The address the certificate actually carries has to parse, or nothing else here
|
||||
// matters: this is the one value that goes on the wire.
|
||||
CHECK(ipv4Bytes(kApGw, ip));
|
||||
}
|
||||
|
||||
CASE("build date: __DATE__ parsed with the field widths it really has");
|
||||
{
|
||||
int y = 0;
|
||||
int mo = 0;
|
||||
int d = 0;
|
||||
// Single-digit days are space padded to two characters: the classic mis-parse.
|
||||
CHECK(parseBuildDate("Nov 8 2026", y, mo, d));
|
||||
CHECK_EQ(y, 2026);
|
||||
CHECK_EQ(mo, 11);
|
||||
CHECK_EQ(d, 8);
|
||||
CHECK(parseBuildDate("Jan 1 1970", y, mo, d));
|
||||
CHECK_EQ(y, 1970);
|
||||
CHECK_EQ(mo, 1);
|
||||
CHECK_EQ(d, 1);
|
||||
CHECK(parseBuildDate("Dec 31 2049", y, mo, d));
|
||||
CHECK_EQ(y, 2049);
|
||||
CHECK_EQ(mo, 12);
|
||||
CHECK_EQ(d, 31);
|
||||
CHECK(parseBuildDate("Feb 29 2028", y, mo, d)); // a leap day is a legal build date
|
||||
CHECK_EQ(d, 29);
|
||||
|
||||
CHECK(!parseBuildDate("Nuv 8 2026", y, mo, d)); // month letters transposed
|
||||
CHECK(!parseBuildDate("Foo 8 2026", y, mo, d));
|
||||
CHECK(!parseBuildDate("Nov 8 26", y, mo, d)); // two-digit year
|
||||
CHECK(!parseBuildDate("Nov 8 20261", y, mo, d)); // trailing junk
|
||||
CHECK(!parseBuildDate("Nov 0 2026", y, mo, d));
|
||||
CHECK(!parseBuildDate("Nov 32 2026", y, mo, d));
|
||||
CHECK(!parseBuildDate("Nov", y, mo, d));
|
||||
CHECK(!parseBuildDate("", y, mo, d));
|
||||
CHECK(!parseBuildDate(nullptr, y, mo, d));
|
||||
|
||||
// The build this binary came from has to parse, or there is no identity at all.
|
||||
CHECK(parseBuildDate(__DATE__, y, mo, d));
|
||||
// And the window still has to fit UTCTime (1950..2049). This is a real, dated
|
||||
// expiry: a build from 2040 onwards cannot issue a ten-year UTCTime certificate and
|
||||
// would fail at boot with a build-date error. Better a red test in 2039 than a board
|
||||
// that refuses to come up in 2040.
|
||||
CHECK(y + kCertValidityYears <= 2049);
|
||||
CHECK(y >= 2020);
|
||||
CHECK_EQ(kCertValidityYears, 10);
|
||||
}
|
||||
|
||||
CASE("calendar arithmetic: month ends, year ends, February");
|
||||
{
|
||||
CHECK(leapYear(2028));
|
||||
CHECK(!leapYear(2026));
|
||||
CHECK(!leapYear(1900));
|
||||
CHECK(leapYear(2000));
|
||||
CHECK_EQ(daysInMonth(2028, 2), 29);
|
||||
CHECK_EQ(daysInMonth(2026, 2), 28);
|
||||
CHECK_EQ(daysInMonth(2026, 1), 31);
|
||||
CHECK_EQ(daysInMonth(2026, 4), 30);
|
||||
CHECK_EQ(daysInMonth(2026, 0), 0);
|
||||
CHECK_EQ(daysInMonth(2026, 13), 0);
|
||||
|
||||
int y, mo, d;
|
||||
y = 2026; mo = 1; d = 1;
|
||||
addDays(y, mo, d, -1);
|
||||
CHECK_EQ(y, 2025); CHECK_EQ(mo, 12); CHECK_EQ(d, 31); // back over a year end
|
||||
|
||||
y = 2028; mo = 3; d = 1;
|
||||
addDays(y, mo, d, -1);
|
||||
CHECK_EQ(y, 2028); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // back into a leap day
|
||||
|
||||
y = 2026; mo = 12; d = 31;
|
||||
addDays(y, mo, d, 1);
|
||||
CHECK_EQ(y, 2027); CHECK_EQ(mo, 1); CHECK_EQ(d, 1);
|
||||
|
||||
y = 2026; mo = 1; d = 31;
|
||||
addDays(y, mo, d, 1);
|
||||
CHECK_EQ(y, 2026); CHECK_EQ(mo, 2); CHECK_EQ(d, 1);
|
||||
|
||||
y = 2026; mo = 6; d = 15;
|
||||
addDays(y, mo, d, 0);
|
||||
CHECK_EQ(y, 2026); CHECK_EQ(mo, 6); CHECK_EQ(d, 15);
|
||||
|
||||
// Ten years from a leap day lands on the last day that exists, never on a date the
|
||||
// certificate writer would reject at build time.
|
||||
y = 2028; mo = 2; d = 29;
|
||||
addYears(y, mo, d, 1);
|
||||
CHECK_EQ(y, 2029); CHECK_EQ(mo, 2); CHECK_EQ(d, 28);
|
||||
|
||||
y = 2028; mo = 2; d = 29;
|
||||
addYears(y, mo, d, 4);
|
||||
CHECK_EQ(y, 2032); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // 2032 is a leap year
|
||||
|
||||
y = 2026; mo = 1; d = 31;
|
||||
addYears(y, mo, d, kCertValidityYears);
|
||||
CHECK_EQ(y, 2036); CHECK_EQ(mo, 1); CHECK_EQ(d, 31);
|
||||
|
||||
y = 2026; mo = 11; d = 8;
|
||||
addYears(y, mo, d, 0);
|
||||
CHECK_EQ(y, 2026); CHECK_EQ(mo, 11); CHECK_EQ(d, 8);
|
||||
}
|
||||
|
||||
CASE("utcTime: the string the certificate writer parses, or a refusal");
|
||||
{
|
||||
char t[32];
|
||||
prime(t, sizeof(t));
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 14);
|
||||
CHECK_STR(t, "20261108000000");
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2049, 12, 31, 23, 59, 59), 14);
|
||||
CHECK_STR(t, "20491231235959");
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 1950, 1, 1, 0, 0, 0), 14);
|
||||
CHECK_STR(t, "19500101000000");
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2028, 2, 29, 12, 34, 56), 14);
|
||||
CHECK_STR(t, "20280229123456");
|
||||
|
||||
// Exactly enough room, and one byte short of it.
|
||||
CHECK_EQ(utcTime(t, 15, 2026, 11, 8, 0, 0, 0), 14);
|
||||
prime(t, sizeof(t));
|
||||
CHECK_EQ(utcTime(t, 14, 2026, 11, 8, 0, 0, 0), 0);
|
||||
CHECK(untouched(t, 14));
|
||||
|
||||
// Outside UTCTime's window: GeneralizedTime is a different encoding, and quietly
|
||||
// emitting one is how every handshake starts failing in 2050.
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 0);
|
||||
// Components out of range, including the date that does not exist.
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 0, 0, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0); // 2026 is not a leap year
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 61), 0);
|
||||
CHECK_EQ(utcTime(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
|
||||
// :60 is the leap-second slot RFC 5280 allows; accepting it is not a bug.
|
||||
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 14);
|
||||
}
|
||||
|
||||
CASE("the boot path's own sequence, with a blob where the key would be");
|
||||
{
|
||||
// What task 02 does at boot: produce a certificate DER, armour it, store the text,
|
||||
// read it back out of storage, hand the bytes to the library. Every step here is the
|
||||
// real code; only key generation is replaced by a blob, because the host has no
|
||||
// crypto library and must not gain one.
|
||||
fillBlob(der, 430);
|
||||
const size_t n = wrapPem(kPemCertLabel, der, 430, pem, kPemMax);
|
||||
CHECK(n > 0);
|
||||
CHECK(n == strlen(pem));
|
||||
// The NVS record is read back as a C string: the armour must carry no NUL of its own
|
||||
// and must end with the newline the writer was asked to leave in.
|
||||
CHECK(pem[n] == '\0');
|
||||
CHECK(pem[n - 1] == '\n');
|
||||
|
||||
uint8_t parsed[1024];
|
||||
size_t parsedLen = 0;
|
||||
CHECK(pemFind(kPemCertLabel, pem, parsed, sizeof(parsed), &parsedLen));
|
||||
CHECK_EQ(parsedLen, 430);
|
||||
CHECK(memcmp(parsed, der, 430) == 0);
|
||||
|
||||
// A record whose tail was lost: refused, not decoded into something shorter.
|
||||
const std::string torn(pem, n - 30);
|
||||
CHECK(!pemFind(kPemCertLabel, torn.c_str(), parsed, sizeof(parsed), &parsedLen));
|
||||
|
||||
// The private key travels through the same two functions under its own label, and a
|
||||
// reader looking for the certificate must not find it.
|
||||
fillBlob(der, 121);
|
||||
char keyPem[512];
|
||||
CHECK(wrapPem(kPemKeyLabel, der, 121, keyPem, sizeof(keyPem)) > 0);
|
||||
CHECK(!pemFind(kPemCertLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
|
||||
CHECK(pemFind(kPemKeyLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
|
||||
CHECK_EQ(parsedLen, 121);
|
||||
CHECK(memcmp(parsed, der, 121) == 0);
|
||||
}
|
||||
|
||||
CASE("fingerprintPrefix: the status frame's label, or a refusal");
|
||||
{
|
||||
// The status frame carries the leading bytes of the fingerprint so the page can show the
|
||||
// same label `cert` prints. Half a stored fingerprint must not turn into a shorter label
|
||||
// that still looks like a label: the whole job of the value is "same board as before".
|
||||
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
char out[80]; // a whole 64-character fingerprint plus the NUL
|
||||
|
||||
prime(out, sizeof(out));
|
||||
CHECK(fingerprintPrefix(fp, 16, out, sizeof(out)));
|
||||
CHECK_STR(out, "ba7816bf8f01cfea");
|
||||
CHECK(fingerprintPrefix(fp, 64, out, sizeof(out)));
|
||||
CHECK_STR(out, fp);
|
||||
|
||||
// Room is exact -- 16 characters need 17 bytes -- and one byte short writes nothing
|
||||
// rather than a 15-character label somebody believes.
|
||||
CHECK(fingerprintPrefix(fp, 16, out, 17));
|
||||
prime(out, sizeof(out));
|
||||
CHECK(!fingerprintPrefix(fp, 16, out, 16));
|
||||
CHECK(untouched(out, 16));
|
||||
|
||||
// Not hex, or too short for the prefix asked for: a torn NVS record is a refusal, never
|
||||
// a prefix of garbage. Upper case is still hex -- refusing it would make a pin somebody
|
||||
// typed in upper case look like a different board.
|
||||
CHECK(!fingerprintPrefix("ba78zzzzzzzzzzzzzz", 16, out, sizeof(out)));
|
||||
CHECK(!fingerprintPrefix("ba78", 16, out, sizeof(out)));
|
||||
CHECK(!fingerprintPrefix("", 16, out, sizeof(out)));
|
||||
CHECK(fingerprintPrefix("BA7816BF8F01CFEA", 16, out, sizeof(out)));
|
||||
CHECK_STR(out, "BA7816BF8F01CFEA");
|
||||
CHECK(!fingerprintPrefix(nullptr, 16, out, sizeof(out)));
|
||||
CHECK(!fingerprintPrefix(fp, 16, nullptr, sizeof(out)));
|
||||
// A zero-length prefix is a legitimate empty string, and still needs room for the NUL.
|
||||
CHECK(fingerprintPrefix(fp, 0, out, 1));
|
||||
CHECK_STR(out, "");
|
||||
}
|
||||
|
||||
CASE("fingerprintPairs: two lines the CDC transmit ring cannot swallow");
|
||||
{
|
||||
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
char line[128];
|
||||
|
||||
prime(line, sizeof(line));
|
||||
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, sizeof(line)), 47);
|
||||
CHECK_STR(line, "ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23");
|
||||
CHECK_EQ(fingerprintPairs(fp, 16, 16, line, sizeof(line)), 47);
|
||||
CHECK_STR(line, "b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad");
|
||||
|
||||
// The two lines the console prints are 47 characters each, which with the 8-space indent
|
||||
// stays under the 128-byte TinyUSB CDC transmit ring. That is the entire reason the print
|
||||
// is wrapped: an unwrapped 95-character line with the global transmit timeout at 0 is
|
||||
// dropped mid-value, which is README bug 1 wearing a fingerprint.
|
||||
CHECK(47 + 8 < 128);
|
||||
|
||||
// Nothing is lost on the way: take the colons out of both lines and it is the digest.
|
||||
char first[64], second[64];
|
||||
CHECK_EQ(fingerprintPairs(fp, 0, 16, first, sizeof(first)), 47);
|
||||
CHECK_EQ(fingerprintPairs(fp, 16, 16, second, sizeof(second)), 47);
|
||||
std::string rejoined;
|
||||
for (const char *p = first; *p; p++) {
|
||||
if (*p != ':') rejoined += *p;
|
||||
}
|
||||
for (const char *p = second; *p; p++) {
|
||||
if (*p != ':') rejoined += *p;
|
||||
}
|
||||
CHECK_EQ(rejoined.size(), 64);
|
||||
CHECK_STR(rejoined.c_str(), fp);
|
||||
|
||||
// One byte short of the 48 it needs is a refusal that writes nothing; so is a source that
|
||||
// stops being hex partway, which must not leave half a fingerprint in the buffer.
|
||||
prime(line, sizeof(line));
|
||||
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, 47), 0);
|
||||
CHECK(untouched(line, 47));
|
||||
std::string bad(fp);
|
||||
bad[4] = 'z';
|
||||
prime(line, sizeof(line));
|
||||
CHECK_EQ(fingerprintPairs(bad.c_str(), 0, 16, line, sizeof(line)), 0);
|
||||
CHECK(untouched(line, 47));
|
||||
|
||||
// An empty range, or one that runs off the end of a 32-byte digest: refused, because
|
||||
// printing past the digest is how a fingerprint grows characters it never had.
|
||||
CHECK_EQ(fingerprintPairs(fp, 0, 0, line, sizeof(line)), 0);
|
||||
CHECK_EQ(fingerprintPairs(fp, 32, 1, line, sizeof(line)), 0);
|
||||
CHECK_EQ(fingerprintPairs(fp, 31, 2, line, sizeof(line)), 0);
|
||||
CHECK_EQ(fingerprintPairs(fp, 31, 1, line, sizeof(line)), 2); // the last pair alone
|
||||
CHECK_STR(line, "ad");
|
||||
CHECK_EQ(fingerprintPairs(nullptr, 0, 16, line, sizeof(line)), 0);
|
||||
CHECK_EQ(fingerprintPairs(fp, 0, 16, nullptr, sizeof(line)), 0);
|
||||
}
|
||||
|
||||
CASE("derContains: the certificate decides whether the printed names are true");
|
||||
{
|
||||
// `cert` prints the name list from sanEntries(); this is the check that the stored
|
||||
// certificate really carries those bytes, so the console can say "and the certificate
|
||||
// agrees" rather than implying it. The needle is the exact extension value, so a name
|
||||
// changed by one character is a refusal, not a near match.
|
||||
uint8_t san[kSanDerMax];
|
||||
const size_t sanLen = sanExtensionDer(san, sizeof(san));
|
||||
CHECK_EQ(sanLen, 34);
|
||||
|
||||
std::vector<uint8_t> cert;
|
||||
for (size_t i = 0; i < 120; i++) cert.push_back(static_cast<uint8_t>(0xA0 ^ (i & 0x3F)));
|
||||
cert.insert(cert.end(), san, san + sanLen);
|
||||
for (size_t i = 0; i < 200; i++) cert.push_back(static_cast<uint8_t>(i * 7));
|
||||
|
||||
CHECK(derContains(cert.data(), cert.size(), san, sanLen));
|
||||
// An empty needle is true of every buffer in existence, so it is a refusal instead: the
|
||||
// answer has to be about these bytes.
|
||||
CHECK(!derContains(cert.data(), cert.size(), san, 0));
|
||||
CHECK(!derContains(cert.data(), 10, san, sanLen));
|
||||
CHECK(!derContains(nullptr, cert.size(), san, sanLen));
|
||||
CHECK(!derContains(cert.data(), cert.size(), nullptr, sanLen));
|
||||
|
||||
// One byte different in the needle and the certificate no longer carries it...
|
||||
uint8_t torn[kSanDerMax];
|
||||
memcpy(torn, san, sanLen);
|
||||
torn[sanLen - 1] ^= 0x01;
|
||||
CHECK(!derContains(cert.data(), cert.size(), torn, sanLen));
|
||||
// ... and one byte different inside the certificate's own SAN, either.
|
||||
std::vector<uint8_t> other = cert;
|
||||
other[120 + 1] ^= 0x01; // the first byte of the first DNS name
|
||||
CHECK(!derContains(other.data(), other.size(), san, sanLen));
|
||||
|
||||
// A one-byte haystack is not a special case that only matches at offset 0 by luck.
|
||||
const uint8_t one = 0x30;
|
||||
CHECK(derContains(&one, 1, &one, 1));
|
||||
CHECK(!derContains(&one, 1, san, sanLen));
|
||||
|
||||
// Found at the very end of a buffer too -- an off-by-one in the loop bound shows up here
|
||||
// and nowhere else, because the SAN is never at the end of a real certificate.
|
||||
std::vector<uint8_t> tailCert(64, 0x5A);
|
||||
tailCert.insert(tailCert.end(), san, san + sanLen);
|
||||
CHECK(derContains(tailCert.data(), tailCert.size(), san, sanLen));
|
||||
}
|
||||
|
||||
CASE("sanExtensionFullDer / derCarriesSan: the extension a client sees, or none");
|
||||
{
|
||||
uint8_t full[kSanExtensionFullDerMax];
|
||||
prime(reinterpret_cast<char *>(full), sizeof(full));
|
||||
const size_t n = sanExtensionFullDer(full, sizeof(full));
|
||||
// SEQUENCE(41) { OID(3) 55 1D 11, OCTET STRING(34) <GeneralNames> }, non-critical so no
|
||||
// BOOLEAN -- and these 43 bytes are lifted from a certificate OpenSSL issued for the
|
||||
// same three names, not derived from this encoder's own output.
|
||||
CHECK_EQ(n, 43);
|
||||
CHECK_STR(hexOf(full, n).c_str(),
|
||||
"30290603551d1104223020820e6b657972656c61792e6c6f63616c82086b657972"
|
||||
"656c61798704c0a80401");
|
||||
CHECK_EQ(sanExtensionFullDer(full, n), n); // exact fit
|
||||
prime(reinterpret_cast<char *>(full), sizeof(full));
|
||||
CHECK_EQ(sanExtensionFullDer(full, n - 1), 0); // one short writes nothing...
|
||||
CHECK(untouched(reinterpret_cast<const char *>(full), n - 1));
|
||||
CHECK_EQ(sanExtensionFullDer(nullptr, sizeof(full)), 0);
|
||||
|
||||
uint8_t san[kSanDerMax];
|
||||
const size_t sanLen = sanExtensionDer(san, sizeof(san));
|
||||
CHECK_EQ(sanLen, 34);
|
||||
|
||||
// A certificate that carries the extension this build writes: yes.
|
||||
std::vector<uint8_t> cert(90, 0x3C);
|
||||
sanExtensionFullDer(full, sizeof(full));
|
||||
cert.insert(cert.end(), full, full + n);
|
||||
cert.insert(cert.end(), {0x03, 0x47, 0x00});
|
||||
CHECK(derCarriesSan(cert.data(), cert.size()));
|
||||
CHECK(derCarriesSan(cert.data(), 90 + n)); // the extension ends exactly here
|
||||
CHECK(!derCarriesSan(cert.data(), 90 + n - 1)); // one byte short of carrying it
|
||||
|
||||
// The certificate a board actually issued before this was fixed -- the right 34 value
|
||||
// bytes, wrapped the right way, under the OID 1.10.46.53.46.50.57.46.49.55 (the ASCII
|
||||
// of "2.5.29.17"). It has no subjectAlternativeName as far as any client is concerned,
|
||||
// and this is where that becomes an observation instead of a surprise. Note what the
|
||||
// weaker question answers here: the value really is in the certificate.
|
||||
static const uint8_t kPreFix[] = {
|
||||
0x30, 0x2F, 0x06, 0x09, '2', '.', '5', '.', '2', '9', '.', '1',
|
||||
'7', 0x04, 0x22,
|
||||
};
|
||||
std::vector<uint8_t> legacy;
|
||||
legacy.insert(legacy.end(), kPreFix, kPreFix + sizeof(kPreFix));
|
||||
legacy.insert(legacy.end(), san, san + sanLen);
|
||||
legacy.insert(legacy.end(), 40, 0x11);
|
||||
CHECK(derContains(legacy.data(), legacy.size(), san, sanLen)); // the easy question: yes
|
||||
CHECK(!derCarriesSan(legacy.data(), legacy.size())); // the real question: no
|
||||
|
||||
|
||||
// One byte of the OID and it is a different extension: a name list under a label nobody
|
||||
// asked for is the same failure in a different costume.
|
||||
std::vector<uint8_t> shifted = cert;
|
||||
const size_t oidAt = 90 + 4; // SEQUENCE(2) + tag + length of the OID
|
||||
CHECK_EQ(shifted[oidAt], 0x55);
|
||||
shifted[oidAt] = 0x54;
|
||||
CHECK(!derCarriesSan(shifted.data(), shifted.size()));
|
||||
|
||||
// The value alone, or the OID alone, is not the extension.
|
||||
std::vector<uint8_t> onlyValue(64, 0x07);
|
||||
onlyValue.insert(onlyValue.end(), san, san + sanLen);
|
||||
CHECK(!derCarriesSan(onlyValue.data(), onlyValue.size()));
|
||||
std::vector<uint8_t> onlyOid(64, 0x07);
|
||||
onlyOid.insert(onlyOid.end(), kSanOidTlv, kSanOidTlv + kSanOidTlvLen);
|
||||
CHECK(!derCarriesSan(onlyOid.data(), onlyOid.size()));
|
||||
|
||||
// Nothing to search, and nothing to search with.
|
||||
CHECK(!derCarriesSan(nullptr, 100));
|
||||
CHECK(!derCarriesSan(cert.data(), 0));
|
||||
}
|
||||
|
||||
CASE("dateTimeText: a validity bound as a person reads it");
|
||||
{
|
||||
char t[32];
|
||||
prime(t, sizeof(t));
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 23);
|
||||
CHECK_STR(t, "2026-11-08 00:00:00 UTC");
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2036, 11, 7, 23, 59, 59), 23);
|
||||
CHECK_STR(t, "2036-11-07 23:59:59 UTC");
|
||||
|
||||
// Exactly enough room, and one byte short of it.
|
||||
CHECK_EQ(dateTimeText(t, 24, 2026, 11, 8, 0, 0, 0), 23);
|
||||
prime(t, sizeof(t));
|
||||
CHECK_EQ(dateTimeText(t, 23, 2026, 11, 8, 0, 0, 0), 0);
|
||||
CHECK(untouched(t, 23));
|
||||
|
||||
// The same component checks utcTime() applies before a certificate is written, including
|
||||
// the February that does not exist -- a date this tree would never have encoded cannot be
|
||||
// printed as if it had been.
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2028, 2, 29, 0, 0, 0), 23);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
|
||||
// :60 is RFC 5280's leap-second slot, accepted here exactly as utcTime() accepts it.
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 23);
|
||||
// NOT utcTime's 1950..2049 window: reading a date is not the same permission as writing
|
||||
// one, and a certificate outliving UTCTime is the writer's problem, not the reader's.
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 23);
|
||||
CHECK_EQ(dateTimeText(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 23);
|
||||
CHECK_EQ(dateTimeText(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
|
||||
}
|
||||
|
||||
return report("test_tls_pem");
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* tests/test_tls_session.cpp -- the policy half of src/tls_session.h, walked on the
|
||||
* host. No mbed TLS, no Arduino, no LWIP: the header's pure section is exactly the
|
||||
* part that must compile without them (the Session class hides behind
|
||||
* KR_TLS && ESP_PLATFORM), and this file is the proof. If this suite ever needs an
|
||||
* mbedtls include to keep passing, the split has failed -- see AGENTS.md.
|
||||
*
|
||||
* What each check pins, and the failure it is defending:
|
||||
*
|
||||
* deadlinePassed -- the handshake/stall timeouts are uint32 clock maths; a plain
|
||||
* `now - start > limit` that is not written wrap-safe silently
|
||||
* un-timeouts every connection around the 49.7-day rollover.
|
||||
* admitConnection -- the kTlsMaxSessions refusal. The three outcomes and the order
|
||||
* they are decided in: the cap BEFORE the free slot, so an
|
||||
* over-cap board refuses rather than "finds" a slot that memory
|
||||
* was not reserved for. The refusal must not know which listener
|
||||
* a socket came from -- there is no transport argument here at
|
||||
* all, which is the point.
|
||||
* mapIo -- the table that decides "would block" vs "peer gone" vs "fatal"
|
||||
* for every mbedtls_ssl_* return. A WANT misread as fatal drops
|
||||
* working controllers; a fatal misread as WANT spins the loop.
|
||||
* The three literals it branches on are pinned against the real
|
||||
* library by static_assert in tls_session.cpp -- here the TABLE
|
||||
* is walked; there the NUMBERS are welded.
|
||||
* renderFatalAlert-- 7 bytes of somebody else's wire format (RFC 5246 alert record).
|
||||
* AGENTS.md's evdev lesson: test the table, not your confidence.
|
||||
* TxRing -- whole-frame-or-nothing staging. A half-queued WebSocket frame
|
||||
* corrupts the keystroke channel; this class makes that the only
|
||||
* failure it cannot produce. It is now the outbound queue of BOTH
|
||||
* transports (kr::Conn, src/conn.h), not just the TLS one.
|
||||
*
|
||||
* Phase 02 task 04 removed `WriteQueue` from this header: the transport view in conn.h
|
||||
* queues a head and a body through the ring plus kr::FlashSend instead, so keeping a second
|
||||
* streaming cursor here would be exactly the second path this phase exists to delete. The
|
||||
* "stream one long segment in bounded pieces" coverage moved with it, to tests/test_conn.cpp.
|
||||
*/
|
||||
#include <string.h>
|
||||
|
||||
#include "test.h"
|
||||
|
||||
#include "tls_session.h"
|
||||
|
||||
using kr::tls::admitConnection;
|
||||
using kr::tls::Admit;
|
||||
using kr::tls::deadlinePassed;
|
||||
using kr::tls::Io;
|
||||
using kr::tls::kFatalAlertLen;
|
||||
using kr::tls::kMbedPeerCloseNotify;
|
||||
using kr::tls::kMbedWantRead;
|
||||
using kr::tls::kMbedWantWrite;
|
||||
using kr::tls::mapIo;
|
||||
using kr::tls::renderFatalAlert;
|
||||
using kr::tls::TxRing;
|
||||
|
||||
int main() {
|
||||
// ---------------------------------------------------------------- deadline
|
||||
CASE("deadline: basics");
|
||||
CHECK(!deadlinePassed(1000, 1000, 5000)); // just started
|
||||
CHECK(!deadlinePassed(5999, 1000, 5000)); // one ms short
|
||||
CHECK(!deadlinePassed(6000, 1000, 5000)); // exactly the limit has NOT passed
|
||||
CHECK(deadlinePassed(6001, 1000, 5000)); // and one past it has
|
||||
CHECK(deadlinePassed(1, 0, 0)); // zero-length limit: instantly stale
|
||||
|
||||
CASE("deadline: uint32 clock rollover");
|
||||
// The board's millis() wraps at 2^32. All four of these evaluate "backwards" under
|
||||
// unsigned arithmetic and only read correctly through the signed-delta form.
|
||||
constexpr uint32_t kNear = 0xFFFFFFF0u; // 16 ms before wrap
|
||||
CHECK(!deadlinePassed(kNear + 10, kNear, 64)); // 10 ms in, no wrap yet
|
||||
CHECK(!deadlinePassed(0x10, kNear, 32)); // wrapped: 32 ms in, limit 32...
|
||||
CHECK(deadlinePassed(0x20, kNear, 16)); // wrapped: 48 ms > 16 ms limit
|
||||
CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0)); // 0xFFFFFFF0 elapsed, limit hit
|
||||
CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0 - 1));
|
||||
CHECK(!deadlinePassed(5000, 1000, 0x7FFFFFFF)); // huge limit stays sane
|
||||
|
||||
CASE("deadline: handshake shape");
|
||||
// kTlsHandshakeMs is the scan bound: "opened and never finished a handshake" means
|
||||
// exactly this comparison going true, every tick, from Session::poll().
|
||||
CHECK(deadlinePassed(10001, 0, kr::kTlsHandshakeMs));
|
||||
CHECK(!deadlinePassed(10000, 0, kr::kTlsHandshakeMs));
|
||||
CHECK_EQ(kr::kTlsHandshakeMs, 10000);
|
||||
|
||||
// ------------------------------------------------------------------ admit
|
||||
CASE("admit: the truth table");
|
||||
// Two slots: controller, spare. Nothing else exists, and the function has no
|
||||
// transport parameter to consult -- listener-independence by construction.
|
||||
CHECK(admitConnection(false, 0, 2) == Admit::Controller); // empty board
|
||||
CHECK(admitConnection(false, 1, 2) == Admit::Controller); // spare only: still room
|
||||
CHECK(admitConnection(true, 1, 2) == Admit::Spare); // controller: one spare
|
||||
CHECK(admitConnection(true, 2, 2) == Admit::Refused); // the third connection
|
||||
CHECK(admitConnection(false, 2, 2) == Admit::Refused); // cap checked BEFORE slot
|
||||
CHECK(admitConnection(true, 0, 2) == Admit::Spare); // count says spare live...
|
||||
// ...but not busy: spare
|
||||
CASE("admit: cap sizes");
|
||||
CHECK(admitConnection(false, 0, 1) == Admit::Controller);
|
||||
CHECK(admitConnection(true, 1, 1) == Admit::Refused); // a 1-slot board: no spare
|
||||
CHECK(admitConnection(true, 1, 3) == Admit::Spare);
|
||||
CHECK(admitConnection(true, 2, 3) == Admit::Spare);
|
||||
CHECK(admitConnection(true, 3, 3) == Admit::Refused);
|
||||
// The ceiling this project ships is two, and the RAM comment in config.h is written
|
||||
// against that number: change one, the other has to be re-derived, so pin it here.
|
||||
CHECK_EQ(kr::kTlsMaxSessions, 2);
|
||||
|
||||
// ------------------------------------------------------------------- mapIo
|
||||
CASE("mapIo: read/write/handshake verdicts");
|
||||
CHECK(mapIo(1) == Io::Bytes);
|
||||
CHECK(mapIo(4096) == Io::Bytes); // a full chunk
|
||||
CHECK(mapIo(7) == Io::Bytes); // a SHORT write is still Bytes --
|
||||
// the caller keeps framing state
|
||||
CHECK(mapIo(0) == Io::Closed); // mbed TLS 3.6: 0 = read end closed
|
||||
CHECK(mapIo(kMbedWantRead) == Io::Want);
|
||||
CHECK(mapIo(kMbedWantWrite) == Io::Want);
|
||||
CHECK(mapIo(kMbedPeerCloseNotify) == Io::Closed); // acknowledged end
|
||||
CHECK(mapIo(-0x0050) == Io::Error); // NET_CONN_RESET
|
||||
CHECK(mapIo(-0x2700) == Io::Error); // SSL fatal class
|
||||
CHECK(mapIo(-0x7B00) == Io::Error); // NEW_SESSION_TICKET: client-side
|
||||
// only; unmapped lands fatal, never
|
||||
// silently "keep going"
|
||||
CHECK(mapIo(-1) == Io::Error);
|
||||
// The numbers themselves. If the shipped core ever renumbers these, the
|
||||
// static_asserts in tls_session.cpp stop the FIRMWARE build; these stop this suite,
|
||||
// so "the table drifted from the library" can never be a green run anywhere.
|
||||
CHECK_EQ(kMbedWantRead, -0x6900);
|
||||
CHECK_EQ(kMbedWantWrite, -0x6880);
|
||||
CHECK_EQ(kMbedPeerCloseNotify, -0x7880);
|
||||
|
||||
CASE("mapIo: Want never reads as Closed and vice versa");
|
||||
// The distinction IS the non-blocking contract: a WANT treated as Closed drops a
|
||||
// working controller every time WiFi is merely slow.
|
||||
CHECK(mapIo(kMbedWantRead) != Io::Closed);
|
||||
CHECK(mapIo(0) != Io::Want);
|
||||
CHECK(mapIo(kMbedWantWrite) != Io::Error);
|
||||
|
||||
// ------------------------------------------------------------------- alert
|
||||
CASE("fatal alert record");
|
||||
uint8_t alert[16];
|
||||
CHECK_EQ(renderFatalAlert(alert, sizeof(alert)), (size_t)kFatalAlertLen);
|
||||
CHECK_EQ(alert[0], 0x15); // record type 21: alert
|
||||
CHECK_EQ(alert[1], 0x03); // version major 3
|
||||
CHECK_EQ(alert[2], 0x01); // version minor 1 (TLS 1.1+ field value, per record)
|
||||
CHECK_EQ(alert[3], 0x00); // length, high byte
|
||||
CHECK_EQ(alert[4], 0x02); // length, low byte: two bytes of payload
|
||||
CHECK_EQ(alert[5], 0x02); // alert level: fatal
|
||||
CHECK_EQ(alert[6], 0x28); // description 40 == 0x28: handshake_failure, RFC 5246
|
||||
// §7.2.2 -- written here in decimal ON PURPOSE: the byte
|
||||
// that was first drafted as "0x40 because forty" was the
|
||||
// whole reason this table got its own test.
|
||||
|
||||
CASE("fatal alert: refuses rather than truncates");
|
||||
CHECK_EQ(renderFatalAlert(alert, kFatalAlertLen - 1), 0);
|
||||
CHECK_EQ(renderFatalAlert(nullptr, 16), 0);
|
||||
uint8_t tight[kFatalAlertLen];
|
||||
memset(tight, 0, sizeof(tight));
|
||||
CHECK_EQ(renderFatalAlert(tight, kFatalAlertLen), kFatalAlertLen);
|
||||
CHECK_EQ(tight[0], 0x15);
|
||||
|
||||
size_t n = 7;
|
||||
const uint8_t head[] = "HTTP/1.1 200 OK\r\n\r\n";
|
||||
|
||||
// ------------------------------------------------------------------ TxRing
|
||||
CASE("tx ring: whole frame or nothing");
|
||||
uint8_t store[64];
|
||||
TxRing ring(store, sizeof(store));
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK_EQ(ring.capacity(), sizeof(store));
|
||||
const char frame[] = "0123456789";
|
||||
CHECK(ring.push(reinterpret_cast<const uint8_t *>(frame), 11));
|
||||
CHECK_EQ(ring.count(), 11);
|
||||
// Fill to exactly capacity minus 1... then a frame of 2 must bounce whole, with the
|
||||
// ring byte-identical to before the refusal.
|
||||
uint8_t pad[53] = {0};
|
||||
CHECK(ring.push(pad, sizeof(pad)));
|
||||
CHECK_EQ(ring.count(), 64u);
|
||||
const char last[] = "ab";
|
||||
CHECK(!ring.push(reinterpret_cast<const uint8_t *>(last), 2));
|
||||
CHECK_EQ(ring.count(), 64u);
|
||||
CHECK(memcmp(store, frame, 11) == 0); // refused push wrote nothing anywhere
|
||||
CHECK(!ring.push(reinterpret_cast<const uint8_t *>(last), 1));
|
||||
CHECK_EQ(ring.count(), 64u);
|
||||
|
||||
CASE("tx ring: push bigger than capacity always refused");
|
||||
ring.reset();
|
||||
uint8_t big[65] = {0};
|
||||
CHECK(!ring.push(big, sizeof(big)));
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(ring.push(big, sizeof(store))); // exactly full is the boundary and passes
|
||||
|
||||
CASE("tx ring: head span, wrap, advance");
|
||||
ring.reset();
|
||||
uint8_t filler[60] = {0};
|
||||
CHECK(ring.push(filler, sizeof(filler)));
|
||||
ring.advance(55); // head sits 9 bytes from the end
|
||||
CHECK_EQ(ring.count(), 5u);
|
||||
CHECK(ring.push(pad, 10)); // fills 4 more to the end, wraps for 6
|
||||
CHECK_EQ(ring.count(), 15u);
|
||||
CHECK(ring.head(64, &n) == store + 55); // the span runs to the wrap: 5 old + 4 new,
|
||||
CHECK_EQ(n, 9); // contiguous -- 9, not 5: the ring packs
|
||||
ring.advance(9); // after the head, not after the old bytes
|
||||
CHECK(ring.head(64, &n) == store); // ...and the rest continues at index 0
|
||||
CHECK_EQ(n, 6);
|
||||
ring.advance(6);
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(ring.head(64, &n) == nullptr);
|
||||
CHECK_EQ(n, 0);
|
||||
|
||||
CASE("tx ring: head cap bounds the write (one record per chunk)");
|
||||
ring.reset();
|
||||
uint8_t bulk[64] = {7};
|
||||
CHECK(ring.push(bulk, sizeof(bulk)));
|
||||
CHECK(ring.head(3, &n) == store);
|
||||
CHECK_EQ(n, 3);
|
||||
// The resume rule: the same head after a refused (advance-less) write yields the
|
||||
// identical bytes -- which is what makes a WANT_WRITE retry legal. kConnWriteChunk is
|
||||
// the cap kr::Conn hands here, for either transport.
|
||||
CHECK(ring.head(kr::kConnWriteChunk, &n) == store);
|
||||
CHECK_EQ(n, 64u);
|
||||
|
||||
CASE("tx ring: default-constructed holds nothing and refuses everything");
|
||||
// kr::Conn is bound after it is constructed, so an unseated ring has to exist safely:
|
||||
// no modulo by zero, no head, nothing accepted. The alternative is a crash on the first
|
||||
// tick after boot, before any socket has arrived.
|
||||
TxRing bare;
|
||||
CHECK_EQ(bare.count(), 0);
|
||||
CHECK(bare.head(64, &n) == nullptr);
|
||||
CHECK_EQ(n, 0);
|
||||
CHECK(!bare.push(reinterpret_cast<const uint8_t *>(head), 4));
|
||||
bare.advance(4); // must not wrap the head or the count
|
||||
CHECK_EQ(bare.count(), 0);
|
||||
CHECK_EQ(bare.head(64, &n), nullptr);
|
||||
// Capacity 0 is coerced to 1 rather than kept: every index in this class is a modulo,
|
||||
// and a ring that cannot exist is a crash on the first tick, before any socket arrives.
|
||||
// The one byte it can hold is never delivered anywhere, because nothing is ever asked
|
||||
// of an unseated Conn.
|
||||
TxRing zero(store, 0);
|
||||
CHECK_EQ(zero.capacity(), 1u);
|
||||
CHECK(zero.push(reinterpret_cast<const uint8_t *>(head), 1));
|
||||
CHECK(!zero.push(reinterpret_cast<const uint8_t *>(head), 2));
|
||||
zero.reset();
|
||||
CHECK(zero.head(64, &n) == nullptr);
|
||||
CHECK_EQ(n, 0u);
|
||||
|
||||
CASE("tx ring: advance clamps to count; reset empties");
|
||||
ring.reset();
|
||||
CHECK(ring.push(bulk, 8));
|
||||
ring.advance(99);
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(ring.push(bulk, 8));
|
||||
ring.reset();
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(ring.push(bulk, 4)); // usable after reset, head back at 0
|
||||
CHECK(ring.head(64, &n) == store);
|
||||
CHECK_EQ(n, 4);
|
||||
|
||||
CASE("tx ring: zero-length push is a no-op success");
|
||||
ring.reset();
|
||||
CHECK(ring.push(reinterpret_cast<const uint8_t *>(frame), 0));
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(!ring.push(nullptr, 4)); // nothing to copy: refused, never guessed
|
||||
|
||||
CASE("tx ring: reseat moves it onto new storage and empties it");
|
||||
// Conn re-binds a slot between sockets; a stale byte left behind from the previous
|
||||
// occupant would be delivered to the new one.
|
||||
uint8_t other[32] = {0};
|
||||
ring.reset();
|
||||
CHECK(ring.push(bulk, 8));
|
||||
CHECK_EQ(ring.count(), 8u);
|
||||
ring.reseat(other, sizeof(other));
|
||||
CHECK_EQ(ring.capacity(), sizeof(other));
|
||||
CHECK_EQ(ring.count(), 0);
|
||||
CHECK(ring.head(64, &n) == nullptr);
|
||||
CHECK(ring.push(bulk, 5));
|
||||
CHECK_EQ(ring.count(), 5u);
|
||||
CHECK(ring.head(64, &n) == other); // the bytes went to the NEW buffer
|
||||
CHECK_EQ(store[0], bulk[0]); // and the old one still holds what it was given
|
||||
ring.reseat(other, 0); // capacity 0 through reseat is the same safe 1
|
||||
CHECK_EQ(ring.capacity(), 1u);
|
||||
|
||||
CASE("config: the constants this layer promises");
|
||||
// kConnTxCap must hold the largest single frame the board writes: a status frame plus
|
||||
// its WebSocket header. If statusJson ever outgrows this arithmetic, the queue would
|
||||
// start dropping status frames -- visible, but this pin makes it a test failure first.
|
||||
// (Task 04 renamed it from kTlsTxRingCap: it is now the queue of both transports.)
|
||||
CHECK(kr::kConnTxCap >= kr::kStatusJsonCap + 16);
|
||||
CHECK(kr::kConnWriteChunk < 16384); // one record per write: no library fragmentation
|
||||
|
||||
return report("test_tls_session");
|
||||
}
|
||||
+27
-1
@@ -24,7 +24,8 @@
|
||||
# actual command — never inferred from these lines.
|
||||
#
|
||||
# One line per fact, prefix "hw-probe", so the output stays greppable:
|
||||
# serial nodes / keyrelay.local resolution / https / http(+redirect) /
|
||||
# serial nodes / keyrelay.local resolution / https / tls handshake (phase 02:
|
||||
# protocol + cipher via openssl s_client) / http(+redirect) /
|
||||
# uv / node / watch_target.py-capable evdev nodes.
|
||||
set -uo pipefail
|
||||
|
||||
@@ -104,6 +105,31 @@ else
|
||||
echo "hw-probe http skipped — curl not installed; https/http reachability unknown"
|
||||
fi
|
||||
|
||||
# --- 4b. the TLS handshake's details (phase 02 task 03's informational probe) -----
|
||||
# The phase asks for `openssl s_client -connect <ip>:443 -servername
|
||||
# keyrelay.local` reporting protocol and cipher. Informational like everything
|
||||
# else here: one attempt, capped, exit status ignored, no verdict. The verify
|
||||
# failure it will print (self-signed, pinned-once identity) is the EXPECTED shape
|
||||
# of a healthy keyrelay handshake — whether to trust that certificate is a human
|
||||
# decision made against the serial console's fingerprint, never this line's.
|
||||
if have openssl; then
|
||||
tls_host="keyrelay.local"
|
||||
[[ -n "${mdns_out:-}" ]] && tls_host="${mdns_out%%[[:space:]]*}"
|
||||
tls_rc=0
|
||||
tls_fact="$(to 5 openssl s_client -connect "${tls_host}:443" -servername keyrelay.local </dev/null 2>&1)" || tls_rc=$?
|
||||
tls_proto="$(printf '%s\n' "$tls_fact" | sed -n 's/^[[:space:]]*Protocol[[:space:]]*:[[:space:]]*//p' | head -1)"
|
||||
tls_cipher="$(printf '%s\n' "$tls_fact" | sed -n 's/^[[:space:]]*Cipher[[:space:]]*:[[:space:]]*//p' | head -1)"
|
||||
if ((tls_rc == 125)); then
|
||||
echo "hw-probe tls skipped — 'timeout' is not installed and an uncapped s_client can hang; skipped on principle"
|
||||
elif [[ -n "$tls_cipher" && "$tls_cipher" != "(NONE)" ]]; then
|
||||
echo "hw-probe tls ${tls_host}:443 negotiated ${tls_proto:-TLS?} / ${tls_cipher} (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)"
|
||||
else
|
||||
echo "hw-probe tls no TLS session on ${tls_host}:443 (rc $tls_rc) — expected until phase 02's listener is flashed; 'unknown ca' ABOVE would mean the session DID complete"
|
||||
fi
|
||||
else
|
||||
echo "hw-probe tls skipped — openssl not installed; protocol/cipher state unknown"
|
||||
fi
|
||||
|
||||
# --- 5./6. the two tools the gate and the suites pin ---------------------------
|
||||
if have uv; then
|
||||
echo "hw-probe uv $(command -v uv) — firmware builds available"
|
||||
|
||||
+268
-27
@@ -1,24 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""relayctl -- talk to a keyrelay board from a terminal.
|
||||
|
||||
Stdlib only (socket, hashlib, base64, json): the board's protocol is a WebSocket of
|
||||
tiny text commands, and the point of a keyboard bridge is that it needs nothing
|
||||
installed to be useful.
|
||||
Stdlib only (socket, ssl, hashlib, base64, json): the board's protocol is a
|
||||
WebSocket of tiny text commands, and the point of a keyboard bridge is that it
|
||||
needs nothing installed to be useful.
|
||||
|
||||
relayctl.py --host keyrelay.local status
|
||||
relayctl.py --host 192.168.4.1 arm
|
||||
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
|
||||
relayctl.py --host 10.2.1.99 mouse move 120 -40 # relative movement, split to fit
|
||||
relayctl.py --host 10.2.1.99 mouse click right # press and release
|
||||
relayctl.py --host 10.2.1.99 mouse drag 200 0 # press, move, release, in order
|
||||
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
|
||||
relayctl.py --host keyrelay.local listen # print every status heartbeat
|
||||
relayctl.py --host keyrelay.local disarm
|
||||
wl-paste | relayctl.py paste --yes # type what is on the clipboard
|
||||
A TLS board (the default) needs exactly one trust argument, and refuses to run
|
||||
without one -- this tool will not be the component that quietly downgrades:
|
||||
|
||||
--fingerprint <sha256> pin the board certificate (the `cert` command on the
|
||||
board's serial console prints it; also $KEYRELAY_TLS_FP).
|
||||
Verification is off during the handshake, then the peer's
|
||||
DER is hashed and a mismatch aborts before a single
|
||||
frame -- including T<token> -- is sent. Real pinning,
|
||||
not TOFU, and nothing to distribute.
|
||||
--ca <pem> verify against a CA (or a pinned self-signed cert file)
|
||||
with hostname checking on.
|
||||
--insecure no verification; prints a warning on every run.
|
||||
--plain plaintext ws:// on :80, for -D KR_TLS=0 rollback boards
|
||||
only. Opt-in, because the TLS board refuses that port
|
||||
anyway (301 / 426).
|
||||
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local status
|
||||
relayctl.py --fingerprint $FP --host 192.168.4.1 arm
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 key Enter # one key, press+release
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse move 120 -40 # split to fit
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse click right # press+release
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 mouse drag 200 0 # in order
|
||||
relayctl.py --fingerprint $FP --host 10.2.1.99 cad # Ctrl+Alt+Del, --yes
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local listen # status heartbeats
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local disarm
|
||||
wl-paste | relayctl.py --fingerprint $FP paste --yes # type what is on the clipboard
|
||||
|
||||
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
|
||||
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
|
||||
because curl is the point of that endpoint; this tool uses the WebSocket instead).
|
||||
Over TLS both the passphrase and the keystrokes are encrypted in transit; the
|
||||
fingerprint is what says the other end is *this* board rather than somebody's.
|
||||
|
||||
`paste` is the one command that types more than a single key, because sending text a
|
||||
human copied is a thing a keyboard bridge is for. It is gated accordingly: --yes is
|
||||
@@ -35,6 +53,15 @@ buttons and need --yes for that reason; a movement does not, because moving a cu
|
||||
reversible by definition. There is no gesture recording, no auto-click and no "move to the
|
||||
OK button", because the moment this tool could actuate a target on its own it would be a
|
||||
different and worse device than a keyboard.
|
||||
|
||||
What the TLS in this tool does NOT do is make anything safe by itself. An encrypted session is
|
||||
still only a controller that has to know the passphrase, and a session that drops is a lost link
|
||||
the board answers by releasing every key and every mouse button. The certificate says *which*
|
||||
board, and only if you asked -- which is why this tool's contribution is procedural: never pick
|
||||
the weaker option for the operator. No fallback from wss:// to ws://, no default-on --insecure,
|
||||
no "try :80 first", no quiet downgrade when a pin fails; every one of those is a decision the
|
||||
operator makes in writing, and the failure text names the real choice instead of blaming the
|
||||
passphrase for a transport problem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,11 +71,13 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_PORT = 80 # plaintext ws:// -- reachable only via --plain
|
||||
TLS_DEFAULT_PORT = 443 # wss:// on a KR_TLS=1 board
|
||||
WS_PATH = "/ws"
|
||||
# The whole point of `keys` is verifying the keystroke path in one armed session.
|
||||
# A cap keeps it that: enough for a test vector, not a way to type a paragraph.
|
||||
@@ -240,6 +269,141 @@ def expect_accept(key: str) -> str:
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
|
||||
def handshake_refusal(status_line: str) -> str:
|
||||
"""Why the board said no, phrased so the operator can act on it.
|
||||
|
||||
These shapes used to collapse into one "refused" and they are not the same problem:
|
||||
|
||||
426 the board refuses to speak plaintext at all. Its controller channel is
|
||||
`wss://` on :443, and a `ws://` upgrade on :80 is answered rather than quietly
|
||||
accepted -- invariant 5, never silently take the weaker option. The fix is a
|
||||
trust argument (--fingerprint from the board's `cert`), not a different port.
|
||||
301 the same refusal, for a request that was not an upgrade: the port redirects.
|
||||
401 the endpoint needs a passphrase this path cannot carry.
|
||||
503 another controller already holds the bridge.
|
||||
|
||||
Saying "wrong passphrase" when the board means "use TLS" is the failure this function
|
||||
exists to prevent: the operator then re-types a passphrase that was never the problem,
|
||||
five times, and locks the token out for kTokenLockMs on a board that was trying to
|
||||
protect them. tests/test_relayctl.py pins that 426 and 401 do not share a sentence.
|
||||
"""
|
||||
code = ""
|
||||
for part in status_line.split():
|
||||
if len(part) == 3 and part.isdigit():
|
||||
code = part
|
||||
break
|
||||
if code == "426":
|
||||
return ("board refuses plaintext: its controller channel is wss:// on :443. "
|
||||
"Re-run with a trust argument (--fingerprint <sha256> from the `cert` "
|
||||
"command on the board's serial console, or --ca); --plain only reaches "
|
||||
"a board built with -D KR_TLS=0")
|
||||
if code == "301":
|
||||
return ("board redirected to https://: it no longer serves content over plaintext "
|
||||
"http -- use the TLS defaults (--fingerprint or --ca)")
|
||||
if code == "401":
|
||||
return "passphrase required (this endpoint does not take one)"
|
||||
if code == "503":
|
||||
return "another controller already holds the bridge"
|
||||
if code == "400":
|
||||
return "malformed request -- not a keyrelay WebSocket endpoint"
|
||||
return "refused"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS: how this client decides to trust the board, or refuses to guess
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINGERPRINT_HEX = 64 # one SHA-256, lowercase hex
|
||||
|
||||
|
||||
def normalize_fingerprint(text: str) -> str:
|
||||
"""Lowercase 64-hex of a SHA-256 fingerprint, whatever shape it was pasted in.
|
||||
|
||||
Accepts what the board's `cert` prints (bare hex) and what OpenSSL prints
|
||||
(`AA:BB:...`); refuses anything else rather than pinning a typo. A pin that
|
||||
silently means nothing is worse than no pin, because it reads like one.
|
||||
"""
|
||||
raw = "".join((text or "").split()).replace(":", "")
|
||||
if len(raw) != FINGERPRINT_HEX or any(c not in "0123456789abcdef" for c in raw.lower()):
|
||||
raise RelayError(
|
||||
f"{text!r} is not a SHA-256 fingerprint: want {FINGERPRINT_HEX} hex "
|
||||
f"digits (the `cert` command on the board's serial console prints it)")
|
||||
return raw.lower()
|
||||
|
||||
|
||||
def select_transport(fingerprint: str, ca: str, insecure: bool,
|
||||
plain: bool) -> str:
|
||||
"""One trust decision from the four switches: "pin" | "ca" | "insecure" | "plain".
|
||||
|
||||
Refuses, in two directions. None of them: refuse to connect at all -- a tool
|
||||
that defaults to plaintext, or to trust-on-first-use, is the component that
|
||||
quietly downgrades (AGENTS.md invariant 5), so the default posture is a
|
||||
refusal that says what to pass. More than one: they are different trust
|
||||
stories (a pin, a chain, no verification, no TLS), and picking one silently
|
||||
is guessing.
|
||||
"""
|
||||
if plain:
|
||||
if fingerprint or ca or insecure:
|
||||
raise RelayError("--plain is the -D KR_TLS=0 rollback path; do not combine "
|
||||
"it with a TLS trust argument (--fingerprint/--ca/--insecure)")
|
||||
return "plain"
|
||||
chosen = [name for name, on in (("--fingerprint", fingerprint), ("--ca", ca),
|
||||
("--insecure", insecure)) if on]
|
||||
if len(chosen) > 1:
|
||||
raise RelayError(f"{' and '.join(chosen)} are different trust decisions; pass "
|
||||
f"exactly one")
|
||||
if not chosen:
|
||||
raise RelayError(
|
||||
"refusing to connect without saying how to trust the board: pass "
|
||||
"--fingerprint <sha256> (or $KEYRELAY_TLS_FP; the `cert` command on the "
|
||||
"board's serial console prints it), or --ca <pem>, or --insecure for an "
|
||||
"unverified run this tool will warn about every time; --plain only for a "
|
||||
"board built with -D KR_TLS=0")
|
||||
if fingerprint:
|
||||
return "pin"
|
||||
return "ca" if ca else "insecure"
|
||||
|
||||
|
||||
def resolve_port(mode: str, port) -> int:
|
||||
"""Explicit --port always wins; otherwise TLS is 443 and --plain is 80.
|
||||
|
||||
One function because "which port did this flag combination pick" must not be
|
||||
folklore spread across call sites -- pinning a certificate on the plaintext
|
||||
port would be a very confident way to verify nothing.
|
||||
"""
|
||||
if port is not None:
|
||||
return int(port)
|
||||
return DEFAULT_PORT if mode == "plain" else TLS_DEFAULT_PORT
|
||||
|
||||
|
||||
def build_tls_context(mode: str, ca: str = "") -> ssl.SSLContext:
|
||||
"""The handshake policy for one TLS mode. Pinning and --insecure share the
|
||||
handshake settings deliberately: the pin does its work AFTER the handshake,
|
||||
on the DER the peer actually presented, so it works against a self-signed
|
||||
certificate with no CA ceremony -- and an empty verification path cannot be
|
||||
mistaken for a filled one. --ca is the opposite shape: OpenSSL verifies the
|
||||
chain AND the hostname, because that is the option for operators who ran
|
||||
their own CA on purpose.
|
||||
"""
|
||||
if mode == "ca":
|
||||
ctx = ssl.create_default_context(cafile=ca)
|
||||
ctx.check_hostname = True # the certificate is issued for keyrelay.local
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
return ctx
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE # the pin hashes the peer cert itself; see above
|
||||
return ctx
|
||||
|
||||
|
||||
def peer_fingerprint(sock) -> str:
|
||||
"""SHA-256 of the DER the peer presented -- the same value `cert` prints."""
|
||||
der = sock.getpeercert(binary_form=True)
|
||||
if not der:
|
||||
raise RelayError("the TLS peer presented no certificate to fingerprint")
|
||||
return hashlib.sha256(der).hexdigest()
|
||||
|
||||
|
||||
def encode_frame(opcode: int, payload: bytes = b"") -> bytes:
|
||||
"""Client frames are always masked (RFC 6455 5.1: a server must reject others)."""
|
||||
out = bytearray([0x80 | opcode])
|
||||
@@ -300,12 +464,62 @@ class Receiver:
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, host: str, port: int = DEFAULT_PORT, token: str = "",
|
||||
timeout: float = 5.0) -> None:
|
||||
def __init__(self, host: str, port=None, token: str = "", timeout: float = 5.0,
|
||||
fingerprint: str = "", ca: str = "", insecure: bool = False,
|
||||
plain: bool = False) -> None:
|
||||
self.host = host
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
self.sock = socket.create_connection((host, port), timeout)
|
||||
|
||||
# The transport decision happens before a socket exists: "no trust
|
||||
# argument" must refuse in the terminal, not connect and find out.
|
||||
self.mode = select_transport(fingerprint, ca, insecure, plain)
|
||||
self.port = resolve_port(self.mode, port)
|
||||
self.pinned = ""
|
||||
if self.mode == "pin":
|
||||
self.pinned = normalize_fingerprint(fingerprint)
|
||||
if self.mode == "insecure":
|
||||
# On every run, not once per install: an opt-in that stops announcing
|
||||
# itself becomes a default.
|
||||
print("WARNING: --insecure -- not verifying the board's certificate. "
|
||||
"Anyone on this network can answer. Use --fingerprint (the `cert` "
|
||||
"command on the serial console prints it) unless you know exactly "
|
||||
"why you cannot.", file=sys.stderr)
|
||||
|
||||
self.sock = socket.create_connection((host, self.port), timeout)
|
||||
if self.mode != "plain":
|
||||
try:
|
||||
ctx = build_tls_context(self.mode, ca=ca)
|
||||
self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
|
||||
except (ssl.SSLError, OSError) as exc:
|
||||
self.sock.close()
|
||||
if self.mode == "ca":
|
||||
raise RelayError(
|
||||
f"TLS to {host} failed verification: {exc} -- a certificate "
|
||||
f"verify failure usually means the board regenerated its "
|
||||
f"identity (`nvs erase`) or you are talking to a different "
|
||||
f"device; compare the fingerprint with `cert`") from exc
|
||||
raise RelayError(
|
||||
f"TLS handshake with {host} failed: {exc} -- if the board answered "
|
||||
f"the plaintext port with a redirect or a 426, you are on the wrong "
|
||||
f"port or the wrong device; a TLS keyrelay is on 443") from exc
|
||||
if self.mode == "pin":
|
||||
# Abort BEFORE a single frame -- above all before T<token>. A pin
|
||||
# checked after the WebSocket handshake is a pin on the passphrase.
|
||||
try:
|
||||
got = peer_fingerprint(self.sock)
|
||||
except RelayError:
|
||||
self.sock.close()
|
||||
raise
|
||||
if got != self.pinned:
|
||||
self.sock.close()
|
||||
raise RelayError(
|
||||
f"certificate fingerprint mismatch: {host} presented "
|
||||
f"{got}, you pinned {self.pinned}. Nothing was sent. Either "
|
||||
f"the board regenerated its identity (`nvs erase` reprints "
|
||||
f"the new one once) or somebody else is answering -- which "
|
||||
f"is exactly the attack this check exists for. Re-read the "
|
||||
f"fingerprint with `cert` on the board's serial console.")
|
||||
self.receiver = Receiver()
|
||||
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
@@ -338,12 +552,8 @@ class Relay:
|
||||
if sep:
|
||||
fields[name.strip().lower()] = value.strip()
|
||||
if " 101 " not in status_line + " ":
|
||||
detail = "refused"
|
||||
if "401" in status_line:
|
||||
detail = "passphrase required (this endpoint does not take one)"
|
||||
elif "503" in status_line:
|
||||
detail = "another controller already holds the bridge"
|
||||
raise RelayError(f"handshake failed: {status_line} ({detail})")
|
||||
raise RelayError(f"handshake failed: {status_line} "
|
||||
f"({handshake_refusal(status_line)})")
|
||||
want = expect_accept(key)
|
||||
got = fields.get("sec-websocket-accept", "")
|
||||
if got != want:
|
||||
@@ -625,16 +835,32 @@ def describe(status: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""The CLI surface, apart from main() so tests can parse without running."""
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default="keyrelay.local",
|
||||
help="board address (default keyrelay.local; on its own AP use "
|
||||
"192.168.4.1)")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
ap.add_argument("--port", type=int, default=None,
|
||||
help="override the port: default 443 over TLS, 80 with --plain")
|
||||
ap.add_argument("--token", default=os.environ.get("KEYRELAY_TOKEN", ""),
|
||||
help="control passphrase (or $KEYRELAY_TOKEN)")
|
||||
ap.add_argument("--timeout", type=float, default=5.0)
|
||||
ap.add_argument("--fingerprint", default=os.environ.get("KEYRELAY_TLS_FP", ""),
|
||||
metavar="SHA256",
|
||||
help="pin the board certificate: abort before sending anything "
|
||||
"unless the peer's DER hashes to this (or $KEYRELAY_TLS_FP); "
|
||||
"the serial `cert` command prints it")
|
||||
ap.add_argument("--ca", default="", metavar="PEM",
|
||||
help="verify the board certificate against this CA file (or "
|
||||
"pinned self-signed certificate), hostname checking on")
|
||||
ap.add_argument("--insecure", action="store_true",
|
||||
help="connect without any verification; warns on stderr on "
|
||||
"every run -- a one-off, not a habit")
|
||||
ap.add_argument("--plain", action="store_true",
|
||||
help="plaintext ws:// on port 80: only for boards built with "
|
||||
"-D KR_TLS=0 (the rollback); a TLS board refuses this")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status", help="print what the board reports")
|
||||
@@ -701,8 +927,11 @@ def main() -> int:
|
||||
p_keys.add_argument("--keep-armed", action="store_true",
|
||||
help="stay armed afterwards (the board still disarms on "
|
||||
"silence 8 s after this process exits)")
|
||||
return ap
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
def main() -> int:
|
||||
args = build_arg_parser().parse_args()
|
||||
if not args.token:
|
||||
print("no passphrase: --token or $KEYRELAY_TOKEN", file=sys.stderr)
|
||||
return 2
|
||||
@@ -752,8 +981,20 @@ def main() -> int:
|
||||
f"({MAX_PASTE_CHARS}); send it in pieces", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Settled locally too: "you gave me no way to trust the board" is a usage
|
||||
# answer, not a connection failure -- and refusing here means no socket was
|
||||
# ever opened to find that out.
|
||||
try:
|
||||
relay = Relay(args.host, args.port, args.token, args.timeout)
|
||||
if select_transport(args.fingerprint, args.ca, args.insecure, args.plain) == "pin":
|
||||
normalize_fingerprint(args.fingerprint)
|
||||
except RelayError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
relay = Relay(args.host, args.port, args.token, args.timeout,
|
||||
fingerprint=args.fingerprint, ca=args.ca,
|
||||
insecure=args.insecure, plain=args.plain)
|
||||
except (OSError, RelayError) as exc:
|
||||
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user