From 675beed6ff688733a9598f9d82d41578f48316be Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 16:16:55 +0200 Subject: feat!: MNP 1.0 — seal index and handshake_ack under the group key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY --- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 75 ++++++++ .../src/meshbay_hub/static/group-page.js | 10 ++ .../src/meshbay_hub/static/transport.js | 190 ++++++++++++++++++--- .../meshbay-hub/tests/harness/index_seal_probe.mjs | 98 +++++++++++ .../meshbay-hub/tests/test_index_seal_client.py | 143 ++++++++++++++++ .../meshbay-hub/tests/test_transport_contracts.py | 75 ++++++++ 6 files changed, 571 insertions(+), 20 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/index_seal_probe.mjs create mode 100644 packages/meshbay-hub/tests/test_index_seal_client.py (limited to 'packages/meshbay-hub') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 69b8c7a..d2e19b7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -109,6 +109,80 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { return new Uint8Array(plaintext); } +// ── Sealing a payload under the group key ──────────────────────────────────── +// +// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta` and the +// `handshake_ack` config payload travel sealed under a GEK-derived subkey; the +// routing fields (type, v, group_id) and the ack's own authentication (node_pk, +// proof, sig) stay in clear, because a receiver must route, version-check and +// *authenticate* before it would trust a decryption. +// +// These take and return BYTES, not objects, and that is not an oversight: +// msgpack here is a minimal hand-written codec private to transport.js, exported +// to nothing (both files are classic scripts on globals, not ES modules). Making +// this layer take objects would mean duplicating that codec or reaching across a +// boundary that does not exist — both worse than one extra line at the call site. + +const GROUPBOX_INFO = { + index: new TextEncoder().encode('meshbay:index:v1'), + ack: new TextEncoder().encode('meshbay:ack:v1'), +}; + +/** + * Derive the AES-256-GCM subkey for one purpose. + * `salt: new Uint8Array(0)` matches Python's `salt=None` — RFC 5869 extracts with + * a zero key either way, which is what deriveChunkKey above already relies on. + */ +async function groupKey(gek, purpose, usages) { + const info = GROUPBOX_INFO[purpose]; + if (!info) throw new Error(`unknown groupbox purpose: ${purpose}`); + const gekKey = gek instanceof CryptoKey + ? gek + : await crypto.subtle.importKey('raw', gek, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info }, + gekKey, + { name: 'AES-GCM', length: 256 }, + false, + usages, + ); +} + +/** What the ciphertext is bound to: this message type, in this group. */ +function groupAad(msgType, groupId) { + return new TextEncoder().encode(`${msgType}|${groupId}`); +} + +/** + * Open a sealed payload. Throws on anything that does not open — a caller must + * never turn that into an empty index or an empty app list (groupbox.py's + * `unseal` says why at length). + * @returns {Promise} the msgpack bytes of the payload + */ +async function openGroup(gek, purpose, msgType, groupId, msg) { + if (!msg || !msg.nonce || !msg.ct) { + throw new Error(`${msgType}: not a sealed message`); + } + const key = await groupKey(gek, purpose, ['decrypt']); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: msg.nonce, additionalData: groupAad(msgType, groupId) }, + key, msg.ct); + return new Uint8Array(plain); +} + +/** + * Seal payload bytes. Returns the `{nonce, ct}` pair to merge into a message. + */ +async function sealGroup(gek, purpose, msgType, groupId, plaintextBytes) { + const key = await groupKey(gek, purpose, ['encrypt']); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, additionalData: groupAad(msgType, groupId) }, + key, plaintextBytes); + return { nonce, ct: new Uint8Array(ct) }; +} + + // ── GEK generation + ECIES wrapping ────────────────────────────────────────── function generateGEK() { @@ -372,6 +446,7 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) { // Export for use in app.js window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunkBin, + openGroup, sealGroup, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, joinTranscript, verifyNodeSignature, constantTimeEqual, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 7d13260..466af53 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -392,6 +392,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (cancelled) return; applyIndexDelta(msg); }; + // A pushed message that will not open under the group key ends the + // session (transport.js _failSession). Nothing is waiting on a push, so + // without this the page would keep showing a stale index with nothing + // wrong on screen — the worst of the three failure shapes. + transport.onSessionFailed = (err) => { + if (cancelled) return; + setError(err.message); + setStatus('error'); + if (onPresence) onPresence(groupId, 'online'); + }; // We are in: an invitation to this group has served its purpose. if (onJoined) onJoined(groupId); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 62ca2d9..329baf1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -178,6 +178,30 @@ window.addEventListener('hashchange', () => { if (location.hash === '#mb-debug') _showTraceView(); }); +// This build's half of the version range (meshbay_common/handshake.py's +// MNP_VERSION and MNP_MIN_SUPPORTED). Declared on the handshake — the only +// message where it is read — so a node we cannot speak to refuses us with a +// code, instead of the mismatch surfacing as a field that is not there. +// +// The `v: '0.1'` on every other message in this file is the historical value +// and is read by nothing; it is left alone deliberately. The range is +// negotiated once, at the start, not restated per message. +const MNP_V = '1.0'; +const MNP_V_MIN = '1.0'; + +// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's +// check_version): `version_too_old` means *we* are too old for it, +// `version_too_new` that it is too old for what we require. The client's own +// check of the node names its two conditions separately — see +// _checkNodeVersion, where reusing this table's wording would read backwards. +const HANDSHAKE_REFUSALS = { + version_too_old: 'This page is older than the node it is talking to. ' + + 'Reload to pick up the current version.', + version_too_new: 'This node is running an older MeshBay than this page needs. ' + + 'Its operator has to update it.', + version_unreadable: 'The node could not read this page\'s protocol version.', +}; + const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', @@ -278,6 +302,9 @@ class MeshBayTransport { set onPhotoRoots(fn) { this._onPhotoRoots = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } + // Fired when a message that must open under the group key does not — + // see _failSession. The session is over by the time this runs. + set onSessionFailed(fn) { this._onSessionFailed = fn; } // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh // handshake, so a consumer with something mid-flight on the old channel — // today only the video player — can pick back up rather than sit dead. @@ -523,7 +550,8 @@ class MeshBayTransport { const reply = await this._sendAndWait({ type: 'handshake', - v: '0.1', + v: MNP_V, + v_min: MNP_V_MIN, token: jwtToken, group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), @@ -531,6 +559,10 @@ class MeshBayTransport { console.log('[MeshBay] Handshake reply:', reply.type); if (reply.type === 'handshake_challenge') { + // The node's half of the range. Checked before anything else in this + // block, because everything below — the join, the proof, the sealed ack + // — assumes both sides mean the same thing by each message. + _checkNodeVersion(reply); if (!window.MeshBayCrypto) { throw new Error('Node requires GEK proof but no crypto available'); } @@ -708,6 +740,30 @@ class MeshBayTransport { _checkNodePin(nodeId, ack.node_pk); this.nodePk = ack.node_pk; + // Verify, then decrypt — in that order, and the order is the point. Every + // check above decides whether this peer is worth trusting at all; opening + // the payload first would mean acting on data from a peer we have not yet + // authenticated. + // + // A payload that does not open aborts the connection. It is emphatically + // not an empty config: `enabled_apps` missing reads as "the operator + // disabled every app" (the documented client-side fallback is the + // opposite — show them all), and either reading is indistinguishable from + // a legitimate state, which is what makes a silent fallback worse than a + // stop. + let config; + try { + config = msgpack_decode( + await C.openGroup(gekRaw, 'ack', 'handshake_ack', gid, ack)); + } catch (e) { + throw new Error( + 'handshake_ack did not open under the group key — refusing connection: ' + + (e && e.message || e)); + } + delete ack.nonce; + delete ack.ct; + Object.assign(ack, config); + return ack; } @@ -716,7 +772,8 @@ class MeshBayTransport { // peer skip proving GEK possession entirely (C3/C6). console.warn('[MeshBay] Handshake rejected:', reply.detail, 'code:', reply.code); const rejected = new Error( - 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); + HANDSHAKE_REFUSALS[reply.code] + || ('MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`))); rejected.reason = reply.code || ''; throw rejected; } @@ -858,6 +915,12 @@ class MeshBayTransport { return resp; } + /** + * The full index. Resolves with the sealed payload already opened — + * `_applyIndexMessage` does that before it hands the message to whoever is + * waiting, so both the reply to this call and the node's own unsolicited + * pushes go through one decrypt path. + */ async fetchIndex() { const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); if (msg.type === 'error') throw new Error(msg.detail); @@ -1876,6 +1939,61 @@ class MeshBayTransport { // ── Internal ────────────────────────────────────────────────────────────── + /** + * Queue one sealed index message for opening. + * + * Opening is asynchronous and `_dispatch` is not, so two messages handled + * independently would be applied in whichever order their decrypt promises + * happened to settle. A delta applied before the sync it is based on — or + * before an earlier delta — is a silently wrong view of the group, so they + * are opened one at a time, in arrival order. + */ + _queueIndexMessage(msg) { + this._indexChain = (this._indexChain || Promise.resolve()) + .then(() => this._applyIndexMessage(msg)) + .catch((e) => this._failSession( + `${msg.type} did not open under the group key`, e)); + } + + async _applyIndexMessage(msg) { + const groupId = msg.group_id || (this._connectArgs && this._connectArgs.groupId) || ''; + const payload = msgpack_decode(await window.MeshBayCrypto.openGroup( + this._gekRaw, 'index', msg.type, groupId, msg)); + // The routing fields stay, the envelope's own two go, the payload lands on + // top — so every consumer keeps reading the flat message it always read. + const opened = { ...msg, ...payload }; + delete opened.nonce; + delete opened.ct; + + if (msg.type === 'index_sync') { + if (this._onIndexSync) this._onIndexSync(opened); + for (const [, handler] of this._pending) { + if (handler._reqType === 'index_sync') { + handler.resolve(opened); + break; + } + } + return; + } + if (this._onIndexDelta) this._onIndexDelta(opened); + } + + /** + * Stop, rather than carry on with a degraded view. + * + * A payload that does not open is not an empty index and not a config + * change — it is a peer we cannot talk to. Reconnecting would only reach the + * same peer with the same key, so the session ends and the failure is named. + */ + _failSession(what, cause) { + const err = new Error(`${what}: ${(cause && cause.message) || cause}`); + console.error('[MeshBay]', err.message); + for (const [, handler] of this._pending) handler.reject(err); + this._pending.clear(); + if (this._onSessionFailed) this._onSessionFailed(err); + this.close(); + } + async _sendAndWait(obj, timeoutMs = 30000) { // A reconnect already in flight (see _reconnectLoop) means the channel // this would send on is the one just declared dead. `_inReconnectAttempt` @@ -2166,24 +2284,16 @@ class MeshBayTransport { return; } - if (msg.type === 'index_sync' && msg.entries) { - if (this._onIndexSync) this._onIndexSync(msg); - for (const [, handler] of this._pending) { - if (handler._reqType === 'index_sync') { - handler.resolve(msg); - break; - } - } - return; - } - - // Incremental update — additions/deletions/updates, never the whole - // index. Only ever arrives after the full index this browser already - // has (the node's first push to a newly connected peer is always - // index_sync, see daemon.py _broadcast_index_change), so there is - // always a base to apply it to. - if (msg.type === 'index_delta') { - if (this._onIndexDelta) this._onIndexDelta(msg); + // Both index messages carry their payload sealed under a GEK-derived + // subkey (MNP 1.0), so they cannot be acted on from here — _dispatch is + // synchronous and opening one is not. `index_delta` is the incremental + // form: additions/deletions/updates, never the whole index, and it only + // ever arrives after the full index this browser already has (the node's + // first push to a newly connected peer is always index_sync, see + // daemon.py _broadcast_index_change), so there is always a base to apply + // it to. + if (msg.type === 'index_sync' || msg.type === 'index_delta') { + this._queueIndexMessage(msg); return; } @@ -2604,6 +2714,46 @@ function _extractDtlsFingerprint(sdp) { const NODE_PIN_PREFIX = 'mb_nodepin_'; +/** + * The node's half of the version range, from `handshake_challenge`. + * + * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no + * range at all is a node that predates negotiation — that is every 0.x node, and + * none of them can serve a sealed index or a sealed ack — so it is refused here + * rather than left to fail later as a message that will not open. + */ +function _checkNodeVersion(reply) { + const parse = (v) => { + const m = /^(\d+)\.(\d+)$/.exec(String(v || '')); + return m ? [Number(m[1]), Number(m[2])] : null; + }; + const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]); + const fail = (reason, message) => { + const e = new Error(message); + e.reason = reason; + throw e; + }; + + const theirs = parse(reply.v); + if (!theirs) { + fail('node_version_unreadable', + 'The node did not declare a readable protocol version.'); + } + // No declared minimum means "only what I speak" — the correct reading of a + // node from before this field existed. + const theirMin = parse(reply.v_min) || theirs; + if (cmp(theirs, parse(MNP_V_MIN)) < 0) { + fail('node_too_old', + 'This node is running an older MeshBay than this page needs. ' + + 'Its operator has to update it.'); + } + if (cmp(theirMin, parse(MNP_V)) > 0) { + fail('client_too_old', + 'This page is older than the node it is talking to. ' + + 'Reload to pick up the current version.'); + } +} + function _checkNodePin(nodeId, nodePk) { if (!nodeId || !nodePk) return; const key = NODE_PIN_PREFIX + nodeId; 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 + * + * 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, + })); +})(); diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py new file mode 100644 index 0000000..ca2c7a2 --- /dev/null +++ b/packages/meshbay-hub/tests/test_index_seal_client.py @@ -0,0 +1,143 @@ +""" +The browser half of MNP 1.0's sealed index, measured rather than read. + +`test_index_no_cleartext.py` proves the node sends no filename in the clear. This +proves the client can still read one — and, the part that matters more, that it +*stops* when it cannot instead of reporting an empty group. + +Driven through `harness/index_seal_probe.mjs`, which runs the shipped +`transport.js` over the shipped `crypto.js` and is fed real frames built here. +A source-reading test could show that `openGroup` is called; only this can show +what a waiting `fetchIndex()` is told when it throws. +""" + +import json +import shutil +import struct +import subprocess +import tempfile +from pathlib import Path + +import msgpack +import pytest +from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import PURPOSE_INDEX, seal + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +PROBE = Path(__file__).resolve().parent / "harness" / "index_seal_probe.mjs" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not PROBE.exists(), + reason="node unavailable — the client half cannot be measured", +) + +GROUP = "g-probe" +GEK = generate_gek() + + +def _entry(name: str) -> dict: + return {"id": "ab" * 32, "name": name, "path": "library", "size": 10, + "type": "file", "added_at": 0, "uploader_id": ""} + + +def _frame(msg: dict) -> str: + body = msgpack.packb(msg, use_bin_type=True) + return (struct.pack(">I", len(body)) + body).hex() + + +def _sync_frame(gek: bytes, *names: str, version: int = 3) -> str: + payload = {"version": version, "entries": [_entry(n) for n in names], + "dirs": ["library"], "roots": [{"name": "library"}]} + return _frame({"type": "index_sync", "v": "1.0", "group_id": GROUP, + **seal(gek, PURPOSE_INDEX, "index_sync", GROUP, payload)}) + + +def _delta_frame(gek: bytes, name: str, base: int, version: int) -> str: + payload = {"base_version": base, "version": version, + "additions": [_entry(name)], "deletions": [], "updates": []} + return _frame({"type": "index_delta", "v": "1.0", "group_id": GROUP, + **seal(gek, PURPOSE_INDEX, "index_delta", GROUP, payload)}) + + +def _run(frames: list[str], gek: bytes = GEK) -> dict: + with tempfile.TemporaryDirectory() as tmp: + vectors = Path(tmp) / "vectors.json" + vectors.write_text(json.dumps( + {"gek": gek.hex(), "group_id": GROUP, "frames": frames})) + proc = subprocess.run( + ["node", str(PROBE), str(STATIC), str(vectors)], + capture_output=True, text=True, timeout=120) + if proc.returncode != 0: + pytest.fail(f"probe failed:\n{proc.stderr}") + return json.loads(proc.stdout) + + +def test_a_sealed_index_reaches_the_consumer_intact(): + out = _run([_sync_frame(GEK, "a-film.mkv", "another.mkv")]) + + assert [e["event"] for e in out["events"]] == ["index_sync"] + sync = out["events"][0] + assert sync["entries"] == ["a-film.mkv", "another.mkv"] + assert sync["dirs"] == ["library"] + # Moved inside the payload (D4) and still delivered flat, so no consumer had + # to change: it reads the same message it always read. + assert sync["version"] == 3 + assert not sync["hasCiphertext"], "the envelope's own fields leaked to consumers" + + # And the waiting caller gets the opened form, not the envelope. + assert out["fetchIndex"]["state"] == "resolved" + assert out["fetchIndex"]["entries"] == ["a-film.mkv", "another.mkv"] + + +def test_an_index_that_does_not_open_ends_the_session(): + """ + §3.4, and the reason it is a rule rather than a preference. An empty + `entries` is a legitimate state — a group whose operator has shared nothing + yet — so a client that fell back to one would show the same screen for + "nothing here" and for "we could not decrypt anything this node sent". + """ + out = _run([_sync_frame(generate_gek(), "a-film.mkv")]) + + kinds = [e["event"] for e in out["events"]] + assert "index_sync" not in kinds, "a failed decrypt was reported as an index" + assert kinds == ["session_failed"] + assert "index_sync" in out["events"][0]["message"], ( + "the failure must name the message type that could not be opened") + + # The caller is told, rather than left to time out 30 s later. + assert out["fetchIndex"]["state"] == "rejected" + assert "index_sync" in out["fetchIndex"]["message"] + assert out["closed"] == 1, "the session carried on after an unopenable message" + + +def test_a_delta_that_does_not_open_ends_the_session_too(): + """ + The delta has no caller waiting on it — it is pushed — so a silent failure + here would leave a browser showing a stale index with nothing wrong on + screen, which is the worst of the three shapes. + """ + out = _run([_sync_frame(GEK, "a-film.mkv"), + _delta_frame(generate_gek(), "new.mkv", 3, 4)]) + + assert [e["event"] for e in out["events"]] == ["index_sync", "session_failed"] + assert "index_delta" in out["events"][1]["message"] + assert out["closed"] == 1 + + +def test_deltas_are_applied_in_arrival_order(): + """ + Opening is asynchronous and `_dispatch` is not. Two messages opened + independently settle in whichever order WebCrypto finishes them, and a delta + applied before the one it follows is a wrong view of the group that nothing + reports. Three deltas in one burst is the cheapest way to force the race. + """ + frames = [_sync_frame(GEK, "a-film.mkv")] + frames += [_delta_frame(GEK, f"added-{i}.mkv", 3 + i, 4 + i) for i in range(3)] + + out = _run(frames) + + assert [e["event"] for e in out["events"]] == [ + "index_sync", "index_delta", "index_delta", "index_delta"] + assert [e["additions"][0] for e in out["events"][1:]] == [ + "added-0.mkv", "added-1.mkv", "added-2.mkv"] + assert [e["base_version"] for e in out["events"][1:]] == [3, 4, 5] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 6011ddb..fee80bb 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -317,3 +317,78 @@ def test_uploads_are_tracked_per_file(transport): """Acks interleave when two files are in flight.""" assert "this._uploaders = new Map()" in transport assert "this._uploaders.set(file.name" in transport + + +# ── MNP 1.0: the sealed handshake ack ──────────────────────────────────────── +# +# The index half is measured for real in `test_index_seal_client.py`. The ack is +# opened inside `connect()`, three messages into a WebRTC negotiation, so these +# read the source — and the ordering they pin is the whole security argument, not +# an implementation detail. + +def _handshake_block(transport: str) -> str: + start = transport.index("if (reply.type === 'handshake_challenge') {") + return transport[start:transport.index(" return ack;", start)] + + +def test_the_ack_is_verified_before_it_is_decrypted(transport): + """ + Verify, then decrypt. Opening the payload first would mean acting on data + from a peer we have not yet authenticated — which is the exact shape of C3, + where `node_pk` was never checked and a peer that had hijacked signaling + could serve a forged index and a forged `is_node_admin`. + """ + block = _handshake_block(transport) + proof = block.index("Node failed to prove GEK possession") + signature = block.index("Node signature invalid") + pinned = block.index("_checkNodePin(") + opened = block.index("openGroup(") + assert proof < opened, "the payload is opened before the GEK proof is checked" + assert signature < opened, "the payload is opened before the signature is checked" + assert pinned < opened, "the payload is opened before the node is pinned" + + +def test_an_ack_that_does_not_open_refuses_the_connection(transport): + """ + Never a default. An `enabled_apps` that failed to open would otherwise reach + the client's documented fallback — show every registered app — which is a + confident wrong answer, indistinguishable from an operator's real choice. + """ + block = _handshake_block(transport) + opened = block[block.index("let config;"):block.index("return ack;") + if "return ack;" in block else len(block)] + assert "throw new Error(" in opened, "a failed decrypt is swallowed" + assert "handshake_ack" in opened, "the failure does not name the message" + for fallback in ("|| {}", "?? {}", "catch { }", "config = {}"): + assert fallback not in opened, ( + f"the ack falls back to {fallback} instead of refusing") + + +def test_the_handshake_declares_a_version_range(transport): + """ + L2: `v` used to be written by everyone and read by nobody, so a mismatch + surfaced as a missing field rather than a refusal. Both halves of the range + ride the handshake, and the node's half is checked before anything below it + in `connect()` runs. + """ + block = transport[transport.index("type: 'handshake',"):] + block = block[:block.index("});")] + assert "v: MNP_V," in block and "v_min: MNP_V_MIN," in block + + challenge = _handshake_block(transport) + assert challenge.index("_checkNodeVersion(") < challenge.index("openGroup("), ( + "the node's version is checked after its messages are relied on") + + +def test_the_index_is_never_reported_from_a_failed_decrypt(transport): + """ + The consumer callbacks may only be reached from inside the opened path — a + `catch` that called `_onIndexSync` with an empty message would show "this + group has no files", which is a state a real group can be in. + """ + body = transport[transport.index("async _applyIndexMessage("):] + body = body[:body.index("\n /**", 1)] + assert "openGroup(" in body + assert "catch" not in body, ( + "_applyIndexMessage swallows its own failure instead of letting " + "_queueIndexMessage end the session") -- cgit v1.2.3