# 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 ` 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.