diff options
Diffstat (limited to 'packages/meshbay-node')
3 files changed, 179 insertions, 102 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 190d580..34a96bd 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -43,6 +43,17 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( ) from meshbay_common import MNP_VERSION +from meshbay_common.handshake import ( + NONCE_LEN, + ROLE_CLIENT, + ROLE_NODE, + HandshakeError, + authorize_token, + handshake_transcript, + make_proof, + verify_proof, + webrtc_binding, +) from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, OP_FILE_DELETE, @@ -207,6 +218,7 @@ class WebRTCPeerSession: self._username: str = "" self._pk_user: str = "" self._gek_challenge: bytes | None = None + self._nonce_client: bytes = b"" self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation self._uploads: dict[str, dict] = {} # filename → {next_index, bytes} @@ -293,58 +305,65 @@ class WebRTCPeerSession: detail=detail, )) + def _channel_binding(self) -> bytes: + """Both DTLS fingerprints, so a proof is valid on this connection only.""" + offer_fp = b"" + answer_fp = b"" + if self._pc.remoteDescription: + offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp) + if self._pc.localDescription: + answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp) + if not offer_fp or not answer_fp: + return b"" + return webrtc_binding(offer_fp, answer_fp) + def _do_handshake(self, msg: dict) -> None: - token = msg.get("token", "") group_id = msg.get("group_id", "") try: - decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"]) - except Exception as e: - self._send({"type": "error", "detail": f"Invalid JWT: {e}"}) - self._audit_auth_failed(group_id, str(e)) - return - - denylist = self._ctx.get("denylist") - if denylist and denylist.is_denied( - decoded.get("sub", ""), decoded.get("jti", ""), group_id): - self._send({"type": "error", "detail": "Token revoked"}) - return - - if group_id and group_id not in decoded.get("groups", []): - self._send({"type": "error", "detail": "Not a member of this group"}) + peer = authorize_token( + msg.get("token", ""), + self._ctx["hub_pk_pem"], + group_id=group_id, + hosted_groups=self._ctx.get("groups"), + denylist=self._ctx.get("denylist"), + ) + except HandshakeError as refusal: + # HandshakeError messages are authored to be peer-safe, unlike arbitrary + # exception text (L3) — the client needs to know *why* it was refused. + self._send({"type": "error", "detail": str(refusal)}) + self._audit_auth_failed(group_id, str(refusal)) return - if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]: - self._send({"type": "error", "detail": "Group not hosted on this node"}) + try: + self._nonce_client = base64.b64decode(msg.get("nonce", "")) + except Exception: + self._nonce_client = b"" + if len(self._nonce_client) < NONCE_LEN: + # The client nonce is what makes the NODE's proof fresh (C3). Without + # it a recorded ack could be replayed by an impersonating peer. + self._send({"type": "error", "detail": "Client nonce required"}) return - # Store decoded JWT data but DO NOT set self._user_id yet — - # the user is not authenticated until they prove GEK possession. - self._pending_sub = decoded["sub"] - self._pending_group = group_id - self._pending_username = decoded.get("username", "") - self._pending_pk_user = decoded.get("pk_user", "") + # Decoded, but NOT authenticated: that happens on the GEK proof. + self._pending_sub = peer.user_id + self._pending_group = peer.group_id + self._pending_username = peer.username + self._pending_pk_user = peer.pk_user - ctx = self._ctx - if "groups" in ctx and group_id: - gctx = ctx["groups"].get(group_id, ctx) - else: - gctx = ctx - gek = gctx.get("gek") - - nonce = os.urandom(32) - self._gek_challenge = nonce - challenge = { - "type": MNP.HANDSHAKE_CHALLENGE, - "v": MNP_VERSION, - "nonce": base64.b64encode(nonce).decode(), - } - if not gek: + gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx + if not gctx.get("gek"): self._send({ "type": "error", "detail": "Group encryption not initialized — contact node operator", }) return - self._send(challenge) + + self._gek_challenge = os.urandom(NONCE_LEN) + self._send({ + "type": MNP.HANDSHAKE_CHALLENGE, + "v": MNP_VERSION, + "nonce": base64.b64encode(self._gek_challenge).decode(), + }) def _do_handshake_response(self, msg: dict) -> None: if not self._gek_challenge or not hasattr(self, "_pending_sub"): @@ -352,44 +371,38 @@ class WebRTCPeerSession: return group_id = self._pending_group - ctx = self._ctx - if "groups" in ctx and group_id: - gctx = ctx["groups"].get(group_id, ctx) - else: - gctx = ctx + gctx = self._ctx["groups"][group_id] if "groups" in self._ctx else self._ctx gek = gctx.get("gek") - if not gek: self._send({"type": "error", "detail": "Group encryption not initialized"}) self._gek_challenge = None return - proof = msg.get("proof", "") try: - proof_bytes = base64.b64decode(proof) + proof_bytes = base64.b64decode(msg.get("proof", "")) except Exception: self._send({"type": "error", "detail": "Invalid proof encoding"}) return - offer_fp = b"" - answer_fp = b"" - if self._pc.remoteDescription: - offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp) - if self._pc.localDescription: - answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp) + binding = self._channel_binding() + if not binding: + # Refuse rather than fall back to an unbound proof (L4). + self._send({"type": "error", "detail": "Channel binding unavailable"}) + self._gek_challenge = None + self._audit_auth_failed(group_id, "no channel binding") + return - data = self._gek_challenge + offer_fp + answer_fp - expected = hmac.new(gek, data, hashlib.sha256).digest() - if not hmac.compare_digest(proof_bytes, expected): + if not verify_proof(gek, proof_bytes, ROLE_CLIENT, group_id, + self._nonce_client, self._gek_challenge, binding): self._send({"type": "error", "detail": "GEK proof failed"}) self._gek_challenge = None self._audit_auth_failed(group_id, "GEK HMAC mismatch") return + self._complete_handshake(gek, binding) self._gek_challenge = None - self._complete_handshake() - def _complete_handshake(self) -> None: + def _complete_handshake(self, gek: bytes, binding: bytes) -> None: # Authenticated peers may send large frames (file uploads); unauthenticated # ones may not (H6). self._buffer.max_message = MAX_MSG @@ -404,10 +417,25 @@ class WebRTCPeerSession: log.info("WebRTC handshake OK — user=%s group=%s", self._user_id[:8], self._group_id[:8] if self._group_id else "none") + # The node proves itself too (C3): possession of the GEK over the client's + # nonce, plus a signature over the same transcript with its long-term key. + # Previously the client received an unverifiable node_pk and trusted + # is_node_admin from whoever answered — so a peer that had hijacked + # signaling could serve a forged index, chat history and permissions. + node_transcript = handshake_transcript( + ROLE_NODE, self._group_id or "", self._nonce_client, + self._gek_challenge or b"", binding) + node_proof = make_proof( + gek, ROLE_NODE, self._group_id or "", self._nonce_client, + self._gek_challenge or b"", binding) + ack = { "type": MNP.HANDSHAKE_ACK, "v": MNP_VERSION, "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), + "proof": base64.b64encode(node_proof).decode(), + "sig": base64.b64encode( + self._ctx["sk_node"].sign(node_transcript)).decode(), "is_node_admin": bool(node_user_id and self._user_id == node_user_id), } if node_user_id: diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index a6d69c6..9299bf4 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -460,10 +460,21 @@ def test_dead_gek_protocol_constants_removed(): def test_peer_errors_do_not_leak_internals(): - """L3: exception text carries filesystem paths and internal state.""" + """ + L3: arbitrary exception text carries filesystem paths and internal state, so + the catch-all handler must not relay it. + + Deliberately narrow: HandshakeError messages ARE sent to the peer, because a + client needs to know why it was refused, and those strings are authored for + that purpose. The check targets the generic `except Exception as e` path. + """ source = (Path(__file__).parent.parent / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text() - assert '"detail": str(e)' not in source + assert '"detail": str(e)' not in source, ( + "generic exception text relayed to peer — use a fixed message" + ) + # And the catch-all must still exist, sending something opaque. + assert '"detail": "Request failed"' in source def test_pre_handshake_message_budget_is_small(): diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index d57f4f5..59b48ac 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -19,7 +19,9 @@ import jwt import msgpack import pytest from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, Ed25519PublicKey, +) from aiortc import RTCPeerConnection, RTCSessionDescription from meshbay_common import MNP_VERSION @@ -32,6 +34,12 @@ from meshbay_common.crypto import ( ) from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP +TEST_GROUP = "g" + +from meshbay_common.handshake import ( + NONCE_LEN, ROLE_CLIENT, ROLE_NODE, handshake_transcript, + make_proof, verify_proof, webrtc_binding, +) from meshbay_common.adminop import ( OP_FILE_DELETE, OP_GEK_BUNDLE_STORE, @@ -77,6 +85,9 @@ def _hub_pk_pem(sk_hub): def _make_jwt(sk_hub, groups=None, pk_user="test"): + # group_id is mandatory now (M1), so the default token must be a member + # of the group the tests connect to. Tests that exercise refusal pass + # groups=[...] explicitly. sk_pem = sk_hub.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, @@ -87,7 +98,7 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"): "iss": "test-hub", "sub": "user-001", "pk_user": pk_user, "hub_id": "test-hub", "jti": "test-jti-webrtc", "iat": now, "exp": now + 3600, - "groups": groups or [], + "groups": groups if groups is not None else [TEST_GROUP], }, sk_pem, algorithm="EdDSA") @@ -123,35 +134,58 @@ def _extract_dtls_fp(sdp: str) -> bytes: return b"" -async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None, - browser_pc=None): - """Send handshake, handle GEK challenge, return handshake_ack.""" - token = _make_jwt(sk_hub, groups=groups) +async def _do_mnp_handshake(channel, received, token, gek, pc, group_id): + """ + Client half of the unified handshake (11.5.4): client nonce, length-prefixed + role-bound transcript, and verification of the node's own proof + signature. + """ + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ - "type": MNP.HANDSHAKE, - "v": MNP_VERSION, - "token": token, + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": token, "group_id": group_id, + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) - if msg["type"] == MNP.HANDSHAKE_CHALLENGE: - nonce = base64.b64decode(msg["nonce"]) - offer_fp = b"" - answer_fp = b"" - if browser_pc: - offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp) - proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest() - channel.send(_pack({ - "type": MNP.HANDSHAKE_RESPONSE, - "v": MNP_VERSION, - "proof": base64.b64encode(proof).decode(), - })) - msg = await asyncio.wait_for(received.get(), timeout=5.0) + if msg["type"] != MNP.HANDSHAKE_CHALLENGE: + return msg + + nonce_s = base64.b64decode(msg["nonce"]) + binding = webrtc_binding( + _extract_dtls_fp(pc.localDescription.sdp), + _extract_dtls_fp(pc.remoteDescription.sdp), + ) + proof = make_proof(gek, ROLE_CLIENT, group_id, nonce_c, nonce_s, binding) + channel.send(_pack({ + "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, + "proof": base64.b64encode(proof).decode(), + })) + ack = await asyncio.wait_for(received.get(), timeout=5.0) + + if ack.get("type") == MNP.HANDSHAKE_ACK: + # The client must authenticate the node too (C3). + assert verify_proof( + gek, base64.b64decode(ack["proof"]), ROLE_NODE, + group_id, nonce_c, nonce_s, binding), "node proof invalid" + transcript = handshake_transcript( + ROLE_NODE, group_id, nonce_c, nonce_s, binding) + Ed25519PublicKey.from_public_bytes( + base64.b64decode(ack["node_pk"]) + ).verify(base64.b64decode(ack["sig"]), transcript) + return ack + + +async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None, + browser_pc=None, group_id=TEST_GROUP): + """Send handshake, handle GEK challenge, return handshake_ack.""" + token = _make_jwt(sk_hub, groups=groups or [group_id]) + msg = await _do_mnp_handshake( + channel, received, token, gek, browser_pc, group_id) assert msg["type"] == MNP.HANDSHAKE_ACK return msg -async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None): +async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None, + group_id=TEST_GROUP): """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" pc = RTCPeerConnection() q = asyncio.Queue() @@ -199,21 +233,10 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us "iss": "test-hub", "sub": jwt_sub, "pk_user": pk_user, "hub_id": "test-hub", "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, - "groups": [], + "groups": [group_id], "scope": "user", }, sk_h_pem, algorithm="EdDSA") - ch.send(_pack({"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token})) - msg = await asyncio.wait_for(q.get(), timeout=5.0) - if msg["type"] == MNP.HANDSHAKE_CHALLENGE: - nonce = base64.b64decode(msg["nonce"]) - offer_fp = _extract_dtls_fp(pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(pc.remoteDescription.sdp) - proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest() - ch.send(_pack({ - "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, - "proof": base64.b64encode(proof).decode(), - })) - msg = await asyncio.wait_for(q.get(), timeout=5.0) + msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id) assert msg["type"] == MNP.HANDSHAKE_ACK return pc, ch, q @@ -696,6 +719,8 @@ async def test_webrtc_wrong_gek_proof_rejected(sk_node, sk_hub, gek, shared_dir) token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) challenge = await asyncio.wait_for(received.get(), timeout=5.0) @@ -754,6 +779,8 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) challenge = await asyncio.wait_for(received.get(), timeout=5.0) @@ -1159,8 +1186,10 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di # Step 1: Send handshake with group_id so _pending_group is set token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1175,11 +1204,12 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di recovered_gek = unwrap_gek(bundle_resp, sk_x_raw, pk_x_raw) assert recovered_gek == gek - nonce = base64.b64decode(msg["nonce"]) - offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp) - proof = hmac.new(recovered_gek, nonce + offer_fp + answer_fp, - hashlib.sha256).digest() + nonce_s = base64.b64decode(msg["nonce"]) + binding = webrtc_binding( + _extract_dtls_fp(browser_pc.localDescription.sdp), + _extract_dtls_fp(browser_pc.remoteDescription.sdp), + ) + proof = make_proof(recovered_gek, ROLE_CLIENT, "g", nonce_c, nonce_s, binding) # Step 4: Complete handshake channel.send(_pack({ @@ -1264,8 +1294,10 @@ async def test_keypair_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1331,8 +1363,10 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1448,6 +1482,8 @@ async def test_webrtc_no_gek_connection_refused(sk_node, sk_hub, shared_dir): token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) @@ -1507,8 +1543,10 @@ async def test_gek_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, tmp_ await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE |