summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 11:46:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 11:46:12 +0200
commite13659f8f3166b5a9a4155314941bc149fec2721 (patch)
tree53e6851a9f0130c3427adec333aebfc4c8e9d43d /packages/meshbay-common/src
parentb86be704df752f2fd3086fcca43b7f4de78389d1 (diff)
downloadmeshbay-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.py203
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