Files
keyrelay/.agents/phases/todo/02_tls_control_channel/00_phase.md
T
ducoterra 56aa97d60d chore(agent): phase roadmap from TODO.md, 5 phases
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
2026-11-08 23:28:25 -05:00

6.0 KiB
Raw Blame History

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

  1. 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 in tests/test_tls.cpp.
  2. 02_identity_persistence.md — NVS storage of the PEM pair, generate-once, serial cert command printing the fingerprint and never the key, StatusSnapshot visibility.
  3. 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.
  4. 04_transport_abstraction.md — ControlServer routes through one transport interface so serveReadonly() stays single-sourced; :80 becomes redirect-only and plaintext /ws is refused rather than downgraded.
  5. 05_page_client_fingerprint.md — the page's wss:// path and pinning banner, relayctl.py over stdlib ssl (--ca, --insecure), and the cross-checks that keep page, tool and firmware agreeing.
  6. 06_tls_docs_rollback.md — README security model/wire table/quick start/backlog, AGENTS.md stale statements, and the -D KR_TLS=0 rollback build proven to compile.

Testing & Quality

  • Unit/integration: tests/test_tls_pem.cpp (new suite registered in tests/run_tests.sh suites=()) 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, --insecure opt-in is loud, and the existing frame/table pins unaffected.
  • tests/test_page.js: the connect path still builds the right URL for both http: and https: 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.sh runs the suites, the sanitiser run and both firmware environments after every task; bridge must still link, and console (KR_HAVE_HID == 0) too.

Completion Criteria

  • bash .agents/validate.sh green, and the report quotes bridge RAM:/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 -c equals the page byte count the gate prints, and curl -s http://keyrelay.local/ no longer serves the page (it redirects).
  • The browser page over https:// reaches linked, arms, types, and watch_target.py on the target sees the keystrokes; relayctl.py --ca <fingerprint's pem> and --insecure both connect and type; a plaintext ws:// 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=0 still builds bridge (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.md stale statements updated in this phase's commit.