// The browser viewer's remote desktop module (remote.js), against the corpus // wai/live-conformance/remote/ (`node --test web/room/remote.test.mjs`): every input statement, // floor statement and latency record of the accepted sessions written byte for byte (§5.1, §7.2, // §8.1, the records signed again under the corpus's keys), every sealed input sealed again from // what the corpus's opened level states it opens to (§5.5); every input of the accepted sessions // given the outcome the corpus states by the module's own replay of the host's rules (§5.3, // §7.3); every vector in which a host stated another outcome than the rules found out; HPKE // against RFC 9180 A.1.1; the page's viewer sealing what it sends to the host's sealing key and // nothing in the clear, and showing another controller's typing, never its text; and the page's // table of keyboard codes the host agent's. import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import * as rd from "./remote.js"; const doc = JSON.parse(readFileSync(new URL("../../../../../../wai/live-conformance/remote/vectors.json", import.meta.url), "utf8")); const vector = (name) => doc.vectors.find((v) => v.name === name); // Every accepted session: the floor session, the open handover, and those whose floor statements // §7.2 refuses, which change nothing. const ACCEPTED = doc.vectors.filter((v) => v.outcome === "accept").map((v) => v.name); const MISSTATED = doc.vectors.filter((v) => v.reason === "outcome_mismatch").map((v) => v.name); const INDEX = { authority: 0, clock: 1, host: 2, alice: 3, bob: 4, viewer: 5, lead: 6, carol: 7 }; /** The corpus's key `i`: SHA-256 of its label, a zero byte and `i` (its README). */ const key = async (i) => rd.keyFromSeed(await rd.sha256(new TextEncoder().encode("wai:remote-desktop/corpus/key\x00"), Uint8Array.of(i))); const byPrincipal = Object.fromEntries(Object.entries(doc.principals).map(([n, p]) => [p, INDEX[n]])); function statements(v) { const out = []; for (const chain of v.chains) { for (const h of chain) { const ev = rd.readEvent(rd.unhex(h)); if (ev.kind !== 1) continue; const s = rd.readStatement(ev.payload); if (s) out.push({ ev, s }); } } return out; } test("input and floor statements are written byte for byte", () => { let inputs = 0, floors = 0; for (const name of ACCEPTED) { for (const { ev, s } of statements(vector(name))) { if (s.kind === "input") { assert.equal(rd.hex(rd.inputPayload(s)), rd.hex(ev.payload), `${name}: an input statement`); inputs++; } else if (s.kind === "floor") { const to = s.to === "0".repeat(64) ? null : s.to; assert.equal(rd.hex(rd.floorPayload({ host: s.host, action: s.action, to })), rd.hex(ev.payload), `${name}: a floor statement`); floors++; } } } assert.ok(inputs >= 15 && floors >= 5, `${inputs} inputs, ${floors} floor statements`); }); test("latency records are written and signed byte for byte", async () => { let n = 0; for (const name of ACCEPTED) { for (const { ev, s } of statements(vector(name))) { if (s.kind !== "latency") continue; const k = await key(byPrincipal[s.viewer]); assert.equal(rd.hex(k.publicKey), s.viewer); assert.equal(rd.hex(await rd.signLatency(s, k.privateKey)), rd.hex(ev.payload), `${name}: a latency record`); n++; } } assert.equal(n, ACCEPTED.reduce((k, name) => k + vector(name).latencies.length, 0), "every latency record the accepted sessions state"); }); test("every input of the accepted sessions gets the corpus's outcome from the module's rules", async () => { for (const name of ACCEPTED) { const v = vector(name); const got = await rd.replay(v, doc.principals.clock, doc.principals.authority); assert.equal(got.length, v.inputs.length, name); got.forEach((g, k) => { const want = v.inputs[k]; assert.equal(g.digest, want.digest); const f = want.fate; if (f.outcome === "unanswered") return assert.equal(g.outcome, "unanswered", `${name} input ${k}`); assert.equal(g.outcome, f.outcome, `${name} input ${k}`); if (f.outcome === "applied") { assert.equal(g.rule, 0, `${name} input ${k}: the rules accept it`); assert.equal(g.anchor.group, f.group); } else if (g.sealUnknown && g.rule === 0) { // A seal only the host's opening key opens: rule 9 is the host's to make (§11.4). assert.equal(f.reason, "input_bad_seal", `${name} input ${k}: rules 1 to 8 accept it`); } else { assert.equal(rd.REFUSAL[g.rule], f.reason, `${name} input ${k}: the rule`); assert.equal(g.stated, g.rule); } }); } }); test("a floor statement §7.2 refuses changes nothing, and is stated with its reason", async () => { let refused = 0; for (const name of ACCEPTED) { const v = vector(name); const got = await rd.replayRoom(v, doc.principals.clock, doc.principals.authority); assert.deepEqual(got.floor, v.floor, `${name}: the floor's changes`); assert.deepEqual(got.floorRefused, v.floor_refused || [], `${name}: the floor statements refused`); refused += got.floorRefused.length; } assert.ok(refused >= 9, `${refused} refused floor statements`); // The late release: the host took the floor back, and the release after it passes nothing. const late = await rd.replayRoom(vector("floor_late_release"), doc.principals.clock, doc.principals.authority); assert.equal(late.floorRefused[0].reason, "floor_not_holder"); assert.deepEqual(late.floor, vector("floor_session").floor, "the holder after it is the holder before it"); }); test("a host that stated another outcome than the rules is found out", async () => { assert.ok(MISSTATED.length >= 9, MISSTATED.join(", ")); for (const name of MISSTATED) { const got = await rd.replay(vector(name), doc.principals.clock, doc.principals.authority); assert.ok(got.some((g) => g.outcome !== "unanswered" && g.stated !== g.rule), `${name}: the module's rules disagree with the host`); } for (const name of ACCEPTED) { const got = await rd.replay(vector(name), doc.principals.clock, doc.principals.authority); assert.ok(got.every((g) => g.outcome === "unanswered" || g.stated === g.rule || (g.sealUnknown && g.rule === 0 && g.stated === 9)), name); } }); // ---------------------------------------------------------------- sealed actions (§5.5) const te = new TextEncoder(); const le32 = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); /** The corpus's seeds (its seal_seed): a sealed action's salt, and a seal's ephemeral key. */ const corpusSalt = (author, screen) => async (k, a) => (await rd.sha256(te.encode("wai:remote-desktop/corpus/salt\x00"), rd.unhex(author), rd.unhex(screen), Uint8Array.of(k), le32(a.dt_us))).subarray(0, 16); const corpusIkm = (author) => async (clear) => rd.sha256(te.encode("wai:remote-desktop/corpus/enc\x00"), rd.unhex(author), clear); const sealPair = async (i) => rd.deriveKeyPair(await rd.sha256(te.encode("wai:remote-desktop/corpus/seal\x00"), Uint8Array.of(i))); test("HPKE base mode is RFC 9180's (Appendix A.1.1)", async () => { const h = rd.unhex; const r = await rd.deriveKeyPair(h("6db9df30aa07dd42ee5e8181afdb977e538f5e1fec8a06223f33f7013e525037")); assert.equal(rd.hex(r.secret), "4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8"); assert.equal(rd.hex(r.publicKey), "3948cfe0ad1ddb695d780e59077195da6c56506b027329794ab02bca80815c4d"); const s = await rd.sealBase(r.publicKey, h("4f6465206f6e2061204772656369616e2055726e"), h("436f756e742d30"), h("4265617574792069732074727574682c20747275746820626561757479"), h("7268600d403fce431561aef583ee1613527cff655c1343f29812e66706df3234")); assert.equal(rd.hex(s.enc), "37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431"); assert.equal(rd.hex(s.ct), "f938558b5d72f1a23810b4be2ab4f84331acc02fc97babc53a52ae8218a355a96d8770ac83d07bea87e13c512a"); }); test("sealed inputs are sealed again byte for byte from what they open to", async () => { const v = vector("sealed_session"); const host = await sealPair(INDEX.host); assert.equal(rd.hex(host.secret), doc.opening_key, "the host's opening key is the corpus's"); const screens = statements(v).filter((x) => x.s.kind === "screen"); assert.equal(screens[0].s.seal, rd.hex(host.publicKey), "the host's screen statement states its sealing key"); assert.equal(screens[1].s.seal, null, "and its second states none"); let n = 0; for (const [k, fact] of v.opened.inputs.entries()) { if (fact.seal !== "opened") continue; const ev = statements(v).find((x) => x.s.kind === "input" && x.ev.principal === fact.author && x.ev.t === fact.t).ev; const clear = rd.readStatement(rd.unhex(fact.opened)); assert.ok(clear.actions.every((a) => a.type !== "sealed") && !clear.seal, `input ${k} reads opened in the clear`); const actions = clear.actions.map(({ bytes, ...a }) => a); const sealed = await rd.sealInput({ room: v.room, author: fact.author, host: doc.principals.host, screen: clear.screen, sealKey: rd.hex(host.publicKey), actions, salt: corpusSalt(fact.author, clear.screen), ikm: corpusIkm(fact.author) }); assert.equal(rd.hex(sealed.payload), rd.hex(ev.payload), `input ${k}: the page seals what the corpus holds`); assert.equal(v.inputs[k].sealed, sealed.input.actions.filter((a) => a.type === "sealed").length); n++; } assert.ok(n >= 3, `${n} sealed inputs sealed again`); }); test("the page's viewer seals keys, text and clipboard text to the host, and sends none in the clear", async () => { const v = vector("sealed_session"); let alice = null; for (const h of v.grants) if ((await rd.readGrant(rd.unhex(h))).principal === doc.principals.alice) alice = h; const host = await sealPair(INDEX.host); const { r, sent } = await viewerOf(v, "alice", alice); r.joined = true; r.screen = { ...r.screen, seal: rd.hex(host.publicKey) }; const payloads = []; const emit = r.emit; r.emit = async (k, p) => { payloads.push(p); return emit(k, p); }; r.salt = async () => new Uint8Array(16).fill(5); r.ikm = async () => new Uint8Array(32).fill(6); const typed = [{ type: "move", x: 3, y: 4, at: 0 }, { type: "key", usage: 0x0b, down: true, modifiers: 0, at: 0 }, { type: "text", text: "hunter2 secret", at: 0 }, { type: "clipboard", text: "a pasted password", at: 0 }]; r.batch.push(...typed); await r.flush(); assert.equal(payloads.length, 1, "one input statement"); const s = sent[0]; assert.deepEqual(s.actions.map((a) => a.type), ["move", "sealed", "sealed", "sealed"], "the move in the clear, the rest sealed"); for (const secret of ["hunter2", "a pasted password"]) { const needle = te.encode(secret); assert.ok(!payloads[0].some((_, i) => needle.every((b, j) => payloads[0][i + j] === b)), `no trace of ${secret}`); } const want = await rd.sealInput({ room: v.room, author: doc.principals.alice, host: doc.principals.host, screen: r.screenDigest, sealKey: rd.hex(host.publicKey), actions: typed.map(({ at, ...a }) => ({ dt_us: 0, ...a })), salt: r.salt, ikm: r.ikm }); assert.equal(rd.hex(payloads[0]), rd.hex(want.payload), "sealed to the host's sealing key, the screen statement's"); // To a host that states no sealing key: no key, text or clipboard action is sent at all. r.screen = { ...r.screen, seal: null }; payloads.length = 0; r.batch.push({ type: "text", text: "hunter2", at: 0 }); await r.flush(); assert.equal(payloads.length, 0, "nothing in the clear"); assert.equal(r.counts.unsealed, 1); r.batch.push({ type: "move", x: 1, y: 1, at: 0 }); await r.flush(); assert.equal(payloads.length, 1, "a pointer move still goes"); }); test("the page's sends keep their order while a seal is made", async () => { const v = vector("sealed_session"); let alice = null; for (const h of v.grants) if ((await rd.readGrant(rd.unhex(h))).principal === doc.principals.alice) alice = h; const { r, sent } = await viewerOf(v, "alice", alice); r.joined = true; r.screen = { ...r.screen, seal: rd.hex((await sealPair(INDEX.host)).publicKey) }; // A seal that takes a while: a key pressed, then a move, sent while the key's seal is made. r.salt = async () => { await new Promise((ok) => setTimeout(ok, 30)); return new Uint8Array(16).fill(5); }; r.batch.push({ type: "key", usage: 0x04, down: true, modifiers: 0, at: 0 }); const first = r.flush(); await new Promise((ok) => setTimeout(ok, 0)); r.batch.push({ type: "move", x: 2, y: 2, at: 0 }); const second = r.flush(); await Promise.all([first, second]); assert.deepEqual(sent.map((s) => s.actions[0].type), ["sealed", "move"], "the key's statement before the move's"); }); test("a viewer's page shows that a controller is typing, never what", async () => { const v = vector("sealed_session"); const { r } = await viewerOf(v, "viewer", null); r.panel = { textContent: "" }; r.screen = { ...r.screen, scope: 1, redactions: [], encoder: "x@1", seal: "22".repeat(32) }; const i1 = statements(v).find((x) => x.s.kind === "input" && x.ev.principal === doc.principals.alice); r.onRecord(rd.unhex(v.chains.flat().find((h) => rd.hex(rd.readEvent(rd.unhex(h)).body) === rd.hex(i1.ev.body)))); assert.match(r.panel.textContent, new RegExp(`${doc.principals.alice.slice(0, 8)} is typing`)); assert.ok(!/pässwörd|hunter/.test(r.panel.textContent), r.panel.textContent); assert.equal(r.received.length, 1); }); test("a text longer than a text action carries goes as several, never inside a character", () => { const long = "é".repeat(40); const pieces = rd.textPieces(long); assert.equal(pieces.join(""), long); assert.ok(pieces.every((p) => te.encode(p).length <= 64 && te.encode(p).length >= 1)); assert.deepEqual(rd.textPieces("abc"), ["abc"]); }); test("the screen statements read, and the track digest is the host's", async () => { const v = vector("floor_session"); const screens = statements(v).filter((x) => x.s.kind === "screen"); assert.equal(screens.length, v.screens.length); for (const [k, { s }] of screens.entries()) { assert.equal(s.encoder, v.screens[k].encoder); assert.equal(s.width, v.screens[k].width); assert.equal(s.track, await rd.screenTrack(v.room, doc.principals.host)); } }); test("the page's keyboard codes are the host agent's HID usages", () => { const rust = readFileSync(new URL("../../../joule-moq-remote/src/inject.rs", import.meta.url), "utf8"); const pairs = [...rust.matchAll(/\(0x([0-9A-F]{2}), "([A-Za-z0-9]+)"\)/g)].map((m) => [m[2], parseInt(m[1], 16)]); assert.ok(pairs.length > 80); assert.deepEqual(Object.fromEntries(pairs), rd.HID); }); // The page's viewer in a room a room service keeps: its control power is its grant's, from each // exchange of it the clock signs; it sends nothing without a grant, before the clock has recorded // it joining, or of a class its grant in effect does not give. async function viewerOf(v, who, grant) { const sent = []; let now = 0; const grants = await Promise.all(v.grants.map(async (h) => ({ h, g: await rd.readGrant(rd.unhex(h)) }))); // The page's clock on the room's: every grant of the corpus room is good from the latest nbf. const unix = Math.max(...grants.map((x) => x.g.nbf)); const r = new rd.RemoteViewer({ room: v.room, me: doc.principals[who], host: doc.principals.host, clock: doc.principals.clock, grant, key: await key(INDEX[who]), roomNow: () => now, unixNow: () => unix, log: () => {}, grantOf: async (d) => (grants.find((x) => x.g.digest === d) || {}).h || null, emit: async (k, p) => { sent.push(rd.readStatement(p)); return { digest: "00".repeat(32), t: now }; }, }); await r.start(); r.screen = { floor: 1, state: 1, accepts: 0x7f, width: 64, height: 64, clip_max: 100, max_age_ms: 1000 }; r.screenDigest = "11".repeat(32); return { r, sent, at: (t) => { now = t; }, grants }; } const move = (x) => ({ type: "move", x, y: 1, at: 0 }); test("the page's viewer sends nothing without its grant", async () => { const v = vector("open_handover"); const { r, sent } = await viewerOf(v, "alice", null); r.joined = true; r.batch.push(move(1)); await r.flush(); await r.requestFloor(); assert.equal(sent.length, 0, "no grant, nothing sent"); assert.equal(r.counts.unsent, 1); }); test("the page's viewer sends nothing once its grant has ended", async () => { const v = vector("floor_session"); let alice = null; for (const h of v.grants) if ((await rd.readGrant(rd.unhex(h))).principal === doc.principals.alice) alice = h; const g = await rd.readGrant(rd.unhex(alice)); const { r, sent } = await viewerOf(v, "alice", alice); r.joined = true; for (const [unix, want] of [[g.nbf, 1], [g.exp - 1, 1], [g.exp, 0], [g.exp + 3600, 0]]) { r.unixNow = () => unix; sent.length = 0; r.batch.push(move(4)); await r.flush(); assert.equal(sent.length, want, `at ${unix}, the grant good from ${g.nbf} to before ${g.exp}`); } }); test("the page's viewer takes its control power from its grant and each exchange the clock signs", async () => { const v = vector("open_handover"); const firstOf = async (who) => { for (const h of v.grants) { const g = await rd.readGrant(rd.unhex(h)); if (g.principal === doc.principals[who]) return h; } return null; }; // Every record of the clock, read by a viewer for each controller whose grant it exchanges. const clock = v.chains.map((c) => c.map((h) => rd.unhex(h))).find((c) => rd.readEvent(c[0]).principal === doc.principals.clock); for (const who of ["alice", "carol"]) { const { r, sent, at } = await viewerOf(v, who, await firstOf(who)); const before = r.control; r.batch.push(move(2)); await r.flush(); assert.equal(sent.length, 0, `${who}: nothing before the clock records it joining`); const exchanges = []; for (const b of clock) { const ev = rd.readEvent(b); r.onRecord(b); await new Promise((ok) => setTimeout(ok, 0)); await new Promise((ok) => setTimeout(ok, 0)); if (ev.kind === 0x14 && rd.hex(ev.payload.subarray(0, 32)) === doc.principals[who]) exchanges.push(ev.t); } assert.equal(exchanges.length, 1, `${who}: one exchange of its grant`); const t = exchanges[0]; assert.equal(r.controlAt(t - 1), before, `${who}: its first grant's power until the exchange`); assert.notEqual(r.controlAt(t), before, `${who}: the new grant's power from the exchange's instant`); // The clock's chain ends with everyone's leave: rejoin for the sends below. r.joined = true; for (const [instant, want] of [[t - 1, before], [t, r.controlAt(t)]]) { at(instant); sent.length = 0; r.batch.push(move(3)); await r.flush(); assert.equal(sent.length, want & rd.CLASS.pointer ? 1 : 0, `${who} at ${instant}: a pointer move sent only under a grant that gives the pointer`); if (sent.length) assert.equal(sent[0].kind, "input"); } } });