Files
keyrelay/tests/test_page.js
T
ducoterra e0780f58e0 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`.
2026-11-09 05:51:57 -05:00

641 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/test_page.js -- behaviour tests for the controller page's script.
//
// node tests/test_page.js
//
// The page is the primary interface, and it lives inside a C++ raw string where no
// compiler can see it: a typo there does not fail a build, it fails silently in
// somebody's browser while a target is live. So the script is extracted from
// src/web_page.h and run against a minimal DOM shim -- enough to exercise the rules
// that matter, which are what the log is allowed to contain, which characters may
// leave the tab, and when a run of pasted text stops.
//
// It is a behaviour test, not a rendering test: no CSS, no layout, no real keyboard.
// tests/run_tests.sh skips it when node is not installed.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
// ---------------------------------------------------------------------------
// A DOM small enough to read, complete enough to run the page.
// ---------------------------------------------------------------------------
function makeEl(tagName) {
const el = {
tagName: (tagName || "div").toUpperCase(), dataset: {}, className: "",
textContent: "", innerHTML: "", value: "", checked: false, disabled: false,
readOnly: false, children: [], listeners: {},
scrollTop: 0, scrollHeight: 0,
classList: { toggle() {}, add() {}, remove() {}, contains() { return false; } },
get firstChild() { return this.children[0] || null; },
get firstElementChild() { return this.children.find(c => c.tagName) || null; },
get lastElementChild() {
const e = this.children.filter(c => c.tagName);
return e.length ? e[e.length - 1] : null;
},
get childElementCount() { return this.children.filter(c => c.tagName).length; },
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
// what the browser turns into a Pointer Lock request.
fire(t, ev) {
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
},
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// Document- and window-level listeners are recorded rather than dropped. Every
// fail-safe this page has is triggered by something the browser does to the document --
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
// prove them.
function makeListeners() {
const map = {};
return {
map,
add(t, fn) { (map[t] = map[t] || []).push(fn); },
fire(t, ev) {
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
},
};
}
const docListeners = makeListeners();
const winListeners = makeListeners();
// The shim hands out an element for any id, which is far less work than a real DOM.
// The weakness in that is obvious, so it is compensated for: every id the script asks
// for is recorded and checked against the ids that are actually in the markup at the
// end. A mistyped id is a null dereference in a browser, and silence here.
const registry = new Map();
const requestedIds = new Set();
const byId = id => {
requestedIds.add(id);
if (!registry.has(id)) registry.set(id, makeEl("div"));
return registry.get(id);
};
class FakeWebSocket {
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);
}
send(text) { sentFrames.push(text); }
close() { this.readyState = 3; }
}
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const doc = {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener: docListeners.add,
body: makeEl("body"),
hidden: false,
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
// event because a real browser does, which is what makes the release path testable.
pointerLockElement: null,
exitPointerLock() {
if (!doc.pointerLockElement) return;
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
},
};
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
// 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
setTimeout, clearTimeout, Date, JSON, Math, Promise, TextEncoder, console,
Number, String, Object, Array, Buffer, parseInt,
};
// The page keeps its state in script-scope `let`s, so the harness reaches into that
// scope once, here, instead of duplicating the page's logic in the test.
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
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,
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
get mButtons() { return mButtons; },
get pendX() { return pendX; }, get pendY() { return pendY; },
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
// 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 = 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;
const check = (cond, label) => {
checks++;
if (!cond) { failures++; console.log(" FAIL " + label); }
};
const checkEq = (a, b, label) => {
checks++;
if (a !== b) {
failures++;
console.log(` FAIL ${label}: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
// sets pointerLockElement and fires pointerlockchange.
function givePointerLock() {
const pad = byId("mPad");
pad.requestPointerLock = () => {
doc.pointerLockElement = pad;
docListeners.fire("pointerlockchange", {});
return Promise.resolve();
};
return pad;
}
function takePointerLockAway() {
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
}
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
{ button: b, preventDefault() {} });
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
logEl.children = []; // the page logs a prompt at load; start clean
x.log("");
x.log(" ");
x.log("\n\t ");
x.log(undefined);
x.log(null);
checkEq(logEl.childElementCount, 0,
"empty, whitespace-only and missing messages never render a line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "a real message renders");
x.log("first line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "an identical line is not repeated");
check(/×3$/.test(logEl.lastElementChild.textContent), "the repeat is counted, not stacked");
x.log("second line");
checkEq(logEl.childElementCount, 2, "a different message still gets its own line");
for (let i = 0; i < 400; i++) x.log("flood " + i);
check(logEl.childElementCount <= 150, "the log is bounded, so it cannot grow all day");
console.log("characters");
checkEq(x.untypeableChars("hello world\n").length, 0, "plain text and newlines are fine");
checkEq(x.untypeableChars("\t\ta\t").length, 0, "tabs are fine");
checkEq(x.untypeableChars("café").join(""), "é", "an accent is caught");
checkEq(x.untypeableChars("a\u2019b").join(""), "\u2019", "a curly quote is caught");
checkEq(x.untypeableChars("\u{1f600}").length, 1, "an astral character is caught");
byId("paste").value = "line1\r\nline2";
checkEq(x.pasteText(), "line1\nline2", "CRLF becomes one Enter, not two");
byId("paste").value = "a\rb";
checkEq(x.pasteText(), "a\nb", "and so does a lone CR");
checkEq(x.hexify("hi"), "6869", "hex encoding");
checkEq(x.hexify("a\n"), "610a", "a newline is one byte on the wire");
console.log("key names in the log");
checkEq(x.describePress("KeyA"), "KeyA", "a bare key is named by its code name");
checkEq(x.describePress("Enter"), "Enter", "Enter is named");
checkEq(x.describePress("ArrowRight"), "ArrowRight", "arrows are named");
checkEq(x.inPageField({ target: { tagName: "TEXTAREA" } }), true, "the text box is the page's own");
checkEq(x.inPageField({ target: { tagName: "INPUT" } }), true, "the passphrase box is too");
checkEq(!!x.inPageField({ target: { tagName: "BODY" } }), false, "the page itself forwards");
checkEq(!!x.inPageField({ target: null }), false, "a missing target does not throw");
(async () => {
// Bring the link up the way the page does, so send()'s guard is the real guard.
byId("token").value = "hunter2";
x.connect();
await wait(5); // onopen fires, and clears `authed`
x.authed = true;
x.armed = true;
byId("trailingEnter").checked = false;
byId("stripBad").checked = false;
console.log("sending text");
byId("paste").value = "hello";
sentFrames.length = 0;
let p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "the frame goes out once the board is idle");
checkEq(frames()[0], "P=12:68656c6c6f", "carrying the text and the requested pace");
idleStatus();
await p;
checkEq(x.typing, false, "and the run reports itself finished");
// A paste longer than one frame: the board's `typing` count gates the next piece,
// so a board that is still busy must not be handed more to type.
const long = "x".repeat(300);
byId("paste").value = long;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "one frame out while the board is idle");
busyStatus(118);
await wait(20);
checkEq(frames().length, 1, "the board saying it is still typing holds the next frame back");
for (let i = 0; i < 20 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
const fs2 = frames();
checkEq(fs2.length, Math.ceil(300 / x.TEXT_FRAME_MAX), "300 characters is three frames");
for (const f of fs2) {
check(f.length <= 256, `every frame fits one WebSocket message (${f.length} bytes)`);
check(/^P=12:[0-9a-f]+$/.test(f), "every frame is well formed");
check(f.length <= 5 + 2 * x.TEXT_FRAME_MAX, "and none carries more than a frame's worth");
}
checkEq(fs2.map(f => f.slice(f.indexOf(":") + 1)).join("").length, 600,
"the frames between them carry every character exactly once");
checkEq(fs2.map(textOf).join(""), long, "and that is the text that was pasted");
console.log("refusing what it cannot type");
byId("paste").value = "bad\u2014text";
sentFrames.length = 0;
await x.sendPaste();
checkEq(frames().length, 0,
"an em dash refuses the whole paste -- nothing partial goes out");
byId("stripBad").checked = true;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
for (let i = 0; i < 10 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
checkEq(frames().map(textOf).join(""), "badtext", "and the opt-in drop sends the rest");
console.log("stopping");
byId("stripBad").checked = false;
byId("paste").value = "nope";
sentFrames.length = 0;
x.armed = false;
await x.sendPaste();
checkEq(frames().length, 0, "while disarmed, not one character is offered");
x.armed = true;
byId("paste").value = "y".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.abortTyping("harness: stop typing");
idleStatus();
await p;
checkEq(frames().length, 1, "a stopped run does not send the frames that were left");
checkEq(x.typing, false, "and it ends, rather than hanging");
x.armed = true;
byId("paste").value = "z".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.setArmed(false, "harness: disarmed mid-run"); // the board disarmed under us
idleStatus();
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("the mouse");
givePointerLock();
x.setArmed(true, "harness: armed");
x.authed = true;
// A capture with nothing behind it must not produce a single frame.
x.setArmed(false, "harness: disarmed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
checkEq(x.mCaptured, false, "and the pointer is not taken");
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
move(30, -4);
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
move(300, -260);
sentFrames.length = 0;
x.flushMouse();
const big = mframes();
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
checkEq(sum(2), -260, "and so does the Y");
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
<= x.MOUSE_DELTA_MAX)),
"and no single report asks for more than one signed byte");
// Order is the whole difference between a click and a drag.
move(12, 0);
sentFrames.length = 0;
btn(0, true);
const order = sentFrames.filter(f => f.startsWith("M="));
checkEq(order.length, 2, "a press with movement behind it is two frames");
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
checkEq(x.mButtons, 0x01, "left is held");
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
checkEq(x.mButtons, 0, "and the tab agrees");
// Two buttons at once, and letting go of one: the byte that goes out is the state,
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
btn(0, true);
btn(2, true);
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
sentFrames.length = 0;
scroll(100, 0); // one detent's worth of pixels
scroll(100, 0);
scroll(25, 0); // not yet a detent: carried, not dropped
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
sentFrames.length = 0;
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
btn(2, false);
btn(0, true);
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
sentFrames.length = 0;
takePointerLockAway();
checkEq(mframes()[0], "M=00:0:0:0:0",
"losing the pointer lock (Esc, focus, the browser) releases every button");
checkEq(x.mCaptured, false, "and the tab stops capturing");
x.takeMouse();
await wait(2);
btn(1, true);
sentFrames.length = 0;
doc.pointerLockElement = null; // the browser already dropped it; blur follows
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"a blur releases the buttons too");
checkEq(x.mCaptured, false, "and ends the capture");
// The stay-armed-on-blur option is an exception for the keyboard and must not become
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
byId("keepArmed").checked = true;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
checkEq(x.mCaptured, false, "the capture does not survive it either");
byId("keepArmed").checked = false;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
x.setArmed(false, "harness: disarmed with a button held");
checkEq(x.mCaptured, false, "disarm ends the capture");
checkEq(x.mButtons, 0, "and the tab's own button state");
// The board releases the buttons itself when it disarms (src/output.h), so the tab
// sends an M= frame to a bridge that has already answered: silence here is correct.
checkEq(mframes().length, 0,
"disarm does not beg a bridge that has already let go");
// A board that cannot report a mouse: refused up front, and a running capture ended.
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
checkEq(mframes().length, 0, "and gets no frames");
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
checkEq(byId("mCapture").disabled, false,
"the button comes back when the board says it can report a mouse");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
checkEq(x.mCaptured, false,
"a capture that the board can no longer honour is ended, not left hanging");
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
// button byte, signed decimal movements, and short enough for one WebSocket message.
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
let malformed = 0, tooLong = 0;
for (const f of mframes()) {
if (!shape.test(f)) malformed++;
if (f.length > 256) tooLong++;
}
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
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 = 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})`);
const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "",
"every element the script reached for exists in the page");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})();