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:
@@ -1,95 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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 <label>-----\n` + base64 in 64-char lines + `\n-----END <label>-----\n`;
|
||||
returns the length, `0` when `cap` is too small (never truncates silently — invariant 5).
|
||||
- `fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap)` →
|
||||
lowercase hex, `out` sized by the caller; refuses a `cap` that cannot hold it.
|
||||
- `subjectLine()` / `sanList()` builders producing
|
||||
`CN=keyrelay.local`, `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1` —
|
||||
the names in `src/config.h` (`kHostname`, `kApGw`), not literals invented here.
|
||||
- `pemFind(label, haystack, out, cap)` → extracts the DER back out of an armour block
|
||||
(tolerant of CRLF and of a missing trailing newline), `false` on a malformed block.
|
||||
2. `src/tls.h` (+ `src/tls.cpp` if it needs a translation unit) — the mbedTLS half:
|
||||
- `bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap)`: `mbedtls_entropy_init`
|
||||
+ `mbedtls_ctr_drbg_seed` (personalisation string `"keyrelay"`) →
|
||||
`mbedtls_pk_setup` + `mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, …)` →
|
||||
`mbedtls_x509write_crt` (subject == issuer, serial from the DRBG, validity from build
|
||||
time to +10 years, `MBEDTLS_MD_SHA256`, SAN extension) →
|
||||
`mbedtls_x509write_crt_der` → `wrapPem("CERTIFICATE")`; key via
|
||||
`mbedtls_pk_write_key_pem`. Free everything on every exit path, including the error
|
||||
paths; no early `return` that leaks a context.
|
||||
- `bool selfCheck(const PemPair &)` — reparse cert+key and prove they match; used at boot
|
||||
so a half-written NVS record is reported instead of producing a listener that fails
|
||||
every handshake.
|
||||
- Header comment must record *how this was verified linkable*: `flags/ld_libs` of
|
||||
`framework-arduinoespressif32-esp32s3-libs` carries `-lmbedtls_2 -lmbedcrypto
|
||||
-lmbedx509 -lesp-tls`, and sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`,
|
||||
`ECP_DP_SECP256R1_ENABLED`, `KEY_EXCHANGE_ECDHE_ECDSA`.
|
||||
3. `src/config.h` — `kCertCommonName[]`, `kCertValidityYears`, `kPemMax` (sized from the
|
||||
measured PEM lengths plus headroom, with the numbers in a comment), and
|
||||
`#ifndef KR_TLS / #define KR_TLS 1` so the rollback flag has one home.
|
||||
4. `tests/test_tls_pem.cpp` — **new suite**, registered in `tests/run_tests.sh` in
|
||||
`suites=()`. It includes `src/tls_pem.h` **only** — it must never include `src/tls.h`
|
||||
or anything pulling in `mbedtls`, or the host build breaks and the pure-module rule is
|
||||
gone:
|
||||
PEM round-trip (`wrapPem` → `pemFind` → same bytes) for a DER blob of 0/1/45/46/47/n bytes
|
||||
around the 64-char line boundary, CRLF and no-trailing-newline tolerance, refusal when
|
||||
`cap` is one byte short, `fingerprintHex` against the known SHA-256 of `"abc"`, SAN list
|
||||
containing every name the README tells the operator to expect, and ASan-clean runs of all
|
||||
of it under `SANITIZE=1`.
|
||||
5. `platformio.ini` — `-D KR_TLS=1` for `bridge` and `console`, in the existing
|
||||
`build_flags` blocks, with a comment naming the rollback value.
|
||||
|
||||
- ASSUMPTION: **on-board generation rather than a keypair built on the host and flashed.**
|
||||
The alternatives were rejected on record: committing a PEM would put every board behind
|
||||
one key *and* trip the pipeline's key-material hygiene gate; a host-generated per-user PEM
|
||||
makes the first build fail for anybody who has not run a script. Nothing in `src/` may
|
||||
contain a key.
|
||||
- ASSUMPTION: the certificate is generated at boot when NVS holds none, and regenerated only
|
||||
when NVS is erased — see task 02. A changed fingerprint after an erase is expected and
|
||||
documented, not a fault.
|
||||
- ASSUMPTION: ECDSA secp256r1 rather than RSA 2048 — this board has no PSRAM, and the EC
|
||||
pair is a fraction of the RAM and flash cost of RSA while being the stronger key. Browser
|
||||
support for self-signed EC certs is universal on the platforms the README targets.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `bash tests/run_tests.sh` → the new suite passes and is listed; `SANITIZE=1` variant clean.
|
||||
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
|
||||
→ both link (this is the task that proves the mbedTLS symbols resolve in a real image).
|
||||
- The pure module includes no Arduino/IDF header: `grep -n "Arduino.h\|esp_\|mbedtls"
|
||||
src/tls_pem.h` finds nothing.
|
||||
- Coverage **>90%** on `src/tls_pem.h` (walked exhaustively), stated for `src/tls.*`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `tests/test_tls_pem.cpp` runs in the suite and the PEM boundary cases (0/45/46/47 bytes,
|
||||
one byte short of `cap`) each have a named check.
|
||||
- [ ] `generateIdentity()` compiles and links into `bridge` **and** `console`; the report
|
||||
quotes the new `RAM:`/`Flash:` pair against `bridge 26.7% / 36.1%`.
|
||||
- [ ] `tests/test_proto.cpp` untouched and still at its previous check count — the wire
|
||||
format did not change in this task.
|
||||
- [ ] Full test suite green; no key material anywhere in the tree (`git status`, `grep`).
|
||||
@@ -1,73 +0,0 @@
|
||||
# Task 02 — Keep the identity across reflashes: NVS, first boot, `cert`
|
||||
|
||||
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
|
||||
password/key input…" (the identity half: an encryption key has to live somewhere that is not
|
||||
a file in this repo)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
The keypair and certificate are generated once, stored in NVS, reused after a reflash, and
|
||||
made observable to the operator as a SHA-256 fingerprint — on the serial console and in the
|
||||
status frame — without the key ever being printed, echoed, or written to a file.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/main.cpp` — the NVS half (follow the existing shape exactly: `Preferences prefs;`,
|
||||
`prefs.begin(kNvsNamespace, true/false)` / `end()`, `constexpr char kKey…[]` next to
|
||||
`kKeyToken`):
|
||||
- `kKeyTlsCert[] = "tlscrt"`, `kKeyTlsKey[] = "tlskey"`, `kKeyTlsFp[] = "tlscrtfp"`.
|
||||
- `loadTlsIdentity()` at boot: load cert+key+fingerprint; if any is missing **or**
|
||||
`kr::tls::selfCheck()` fails, generate, store, and print the fingerprint **once**
|
||||
(`[tls] identity generated — pin this certificate:` then the hex, wrapped 2-by-2).
|
||||
Never `Serial.printf` the key, the cert body, or a length that identifies the text.
|
||||
- A corrupt/half-written record regenerates rather than booting a listener that cannot
|
||||
complete a handshake — and says so, once, loudly.
|
||||
2. `src/main.cpp`, serial console — a `cert` command next to `token` in the help text
|
||||
(`~main.cpp:893` block) and the dispatch chain (`~main.cpp:1010`): prints the
|
||||
fingerprint, the certificate's notBefore/notAfter and the SAN list. **No** argument form
|
||||
that writes a key, **no** subcommand that dumps the PEM. Update the help line and the
|
||||
`usage` output.
|
||||
3. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool tls` and
|
||||
`const char *fp` (the first 8 bytes of the fingerprint as 16 hex chars, sized by a named
|
||||
constant); `statusJson()` emits `"tls":u,"fp":"…"`; `kStatusJsonCap` grows accordingly
|
||||
with the arithmetic in the comment (the reason that constant exists is that a truncated
|
||||
status lies about state).
|
||||
4. `src/main.cpp` — fill both fields where `StatusSnapshot` is populated (`~main.cpp:522`,
|
||||
next to `s.defaultToken`).
|
||||
5. `tests/test_proto.cpp` — extend the status-frame assertions: the new fields render, a
|
||||
`tls:0` snapshot renders `fp:""`, and the rendered frame still fits
|
||||
`kStatusJsonCap` for the longest legal SSID (that test exists; make sure the new fields
|
||||
do not push it over).
|
||||
6. `README.md` — "First bring-up" / Quick start: the fingerprint appears once at first boot,
|
||||
`cert` reprints it, an `nvs erase` changes it, and the browser asks you to accept the
|
||||
certificate once per browser. Security-model wording is task 06's job; do not restate the
|
||||
whole model here.
|
||||
|
||||
- ASSUMPTION: the fingerprint lives in NVS next to the cert rather than being recomputed at
|
||||
boot, so `cert` costs no hashing and the page can show it from the status frame; it is
|
||||
public data, so storing it is not a secret-at-rest question.
|
||||
- ASSUMPTION: `Preferences.putString` is fine for ~1 KB PEM blobs in the existing namespace
|
||||
(`huge_app.csv` gives NVS 20 KB, already holding SSID/pass/token) — verify with the boot
|
||||
log and the "nvs: " lines; if it does not fit, the fallback is a dedicated small partition
|
||||
and that is a stop-and-report, not a silent change of layout.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_proto.cpp`: new status fields pinned, cap arithmetic pinned.
|
||||
- `bash .agents/validate.sh` green, including `console` (`KR_HAVE_HID == 0`) — the console
|
||||
environment is what proves the TLS/identity path does not depend on USB HID.
|
||||
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): first boot prints a
|
||||
fingerprint; reflash prints the same one; `cert` prints it again; `grep` over the serial
|
||||
transcript for `PRIVATE KEY` finds nothing. Record `ok`/`skipped (no device)`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `statusJson()` output contains `"tls":1` and a 16-hex `fp` on a TLS build; the frame
|
||||
length is asserted `<= kStatusJsonCap`.
|
||||
- [ ] `cert` on the serial console prints fingerprint + validity + SANs and no PEM body.
|
||||
- [ ] Fingerprint survives a reflash (or the report says `skipped (no device)` and the exact
|
||||
commands to confirm it).
|
||||
- [ ] No private key material in any file, log, or `Serial.printf` (`git grep -i "private
|
||||
key" src/` finds only the mbedTLS API call names).
|
||||
- [ ] Full test suite green.
|
||||
@@ -1,106 +0,0 @@
|
||||
# Task 03 — The TLS listener: one session at a time, never blocking the loop
|
||||
|
||||
**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
|
||||
|
||||
A second listener on 443 accepts a socket, drives an mbedTLS **server** handshake from
|
||||
`tick()` without ever blocking `loop()`, and hands the control server a byte pipe that looks
|
||||
like the plain socket it already reads. The keyboard path must not wait on the network stack
|
||||
— that is the reason `tick()` is non-blocking today, and TLS must not break it.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/tls_session.h` (+ `.cpp`) — `kr::tls::Session`, one TLS connection over an already
|
||||
accepted fd:
|
||||
- `bool open(int fd, const PemPair &id)` — `mbedtls_ssl_init` +
|
||||
`mbedtls_ssl_config_init` + `mbedtls_ssl_config_defaults(...,
|
||||
MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)`,
|
||||
`mbedtls_ssl_conf_endpoint(MBEDTLS_SSL_IS_SERVER)`, `mbedtls_x509_crt_parse` +
|
||||
`mbedtls_pk_parse_key` + `mbedtls_ssl_conf_own_cert`, and
|
||||
`mbedtls_ssl_set_bio(&fd, recvCb, sendCb, NULL)` with `recvCb`/`sendCb` wrapping
|
||||
`::recv`/`::send` on the fd and mapping `EWOULDBLOCK` to
|
||||
`MBEDTLS_ERR_SSL_WANT_READ`/`WANT_WRITE`. Set the fd non-blocking
|
||||
(`fcntl(fd, F_SETFL, O_NONBLOCK)`) before the handshake. Client certification is **not**
|
||||
requested (`MBEDTLS_SSL_VERIFY_NONE`) — the passphrase is still the controller's
|
||||
credential, not the certificate.
|
||||
- `Handshake poll()` → `InProgress | Ready | Failed`, calling `mbedtls_ssl_handshake()`
|
||||
once per `tick()` and returning on `WANT_READ`/`WANT_WRITE`. A session that has not
|
||||
completed within `kTlsHandshakeMs` is closed — a socket that opens and never finishes a
|
||||
handshake is the same kind of scan as one that opens and never sends `T<token>`.
|
||||
- `size_t read(uint8_t *dst, size_t cap)` / `size_t write(const uint8_t *src, size_t len)`
|
||||
— `mbedtls_ssl_read`/`mbedtls_ssl_write`, returning 0 on `WANT_READ`/`WANT_WRITE` and
|
||||
never spinning; a short write is returned as a short write (the caller keeps its
|
||||
framing state, exactly as with `WiFiClient`).
|
||||
- `bool alive()`, `void close()` — `mbedtls_ssl_session_reset` + `mbedtls_ssl_free` +
|
||||
`mbedtls_x509_crt_free` + `mbedtls_pk_free` + `::close(fd)` on **every** path,
|
||||
including the error paths out of `open()`.
|
||||
- Do **not** use `esp_tls` for this: `esp_tls_server_session_create()` owns the socket
|
||||
lifetime and its handshake is not driven the way `tick()` needs. Note that in the
|
||||
header comment along with what was checked in `esp_tls.h`, so the next reader does not
|
||||
"simplify" it back.
|
||||
2. `src/control_server.h` / `.cpp` — a second `WiFiServer _tlsServer(kHttpsPort)`;
|
||||
`acceptNew()` accepts from both listeners; `tick()` gives the TLS path the same shape as
|
||||
the existing one: `acceptNew → serveHttp/serveWebSocket → timeout checks`. Keep the
|
||||
controller slot in exactly **one** place: a socket is either HTTP, WebSocket or none, no
|
||||
matter which listener it came from, so a TLS controller and a plaintext controller can
|
||||
never both hold the keyboard.
|
||||
- At most **two** live sessions at once (`kTlsMaxSessions`): the controller's, and one
|
||||
more — a short-lived read-only request destroyed the moment its response is flushed,
|
||||
or (phase 03) a pending takeover claim. A third connection is refused **explicitly**
|
||||
(TLS alert then close, plus a counter), never queued and never half-opened: the
|
||||
read-only path must fail visibly rather than push the board out of memory. This is a
|
||||
RAM decision, not a policy one — say so in the comment, and keep the refusal
|
||||
independent of which listener the socket came from.
|
||||
3. `src/config.h` — `kHttpsPort = 443`, `kTlsHandshakeMs = 10000`,
|
||||
`kTlsMaxSessions = 2`, and the RAM note: `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` is 16384 in
|
||||
this core, so a session's I/O buffers are ~2×16 KB. Try
|
||||
`mbedtls_ssl_conf_max_frag_len(&ssl, MBEDTLS_SSL_MAX_FRAG_LEN_4096)` **only** if the
|
||||
measured heap says it is needed, and record the measurement either way.
|
||||
4. Measure, in the report and in `.agents/HW_VERIFICATION.md`: `ESP.getFreeHeap()` at boot,
|
||||
with one session open, with a second (viewer) session open, and after both close —
|
||||
against the phase 01 baseline (`bridge RAM 26.7% / 87 592 B`). Print the numbers in the
|
||||
boot log behind `KR_DEBUG` if the project has a debug switch, otherwise log once at
|
||||
startup and once per session close *as numbers only* (invariant 4: nothing about content).
|
||||
5. `tools/hw_probe.sh` (from phase 01) — extend with a `openssl s_client -connect
|
||||
<ip>:443 -servername keyrelay.local` probe wrapped in `timeout 5` reporting protocol and
|
||||
cipher, informational, exit status unchanged.
|
||||
|
||||
- ASSUMPTION: raw mbedTLS rather than `esp_tls` (rationale above). If raw mbedTLS turns out
|
||||
unable to complete a handshake non-blockingly on this core, the fallback is `esp_tls` in
|
||||
async mode on the same fd — but that is a design change to be recorded in the task report
|
||||
and the README's Design decisions, not a quiet swap.
|
||||
- ASSUMPTION: TLS on 443 only, no plaintext WebSocket on 8443, no dual-stack port list. The
|
||||
rollback is `-D KR_TLS=0` (task 06), not a second open port.
|
||||
- ASSUMPTION: 2 concurrent sessions is the ceiling. Today's server already holds at most one
|
||||
controller plus one one-shot viewer, so the policy is unchanged; only the cost per socket
|
||||
went up.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Host: the parsing/timeout arithmetic that can be isolated (handshake-deadline comparison,
|
||||
"a session that never completes is dropped", "read returns 0 rather than spinning") goes
|
||||
into a pure helper in `src/tls_session.h` guarded the same way `output.h` is, and is
|
||||
checked in `tests/test_tls_pem.cpp` or a new `tests/test_tls_session.cpp` if the helper is
|
||||
big enough — no mbedTLS includes in either.
|
||||
- Firmware: `bridge` and `console` both build; the report quotes the four heap numbers.
|
||||
- Device (best effort, one attempt): `openssl s_client` completes a handshake and negotiates
|
||||
`TLSv1.2`/`TLSv1.3` with `ECDHE-ECDSA-*`; a second concurrent connection is accepted while
|
||||
the first is open; a connection that never completes the handshake is dropped after
|
||||
`kTlsHandshakeMs` and the loop keeps typing (verify by watching the LED heartbeat / a
|
||||
`status` poll during the stalled attempt).
|
||||
- The keystroke path's latency is unchanged while a *second* client connects — that is the
|
||||
regression this task can introduce, so it gets named in the report even when the answer is
|
||||
"not measured".
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] A TLS connection to :443 completes a handshake and survives `kTlsHandshakeMs`-bound
|
||||
teardown when it does not.
|
||||
- [ ] `loop()` never blocks on the TLS path: the report states how that was shown (code walk
|
||||
of every `mbedtls_ssl_*` call site plus the device observation), not asserted.
|
||||
- [ ] Heap numbers recorded for boot / 1 session / 2 sessions / after close, and no leak over
|
||||
20 connect/disconnect cycles.
|
||||
- [ ] `bash .agents/validate.sh` green; full test suite green; `test_proto.cpp` untouched.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Task 04 — One routing path, two transports: TLS is not a second server
|
||||
|
||||
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
|
||||
password/key input…" (the routing half: the routes must not fork into a secure one and an
|
||||
insecure one that can disagree)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
Put the transport underneath `ControlServer` instead of beside it: every route — page,
|
||||
`/status`, `/ws`, the read-only viewer — is served through `serveReadonly()`/`serveHttp()`
|
||||
once, over either a plain socket or a TLS session, and `:80` stops carrying content: it
|
||||
redirects and otherwise tells the client to use `https://`. Invariant 8 explicitly requires
|
||||
that both routes keep going through `serveReadonly()` "so they cannot drift apart"; a
|
||||
half-encrypted server is the shape that drifts.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/conn.h` — a small transport view used by the routing code: `read(dst,cap)`,
|
||||
`write(src,len)` (may be partial — returns what went), `alive()`, `stop()`, and
|
||||
`writeProgmem(src,len)` for the page. Implemented by `PlainConn` (wraps `WiFiClient`) and
|
||||
`TlsConn` (wraps `kr::tls::Session` from task 03). No virtual calls in the keystroke path
|
||||
if a tagged union is cheaper — measure, then choose, and say which in the comment.
|
||||
2. `src/control_server.cpp`:
|
||||
- `readSome()`, `writeTextFrame()`, `httpResponse()`, `httpError()`, `sendStatus()`,
|
||||
`sendError()` go through the conn. **`readSome()` keeps its rule**: only bytes from an
|
||||
authenticated controller on the WebSocket refresh `_lastInboundMs`. A TLS session must
|
||||
not change that — a handshake, a `/status` poll or a viewer refresh is not the operator
|
||||
being alive (that comment in `readSome()` explains a real bug; carry it over).
|
||||
- `serveReadonly()` and `serveViewer()` keep serving *both* listeners; a read-only
|
||||
request over TLS is the same code with a different conn.
|
||||
- The page write becomes **resumable**: `c.write_P(kIndexHtml, kIndexHtmlLen)` works for a
|
||||
`Print`, not for a socket. Copy PROGMEM into a bounded RAM chunk (name the size) and
|
||||
carry a per-connection send cursor (`sent`, `total`) so a 35 KB page drains across
|
||||
several `tick()`s under a per-tick byte budget. If the client stalls, the existing 10 s
|
||||
stall rule drops it. **Never** block in `loop()` waiting for a slow page consumer, and
|
||||
never queue the page in RAM beyond the chunk.
|
||||
- `closeLink()` stays the single funnel: whichever transport it is handed, it releases on
|
||||
link change (`wasLinked` semantics, bug 10) and destroys the TLS session. A TLS teardown
|
||||
failure must not skip the release.
|
||||
3. `:80` behaviour (`KR_TLS == 1`):
|
||||
- `GET /` and `/index.html` → `301` with `Location: https://<host>/…`.
|
||||
- A request carrying a `token` query parameter (i.e. `/status?token=…`) → **`426 Upgrade
|
||||
Required`** with a one-line body telling the operator to use `https://`, and **no
|
||||
redirect** — a redirect that echoes `?token=` would expose the passphrase twice instead
|
||||
of once. `AGENTS.md` says the token stays a query parameter on `/status` because `curl`
|
||||
is the point of that endpoint; this keeps `curl` working (over TLS) without making the
|
||||
plaintext path leakier.
|
||||
- A `/ws` upgrade on :80 → `426`, plaintext controller refused rather than silently
|
||||
downgraded (invariant 5: never quietly accept the weaker thing).
|
||||
- All of the above is compiled out by `-D KR_TLS=0`, which restores today's `:80` server
|
||||
exactly.
|
||||
4. `src/config.h` — `kHttpRedirectPort`/`kHttpsPort` naming, `kPageChunkBytes`, and the
|
||||
per-tick page budget, each with the measured numbers that chose it.
|
||||
5. `tests/test_relayctl.py` + `tests/test_page.js` — assert the *refusal* shapes that can be
|
||||
checked from the client side: the tool's error text distinguishes "board wants TLS" from
|
||||
"wrong passphrase", and the page's URL builder picks `wss://` from an `https:` origin and
|
||||
`ws://` only on a `-D KR_TLS=0` build (pin it by asserting the served page contains no
|
||||
plaintext `ws://` literal when TLS is on).
|
||||
|
||||
- ASSUMPTION: 426 (Upgrade Required) for token-carrying and WebSocket requests on the
|
||||
plaintext port, 301 for everything else. The alternative — redirect everything including
|
||||
`?token=` — was rejected for the reason above.
|
||||
- ASSUMPTION: mDNS stays one service on port 80 plus a second `_https._tcp` advertisement;
|
||||
`keyrelay.local` must keep resolving, which is the whole point of the README's "IP if the
|
||||
name fails" note.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Host: no protocol change, so `tests/test_proto.cpp` (2 327 checks) and `test_output.cpp`
|
||||
must stay green untouched — if either needs editing, the task has changed the wire or the
|
||||
funnel and must stop and say what it changed and why.
|
||||
- The `readSome()` rule gets a **source pin** in `tests/test_relayctl.py` alongside the
|
||||
existing three: the TLS conn's bytes reach `_lastInboundMs` only when
|
||||
`_phase == WebSocket && _authed`. (Pinning source sentences is how bug 10 stays dead.)
|
||||
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
|
||||
gate's page count; `curl -sI http://keyrelay.local/` is a 301; `curl -s
|
||||
'http://keyrelay.local/status?token=x'` is 426 and its body contains no token; the page
|
||||
still reaches `linked` over `wss://` while a second tab watches `/status`.
|
||||
- RAM: the chunk size + one page cursor, quoted in the report.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] One `serveReadonly()` implementation reachable from both listeners (a `grep` for a
|
||||
second HTTP response writer in `src/` comes up empty).
|
||||
- [ ] Page delivered intact over TLS (byte-for-byte, `wc -c` and a `sha256sum` against the
|
||||
PROGMEM count) and the send is resumable, not blocking.
|
||||
- [ ] `:80` serves only the redirect and the two 426s; no keystroke-bearing path listens on 80.
|
||||
- [ ] `bash .agents/validate.sh` green; the new source pin present; full test suite green.
|
||||
@@ -1,86 +0,0 @@
|
||||
# Task 05 — The client side of pinning: page and `relayctl.py`
|
||||
|
||||
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
|
||||
password/key input…" (the half the operator actually touches: a browser that must trust the
|
||||
board, and the terminal controller)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
The page talks `wss://` when it was served over `https://`, shows the board's certificate
|
||||
fingerprint so the operator can confirm it against the serial console, and refuses to accept
|
||||
a passphrase when it was loaded over plaintext; `relayctl.py` speaks TLS over stdlib `ssl`
|
||||
with **fingerprint pinning** as the default posture, `--ca` for a real CA, and `--insecure`
|
||||
only as a loud, explicit opt-in.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/web_page.h`:
|
||||
- `connect()` already picks `wss://` from `location.protocol === "https:"` — keep it, and
|
||||
add the guard the other way round: when the page was loaded over `http:` while the board
|
||||
reports `tls:1`, do **not** open a socket and do **not** accept the passphrase input;
|
||||
say why ("this page arrived in the clear; reload over https"). Do not silently fall
|
||||
back to `ws://`.
|
||||
- A fingerprint line in the Connection card, driven by the status frame: `certificate
|
||||
SHA-256 aabbccdd… — confirm it against the `cert` command on the board's serial
|
||||
console`. Rendered only from `s.fp` (task 02), never assembled from anything the page
|
||||
invents, and the pill says `tls` vs `plaintext` so the state is visible without reading.
|
||||
- Keep every element id the script reaches for intact (`tests/test_page.js` checks that
|
||||
coverage) and add the new ids to the markup in the same change.
|
||||
2. `tools/relayctl.py`:
|
||||
- `Relay.__init__`: build the socket through `ssl` when TLS is on — default port 443,
|
||||
`WS_PATH` unchanged.
|
||||
- Pinning: `--fingerprint <sha256>` (also honoured as `$KEYRELAY_TLS_FP`) connects with
|
||||
certificate verification off, then takes `getpeercert(binary_form=True)`, hashes it and
|
||||
**aborts on mismatch** before a single frame is sent. That is real pinning rather than
|
||||
TOFU, and it needs no CA distribution for a self-signed device certificate.
|
||||
- `--ca <pem>` uses `ssl.create_default_context()` with hostname checking on.
|
||||
- `--insecure` sets `CERT_NONE` and prints a warning line to stderr on every run.
|
||||
If none of the three is given, refuse to connect and say what to pass — the tool must
|
||||
not be the component that quietly downgrades (`AGENTS.md` invariant 5).
|
||||
- Keep `--token` / `$KEYRELAY_TOKEN` behaviour, including the pre-flight check that does
|
||||
not require a reachable board, and never print the token.
|
||||
- `-D KR_TLS=0` boards: `--plain` (explicit) selects the old `ws://` on port 80, so the
|
||||
rollback path stays scriptable and remains opt-in.
|
||||
3. `tests/test_relayctl.py`: URL/port selection per flag combination; the "no TLS option
|
||||
given → refuse" rule; a fingerprint mismatch aborts **before** `T<token>` is sent (assert
|
||||
on the recorded frame list, which the suite already has); `--insecure` emits the warning;
|
||||
`--plain` is the only path to port 80; and all existing frame/table pins unchanged.
|
||||
4. `tests/test_page.js`: the `https:` origin builds `wss://`, the `http:` origin refuses and
|
||||
never sends a frame; the fingerprint line renders exactly the `fp` the status frame
|
||||
carried and renders nothing when `tls:0`; every id the script touches exists in the markup.
|
||||
5. `README.md` — Quick start: accept the certificate warning once per browser, or pin;
|
||||
`curl -sk https://keyrelay.local/status?token=…`; `relayctl.py --fingerprint`; the
|
||||
fingerprint from `cert`. Troubleshooting: `SSL_ERROR_R…`/`certificate verify failed` means
|
||||
the board regenerated its identity (`nvs erase`) or you are on the wrong network — which
|
||||
is exactly the attack the fingerprint is for, so say it in those words.
|
||||
|
||||
- ASSUMPTION: fingerprint pinning is the default posture rather than shipping a CA. A CA
|
||||
distributed with the repo would be one shared key for every board (rejected in task 01);
|
||||
per-board CA installation on every controller device is more ceremony than a keyboard
|
||||
bridge is worth. `--ca` remains for the operator who wants a real chain.
|
||||
- ASSUMPTION: the passphrase stays in `sessionStorage` (as today) and stays a `/status` query
|
||||
parameter (per `AGENTS.md`). Over TLS both are encrypted in transit; the remaining exposure
|
||||
is the local browser and shell history, which the README already names.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Both host suites extended as above; `bash tests/run_tests.sh` and the `SANITIZE=1` variant green.
|
||||
- `uv … run -e bridge -e console` green, and `curl -sk https://keyrelay.local/ | wc -c` still
|
||||
equals the gate's page count after the markup change (the page is served from flash — an
|
||||
unflashed board shows the old page, so say which you observed).
|
||||
- Device (best effort, one attempt): phone or laptop reaches the page over `https://`,
|
||||
`linked` appears, the fingerprint shown matches `cert` on the console, and `relayctl.py
|
||||
--fingerprint …` types while `watch_target.py` on the target confirms it.
|
||||
- Coverage **>90%** for the changed `relayctl.py` paths (the suite drives the connection
|
||||
object against a fake socket, as it already does for frames).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] An `http:`-loaded page refuses to connect and to accept a passphrase (shown by
|
||||
`test_page.js`, and by the served bytes when the board is reachable).
|
||||
- [ ] `relayctl.py` with no TLS argument refuses; with a wrong fingerprint it aborts before
|
||||
sending `T<token>`; with the right one it arms and types.
|
||||
- [ ] The page displays the board's fingerprint from the status frame, and the pill says
|
||||
`tls`.
|
||||
- [ ] Full test suite green; `test_proto.cpp` untouched.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Task 06 — Tell the truth about it: docs, backlog, and the rollback build
|
||||
|
||||
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
|
||||
password/key input. Doesn't need to be proper x509, but something" (the documentation half
|
||||
`AGENTS.md` requires in the same change: *"Docs move with the code."*)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
The README stops describing a plaintext control channel — including the paragraph that
|
||||
currently *apologises* for it — and Backlog item 2 ("TLS with a pinned certificate") is
|
||||
closed. The `-D KR_TLS=0` rollback is built, documented and named, so the encryption is a
|
||||
decision an operator can reverse rather than a dependency they are stuck with.
|
||||
|
||||
## Work
|
||||
|
||||
1. `README.md`, in place, keeping the reasoning and not just the behaviour:
|
||||
- **Intro bullets + the check count**: update `**3406 checks**` to the new total and add
|
||||
one bullet for what is now encrypted in transit.
|
||||
- **"Security model and trust boundaries"**: the trust table — "Anyone on the board's
|
||||
network" can no longer read keystrokes or the passphrase; add/adjust rows for
|
||||
"Anyone who can see the traffic" (sees a TLS handshake to an unnamed host and nothing
|
||||
else) and "Anyone with a copy of this firmware" (gets no key: the identity is generated
|
||||
on each board). The paragraph beginning **"What the passphrase does and does not do"**
|
||||
currently states that the page is plain HTTP, the socket is `ws://` and "the token is
|
||||
visible at first contact"; rewrite it in place, keeping its structure — what TLS now
|
||||
buys, what it does **not** buy (an unpinned connection is still an on-path risk until you
|
||||
confirm the fingerprint; the target still sees whatever you type on it; the local
|
||||
machine is still not trusted), and the **Practical rule** paragraph adjusted to
|
||||
"the certificate you did not pin is the one thing to check".
|
||||
- **Safety model**: one sentence locating TLS where it belongs — transport, not an arming
|
||||
input. A TLS session that is not armed types nothing, and a dropped TLS session is a
|
||||
lost link, which runs the funnel and disarms.
|
||||
- **Wire protocol**: the table is unchanged (same frames), but add the line that the
|
||||
transport is `wss://` and that `T<token>` no longer crosses the network in the clear;
|
||||
`426` on the plaintext port documented with the error-code list.
|
||||
- **Quick start**: first boot prints the fingerprint once, `cert` reprints it, the browser
|
||||
asks once per browser, `curl -sk https://keyrelay.local/status?token=…`, `relayctl.py
|
||||
--fingerprint`. Note the `-D KR_TLS=0` rollback right where a stuck operator looks for it.
|
||||
- **Controller page**: the `tls` pill and the fingerprint line; keep the existing element
|
||||
table current rather than appending a new section.
|
||||
- **Architecture** file map: `src/tls_pem.h` (pure), `src/tls.h` (identity),
|
||||
`src/tls_session.h` (session), `src/conn.h` (transport view), and what each owns.
|
||||
- **Tests**: new `test_tls_pem` row with its real check count; the totals in the intro and
|
||||
the table made to agree.
|
||||
- **Measured behaviour**: the TLS numbers that task 03 measured — handshake time, heap
|
||||
with 1 and 2 sessions, flash growth — as measurements, with the command that produced them.
|
||||
- **Backlog**: remove item 2 and renumber the rest; if a narrower remainder exists (e.g.
|
||||
"a real CA so browsers stop warning", or session-resumption/ticket tuning), that becomes
|
||||
an item — do not leave a closed item in place.
|
||||
- **Design decisions**: why TLS instead of a challenge–response over the passphrase; why
|
||||
the identity is generated on the board rather than shipped as a PEM; why raw mbedTLS
|
||||
rather than `esp_tls`; why a `token`-carrying request on `:80` is refused with 426
|
||||
instead of redirected.
|
||||
2. `AGENTS.md` — **fix stale statements only, do not expand**: the page-size check
|
||||
`curl -s http://keyrelay.local/ | wc -c` becomes `curl -sk https://keyrelay.local/ |
|
||||
wc -c` (the page is still served from flash, so the "reflash before you believe the
|
||||
network" warning stays); "Current defaults worth knowing" gains the TLS identity fact in
|
||||
the same one-line style (generated on board, in NVS, survives reflashing, `cert` prints
|
||||
the fingerprint); the "Secrets" bullet keeps its rule and, if it does not already cover
|
||||
it, the concrete instance that the private key lives in NVS and never in a file. Nothing
|
||||
else in this file: phase narrative belongs in `.agents/phases/`.
|
||||
3. `src/tls.h`, `src/tls_pem.h`, `src/tls_session.h`, `src/conn.h`, `tools/relayctl.py` —
|
||||
the docstring at the top of each module describes *why*, in the house style of
|
||||
`proto.h`/`web_page.h`/`control_server.h`: what it protects, what it does not, and the
|
||||
decisions a later editor must not reverse by accident.
|
||||
4. Build the rollback and prove it exists: `uv run --python 3.14 --with platformio python
|
||||
-m platformio run -e bridge -e bridge --upload-port "" --build-flags "-D KR_TLS=0"` or
|
||||
the equivalent temporary env (document the exact working command in the README rather
|
||||
than inventing prose around it), and record the resulting `RAM:`/`Flash:` pair.
|
||||
5. `.agents/HW_VERIFICATION.md` — fill in the device rows this phase touched with what was
|
||||
actually observed, or `skipped (no device)` plus the command. Never a claim that was not
|
||||
made.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `bash .agents/validate.sh` green, including both firmware environments and the rollback build.
|
||||
- Every number quoted in the README matches a command in this repository (check count from
|
||||
the suite, page bytes from the gate, heap/RAM/flash from the build and the boot log).
|
||||
- A reader who has never seen the code can bring the board up over TLS from the README alone.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `grep -n "plain HTTP\|ws://" README.md` returns only statements about the rollback
|
||||
build, not about the default.
|
||||
- [ ] Backlog has no TLS item and the numbering is contiguous.
|
||||
- [ ] The trust-boundary table reflects the encrypted channel, and the "does and does not"
|
||||
paragraph names what TLS still does not protect.
|
||||
- [ ] `-D KR_TLS=0` builds, and the README states the exact command and the trade.
|
||||
- [ ] README check count and Tests table agree with the suite's own output; `AGENTS.md`
|
||||
carries no new prose, only corrected facts.
|
||||
- [ ] Full test suite green.
|
||||
Reference in New Issue
Block a user