Files
keyrelay/.agents/phases/complete/02_tls_control_channel/00_phase.md
T
ducoterra e0780f58e0 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`.
2026-11-09 05:51:57 -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.