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:
+135
-41
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes.
|
||||
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes,
|
||||
* over a plaintext socket or a TLS session -- into one controller slot.
|
||||
*
|
||||
* Shape of it:
|
||||
* - one controller at a time. A second WebSocket upgrade is refused outright rather
|
||||
@@ -8,14 +9,28 @@
|
||||
* Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board.
|
||||
* Refusing everything meant pressing reload on your own tab failed, and a live
|
||||
* controller could not be inspected by anything else (serveViewer).
|
||||
* - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both
|
||||
* feed the same accept decision -- tls::admitConnection -- the same serveHttp() /
|
||||
* serveWebSocket() routing and the same serveReadonly(); the only thing a socket's
|
||||
* origin changes is which Conn it got bound to, and the only place the difference is
|
||||
* visible at all is the plaintext policy in serveReadonly() (:80 answers a redirect or
|
||||
* 426 instead of content, compiled out entirely by -D KR_TLS=0). A socket is HTTP,
|
||||
* WebSocket or none regardless of port, so a TLS controller and a plaintext controller
|
||||
* can never both hold the keyboard.
|
||||
* - non-blocking throughout: tick() runs once per loop() and consumes only what is
|
||||
* already buffered. The keystroke path must not wait on the network stack.
|
||||
* already buffered. The keystroke path must not wait on the network stack -- under
|
||||
* TLS that includes the handshake (Conn::poll(), one library call per tick) and every
|
||||
* response write (queued, pumped under kPageBytesPerTick, never waited on); in
|
||||
* plaintext it includes the write that used to be able to sit in a one-second select
|
||||
* ten times over (see Conn::writeRaw).
|
||||
* - nothing is trusted before the passphrase. The socket opens, must present T<token>
|
||||
* inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the
|
||||
* evaluation out for kTokenLockMs.
|
||||
* evaluation out for kTokenLockMs. TLS is transport, never an arming input: a
|
||||
* TLS session that authenticates but does not arm types nothing.
|
||||
*
|
||||
* Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse
|
||||
* (host-tested). What is left here is routing, framing and lifecycle -- the part that
|
||||
* (host-tested); the transport and the plaintext-port tables live in conn.h (its pure
|
||||
* half host-tested). What is left here is routing, framing and lifecycle -- the part that
|
||||
* genuinely needs a socket to be worth testing.
|
||||
*/
|
||||
#pragma once
|
||||
@@ -23,40 +38,18 @@
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "conn.h"
|
||||
#include "proto.h"
|
||||
#include "status.h"
|
||||
#include "tls_session.h"
|
||||
#include "ws_codec.h"
|
||||
|
||||
namespace kr {
|
||||
|
||||
// What the board knows about itself, as shown to the controller. The JSON field
|
||||
// names in StatusJson below are the contract the page in web_page.h reads.
|
||||
struct StatusSnapshot {
|
||||
const char *version = "0";
|
||||
uint32_t uptimeS = 0;
|
||||
bool usbHost = false; // TinyUSB is attached to a target
|
||||
bool armed = false;
|
||||
bool netUp = false;
|
||||
const char *net = "down"; // "sta" | "ap" | "down"
|
||||
const char *ssid = "";
|
||||
const char *ip = "";
|
||||
uint32_t keysSent = 0;
|
||||
uint32_t chordsSent = 0;
|
||||
uint32_t dropped = 0;
|
||||
uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text)
|
||||
uint16_t typing = 0; // characters still queued in the paste in progress
|
||||
bool rollover = false;
|
||||
// The mouse: whether it can report at all, which buttons the board believes are
|
||||
// down, and how many reports have gone out. `mouse` is false when the interface is
|
||||
// switched off on the board or when the target has forced boot protocol -- in both
|
||||
// cases the keyboard is untouched and M= frames are refused, not ignored.
|
||||
bool mouse = false;
|
||||
uint8_t mouseButtons = 0;
|
||||
uint32_t mouseFrames = 0;
|
||||
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
|
||||
bool defaultToken = false;
|
||||
bool apForced = false;
|
||||
};
|
||||
|
||||
// StatusSnapshot and statusJson() live in status.h, which includes nothing but stdio and
|
||||
// config.h. That is not tidiness: it is what lets tests/test_proto.cpp render the real
|
||||
// frame and measure it against kStatusJsonCap instead of taking that on trust from a
|
||||
// device. Include status.h rather than re-declaring anything here.
|
||||
// Error codes, named. The wire form is `E<code>:<text>`; see proto.h for the list.
|
||||
enum ErrorCode : uint8_t {
|
||||
kErrNotAuthed = 1,
|
||||
@@ -72,12 +65,6 @@ enum ErrorCode : uint8_t {
|
||||
kErrMouseBootProtocol = 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the status frame. Split out of the class so it is callable from the serial
|
||||
* console too, and so the field names stay in one place.
|
||||
*/
|
||||
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap);
|
||||
|
||||
class ControlServer {
|
||||
public:
|
||||
// ctx-first function pointers rather than std::function: no allocation, and the
|
||||
@@ -109,12 +96,77 @@ class ControlServer {
|
||||
uint32_t rejected() const { return _rejected; }
|
||||
uint32_t authFails() const { return _authFails; }
|
||||
|
||||
// Frames the transport could not take (its queue was full) -- a periodic status frame
|
||||
// loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either
|
||||
// transport: the queue is the transport view's, so the count means one thing everywhere.
|
||||
uint32_t txDropped() const { return _txDropped; }
|
||||
|
||||
// Connections refused because the slot table was full -- either listener, either
|
||||
// transport. A number, about a refusal; the refusal itself is visible to the peer
|
||||
// (TLS alert then close on :443, immediate close on :80).
|
||||
uint32_t slotRefused() const { return _slotRefused; }
|
||||
|
||||
#if KR_TLS
|
||||
// The identity the TLS listener serves. Must be set before begin(), and to nullptr
|
||||
// (or left unset) when there is no usable identity: begin() then does not open :443
|
||||
// at all, rather than open a listener that fails every handshake. The PemPair lives
|
||||
// in main.cpp next to the NVS code that loaded it; the server only reads it, at
|
||||
// session-open time -- copying 2 kB into every server object would be silly.
|
||||
void setIdentity(const tls::PemPair *identity) { _identity = identity; }
|
||||
|
||||
// Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this
|
||||
// changes. A number, about numbers; nothing about content (invariant 4).
|
||||
uint32_t tlsLiveSessions() const {
|
||||
return (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u);
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
// No separate "handshake in progress" phase: the handshake is what Conn::ready() and
|
||||
// Conn::poll() are for, and a slot in Phase::Http that is not ready yet is a slot still
|
||||
// handshaking. A phase for it would be a second place to remember the same fact, which
|
||||
// is how the plaintext and TLS paths used to drift.
|
||||
enum class Phase : uint8_t { None, Http, WebSocket };
|
||||
|
||||
// What the slot does when its outbound queue finally drains: the response is finished,
|
||||
// so the link goes. Nothing else closes a link on a *write* boundary -- and a response
|
||||
// that is still draining must never be cut short, or the page arrives truncated and
|
||||
// parses as garbage.
|
||||
enum class AfterDrain : uint8_t { Nothing, CloseLink, StopViewer };
|
||||
|
||||
// A response built but not yet written: the status line and headers, plus a body that
|
||||
// lives somewhere else -- a string literal in flash, the rendered status JSON, or the
|
||||
// 48 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of
|
||||
// the response (literals and kIndexHtml live forever; the JSON lives in this struct,
|
||||
// which the caller keeps until the response is queued), which is what makes "build once,
|
||||
// queue on either transport" safe.
|
||||
struct BuiltResponse {
|
||||
enum class Body : uint8_t { None, Text, Json };
|
||||
char head[kHttpResponseHeadCap];
|
||||
size_t headLen = 0;
|
||||
Body body = Body::None;
|
||||
const char *text = nullptr; // Body::Text: a literal, or kIndexHtml in PROGMEM
|
||||
size_t textLen = 0;
|
||||
bool textInFlash = false; // true for the page: streamed through a bounded chunk
|
||||
char json[kStatusJsonCap]; // Body::Json: rendered BEFORE buildResponse is called
|
||||
size_t jsonLen = 0;
|
||||
};
|
||||
|
||||
// One response formatter for every route, every transport and both ports; the header
|
||||
// block itself is rendered by the pure kr::renderHttpResponseHead() in conn.h, which is
|
||||
// what makes its sizing host-testable.
|
||||
static void buildResponse(BuiltResponse &rsp, const char *status,
|
||||
const char *contentType, BuiltResponse::Body body,
|
||||
const char *text, size_t textLen,
|
||||
const char *extra = nullptr, bool textInFlash = false);
|
||||
|
||||
void acceptNew(uint32_t nowMs);
|
||||
void serveHttp(uint32_t nowMs);
|
||||
void serveReadonly(WiFiClient &c, const ws::Request &req);
|
||||
// The read-only routes, built rather than written: the routing answer must have exactly
|
||||
// one source (invariant 8) while the transport answer differs only in which Conn the
|
||||
// built response is queued on. `plaintext` is what selects the :80 policy (redirect /
|
||||
// 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback.
|
||||
void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext);
|
||||
void serveViewer(uint32_t nowMs);
|
||||
void stopViewer();
|
||||
void serveWebSocket(uint32_t nowMs);
|
||||
@@ -124,15 +176,43 @@ class ControlServer {
|
||||
void writeTextFrame(const char *text, size_t len);
|
||||
size_t readSome(uint8_t *dst, size_t cap);
|
||||
|
||||
// Queue a built response on a slot's transport. One response writer for both slots and
|
||||
// both transports -- the grep for a second HTTP response writer in src/ is part of this
|
||||
// task's completion criteria -- so `done` says what that slot does when the bytes have
|
||||
// all left, and the two callers differ only in that answer.
|
||||
bool queueResponse(Conn &conn, AfterDrain &after, AfterDrain done,
|
||||
const BuiltResponse &rsp);
|
||||
void respondAndClose(const BuiltResponse &rsp); // controller slot
|
||||
void respondViewer(const BuiltResponse &rsp); // read-only slot
|
||||
// Drain both slots' queues under the per-tick budget, and run the AfterDrain decisions.
|
||||
void pumpSlots(uint32_t nowMs);
|
||||
bool spareBusy() const { return _viewOpen; } // the read-only slot, either transport
|
||||
|
||||
#if KR_TLS
|
||||
void startTlsListener();
|
||||
void admitTls(uint32_t nowMs); // accept at most one TLS socket per tick
|
||||
#endif
|
||||
|
||||
WiFiServer _server;
|
||||
// The controller slot: a Conn over one of these two, plus the slot's request buffer.
|
||||
// The WiFiClient and tls::Session objects are the owners; Conn is a view of one of them.
|
||||
WiFiClient _client;
|
||||
Conn _ctl;
|
||||
uint8_t _ctlTx[kConnTxCap];
|
||||
AfterDrain _ctlAfter = AfterDrain::Nothing;
|
||||
Phase _phase = Phase::None;
|
||||
|
||||
char _http[kHttpHeaderCap];
|
||||
size_t _httpLen = 0;
|
||||
|
||||
// A second socket, read-only, for when the controller slot is taken.
|
||||
// A second socket, read-only, for when the controller slot is taken. Same shape as the
|
||||
// controller slot -- a Conn over whichever transport answered -- because the spare slot
|
||||
// is one slot and a second set of route handling would be a second answer to give.
|
||||
WiFiClient _viewer;
|
||||
Conn _view;
|
||||
uint8_t _viewTx[kConnViewTxCap];
|
||||
AfterDrain _viewAfter = AfterDrain::Nothing;
|
||||
bool _viewOpen = false;
|
||||
char _viewerHttp[kHttpHeaderCap];
|
||||
size_t _viewerLen = 0;
|
||||
uint32_t _viewerAtMs = 0;
|
||||
@@ -149,6 +229,20 @@ class ControlServer {
|
||||
uint32_t _lockUntilMs = 0;
|
||||
uint32_t _authFails = 0;
|
||||
uint32_t _rejected = 0;
|
||||
uint32_t _txDropped = 0;
|
||||
uint32_t _slotRefused = 0;
|
||||
|
||||
#if KR_TLS
|
||||
// The encrypted listener: a raw LWIP socket rather than WiFiServer, because the
|
||||
// session must own the accepted fd -- NetworkClient closes its fd through a shared
|
||||
// handle the moment the last copy dies, which is the double-close tls::Session is
|
||||
// written to make impossible. bind/listen happen in startTlsListener().
|
||||
int _tlsListen = -1;
|
||||
const tls::PemPair *_identity = nullptr;
|
||||
|
||||
tls::Session _tlsCtl; // the controller slot, when _ctl.isTls()
|
||||
tls::Session _tlsView; // the spare slot, when _view.isTls()
|
||||
#endif
|
||||
|
||||
OnMessage _onMessage = nullptr;
|
||||
OnLinkChange _onLinkChange = nullptr;
|
||||
|
||||
Reference in New Issue
Block a user