diff options
Diffstat (limited to 'packages/meshbay-common')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 203 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_handshake.py | 199 |
2 files changed, 402 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py new file mode 100644 index 0000000..73a2858 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -0,0 +1,203 @@ +""" +Unified MNP handshake — one implementation, every transport. + +Finding C6: the handshake existed three times over (WebRTC, QUIC, TCP), and only +the newest copy enforced the GEK proof. QUIC and TCP accepted a bare JWT, so a +forged or stolen token reached the node and could inject chat messages without +ever holding the group key. TCP is gone (11.5.2); QUIC and WebRTC now share this +module, and a parity test fails if either skips a step. + +The sequence: + + client → node handshake {token, group_id, nonce_c} + node authorize_token() JWT, scope, denylist, membership, hosting + node → client handshake_challenge {nonce_s} + client → node handshake_response {proof} + node verify client proof HMAC(GEK, client transcript) + node → client handshake_ack {proof, sig, node_pk, is_node_admin} + client verify node proof HMAC(GEK, node transcript) + Ed25519 + +Two properties this adds over the previous design: + +**Mutual authentication (C3).** Authentication used to run 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 node now proves GEK possession over a +client-chosen nonce *and* signs the transcript with its long-term key, so the +client can pin it. + +**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp` +— bare concatenation, and a missing fingerprint silently degraded it to nonce-only. +Every field is now length-prefixed and domain-separated, the role is bound so a +client proof can never be replayed as a node proof, and an empty channel binding is +refused rather than tolerated. +""" + +from __future__ import annotations + +import hashlib +import hmac +from dataclasses import dataclass +from typing import Any, Protocol + +import jwt + +HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" + +ROLE_CLIENT = "client" +ROLE_NODE = "node" + +NONCE_LEN = 32 + + +class HandshakeError(Exception): + """Refusal, with a message safe to hand to the peer.""" + + +class DenylistLike(Protocol): + def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool: ... + + +@dataclass +class AuthorizedPeer: + user_id: str + group_id: str + username: str + pk_user: str + jti: str + + +def handshake_transcript( + role: str, + group_id: str, + nonce_client: bytes, + nonce_node: bytes, + binding: bytes, +) -> bytes: + """ + Bytes covered by a handshake proof. + + `binding` ties the proof to the concrete connection: the two DTLS fingerprints + for WebRTC, the TLS certificate hashes for QUIC. Without it a proof captured on + one connection is replayable on another (NS5). + """ + fields = [ + role.encode(), + group_id.encode(), + nonce_client, + nonce_node, + binding, + ] + out = bytearray(HANDSHAKE_PREFIX) + for field in fields: + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) + + +def make_proof( + gek: bytes, + role: str, + group_id: str, + nonce_client: bytes, + nonce_node: bytes, + binding: bytes, +) -> bytes: + if not binding: + # An empty binding means the transport could not identify the channel. + # Proceeding would silently drop MitM detection (L4). + raise HandshakeError("Channel binding unavailable") + if not gek: + raise HandshakeError("Group encryption not initialized") + transcript = handshake_transcript( + role, group_id, nonce_client, nonce_node, binding) + return hmac.new(gek, transcript, hashlib.sha256).digest() + + +def verify_proof( + gek: bytes, + proof: bytes, + role: str, + group_id: str, + nonce_client: bytes, + nonce_node: bytes, + binding: bytes, +) -> bool: + try: + expected = make_proof( + gek, role, group_id, nonce_client, nonce_node, binding) + except HandshakeError: + return False + return hmac.compare_digest(proof, expected) + + +def authorize_token( + token: str, + hub_pk_pem: bytes, + *, + group_id: str, + hosted_groups: Any | None = None, + denylist: DenylistLike | None = None, + require_scope: str | None = "user", +) -> AuthorizedPeer: + """ + Everything decided from the JWT, before any proof is exchanged. + + Raises HandshakeError with a peer-safe message. Deliberately strict about + `group_id`: it used to be optional, and omitting it skipped the membership + check entirely and fell back to the node's first group (M1). + """ + try: + decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) + except Exception as exc: + raise HandshakeError(f"Invalid JWT: {exc}") from exc + + # A node-scoped daemon token must not be usable as a client token (M9). + if require_scope is not None and decoded.get("scope", "user") != require_scope: + raise HandshakeError("Wrong token scope") + + user_id = decoded.get("sub", "") + jti = decoded.get("jti", "") + if not user_id: + raise HandshakeError("Token has no subject") + + if not group_id: + raise HandshakeError("group_id is required") + + if denylist is not None and denylist.is_denied(user_id, jti, group_id): + raise HandshakeError("Token revoked") + + if group_id not in decoded.get("groups", []): + raise HandshakeError("Not a member of this group") + + if hosted_groups is not None and group_id not in hosted_groups: + raise HandshakeError("Group not hosted on this node") + + return AuthorizedPeer( + user_id=user_id, + group_id=group_id, + username=decoded.get("username", ""), + pk_user=decoded.get("pk_user", ""), + jti=jti, + ) + + +def webrtc_binding(offer_fp: bytes, answer_fp: bytes) -> bytes: + """Channel binding for WebRTC: both DTLS certificate fingerprints.""" + return (len(offer_fp).to_bytes(4, "big") + offer_fp + + len(answer_fp).to_bytes(4, "big") + answer_fp) + + +def quic_binding(server_cert_der: bytes) -> bytes: + """ + Channel binding for QUIC. + + QUIC has no DTLS fingerprint to reuse, so the anchor is a hash of the server's + self-signed certificate — the same value a client pins as the node identity. + An RFC 5705 exporter would be stronger; aioquic does not currently expose one + (11.5.6). + """ + digest = hashlib.sha256(server_cert_der).digest() + return len(digest).to_bytes(4, "big") + digest 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") |