aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-hub/tests/harness
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/index_seal_probe.mjs98
1 files changed, 98 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/index_seal_probe.mjs b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
new file mode 100644
index 0000000..36302bb
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/index_seal_probe.mjs
@@ -0,0 +1,98 @@
+/**
+ * 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 <static-dir> <vectors.json>
+ *
+ * Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how
+ * the outstanding request ended.
+ */
+import fs from 'fs';
+
+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'))();
+new Function(fs.readFileSync(`${STATIC}/transport.js`, 'utf8'))();
+
+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,
+ }));
+})();