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 --- packages/meshbay-common/tests/test_groupbox.py | 120 ++++++++++++++++ .../meshbay-common/tests/test_js_python_parity.py | 157 +++++++++++++++++++++ .../tests/test_version_negotiation.py | 78 ++++++++++ 3 files changed, 355 insertions(+) create mode 100644 packages/meshbay-common/tests/test_groupbox.py create mode 100644 packages/meshbay-common/tests/test_version_negotiation.py (limited to 'packages/meshbay-common/tests') diff --git a/packages/meshbay-common/tests/test_groupbox.py b/packages/meshbay-common/tests/test_groupbox.py new file mode 100644 index 0000000..65d8ce6 --- /dev/null +++ b/packages/meshbay-common/tests/test_groupbox.py @@ -0,0 +1,120 @@ +""" +The group-key envelope: round trip, and every refusal it owes. + +`groupbox.seal`/`unseal` is what puts `index_sync`, `index_delta` and the +`handshake_ack` configuration under a key the hub does not hold. The structural +tests here matter less than `test_index_no_cleartext.py`, which asserts the +property on a real frame; these pin the primitive. +""" + +import msgpack +import pytest +from cryptography.exceptions import InvalidTag +from meshbay_common.crypto import generate_gek +from meshbay_common.groupbox import ( + PURPOSE_ACK, + PURPOSE_INDEX, + group_key, + seal, + unseal, +) + +PAYLOAD = {"version": 7, "entries": [{"name": "a.mkv", "size": 12}], "dirs": ["root"]} + + +@pytest.fixture +def gek(): + return generate_gek() + + +def test_round_trip(gek): + for purpose in (PURPOSE_INDEX, PURPOSE_ACK): + sealed = seal(gek, purpose, "index_sync", "g1", PAYLOAD) + assert set(sealed) == {"nonce", "ct"} + assert unseal(gek, purpose, "index_sync", "g1", sealed) == PAYLOAD + + +def test_a_wrong_key_does_not_open(gek): + sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD) + with pytest.raises(InvalidTag): + unseal(generate_gek(), PURPOSE_INDEX, "index_sync", "g1", sealed) + + +def test_purposes_are_separate_key_spaces(gek): + """ + The reason there are two info strings rather than one key reused. + + An ack sealed under the index subkey would otherwise be openable by anything + holding the index subkey, which is the confusion `GroupIndex.serialize()`'s + "the index as chunk 0 of a virtual index file" creates for chunk keys. + """ + assert group_key(gek, PURPOSE_INDEX) != group_key(gek, PURPOSE_ACK) + sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD) + with pytest.raises(InvalidTag): + unseal(gek, PURPOSE_ACK, "index_sync", "g1", sealed) + + +def test_a_body_cannot_be_replayed_as_another_message_type(gek): + """The AAD's first half: an index_sync body is not an index_delta.""" + sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD) + with pytest.raises(InvalidTag): + unseal(gek, PURPOSE_INDEX, "index_delta", "g1", sealed) + + +def test_a_body_cannot_be_moved_between_groups(gek): + """ + The AAD's second half. Two groups on one node share a GEK-holding process but + not a GEK; this closes the case where they do share one (a rotation in flight, + a test fixture, an operator reusing a key) as well. + """ + sealed = seal(gek, PURPOSE_INDEX, "index_sync", "group-a", PAYLOAD) + with pytest.raises(InvalidTag): + unseal(gek, PURPOSE_INDEX, "index_sync", "group-b", sealed) + + +def test_a_tampered_ciphertext_does_not_open(gek): + sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD) + sealed["ct"] = bytes([sealed["ct"][0] ^ 1]) + sealed["ct"][1:] + with pytest.raises(InvalidTag): + unseal(gek, PURPOSE_INDEX, "index_sync", "g1", sealed) + + +def test_a_message_that_is_not_sealed_is_refused_as_such(gek): + """ + Not an empty payload, and not a crash on a missing key — the two shapes a + caller might otherwise paper over. + """ + with pytest.raises(ValueError): + unseal(gek, PURPOSE_INDEX, "index_sync", "g1", {"entries": []}) + + +def test_a_fresh_nonce_per_message(gek): + """ + Never derived from the payload: two identical payloads under one long-lived + subkey would then reuse a nonce, which for GCM is a total break. + """ + nonces = {seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)["nonce"] + for _ in range(50)} + assert len(nonces) == 50 + + +def test_no_key_is_an_error_not_a_plaintext_fallback(gek): + with pytest.raises(ValueError): + seal(None, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD) + + +def test_an_unknown_purpose_is_refused(gek): + with pytest.raises(ValueError): + group_key(gek, "chat") + + +def test_the_envelope_carries_no_readable_payload(gek): + """ + The property, at the level of the primitive: what `seal` returns holds nothing + of what went in. `test_index_no_cleartext.py` asserts the same thing on the + real frames. + """ + payload = {"video_root": "holidays-2019-invoices", "entries": ["ledger.pdf"]} + frame = msgpack.packb(seal(gek, PURPOSE_ACK, "handshake_ack", "g1", payload)) + for word in (b"holidays", b"invoices", b"ledger", b"video_root"): + assert word not in frame diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 340ea3e..6f7437f 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -16,6 +16,7 @@ Skipped when node is unavailable; that is a coverage gap, not a pass. import json import shutil import subprocess +import tempfile from pathlib import Path import pytest @@ -234,3 +235,159 @@ def test_length_prefixing_actually_disambiguates(js_output): a = js_output["handshake"][2] # group_id "g" b = js_output["handshake"][3] # group_id "" assert a != b, "JS transcripts collide across different group ids" + + +# ── groupbox: the sealed payload, both directions ──────────────────────────── +# +# Unlike the transcripts above, this one has a wire format to disagree about as +# well as a derivation: the HKDF salt (Python's `salt=None` against WebCrypto's +# `salt: new Uint8Array(0)`) and the AAD's UTF-8 encoding are both invisible to +# every other test, and a disagreement in either means no browser can open an +# index or a handshake ack from any node — with the AEAD reporting only "it did +# not open", which is the same thing a wrong key reports. + +# (purpose, msg_type, group_id) +GROUPBOX_VECTORS = [ + ("index", "index_sync", "g" * 32), + ("index", "index_delta", "g" * 32), + ("ack", "handshake_ack", "g" * 32), + # Empty group id — the operator-pairing shape, and the one a naive + # concatenation would let collide with a short id. + ("ack", "handshake_ack", ""), + # Non-ASCII: TextEncoder and Python's .encode() must agree on the AAD. + ("index", "index_sync", "groupe-café-日本"), + # A '|' inside the group id, which is the AAD's own separator. + ("index", "index_sync", "a|b"), +] + +GROUPBOX_GEK = bytes.fromhex("5a" * 32) + +_GROUPBOX_HARNESS = r""" +const fs = require('fs'); + +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + '\nreturn { sealGroup, openGroup };')(); + +const hex = (s) => { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16); + return out; +}; +const toHex = (u8) => + Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const gek = hex(input.gek); + const out = { opened: [], sealed: [] }; + + for (const v of input.vectors) { + // Python sealed it; open it here. + out.opened.push(toHex(await M.openGroup( + gek, v.purpose, v.msg_type, v.group_id, + { nonce: hex(v.nonce), ct: hex(v.ct) }))); + // Seal the same plaintext here, for Python to open. + const sealed = await M.sealGroup( + gek, v.purpose, v.msg_type, v.group_id, hex(v.plaintext)); + out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) }); + } + + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _groupbox_payload(idx: int) -> dict: + """A distinct payload per vector, so a crossed result cannot pass.""" + return {"n": idx, "name": f"entry-{idx}.bin", "flags": [True, None, idx * 7]} + + +@pytest.fixture(scope="module") +def groupbox_js(tmp_path_factory): + import msgpack + + from meshbay_common.groupbox import seal + + d = tmp_path_factory.mktemp("groupbox-parity") + harness = d / "harness.js" + harness.write_text(_GROUPBOX_HARNESS) + + vectors = [] + for i, (purpose, msg_type, group_id) in enumerate(GROUPBOX_VECTORS): + payload = _groupbox_payload(i) + sealed = seal(GROUPBOX_GEK, purpose, msg_type, group_id, payload) + vectors.append({ + "purpose": purpose, "msg_type": msg_type, "group_id": group_id, + "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(), + "plaintext": msgpack.packb(payload, use_bin_type=True).hex(), + }) + + payload_file = d / "vectors.json" + payload_file.write_text(json.dumps({"gek": GROUPBOX_GEK.hex(), + "vectors": vectors})) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload_file)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node groupbox harness failed:\n{proc.stderr}") + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS))) +def test_browser_opens_what_python_sealed(idx, vector, groupbox_js): + """A mismatch means no browser can read an index or a handshake ack.""" + import msgpack + + opened = bytes.fromhex(groupbox_js["opened"][idx]) + assert msgpack.unpackb(opened, raw=False) == _groupbox_payload(idx), ( + f"crypto.js and groupbox.py disagree for {vector!r}") + + +@pytest.mark.parametrize("idx,vector", list(enumerate(GROUPBOX_VECTORS))) +def test_python_opens_what_the_browser_sealed(idx, vector, groupbox_js): + """ + The other direction. Nothing in the SPA seals today — `sealGroup` exists for + the chat plan, which needs the same primitive — but a codec that only ever + runs one way is a codec whose encoder is untested. + """ + from meshbay_common.groupbox import unseal + + purpose, msg_type, group_id = vector + sealed = groupbox_js["sealed"][idx] + msg = {"nonce": bytes.fromhex(sealed["nonce"]), + "ct": bytes.fromhex(sealed["ct"])} + assert unseal(GROUPBOX_GEK, purpose, msg_type, group_id, msg) == \ + _groupbox_payload(idx) + + +def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js): + """ + The AAD, checked across the boundary rather than only within Python: a JS + `openGroup` that dropped `additionalData` would still round-trip against + itself and against Python, and would pass every other test here. + """ + from meshbay_common.groupbox import seal + + sealed = seal(GROUPBOX_GEK, "index", "index_sync", "g1", {"x": 1}) + script = ( + "globalThis.window = {};\n" + "const fs = require('fs');\n" + "const M = new Function(fs.readFileSync(process.argv[2], 'utf8')\n" + " + '\\nreturn { openGroup };')();\n" + "const hex = (s) => Uint8Array.from(s.match(/../g).map(b => parseInt(b, 16)));\n" + "M.openGroup(hex(process.argv[3]), 'index', 'index_delta', 'g1',\n" + " { nonce: hex(process.argv[4]), ct: hex(process.argv[5]) })\n" + " .then(() => { console.log('OPENED'); })\n" + " .catch(() => { console.log('REFUSED'); });\n" + ) + with tempfile.TemporaryDirectory() as tmp: + h = Path(tmp) / "aad.js" + h.write_text(script) + proc = subprocess.run( + ["node", str(h), str(CRYPTO_JS), GROUPBOX_GEK.hex(), + sealed["nonce"].hex(), sealed["ct"].hex()], + capture_output=True, text=True, timeout=60) + assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr diff --git a/packages/meshbay-common/tests/test_version_negotiation.py b/packages/meshbay-common/tests/test_version_negotiation.py new file mode 100644 index 0000000..50bdf54 --- /dev/null +++ b/packages/meshbay-common/tests/test_version_negotiation.py @@ -0,0 +1,78 @@ +""" +The version range, checked rather than merely written. + +Before MNP 1.0 every message carried a `v` that no one read, so a mismatch +surfaced as a *missing field*: an 0.x client reading a 1.0 handshake ack finds no +`enabled_apps` and applies its documented fallback — show every app — which is a +wrong answer rather than an error. The three failure modes 1.0's flag day was +called for all present that way, which is why negotiation ships in the same +deployment rather than after it (phase 15.6, decision D2). +""" + +import pytest +from meshbay_common import MNP_VERSION +from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, + HandshakeError, + check_version, + parse_version, +) + + +def test_this_build_accepts_itself(): + check_version(MNP_VERSION, MNP_MIN_SUPPORTED) + + +def test_a_peer_that_declares_no_minimum_is_read_as_speaking_only_its_own(): + """ + Which is the right reading of every 0.x peer: none of them declared a range, + because none of them checked one. + """ + check_version(MNP_VERSION) + + +def test_an_older_peer_is_refused_with_a_code(): + with pytest.raises(HandshakeError) as caught: + check_version("0.15") + assert caught.value.code == "version_too_old" + # The text is for a human and may be reworded; the client matches the code. + assert "0.15" in str(caught.value) + + +def test_a_peer_requiring_more_than_we_speak_is_refused_with_a_code(): + ours = parse_version(MNP_VERSION) + future = f"{ours[0] + 1}.0" + with pytest.raises(HandshakeError) as caught: + check_version(future, future) + assert caught.value.code == "version_too_new" + + +def test_a_newer_peer_that_still_accepts_us_is_allowed(): + """ + The point of a range rather than an equality: a 1.4 node that still speaks to + 1.0 clients must not refuse one. + """ + ours = parse_version(MNP_VERSION) + check_version(f"{ours[0]}.{ours[1] + 4}", MNP_MIN_SUPPORTED) + + +@pytest.mark.parametrize("bad", ["", "one.two", "1", None, "1.0.0", "v1.0"]) +def test_an_unreadable_version_is_refused_not_guessed(bad): + with pytest.raises(HandshakeError) as caught: + check_version(bad) + assert caught.value.code == "version_unreadable" + + +def test_versions_order_numerically_not_lexically(): + """`"0.9" < "0.15"` as strings, and the opposite as versions.""" + assert parse_version("0.9") < parse_version("0.15") < parse_version("1.0") + + +def test_mnp_1_0_is_a_major_bump(): + """ + Recorded as a test because the number is the only thing that says "this one is + different". 1.0 seals the index and the ack under the group key: no 0.x peer + can open either, and there is nothing to be compatible with. + """ + assert parse_version(MNP_VERSION) >= (1, 0) + assert parse_version(MNP_MIN_SUPPORTED) >= (1, 0) -- cgit v1.2.3