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
122 lines
10 KiB
Markdown
122 lines
10 KiB
Markdown
# 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.
|