/** * Does the browser open a sealed index — and does it stop when it cannot? * * Drives **the real `MeshBayTransport` over the real `crypto.js`**, fed real * length-prefixed msgpack frames built by Python's `groupbox.seal`. Only the DOM * and the DataChannel are stand-ins; the framing, the msgpack decode, the * dispatch, the HKDF and the AES-GCM are all the shipped code. * * It exists because the two things worth knowing here are invisible to a * source-reading test. The first is §3.4: a payload that does not open must * *raise*, never become an empty index — "this group has no files" is a * legitimate state, so a silent fallback is indistinguishable from the truth. * The second is ordering: opening is asynchronous while `_dispatch` is not, so * two index messages could be applied in whichever order their decrypt promises * happened to settle, and a delta applied before its base is a silently wrong * view of the group. * * node index_seal_probe.mjs * * Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how * the outstanding request ended. */ import fs from 'fs'; import { delimiter } from 'path'; const STATIC = process.argv[2]; const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); // The transport logs to the console on the paths under test; stdout is this // probe's JSON result, so everything it says goes to stderr instead. for (const level of ['log', 'warn', 'error', 'info', 'debug']) { console[level] = (...args) => process.stderr.write(args.join(' ') + '\n'); } // Just enough DOM for two classic scripts that expect a page. globalThis.window = globalThis; globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {}; globalThis.location = { hash: '' }; globalThis.document = { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', }; new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))(); // argv[4]: transport.js and the parts split out of it, joined as one scope. const transportSrc = process.argv[4].split(delimiter) .map((p) => fs.readFileSync(p, 'utf8')).join('\n'); new Function(transportSrc)(); const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16))); const events = []; const tp = new window.MeshBayTransport('', 'token'); tp._connected = true; tp._channel = { readyState: 'open', send() {}, close() {} }; tp._pc = { close() {} }; tp._gekRaw = hex(input.gek); tp._connectArgs = { groupId: input.group_id }; tp.onIndexSync = (msg) => events.push({ event: 'index_sync', entries: (msg.entries || []).map((e) => e.name), dirs: msg.dirs || [], version: msg.version, // Present on the message a consumer sees? The envelope's own fields should // be gone, and the payload's should have taken their place. hasCiphertext: 'ct' in msg || 'nonce' in msg, }); tp.onIndexDelta = (msg) => events.push({ event: 'index_delta', additions: (msg.additions || []).map((e) => e.name), base_version: msg.base_version, version: msg.version, }); tp.onSessionFailed = (err) => events.push({ event: 'session_failed', message: err.message }); // One outstanding fetchIndex, so the probe can say what a *waiting caller* is // told — which is the half of §3.4 a callback cannot show. const fetchOutcome = { state: 'pending' }; tp._send = () => {}; tp.fetchIndex() .then((msg) => { fetchOutcome.state = 'resolved'; fetchOutcome.entries = (msg.entries || []).map((e) => e.name); }) .catch((e) => { fetchOutcome.state = 'rejected'; fetchOutcome.message = e.message; }); const closed = { count: 0 }; const realClose = tp.close.bind(tp); tp.close = () => { closed.count += 1; realClose(); }; (async () => { // Delivered exactly as the DataChannel delivers them: one call per frame, in // order, with no await between. for (const frame of input.frames) tp._onMessage(hex(frame).buffer); // Let the opening chain drain. Each message costs two WebCrypto promises, so // a handful of turns is not enough to be sure; a real delay is. await new Promise((r) => setTimeout(r, 200)); process.stdout.write(JSON.stringify({ events, fetchIndex: fetchOutcome, closed: closed.count, })); })();