**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`.
6.0 KiB
Phase 02 — Encrypted control channel: HTTPS + wss://
Source: TODO.md L3 — "Some encryption for password/key input. Doesn't need to be
proper x509, but something"
Story: n/a
Context: PLAN.md anchors "Crypto/TLS stack", "TLS transport shape", "TLS identity",
"Plaintext rollback"; src/control_server.{h,cpp}, src/config.h, src/main.cpp,
src/web_page.h, tools/relayctl.py, README "Security model and trust boundaries"
Objective
Encrypt the control channel end to end: the board serves the page and the WebSocket over
TLS on 443 with a self-signed ECDSA certificate it generated itself and keeps in NVS, the
browser pins its SHA-256 fingerprint once, and the passphrase never crosses the network in
cleartext again. :80 stops carrying anything except a redirect, so an on-path attacker can
no longer replace the page or read the keystrokes.
Why not "just encrypt the passphrase". A challenge–response would hide the passphrase
and nothing else: the keystrokes, the pasted text and the pointer stay readable on a network
the user does not control, and a page served over http:// can be replaced on-path by
anybody who can see the traffic — at which point the attacker does not need the passphrase,
they need one keystroke from a page they wrote. The user asked for TLS rather than the
lighter option, and the trust-boundary table in the README only closes under TLS.
Safety position (unchanged, and load-bearing for every task here)
TLS is transport, never an arming input. Arming still needs the passphrase, the board
button or the serial console; a TLS session that is authenticated but not armed types
nothing; every fail-safe in AGENTS.md keeps working when TLS drops, because a dropped
session is a lost link, which runs the release funnel and disarms. The rollback -D KR_TLS=0 restores today's behaviour and must stay buildable.
Dependencies
01_verification_gate(todo) — the gate must be running the real checks before a change this central can be trusted; the hardware contract tells this phase how to observe the board without blocking on it.
Tasks
01_tls_identity_module.md—src/tls.h: on-board ECDSA secp256r1 keypair + self-signed cert via mbedTLS, with the pure PEM/fingerprint helpers host-tested intests/test_tls.cpp.02_identity_persistence.md— NVS storage of the PEM pair, generate-once, serialcertcommand printing the fingerprint and never the key,StatusSnapshotvisibility.03_tls_listener.md— the 443 listener, non-blocking mbedTLS session over the accepted socket, session lifetime, heap cost measured against the phase 01 baseline.04_transport_abstraction.md—ControlServerroutes through one transport interface soserveReadonly()stays single-sourced;:80becomes redirect-only and plaintext/wsis refused rather than downgraded.05_page_client_fingerprint.md— the page'swss://path and pinning banner,relayctl.pyover stdlibssl(--ca,--insecure), and the cross-checks that keep page, tool and firmware agreeing.06_tls_docs_rollback.md— README security model/wire table/quick start/backlog,AGENTS.mdstale statements, and the-D KR_TLS=0rollback build proven to compile.
Testing & Quality
- Unit/integration:
tests/test_tls_pem.cpp(new suite registered intests/run_tests.shsuites=()) covering the pure half only — PEM armour round-trip, fingerprint formatting, SAN/subject string building, PEM truncation and CRLF handling, rejecting a key/cert pair that does not match. The mbedTLS-bound half is exercised by the firmware build plus the device check (openssl s_client, browser,watch_target.py). tests/test_proto.cpp: nothing on the wire changes shape, so its 2 327 checks must stay green untouched — that is the point: this phase does not change the protocol.tests/test_relayctl.py: TLS connection setup, refusal to fall back to plaintext silently,--insecureopt-in is loud, and the existing frame/table pins unaffected.tests/test_page.js: the connect path still builds the right URL for bothhttp:andhttps:origins, the fingerprint banner renders only when the board reports TLS, and every element id still exists in the markup.- Coverage: >90% on new/modified code (the pure module is fully walked; the socket layer is measured by the device checks and stated, not faked).
.agents/validate.shruns the suites, the sanitiser run and both firmware environments after every task;bridgemust still link, andconsole(KR_HAVE_HID == 0) too.
Completion Criteria
bash .agents/validate.shgreen, and the report quotesbridgeRAM:/Flash:against the phase 01 baseline (RAM 26.7% / Flash 36.1%) plus the measured heap cost of one and two live TLS sessions.- A fresh board (or
nvs erase) generates an identity at first boot and prints its SHA-256 fingerprint once, on the serial console; reflashing keeps the same fingerprint (NVS survives), and nothing about the key ever appears in a log. curl -sk https://keyrelay.local/ | wc -cequals the page byte count the gate prints, andcurl -s http://keyrelay.local/no longer serves the page (it redirects).- The browser page over
https://reacheslinked, arms, types, andwatch_target.pyon the target sees the keystrokes;relayctl.py --ca <fingerprint's pem>and--insecureboth connect and type; a plaintextws://upgrade on :80 is refused. grep -rn "\.pem\|\.key" --include=* .finds no key material in tracked files; the hygiene gate in the gate script is still enabled.-D KR_TLS=0still buildsbridge(the rollback path), and README says how to use it.- README (intro check count, Safety model, Security model table + the paragraph that
currently admits the token is visible in cleartext, Wire protocol, Controller page,
Architecture file map, Tests table, Quick start, Troubleshooting, Design decisions,
Backlog item 2) and
AGENTS.mdstale statements updated in this phase's commit.