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/src | |
| 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/src')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 203 |
1 files changed, 203 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 |