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