From 56aa97d60d96de481fe076608c1cb63d1d673ef7 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 8 Nov 2026 23:28:17 -0500 Subject: [PATCH] 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 --- .agents/PLAN.md | 121 ++++++++++++++++++ .agents/phases/complete/.gitkeep | 0 .../todo/01_verification_gate/00_phase.md | 49 +++++++ .../01_adapt_validate_sh.md | 65 ++++++++++ .../02_hardware_contract.md | 68 ++++++++++ .../todo/02_tls_control_channel/00_phase.md | 95 ++++++++++++++ .../01_tls_identity_module.md | 87 +++++++++++++ .../02_identity_persistence.md | 73 +++++++++++ .../02_tls_control_channel/03_tls_listener.md | 106 +++++++++++++++ .../04_transport_abstraction.md | 89 +++++++++++++ .../05_page_client_fingerprint.md | 86 +++++++++++++ .../06_tls_docs_rollback.md | 92 +++++++++++++ .../todo/03_session_takeover/00_phase.md | 75 +++++++++++ .../01_pending_claim_socket.md | 80 ++++++++++++ .../03_session_takeover/02_takeover_funnel.md | 78 +++++++++++ .../03_status_page_takeover_ui.md | 76 +++++++++++ .../04_takeover_tests_docs.md | 74 +++++++++++ .../04_controller_page_responsive/00_phase.md | 70 ++++++++++ .../01_responsive_layout.md | 66 ++++++++++ .../02_prose_trim_pinned.md | 70 ++++++++++ .../03_suite_size_and_docs.md | 61 +++++++++ .../todo/05_mobile_controller/00_phase.md | 96 ++++++++++++++ .../05_mobile_controller/01_touch_trackpad.md | 81 ++++++++++++ .../02_soft_keyboard_capture.md | 94 ++++++++++++++ .../03_modifier_latch_q_frame.md | 92 +++++++++++++ .../04_mobile_tests_docs.md | 78 +++++++++++ .gitignore | 2 + AGENTS.md | 9 ++ 28 files changed, 2033 insertions(+) create mode 100644 .agents/PLAN.md create mode 100644 .agents/phases/complete/.gitkeep create mode 100644 .agents/phases/todo/01_verification_gate/00_phase.md create mode 100644 .agents/phases/todo/01_verification_gate/01_adapt_validate_sh.md create mode 100644 .agents/phases/todo/01_verification_gate/02_hardware_contract.md create mode 100644 .agents/phases/todo/02_tls_control_channel/00_phase.md create mode 100644 .agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md create mode 100644 .agents/phases/todo/02_tls_control_channel/02_identity_persistence.md create mode 100644 .agents/phases/todo/02_tls_control_channel/03_tls_listener.md create mode 100644 .agents/phases/todo/02_tls_control_channel/04_transport_abstraction.md create mode 100644 .agents/phases/todo/02_tls_control_channel/05_page_client_fingerprint.md create mode 100644 .agents/phases/todo/02_tls_control_channel/06_tls_docs_rollback.md create mode 100644 .agents/phases/todo/03_session_takeover/00_phase.md create mode 100644 .agents/phases/todo/03_session_takeover/01_pending_claim_socket.md create mode 100644 .agents/phases/todo/03_session_takeover/02_takeover_funnel.md create mode 100644 .agents/phases/todo/03_session_takeover/03_status_page_takeover_ui.md create mode 100644 .agents/phases/todo/03_session_takeover/04_takeover_tests_docs.md create mode 100644 .agents/phases/todo/04_controller_page_responsive/00_phase.md create mode 100644 .agents/phases/todo/04_controller_page_responsive/01_responsive_layout.md create mode 100644 .agents/phases/todo/04_controller_page_responsive/02_prose_trim_pinned.md create mode 100644 .agents/phases/todo/04_controller_page_responsive/03_suite_size_and_docs.md create mode 100644 .agents/phases/todo/05_mobile_controller/00_phase.md create mode 100644 .agents/phases/todo/05_mobile_controller/01_touch_trackpad.md create mode 100644 .agents/phases/todo/05_mobile_controller/02_soft_keyboard_capture.md create mode 100644 .agents/phases/todo/05_mobile_controller/03_modifier_latch_q_frame.md create mode 100644 .agents/phases/todo/05_mobile_controller/04_mobile_tests_docs.md diff --git a/.agents/PLAN.md b/.agents/PLAN.md new file mode 100644 index 0000000..d138b0d --- /dev/null +++ b/.agents/PLAN.md @@ -0,0 +1,121 @@ +# keyrelay — Master Plan + +> Master design document for a phased-execution project. The phase roadmap below was +> derived from `TODO.md` on 2026-11-08; each phase overview and task file cites its +> source lines in that file. The **LOCKED DECISIONS** below are binding: no phase may +> introduce technology outside them without explicit user permission. +> +> `README.md` remains the single source of truth for behaviour, protocol and safety. +> `AGENTS.md` remains the operating manual. This file adds *what is left to build* and +> the anchors those phases may not move. If this plan and the README disagree about +> arming, the wire format or the safety model, the README wins and this file is wrong. + +## Assumptions & Design Principles + +- **The safety model is not on the roadmap.** Disarmed-is-the-only-default, the release + funnel, state-not-events, nothing-stored, reject-never-guess, fixed `Ctrl+Alt+Del`, + one-frame-one-command, exclusive-keyboard-not-exclusive-board: every phase adds + capability *inside* those rules. A phase that needs one of them relaxed is wrong, not + ahead of schedule. +- **Every new capability has to be cancelable.** Anything that can outlive a disconnect — + a session, a TLS context, a touch capture, a modifier latch — is cancelled in + `releaseEverything()` or on the link change that ends it, exactly like the paste run. +- **Auth is a precondition, never a consequence.** Takeover (`03`) and the soft keyboard + (`05`) build on the authenticated channel (`02`); neither may gain the ability to arm. +- **No secrets in files.** Not in the repo, not in a log, not in a URL, not in shell + history. This is why the TLS identity is generated *on the board* rather than shipped + as a PEM in the tree — the pipeline's own hygiene gate refuses tracked `*.pem|*.key|*.crt`. +- **No unit blocks on hardware.** Board access is best effort: one attempt, wrapped in + `timeout`, outcome recorded in `.agents/HW_VERIFICATION.md`. The gate is host-observed + (test suites + both firmware builds + `curl` against a reachable board *when* one is + reachable). A phase may not hang, retry forever, or leave the board in download mode + waiting for a human who is not there. +- **Docs move in the same change** (README sections named per task; `AGENTS.md` only gets + stale statements fixed, never expanded). + +## Architectural Anchors + +| COMPONENT | DECISION | RATIONALE | STATUS | +|-----------|----------|-----------|--------| +| Target hardware | M5Stack AtomS3 Lite (ESP32-S3FN8, 8 MB flash, **no PSRAM**), one USB port shared between USB-Serial/JTAG and TinyUSB | `platformio.ini` header, README "The one USB port" | LOCKED | +| Build | PlatformIO `espressif32@8.0.0` (Arduino core 3.3.12), envs `bridge` (HID) and `console` (`ARDUINO_USB_MODE=1`, `KR_HAVE_HID == 0`) | `console` is the only thing that compiles the no-HID branches | LOCKED | +| Control plane | One browser page served from flash (`src/web_page.h`), one WebSocket, ASCII one-frame-one-command, no JSON inbound | invariant 7 | LOCKED | +| Pure modules | `keystate.h`, `proto.h`, `text.h`, `mouse.h`, `output.h`, `ws_codec.h`, `sha1.h` include no Arduino/IDF header, so the identical code runs on a host under ASan/UBSan | `tests/run_tests.sh` | LOCKED | +| Crypto/TLS stack | **mbedTLS + esp-tls exactly as shipped by the installed core** — no new third-party dependency. Verified in `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`: `flags/ld_libs` carries `-lmbedtls_2 -lmbedcrypto -lmbedx509 -lmbedtls -lesp-tls -llwip`; `esp_tls_server_session_create/_continue_async/_delete`, `esp_tls_conn_read/write`, `mbedtls_ssl_setup/set_bio/handshake/conf_max_frag_len`, `mbedtls_pk_write_key_pem`, `mbedtls_ecp_gen_key`, `mbedtls_ctr_drbg_seed`, `mbedtls_x509write_crt_init`, `mbedtls_base64_encode` all resolve; sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`, `ECP_DP_SECP256R1`, `KEY_EXCHANGE_ECDHE_ECDSA`, `SSL_ALPN` | the user chose full WSS; nothing new has to be fetched, vendored or audited | LOCKED | +| TLS transport shape | **HTTPS on 443 + `wss://`**, one TLS context for the controller and a short-lived one per read-only request; `:80` becomes redirect-only | a page loaded over `http://` can be replaced on-path by an attacker who then owns the keystroke path, so half-encryption is not a smaller risk — it is a smaller *apparent* risk | LOCKED | +| TLS identity | **Self-signed ECDSA secp256r1 certificate generated on first boot, PEM key+cert in NVS, SHA-256 fingerprint printed once on the serial console** for pinning; regenerated only if NVS is erased | keeps the private key out of the repo (AGENTS.md), survives reflashing (NVS does), and a per-board key means stealing the repo does not buy a MITM position | LOCKED | +| Plaintext rollback | `-D KR_TLS=0` builds the current `ws://`-only behaviour; documented in README as the rollback, same standing as `mouse off` | a device that cannot be reached over TLS after a bad provisioning change must have a way back that is a reflash, not a repair | LOCKED | +| Arming | Unchanged by every phase in this roadmap: TLS, takeover and mobile input are all gated on the same arming as a keystroke today | invariant 2 | LOCKED | +| Host gate | `tests/run_tests.sh` (+ `SANITIZE=1`) and both PlatformIO builds; `pio run -t upload` never runs inside `.agents/validate.sh` | AGENTS.md: an upload in a gate is a device side effect on somebody else's schedule | LOCKED | + +## High-Level Architecture + +``` +laptop / phone browser target machine + src/web_page.h -- https:// + wss:// ---> ┌───────────────────────────────┐ + │ CODE_TO_USAGE, Pointer Lock, │ AtomS3 Lite (ESP32-S3) │ + │ touch pad, soft keyboard │ src/main.cpp loop(), arming│ + ▼ │ src/control_server.{h,cpp} │ + one ASCII frame (T N X A S C Y P M R K) │ ├ src/tls.{h,cpp} mbedTLS │ USB HID → target + one status frame (S{json}) <---│ ├ src/ws_codec.h SHA-1/WS │ + │ ├ src/proto.h → keystate, │ + tools/relayctl.py (terminal controller, │ │ text, mouse, output │ + stdlib ssl, same frames) │ └ serial console (no typing)│ + └───────────────────────────────┘ +``` + +- `ControlServer` owns exactly one controller socket plus one read-only socket, and both + read-only paths go through `serveReadonly()` so they cannot drift (invariant 8). Phase + `02` puts a transport underneath it (plain socket vs mbedTLS session) without splitting + the routing. +- `main.cpp` owns arming, the report queue and `releaseEverything()` — the single funnel + that clears the queue, raises the keys, ends the chord and discards a pasted run. +- The page is one C++ raw string; `tests/test_page.js` executes it against a DOM shim. + +## Data Model + +- **Key state**: `S=:` restates everything; applying the same state twice is + a no-op. No events, no sequence numbers, no resynchronisation timer. +- **Mouse**: `M=::::`, button byte restated every frame, deltas + signed-byte bounded. +- **Status** (`statusJson()` in `control_server.cpp`): `v up usb armed net ssid ip keys + chords drop roll leds dflt ap typed typing mouse mbtn mframes`. `typed`/`typing` are + counts, never text — that rule is not negotiable for any new field either. +- **NVS** (namespace in `src/main.cpp`, survives reflashing): `sta_ssid`, `sta_pass`, + `token`, `ap_mode`, `mouse`. Phase `02` adds `tlscrt`, `tlskey` (PEM) and `tlscrtfp`. +- **Persistence never holds text.** No macro store, no schedule, no replay, no auto-arm. + +## Validation / Verification Workflow + +1. **Host unit/integration** — `tests/run_tests.sh`: `test_keystate`, `test_proto`, + `test_text`, `test_mouse`, `test_output`, `test_ws_codec` compiled from the *same* + headers the board runs, plus `test_relayctl.py` (tool↔page↔`config.h` tables) and + `tests/test_page.js` (the page script, executed). `SANITIZE=1` for anything touching a + buffer. Baseline at the start of this roadmap: **3406 checks, all passing**. +2. **Firmware builds** — `uv run --python 3.14 --with platformio python -m platformio + run -e bridge -e console` (the `pio` on PATH is broken; `pio run -t upload` reports + SUCCESS on a stale binary, so build status is checked before anything is trusted). + Baseline: bridge `RAM 26.7% (87 592 B)`, `Flash 36.1% (1 135 293 B)`; console + `RAM 16.8%`, `Flash 33.6%`. Any phase that grows RAM is expected to quote the new pair. +3. **Device observation** — `tools/watch_target.py` on the target (evdev, focus + independent) is the only thing that says what the target received; `curl` against the + served page pins page claims. Best effort per the hardware rule above, logged in + `.agents/HW_VERIFICATION.md`. +4. **Gate** — `.agents/validate.sh` runs 1 + 2 (plus repo hygiene) after every task; a + phase commits only when it exits 0. + +## Phase Roadmap + +| # | Phase | TODO source | Objective (one line) | +|---|-------|-------------|----------------------| +| 01 | `01_verification_gate` | — (foundation) | `.agents/validate.sh` runs keyrelay's real gates (host suites, sanitiser run, both firmware builds) and the hardware contract is written down so no unit blocks on a board | +| 02 | `02_tls_control_channel` | `TODO.md` L3 | HTTPS + `wss://` with an on-board-generated self-signed EC identity, so the passphrase and the keystrokes are encrypted in transit and the board's identity can be pinned | +| 03 | `03_session_takeover` | `TODO.md` L6 | An authenticated controller can deliberately take the bridge from a session left connected; the incumbent is told, the funnel runs, and the board comes back **disarmed** | +| 04 | `04_controller_page_responsive` | `TODO.md` L4 | Less prose, tighter layout: one-screen controls on a laptop, single-column and thumb-sized on a phone, safety statements kept verbatim | +| 05 | `05_mobile_controller` | `TODO.md` L5 | Arm on a phone and the soft keyboard appears, with an on-screen modifier row; press-and-hold the pad and the target's pointer moves, release and every button lets go | + +Excluded (already done, no phase): nothing — `TODO.md` held no `[x]` items. + +Deferred, deliberately not in this roadmap (still in README Backlog): OS-wide evdev +capture client, GPIO panic release path, per-connection rate limiting, mDNS on the AP +interface, WPA3/PMF, report-rate telemetry, multi-run pastes, non-US paste maps. diff --git a/.agents/phases/complete/.gitkeep b/.agents/phases/complete/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agents/phases/todo/01_verification_gate/00_phase.md b/.agents/phases/todo/01_verification_gate/00_phase.md new file mode 100644 index 0000000..cec1402 --- /dev/null +++ b/.agents/phases/todo/01_verification_gate/00_phase.md @@ -0,0 +1,49 @@ +# Phase 01 — Verification gate and hardware contract + +**Source:** `TODO.md` — (foundation, no TODO line). Required before the four TODO items +can be executed by subprocesses behind a pass/fail gate. +**Story:** `n/a` +**Context:** `PLAN.md` "Validation / Verification Workflow"; `tests/run_tests.sh`; `AGENTS.md` "Build, test, flash" + +## Objective + +Give this repository a `.agents/validate.sh` that actually fails when keyrelay is broken — +host suites (including the sanitiser run and the page suite) plus **both** firmware builds — +and write down the hardware contract so that no later unit ever blocks waiting for a board +that nobody is holding. + +## Dependencies + +- — (none) + +## Tasks + +1. `01_adapt_validate_sh.md` — author `.agents/validate.sh` with keyrelay's real checks and + the template's repo-hygiene gates, and prove it fails on a deliberately broken tree. +2. `02_hardware_contract.md` — `.agents/HW_VERIFICATION.md` + `tools/hw_probe.sh`: what can + only be checked on hardware, how to check it, the baseline RAM/flash numbers, and the + rule that a unit attempts a device step once, with a timeout, and records the outcome. + +## Testing & Quality + +- Unit/integration: the gate itself is the artifact. It must pass on a healthy tree **and** + fail on each injected fault: a failing C++ assertion, a page-suite id mismatch, a firmware + compile error in `src/main.cpp`, and a tracked `*.pem`. +- Coverage: n/a for shell scripts; >90% applies only to any Python added (`tools/hw_probe.sh` + is shell, not Python). +- `.agents/validate.sh` must run the **full** suite — a partial gate defeats the + "no phase commits unless 100% passing" guarantee. + +## Completion Criteria + +- [ ] `bash .agents/validate.sh` exits 0 on the untouched tree and prints, per check: the + suite results (`RESULT: all suites passed`), the two build results, and the hygiene verdict. +- [ ] `SANITIZE=1` is exercised by the gate (or by a documented, gate-called second pass) and + passes. +- [ ] The gate contains no `pio … -t upload`, no `curl` to a device, no network fetch. +- [ ] Each of the four injected faults makes the gate exit non-zero (demonstrated in the + task's report, then reverted). +- [ ] `.agents/HW_VERIFICATION.md` exists and names, for every device-only claim in this + roadmap, the command that observes it (`tools/watch_target.py`, the serial console, + `curl -k https://keyrelay.local/status`). +- [ ] Full test suite green; `git status` shows only planning artifacts. diff --git a/.agents/phases/todo/01_verification_gate/01_adapt_validate_sh.md b/.agents/phases/todo/01_verification_gate/01_adapt_validate_sh.md new file mode 100644 index 0000000..da9d97b --- /dev/null +++ b/.agents/phases/todo/01_verification_gate/01_adapt_validate_sh.md @@ -0,0 +1,65 @@ +# Task 01 — Author the gate: `.agents/validate.sh` + +**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation: no TODO item; the +gate the other four items need) +**Story:** `n/a` + +## Objective + +`.agents/validate.sh` becomes the authoritative pass/fail gate for this firmware project: +the host suites (normal and sanitised), both PlatformIO environments, and the repo-hygiene +checks. Nothing in it may need a device or the network. + +## Work + +1. `.agents/validate.sh` — if the pipeline already installed its generic template, adapt it + in place; otherwise write it, keeping the template's three hygiene gates verbatim + (tracked-but-ignored files, key-material shapes `.pem/.key/.crt/…`, `PII_PATTERNS`). + Add, in this order, each one printing its own header and accumulating `rc`: + - `bash tests/run_tests.sh` — the C++ suites, `test_relayctl.py` and `test_page.js`. + - `SANITIZE=1 bash tests/run_tests.sh` — ASan/UBSan, the run that found 5 of the 10 bugs. + - `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console` + — the exact invocation from `AGENTS.md`. **Never** `-t upload`. If `uv` is missing, the + gate **fails** with the message from `AGENTS.md` (`pio` on PATH is broken; the fallback + probe is `find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`) — a + silent skip here would let a firmware that does not compile reach `complete/`. + - A page-size sanity check that the served page's byte length equals + `kIndexHtmlLen`-by-construction: `python3` (or `grep`) counts the bytes of the raw + string between `R"HTML(` and `)HTML"` in `src/web_page.h` and prints it; the phase + final passes compare it to the README claim. No device. +2. `.gitignore` — add `.agents/phase-sessions/` and `.agents/pipeline.log` if the pipeline + has not already; never ignore `.agents/` itself. +3. Run `bash .agents/validate.sh` and record the output in the task report, including the + bridge `RAM:`/`Flash:` lines, so later phases can quote a delta instead of a guess. +4. Prove the gate bites, one fault at a time, reverting immediately after each and + re-running to confirm it goes green again: + (a) invert one assertion in `tests/test_keystate.cpp`; + (b) rename an element id in `src/web_page.h` that the script reaches for; + (c) introduce a syntax error in `src/main.cpp`; + (d) `touch certs/test.pem && git add certs/test.pem` (then `git rm --cached`). + `AGENTS.md` warns that a multi-line regex rewrite across a source file has destroyed + `main.cpp` before — make these four edits with exact-string edits only. + +- ASSUMPTION: the gate is allowed to take ~30 s per task (measured: suites ≈2 s with g++ + inside `localhost/ai-sandbox:ubuntu-9a08b1d500b0`, builds ≈2 s incremental). The full + recompile of `bridge` from clean is the worst case and is acceptable. +- ASSUMPTION: a missing `uv`/PlatformIO is a gate failure rather than a skip, because every + phase in this roadmap touches firmware and a skipped build check is the exact failure + `AGENTS.md` records ("`pio run -t upload` reports SUCCESS on a stale binary"). + +## Testing & Quality + +- The four injected faults each make the gate exit non-zero; the untouched tree exits 0. +- The gate is idempotent and safe to run from any cwd (`cd` to the repo root by + `BASH_SOURCE` like `tests/run_tests.sh` does). +- No `set -e` swallowing: use the `rc` accumulation pattern so every check reports, not + just the first failure. + +## Completion Criteria + +- [ ] `bash .agents/validate.sh` → exit 0, printing suite results, both build results, page + byte count, and the hygiene verdict. +- [ ] Each of the four injected faults produced a non-zero exit (paste the four lines into + the report), and the tree is reverted (`git diff` clean apart from `.agents/`). +- [ ] No `upload`, no `curl`, no `podman pull`, no network access in the gate. +- [ ] Full test suite green. diff --git a/.agents/phases/todo/01_verification_gate/02_hardware_contract.md b/.agents/phases/todo/01_verification_gate/02_hardware_contract.md new file mode 100644 index 0000000..ceb4d7c --- /dev/null +++ b/.agents/phases/todo/01_verification_gate/02_hardware_contract.md @@ -0,0 +1,68 @@ +# Task 02 — The hardware contract: what the gate cannot see + +**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation; `AGENTS.md` +"Verification standard": *"A feature that lives on the device is not done when the source +builds — it is done when the device is running it and something observed it."*) +**Story:** `n/a` + +## Objective + +Make the device-only half of this roadmap explicit, runnable and non-blocking: a probe that +reports what hardware is reachable right now, and a file that pairs every device-only claim +with the exact command that would confirm it — so a unit running unattended never hangs on +a board and never claims a device result it did not observe. + +## Work + +1. `.agents/HW_VERIFICATION.md` — three sections: + - **Baseline** (measured, from task 01's gate run): `bridge RAM 26.7% (87 592 B) / + Flash 36.1% (1 135 293 B)`, `console RAM 16.8% (55 200 B) / Flash 33.6% (1 056 081 B)`, + `3406 host checks`. Later phases quote a delta against these numbers. + - **Claims that need hardware**, each with its command and its expected observation, at + least: keystroke/chord delivery and *which* keys a chord really was + (`python3 tools/watch_target.py` on the target); served-page byte agreement + (`curl -sk https://keyrelay.local/ | wc -c` vs the gate's page count — `http` until + phase 02 lands, `https -k` after); TLS handshake + the SHA-256 fingerprint printed once + on the serial console; takeover actually releasing the incumbent's held keys + (`watch_target.py` must show key-up *before* the new session's first key); the mobile + soft keyboard and touch pad (a phone on the board's AP, `watch_target.py` for both the + characters and the pointer deltas). + - **The blocking rule**: a unit attempts a device step **at most once**, wrapped in + `timeout`, and writes the outcome — `ok`, `skipped (no device)`, or `failed: …` — with + the command it ran into the section's table. It never loops on a port, never re-flashes + to "see if it works", and never leaves the board in ROM download mode without saying so + in the report (`tools/to_bootloader.py` drops it there deliberately; a power cycle is + the only way back and nobody may be there to do it). +2. `tools/hw_probe.sh` (executable, `set -uo pipefail`, no side effects) — prints one line + per fact and **always exits 0** (it informs, it does not gate): + `/dev/ttyACM*`/`/dev/ttyUSB*` present?; `keyrelay.local` resolves?; `https` reachable + (`curl -sk -m 2 -o /dev/null -w '%{http_code}'`)?; `http` reachable and does it redirect?; + `uv` on PATH?; `node` on PATH?; is a `watch_target.py`-capable evdev device visible + (informational only). Nothing is opened on a serial port except a `test -e`/`test -r` + check — opening it toggles DTR/RTS and resets the board, so the probe must not. +3. `.agents/validate.sh` — append `bash tools/hw_probe.sh` as the **last, non-affecting** + line (its exit status is deliberately not folded into `rc`), with a comment saying why: + it is context for whoever reads the gate output, not a check. +4. `README.md` — no behaviour change yet; only if the "End-to-end verification" section + contradicts what the probe just printed, fix the stale line. Do not restructure it here. + +- ASSUMPTION: at execution time a board *may* be attached and reachable but nobody is + watching it, so "board available" is treated as best-effort. Any phase that needs an + on-target second machine (`watch_target.py`) records `skipped (no device)` rather than + inventing a result. This is the user-facing cost of running the roadmap unattended; the + file is where they pick those checks up. + +## Testing & Quality + +- `bash tools/hw_probe.sh` exits 0 with and without a board, with and without `uv`, from any + cwd; it never blocks (every probe has a `-m`/`timeout`). +- The gate still passes with the probe line added, and its output cannot flip `rc`. +- `shellcheck`-clean if `shellcheck` is installed; otherwise `-n` syntax check in the report. + +## Completion Criteria + +- [ ] `bash tools/hw_probe.sh` prints the six facts and exits 0 (paste its output). +- [ ] `.agents/HW_VERIFICATION.md` covers every phase 02–05 device claim listed above, each + with command + expected observation + the rule about one attempt. +- [ ] `bash .agents/validate.sh` still exits 0. +- [ ] Full test suite green; no firmware source touched. diff --git a/.agents/phases/todo/02_tls_control_channel/00_phase.md b/.agents/phases/todo/02_tls_control_channel/00_phase.md new file mode 100644 index 0000000..5cac3c2 --- /dev/null +++ b/.agents/phases/todo/02_tls_control_channel/00_phase.md @@ -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 ` 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. diff --git a/.agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md b/.agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md new file mode 100644 index 0000000..9c4bac9 --- /dev/null +++ b/.agents/phases/todo/02_tls_control_channel/01_tls_identity_module.md @@ -0,0 +1,87 @@ +# Task 01 — Device identity: keypair + self-signed certificate on the board + +**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for +password/key input. Doesn't need to be proper x509, but something" +**Story:** `n/a` + +## Objective + +`kr::tls` can, on the board, produce an ECDSA secp256r1 keypair and a self-signed X.509 +certificate for `keyrelay.local`, as PEM strings, plus a SHA-256 fingerprint of the +certificate — with the string-handling half of that testable on a host. + +## Work + +1. `src/tls_pem.h` — **new, pure** module (no Arduino, no IDF headers; the rule in + `AGENTS.md` exists so this compiles and runs under sanitisers on the host): + - `wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out, size_t cap)` + → `-----BEGIN