diff options
Diffstat (limited to 'packages/meshbay-common/tests/test_js_python_parity.py')
| -rw-r--r-- | packages/meshbay-common/tests/test_js_python_parity.py | 236 |
1 files changed, 236 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 new file mode 100644 index 0000000..340ea3e --- /dev/null +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -0,0 +1,236 @@ +""" +Cross-language parity: the browser's transcripts must be byte-identical to Python's. + +The handshake proof and the admin signature are computed independently on both sides +and compared by producing the same bytes. Nothing on the wire carries the transcript, +which is the point — but it also means a one-byte disagreement between `crypto.js` and +`meshbay_common` is invisible to every other test and produces a total outage: no +browser can complete a handshake with any node. + +The rest of the suite runs in Python only, so nothing else crosses this boundary. These +tests drive the real `crypto.js` under node and compare against the real Python. + +Skipped when node is unavailable; that is a coverage gap, not a pass. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from meshbay_common.adminop import admin_transcript +from meshbay_common.handshake import handshake_transcript, webrtc_binding +from meshbay_common.join import join_transcript + +CRYPTO_JS = (Path(__file__).resolve().parents[2] + / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "crypto.js") + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not CRYPTO_JS.exists(), + reason="node or crypto.js unavailable — parity cannot be checked", +) + +# (role, group_id, nonce_c hex, nonce_s hex, offer_fp hex, answer_fp hex) +HANDSHAKE_VECTORS = [ + ("client", "g" * 32, "01" * 32, "02" * 32, "aa" * 32, "bb" * 32), + ("node", "g" * 32, "01" * 32, "02" * 32, "aa" * 32, "bb" * 32), + # Short and empty group ids — length prefixing must keep these distinct. + ("client", "g", "03" * 32, "04" * 32, "cc" * 32, "dd" * 32), + ("client", "", "03" * 32, "04" * 32, "cc" * 32, "dd" * 32), + # Non-ASCII: JS TextEncoder and Python .encode() must agree on UTF-8. + ("client", "groupe-café-日本", "05" * 32, "06" * 32, "ee" * 32, "ff" * 32), + # Fields that could run together under naive concatenation. + ("client", "gg", "07" * 32, "08" * 32, "11" * 32, "22" * 32), +] + +# (op, node_pk_b64, group_id, subject, nonce hex, ts) +ADMIN_VECTORS = [ + ("file_delete", "Tk9ERVBL", "g" * 32, "file-1", "01" * 32, 1_700_000_000), + ("gek_bundle_store", "Tk9ERVBL", "g" * 32, "user-2", "02" * 32, 1_700_000_001), + ("file_delete", "Tk9ERVBL", "", "", "03" * 32, 0), + ("file_delete", "Tk9ERVBL", "café", "fichier é.mp4", "04" * 32, 1_700_000_002), +] + +# (node_pk_b64, group_id, user_id, pk_ed b64, pk_x b64, nonce hex, ts) +JOIN_VECTORS = [ + # Operator pairing: group_id is empty and must stay distinguishable from a + # request that names a group. + ("Tk9ERVBL", "", "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000), + ("Tk9ERVBL", "g" * 32, "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000), + # Identical to the previous vector except that the two keys are swapped — + # they are adjacent fields, so this isolates the ordering. + ("Tk9ERVBL", "g" * 32, "grenet", "QkJC", "QUFB", "01" * 32, 1_700_000_000), + ("Tk9ERVBL", "café", "utilisateur-é", "QUFB", "QkJC", "03" * 32, 0), +] + +_HARNESS = r""" +const fs = require('fs'); + +// crypto.js ends with `window.MeshBayCrypto = {...}` and references SubtleCrypto in +// functions we do not call. A stub is enough to evaluate the module body. +globalThis.window = {}; +globalThis.crypto = globalThis.crypto || {}; + +const src = fs.readFileSync(process.argv[2], 'utf8'); +const load = new Function( + src + '\nreturn { handshakeTranscript, adminTranscript, joinTranscript, ' + + 'webrtcBinding, b64encode };'); +const M = load(); + +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(''); + +const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +const out = { handshake: [], admin: [], join: [] }; + +for (const v of input.handshake) { + const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp)); + out.handshake.push(toHex(M.handshakeTranscript( + v.role, v.group_id, hex(v.nonce_c), hex(v.nonce_s), binding))); +} + +for (const v of input.admin) { + out.admin.push(toHex(M.adminTranscript( + v.op, v.node_pk, v.group_id, v.subject, M.b64encode(hex(v.nonce)), v.ts))); +} + +for (const v of input.join) { + out.join.push(toHex(M.joinTranscript( + v.node_pk, v.group_id, v.user_id, v.pk_ed, v.pk_x, hex(v.nonce), v.ts))); +} + +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def js_output(tmp_path_factory): + """Run the real crypto.js under node and return its transcripts as hex.""" + d = tmp_path_factory.mktemp("parity") + harness = d / "harness.js" + harness.write_text(_HARNESS) + + payload = d / "vectors.json" + payload.write_text(json.dumps({ + "handshake": [ + {"role": r, "group_id": g, "nonce_c": nc, + "nonce_s": ns, "offer_fp": ofp, "answer_fp": afp} + for r, g, nc, ns, ofp, afp in HANDSHAKE_VECTORS + ], + "admin": [ + {"op": op, "node_pk": pk, "group_id": g, + "subject": s, "nonce": n, "ts": ts} + for op, pk, g, s, n, ts in ADMIN_VECTORS + ], + "join": [ + {"node_pk": pk, "group_id": g, "user_id": u, + "pk_ed": pe, "pk_x": px, "nonce": n, "ts": ts} + for pk, g, u, pe, px, n, ts in JOIN_VECTORS + ], + })) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr}") + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(HANDSHAKE_VECTORS))) +def test_handshake_transcript_parity(idx, vector, js_output): + """ + A mismatch here means no browser can complete a handshake with any node — + the GEK proof would never verify, and no other test would notice. + """ + role, group_id, nonce_c, nonce_s, offer_fp, answer_fp = vector + + expected = handshake_transcript( + role=role, + group_id=group_id, + nonce_client=bytes.fromhex(nonce_c), + nonce_node=bytes.fromhex(nonce_s), + binding=webrtc_binding(bytes.fromhex(offer_fp), bytes.fromhex(answer_fp)), + ) + assert js_output["handshake"][idx] == expected.hex(), ( + f"crypto.js and meshbay_common.handshake disagree for role={role!r} " + f"group={group_id!r}" + ) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(ADMIN_VECTORS))) +def test_admin_transcript_parity(idx, vector, js_output): + """ + A mismatch here means the browser signs bytes the node did not ask for, so every + file deletion and GEK bundle store is rejected. + """ + op, node_pk, group_id, subject, nonce, ts = vector + + expected = admin_transcript( + op=op, + node_pk_b64=node_pk, + group_id=group_id, + subject=subject, + nonce=bytes.fromhex(nonce), + ts=ts, + ) + assert js_output["admin"][idx] == expected.hex(), ( + f"crypto.js and meshbay_common.adminop disagree for op={op!r} " + f"subject={subject!r}" + ) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(JOIN_VECTORS))) +def test_join_transcript_parity(idx, vector, js_output): + """ + A mismatch here means no browser can pair with a node and no member can be + recognised — the node would reject every signature as invalid, and, as with + the other two, nothing else in the suite crosses this boundary. + """ + node_pk, group_id, user_id, pk_ed, pk_x, nonce, ts = vector + + expected = join_transcript( + node_pk_b64=node_pk, + group_id=group_id, + user_id=user_id, + pk_ed25519_b64=pk_ed, + pk_x25519_b64=pk_x, + nonce_node=bytes.fromhex(nonce), + ts=ts, + ) + assert js_output["join"][idx] == expected.hex(), ( + f"crypto.js and meshbay_common.join disagree for user={user_id!r} " + f"group={group_id!r}" + ) + + +def test_join_transcript_binds_the_two_keys_in_order(js_output): + """ + The X25519 key is trusted only because the Ed25519 identity signed it, so the + two must not be interchangeable: swapping them has to produce different bytes. + """ + assert js_output["join"][1] != js_output["join"][2] + + +def test_operator_pairing_is_distinguishable_from_a_group_join(js_output): + """An empty group_id (node-wide operator authority) must not collide.""" + assert js_output["join"][0] != js_output["join"][1] + + +def test_length_prefixing_actually_disambiguates(js_output): + """ + The reason both sides length-prefix: two different field splits must not collide. + Verified across the language boundary, since a JS implementation that concatenated + naively would still agree with itself. + """ + 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" |