summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/tests')
-rw-r--r--packages/meshbay-common/tests/test_handshake.py225
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py236
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"