""" 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, v, v_min} node check_version() supported range, both ways node authorize_token() JWT, scope, denylist, membership, hosting node → client handshake_challenge {nonce_s, v, v_min} client → node handshake_response {proof} node verify client proof HMAC(GEK, client transcript) node → client handshake_ack {proof, sig, node_pk, nonce, ct} client verify node proof HMAC(GEK, node transcript) + Ed25519 client THEN open ct the session config, sealed (groupbox.py) 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. **A version that is checked (L2).** `v` used to be written by everyone and read by nobody, so a version mismatch surfaced as a missing field — an old client reading a 1.0 ack found no `enabled_apps` and applied its documented fallback, "show every app", which is a wrong answer rather than an error. Both sides now declare the range they speak, in the first message each sends, and a peer outside it is refused with a code rather than served a message it will misread. Without this the *next* breaking change costs another coordinated deployment; with it, it costs a refusal. **A payload the hub cannot forge.** The transcript above names `role`, `group_id`, both nonces and the binding — and **no ack field**. So `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the channel alone. Since MNP 1.0 they travel sealed under a GEK-derived subkey (`groupbox.py`), which gives them an AEAD tag from a key the hub does not hold. Verify first, then decrypt: opening the payload before the proof and the signature would mean acting on data from a peer not yet authenticated. **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 from meshbay_common import MNP_VERSION HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" # The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`, # `index_delta` and the `handshake_ack` payload under the group key, which no # 0.x peer can open and which a 0.x peer's own messages do not carry — there is # nothing to be compatible with, which is what makes it a MAJOR bump. MNP_MIN_SUPPORTED = "1.0" ROLE_CLIENT = "client" ROLE_NODE = "node" NONCE_LEN = 32 # Clock-skew tolerance for JWT `iat`/`exp`/`nbf`. The token is issued by the # hub and verified by a node, on two machines whose clocks are only as close # as their NTP — and a VM guest that has just resumed can be tens of seconds # out. Without this a slightly-fast client cannot connect at all # ("token is not yet valid (iat)"). JWT_LEEWAY_SECONDS = 60 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 parse_version(v: str) -> tuple[int, int]: """`"1.0"` → `(1, 0)`. Raises ValueError on anything else.""" major, _, minor = str(v).partition(".") return int(major), int(minor) def check_version(peer_v: str, peer_min: str = "") -> None: """ Refuse a peer outside the range this build speaks, before anything else. `peer_min` is the oldest version the peer accepts *from us*; a peer that declares none is treated as accepting only what it speaks, which is the right reading of every 0.x peer — none of them declared a range because none of them checked one. Raises HandshakeError with a code the other side can act on, rather than letting the mismatch surface later as a field that is missing. """ ours = parse_version(MNP_VERSION) our_min = parse_version(MNP_MIN_SUPPORTED) try: theirs = parse_version(peer_v) their_min = parse_version(peer_min) if peer_min else theirs except (ValueError, AttributeError): raise HandshakeError( f"Unreadable protocol version {peer_v!r}", code="version_unreadable" ) from None if theirs < our_min: raise HandshakeError( f"Protocol {peer_v} is too old for this peer, which needs " f"{MNP_MIN_SUPPORTED} or later", code="version_too_old") if their_min > ours: raise HandshakeError( f"This peer speaks protocol {MNP_VERSION}, older than the " f"{peer_min} the other side requires", code="version_too_new") 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"], leeway=JWT_LEEWAY_SECONDS) 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