The four open items in TODO.md become an executable pipeline: 01 the verification gate (host suites + sanitiser + both firmware envs, and the hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS and wss:// with an identity the board generates itself into NVS, 03 session takeover that always lands disarmed, 04 a responsive page whose safety statements are pinned before they are trimmed, 05 the phone as a controller (press-and-hold pad, soft keyboard, modifier latches over a new Q= frame). TODO.md is cleared to its empty title; the items live in .agents/phases/ now. AGENTS.md gains the five protocol rules and nothing else. LLM-Generated: true
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.