summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/handshake.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/handshake.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py220
1 files changed, 220 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..fca218f
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -0,0 +1,220 @@
+"""
+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.
+
+ `code` is the same refusal in a form a client can act on. The text is for a
+ human and may be reworded; matching on it from the client would be a string
+ comparison that breaks silently the day someone improves the wording.
+ """
+
+ def __init__(self, message: str, code: str = ""):
+ super().__init__(message)
+ self.code = code
+
+
+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
+ jti: str
+
+ # No `pk_user`. The hub used to put a user key in the token and the node
+ # recorded it as the uploader's identity, which let whoever issued tokens
+ # decide who could delete a file. Identity keys are pinned by the node
+ # (see roster.py); the hub certifies accounts, not keys.
+
+
+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", []):
+ # Almost always a token issued before the person was added to the group:
+ # `groups` is baked in at login and the hub does not push updates. The
+ # client refreshes and retries on this code rather than telling someone
+ # who *is* a member that they are not one.
+ raise HandshakeError("Not a member of this group", code="not_a_member")
+
+ 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", ""),
+ 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