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`.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user