diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 16:16:55 +0200 |
| commit | 675beed6ff688733a9598f9d82d41578f48316be (patch) | |
| tree | 78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-hub/tests | |
| parent | 15087b0e8fdb872602310119f14680aaa443fd93 (diff) | |
| download | meshbay-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')
3 files changed, 316 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, + })); +})(); 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") |