diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:46:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:46:12 +0200 |
| commit | e13659f8f3166b5a9a4155314941bc149fec2721 (patch) | |
| tree | 53e6851a9f0130c3427adec333aebfc4c8e9d43d /packages/meshbay-common/tests | |
| parent | b86be704df752f2fd3086fcca43b7f4de78389d1 (diff) | |
| download | meshbay-e13659f8f3166b5a9a4155314941bc149fec2721.tar.gz | |
feat(mnp): unified handshake with mutual authentication
Phase 11.5.4/5/7/8 — findings C6 (WebRTC half), C3, L4, M1, M9.
New meshbay_common/handshake.py is the single implementation of authorization
and proof: JWT verify, scope, denylist, mandatory group_id, membership, hosting.
The handshake previously existed three times over and only the newest copy
enforced the GEK proof.
C3 — mutual authentication. Authentication ran one way: the client proved
itself, the node proved nothing. handshake_ack.node_pk was never verified
against anything and per-chunk signatures had been dropped in Phase 9.15, so a
peer that had hijacked signaling (C2) or been substituted by the hub could
accept the client's proof, ignore it, and serve a forged index, forged chat
history and a forged is_node_admin flag. The client now sends a nonce; the node
answers with its own GEK proof over that nonce AND an Ed25519 signature over
the transcript; the browser verifies both and refuses otherwise. It also
refuses an unchallenged handshake_ack, which previously let a peer skip proving
anything at all.
L4 — the proof was nonce ‖ offer_fp ‖ answer_fp: bare concatenation, and a
missing fingerprint silently degraded it to nonce-only, dropping MitM detection
(NS5). Every field is now length-prefixed and domain-separated, the role is
bound so a client proof cannot be replayed as a node proof, and an absent
channel binding is refused rather than tolerated.
M1 — group_id was optional; omitting it skipped the membership check entirely
and fell back to the node's first group. Now mandatory.
M9 — node-scoped daemon tokens are refused on the client path.
NOT DONE: quic_server.py still runs its own JWT-only handshake, so C6 remains
open — a forged or stolen token reaches a node over QUIC and can inject chat
without holding the GEK. quic_binding() is written and unit-tested but unwired.
11.5.6 (whether the certificate-hash anchor works with aioquic, or an RFC 5705
exporter is reachable) is unproven. 11.5.8 TOFU pinning of pk_node is not done:
the client verifies the node's signature but does not yet remember which key it
saw last.
Adds packages/meshbay-common/tests/test_handshake.py (18 tests) covering the
properties every transport must inherit. WebRTC test helpers rewritten around
the shared module; _make_jwt now defaults to the test group, since group_id is
mandatory.
Tests: 24 webrtc, 176+ node+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_handshake.py | 199 |
1 files changed, 199 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..ba8788a --- /dev/null +++ b/packages/meshbay-common/tests/test_handshake.py @@ -0,0 +1,199 @@ +""" +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") |