Files
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

10 KiB

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=<mods>:<usages> restates everything; applying the same state twice is a no-op. No events, no sequence numbers, no resynchronisation timer.
  • Mouse: M=<btn>:<dx>:<dy>:<wh>:<pan>, 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.