summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/tests/test_js_python_parity.py
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-common/tests/test_js_python_parity.py
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-common/tests/test_js_python_parity.py')
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py157
1 files changed, 157 insertions, 0 deletions
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