diff options
Diffstat (limited to 'packages/meshbay-common/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_handshake.py | 225 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_js_python_parity.py | 236 |
2 files changed, 461 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py new file mode 100644 index 0000000..8981db8 --- /dev/null +++ b/packages/meshbay-common/tests/test_handshake.py @@ -0,0 +1,225 @@ +""" +Unified handshake — properties every transport must inherit (11.5.4/5, C6, C3, L4). + +These test the shared module rather than any one transport. The point of the module +is that WebRTC and QUIC cannot drift apart again: the handshake existed three times +over and only the newest copy enforced the GEK proof. +""" + +import time + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.handshake import ( + HANDSHAKE_PREFIX, + NONCE_LEN, + ROLE_CLIENT, + ROLE_NODE, + AuthorizedPeer, + HandshakeError, + authorize_token, + handshake_transcript, + make_proof, + quic_binding, + verify_proof, + webrtc_binding, +) + +GEK = b"\x11" * 32 +GROUP = "g" * 32 +NONCE_C = b"\x01" * NONCE_LEN +NONCE_S = b"\x02" * NONCE_LEN +BINDING = webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) + + +# ── Token authorization ─────────────────────────────────────────────────────── + +@pytest.fixture +def hub_key(): + sk = Ed25519PrivateKey.generate() + pem = sk.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + pub = sk.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return pem, pub + + +def _token(sk_pem, **over): + now = int(time.time()) + payload = { + "iss": "test-hub", "sub": "user-1", "jti": "jti-1", + "iat": now, "exp": now + 3600, + "groups": [GROUP], "scope": "user", "pk_user": "pk", + } + payload.update(over) + return jwt.encode(payload, sk_pem, algorithm="EdDSA") + + +def test_valid_token_authorizes(hub_key): + sk_pem, pk_pem = hub_key + peer = authorize_token(_token(sk_pem), pk_pem, group_id=GROUP) + assert isinstance(peer, AuthorizedPeer) + assert peer.user_id == "user-1" + + +def test_group_id_is_mandatory(hub_key): + """ + M1: group_id used to be optional, and omitting it skipped the membership check + entirely while falling back to the node's first group. + """ + sk_pem, pk_pem = hub_key + with pytest.raises(HandshakeError, match="group_id"): + authorize_token(_token(sk_pem), pk_pem, group_id="") + + +def test_non_member_refused(hub_key): + sk_pem, pk_pem = hub_key + token = _token(sk_pem, groups=["other-group"]) + with pytest.raises(HandshakeError, match="Not a member"): + authorize_token(token, pk_pem, group_id=GROUP) + + +def test_node_scoped_token_refused_on_client_path(hub_key): + """M9: a daemon's node-scoped token must not be usable as a client token.""" + sk_pem, pk_pem = hub_key + token = _token(sk_pem, scope="node") + with pytest.raises(HandshakeError, match="scope"): + authorize_token(token, pk_pem, group_id=GROUP) + + +def test_unhosted_group_refused(hub_key): + sk_pem, pk_pem = hub_key + with pytest.raises(HandshakeError, match="not hosted"): + authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, + hosted_groups={"some-other-group"}) + + +def test_denylisted_token_refused(hub_key): + sk_pem, pk_pem = hub_key + + class _Deny: + def is_denied(self, user_id, jti, group_id=""): + return group_id == GROUP + + with pytest.raises(HandshakeError, match="revoked"): + authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, denylist=_Deny()) + + +def test_forged_token_refused(hub_key): + _, pk_pem = hub_key + other = Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + with pytest.raises(HandshakeError, match="Invalid JWT"): + authorize_token(_token(other), pk_pem, group_id=GROUP) + + +# ── Proof transcript ────────────────────────────────────────────────────────── + +def test_transcript_is_domain_separated(): + assert handshake_transcript( + ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING + ).startswith(HANDSHAKE_PREFIX) + + +def test_client_proof_is_not_a_node_proof(): + """ + C3: the node proves itself with the same key over the same connection. Without + the role bound in, a client's proof would satisfy the node check and vice + versa, so an impersonating peer could simply echo it back. + """ + client = make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING) + assert not verify_proof(GEK, client, ROLE_NODE, GROUP, NONCE_C, NONCE_S, BINDING) + + node = make_proof(GEK, ROLE_NODE, GROUP, NONCE_C, NONCE_S, BINDING) + assert not verify_proof(GEK, node, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING) + assert client != node + + +@pytest.mark.parametrize("field,value", [ + ("group_id", "other-group"), + ("nonce_client", b"\x09" * NONCE_LEN), + ("nonce_node", b"\x09" * NONCE_LEN), + ("binding", webrtc_binding(b"\xcc" * 32, b"\xdd" * 32)), +]) +def test_proof_binds_every_field(field, value): + base = dict(role=ROLE_CLIENT, group_id=GROUP, nonce_client=NONCE_C, + nonce_node=NONCE_S, binding=BINDING) + proof = make_proof(GEK, **base) + altered = dict(base, **{field: value}) + assert not verify_proof(GEK, proof, **altered), ( + f"proof ignores {field} — replayable across connections") + + +def test_proof_requires_channel_binding(): + """ + L4/NS5: the old transcript was nonce ‖ offer_fp ‖ answer_fp, and a missing + fingerprint silently degraded it to nonce-only, dropping MitM detection. + """ + with pytest.raises(HandshakeError, match="binding"): + make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, b"") + + assert not verify_proof( + GEK, b"\x00" * 32, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, b"") + + +def test_proof_requires_gek(): + with pytest.raises(HandshakeError): + make_proof(b"", ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING) + + +def test_wrong_gek_fails(): + proof = make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING) + assert not verify_proof( + b"\x22" * 32, proof, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING) + + +def test_transcript_is_unambiguous(): + """ + L4: with bare concatenation, a crafted group id could impersonate the + following field and two different handshakes would produce identical bytes. + """ + a = handshake_transcript(ROLE_CLIENT, "gg", NONCE_C, NONCE_S, BINDING) + b = handshake_transcript(ROLE_CLIENT, "g", b"g" + NONCE_C, NONCE_S, BINDING) + assert a != b + + +def test_bindings_differ_by_transport(): + """A WebRTC proof must not be replayable on a QUIC connection.""" + assert webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) != quic_binding(b"cert-der") + + +def test_membership_refusal_carries_a_code_a_client_can_act_on(): + """ + `groups` is baked into the token at login, so someone added to a group after + signing in is refused although they are a member. The client refreshes and + retries on this code — it must not have to match on the human wording, which + is exactly the kind of coupling that breaks when someone improves a message. + """ + import jwt as _jwt + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + + sk = Ed25519PrivateKey.generate() + pem_priv = sk.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + pem_pub = sk.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []}, + pem_priv, algorithm="EdDSA") + + with pytest.raises(HandshakeError) as excinfo: + authorize_token(token, pem_pub, group_id="g" * 32) + assert excinfo.value.code == "not_a_member" 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" |