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:
2026-11-09 05:51:57 -05:00
parent 121491bab4
commit e0780f58e0
61 changed files with 8860 additions and 359 deletions
+154 -10
View File
@@ -76,12 +76,11 @@ const byId = id => {
return registry.get(id);
};
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
class FakeWebSocket {
constructor() {
constructor(url) {
this.url = url; // what the page asked to connect to
this.readyState = 1; // OPEN
FakeWebSocket.instances.push(this);
setTimeout(() => this.onopen && this.onopen(), 0);
@@ -112,7 +111,11 @@ const doc = {
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
location: { protocol: "http:", host: "keyrelay.local" },
// A TLS board never hands its page an http: origin (:80 answers a redirect), so
// the origin this suite boots on is https:, like the real first load. The
// connect-path section switches it deliberately to walk both the refusal and
// the -D KR_TLS=0 rollback.
location: { protocol: "https:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
@@ -125,7 +128,9 @@ const sandbox = {
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect, boot,
servedInClear,
get tlsBuild() { return KR_TLS_BUILD; }, set tlsBuild(v) { KR_TLS_BUILD = v; },
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
@@ -140,19 +145,52 @@ const expose = `
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
const from = headerText.indexOf("<script>");
const to = headerText.indexOf("<\/script>", from);
// The page is a chain of R"HTML( ... )HTML" raw-string chunks joined by exactly one
// #if KR_TLS build marker (task 05: the page has to know its own build before any
// socket exists). Assemble the bytes one build serves, the same assembly
// tests/test_relayctl.py and .agents/validate.sh perform -- three readers of one
// layout, each failing if the layout drifts.
function servedPage(text, tls) {
const chunks = [...text.matchAll(/R"HTML\(([\s\S]*?)\)HTML"/g)].map(m => m[1]);
const marks = [...text.matchAll(
/#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif/g)];
if (chunks.length === 0 || marks.length !== chunks.length - 1) {
console.error("web_page.h: raw-string chunks and #if KR_TLS markers no longer " +
"join up; fix this harness rather than shipping a guessed page");
process.exit(1);
}
let out = "";
chunks.forEach((c, i) => {
out += c;
if (i < marks.length) out += tls ? marks[i][1] : marks[i][2];
});
return out;
}
const served = servedPage(headerText, true); // the TLS build, by default
const rollbackPage = servedPage(headerText, false);
const from = served.indexOf("<script>");
const to = served.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = headerText.slice(from + "<script>".length, to);
const pageScript = served.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
const x = sandbox.__x;
const logEl = x.logEl;
// Declared after FakeWebSocket's class body only because the class closes over
// it: send() runs long after this line has, and a hoisted `let` would be a
// temporal-dead-zone trap waiting for a socket that opens during page load.
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
// ---------------------------------------------------------------------------
let checks = 0, failures = 0;
@@ -482,8 +520,114 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
x.mCaptured = false;
x.setArmed(false, "harness: done");
console.log("the connect path");
// The board serves the page over https:// (and, in a -D KR_TLS=0 build, over http://).
// The socket scheme follows the page's own origin: an https: page that opened ws://
// would be blocked by the browser as mixed content, and the failure would look exactly
// like a dead board. The other direction is the guard task 05 added: a TLS build's
// page on an http: origin is bytes nobody legitimate served (a TLS board answers :80
// with a redirect), so it refuses -- and the rollback build, whose marker is 0,
// keeps its ws:// because that is the documented way back.
checkEq(Buffer.byteLength(served), Buffer.byteLength(rollbackPage),
"both builds' pages are the same byte count -- the marker branches are " +
"equal length, so every pinned page count is one number");
byId("token").value = "hunter2"; // connect() refuses without one
sandbox.location.protocol = "https:";
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
let inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "connect() opened a socket on an https: origin");
checkEq(inst.url, "wss://keyrelay.local/ws",
"an https: origin connects over wss:// -- the encrypted controller channel");
// The passphrase travels in the first frame, never in a URL: a query string lands in
// proxy logs, browser history and the Referer of anything the page later loads.
check(!/token|pass/i.test(inst.url), `the connect URL carries no credential (${inst.url})`);
// An http: origin on the TLS build: no socket, no frame, and the reason is said.
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
sandbox.location.protocol = "http:";
x.connect();
await wait(5);
checkEq(FakeWebSocket.instances.length, 0,
"a clear-loaded TLS page opens no socket -- it does not fall back to plaintext");
checkEq(sentFrames.length, 0,
"and sends not one frame -- above all not the T<passphrase>");
check(String(logEl.lastElementChild && logEl.lastElementChild.textContent)
.includes("this page arrived in the clear; reload over https"),
"and it says why, in the words the operator can act on");
// boot() on the same origin: the passphrase box itself comes disabled, with the
// banner up -- refusing the socket while still accepting the passphrase would be
// half a guard.
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
x.boot();
checkEq(byId("token").disabled, true, "a clear-loaded page will not take a passphrase");
checkEq(byId("connect").disabled, true, "and its connect button is inert");
checkEq(byId("clearWarn").hidden, false, "and the banner explains the refusal");
checkEq(FakeWebSocket.instances.length, 0, "boot() on http: connects to nothing");
// The rollback build (-D KR_TLS=0) keeps today's behaviour on the same origin:
// ws:// is its documented way back, opted into by the build flag itself.
x.tlsBuild = 0;
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "the KR_TLS=0 page still connects on an http: origin");
if (inst) checkEq(inst.url, "ws://keyrelay.local/ws",
"the rollback page speaks plaintext ws:// because that is the rollback");
x.tlsBuild = 1;
sandbox.location.protocol = "https:"; // as the shim started
// The plaintext spelling exists exactly once in the served bytes -- the fallback
// the rollback build reaches. A TLS board never hands a browser an http: origin
// (port 80 answers a redirect and a 426, pinned in tests/test_relayctl.py and
// walked in tests/test_conn.cpp), so one occurrence is the whole rollback and
// two would be a second, ungated path.
checkEq(served.split("ws://").length - 1, 1,
"the served page contains exactly one plaintext ws:// -- the rollback's path");
checkEq(served.split("wss://").length - 1, 1, "and exactly one wss://, its counterpart");
checkEq(served.split("new WebSocket(").length - 1, 1,
"one socket is opened in the whole page, and it opens the url just built");
check(served.includes("var KR_TLS_BUILD=1;"),
"the TLS build's assembled page carries its marker");
check(rollbackPage.includes("var KR_TLS_BUILD=0;"),
"and the assembled rollback page carries the other one");
console.log("the channel pill and the certificate line");
// Both render from the status frame only -- the page has no other way to know,
// and an operator compares these bytes against `cert` on the serial console.
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "tls", "a TLS board's pill says tls");
checkEq(byId("pTls").className, "pill on", "and it is the calm green state");
checkEq(byId("certFp").hidden, false, "the certificate line is up");
check(byId("certFp").textContent.includes("0123abcd456789ef"),
"the line renders exactly the fp the status frame carried");
check(byId("certFp").textContent.includes("cert"),
"and names where to confirm it (the serial `cert` command)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":0,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "plaintext", "a board without TLS says plaintext");
check(byId("pTls").className.includes("bad"),
"and says it in the alarming colour -- unencrypted is the state to notice");
checkEq(byId("certFp").hidden, true, "with tls:0 no fingerprint line renders, ...");
checkEq(byId("certFp").textContent, "",
"... even when the frame carried one anyway (the page does not launder it)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1}');
checkEq(byId("certFp").hidden, true,
"and a tls:1 frame with no fp renders no line either -- nothing is invented");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"00aa11bb22cc33dd"}');
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
"a new status re-renders the new fingerprint");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const markup = served;
const present = new Set();
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
check(present.size > 15, `the markup's ids were parsed (${present.size})`);