aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/handshake.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-common/src/meshbay_common/handshake.py
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they changed about what this project may claim. Phase 11.5 closed the gap between the documents and the code: the unauthenticated node HTTP API and the TCP transport deleted, one handshake shared by the remaining two transports, mutual authentication, structured admin transcripts, upload confinement, group isolation, revocation that reaches nodes. Six critical and seven high findings closed, bounded, or deferred by decision. The invite redesign closed H3 and M3 — the last open High. The hub was the key directory: an inviter fetched the invitee's key from it and wrapped the group key for whatever came back, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. That lookup is gone. The node holds the group key and wraps it itself, for a key its recipient proves possession of, bound to an account by a one-time code the hub never sees. M3 fell out of the same work: node authority comes from a local roster, never from the hub. Per-node identity cut what remains of C4 down to one operator. A single keypair used to be copied to every node its owner joined; each node now gets its own, so cracking the bundle on one machine yields a key that is a stranger everywhere else — and on that machine, one that unlocks nothing its holder did not already serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or publishing user keys at all. What this project may now say: the hub cannot read your content unless it ships you malicious client code. T3 remains, accepted (D1), and is what the native client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds against, which is the convention this branch exists to keep. Four defects were found by deploying it and using a browser, none by the test suite: a node going deaf on its hub socket, a token that predated group membership, a client reading values before they were assigned, and identity keys a browser held but never re-read. The lessons are recorded in CLAUDE.md. Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair, invite, join, download, stream, second browser, revoke — run against the live deployment on a wiped hub and node.
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