diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_server.py | 183 |
1 files changed, 163 insertions, 20 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index f439e62..ed3925d 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -20,6 +20,7 @@ The transport is the only change — all crypto, auth, and message types stay th import asyncio import base64 import logging +import os import struct import subprocess from pathlib import Path @@ -34,6 +35,17 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common import MNP_VERSION +from meshbay_common.handshake import ( + NONCE_LEN, + ROLE_CLIENT, + ROLE_NODE, + HandshakeError, + authorize_token, + handshake_transcript, + make_proof, + quic_binding, + verify_proof, +) from meshbay_common.crypto import ( sign_chunk, pk_to_b64, @@ -41,7 +53,6 @@ from meshbay_common.crypto import ( from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex -from meshbay_node.transport.tls_cert import server_ssl_context log = logging.getLogger(__name__) @@ -51,22 +62,69 @@ ALPN = ["meshbay-mnp"] class Denylist: - """Shared denylist for revoked users and invalidated JWTs.""" + """ + Denylist for revoked users, groups and invalidated JWTs. - def __init__(self): + Finding H4: revocations used to live only in memory, so a node restart silently + un-revoked everyone, and group revocations were dropped entirely — the hub + signed and broadcast them but the node's handler only understood "user" and + "jti". Now persisted to disk and group targets are honoured. + """ + + def __init__(self, path: Path | None = None): self.user_ids: set[str] = set() + self.group_ids: set[str] = set() self.jtis: set[str] = set() + self._path = path + self._load() - def is_denied(self, user_id: str, jti: str) -> bool: - return user_id in self.user_ids or jti in self.jtis + def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool: + return (user_id in self.user_ids + or jti in self.jtis + or (bool(group_id) and group_id in self.group_ids)) def deny_user(self, user_id: str) -> None: self.user_ids.add(user_id) log.info("Denied user: %s", user_id[:8]) + self._save() + + def deny_group(self, group_id: str) -> None: + self.group_ids.add(group_id) + log.info("Denied group: %s", group_id[:8]) + self._save() def deny_jti(self, jti: str) -> None: self.jtis.add(jti) log.info("Denied jti: %s", jti[:8]) + self._save() + + def _load(self) -> None: + if not self._path or not self._path.exists(): + return + try: + import json + data = json.loads(self._path.read_text()) + self.user_ids = set(data.get("users", [])) + self.group_ids = set(data.get("groups", [])) + self.jtis = set(data.get("jtis", [])) + log.info("Denylist loaded: %d users, %d groups, %d jtis", + len(self.user_ids), len(self.group_ids), len(self.jtis)) + except Exception as e: + log.warning("Could not load denylist from %s: %s", self._path, e) + + def _save(self) -> None: + if not self._path: + return + try: + import json + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps({ + "users": sorted(self.user_ids), + "groups": sorted(self.group_ids), + "jtis": sorted(self.jtis), + })) + except Exception as e: + log.warning("Could not persist denylist to %s: %s", self._path, e) # ── Wire helpers ────────────────────────────────────────────────────────────── @@ -111,6 +169,9 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._user_id: str | None = None self._group_id: str | None = None self._buffers: dict[int, _StreamBuffer] = {} + self._nonce_client: bytes = b"" + self._gek_challenge: bytes | None = None + self._pending = None def quic_event_received(self, event: QuicEvent) -> None: if isinstance(event, StreamDataReceived): @@ -130,6 +191,8 @@ class _MNPServerProtocol(QuicConnectionProtocol): try: if mtype == MNP.HANDSHAKE: self._do_handshake_sync(stream_id, msg) + elif mtype == MNP.HANDSHAKE_RESPONSE: + self._do_handshake_response_sync(stream_id, msg) elif self._user_id is None: self._send(stream_id, {"type": "error", "detail": "Handshake required"}) elif mtype == MNP.INDEX_SYNC: @@ -147,44 +210,117 @@ class _MNPServerProtocol(QuicConnectionProtocol): self._send(stream_id, {"type": "error", "detail": str(e)}) def _do_handshake_sync(self, stream_id: int, msg: dict) -> None: - token = msg.get("token", "") - group_id = msg.get("group_id", "") + """ + Authorization half of the unified handshake (11.5.4). + + This used to be a second, weaker copy of the WebRTC logic: group_id was + optional (so omitting it skipped the membership check entirely — M1), + node-scoped daemon tokens were accepted as client tokens (M9), and the + checks could drift from the WebRTC path independently. All of that now + comes from meshbay_common.handshake, shared with WebRTC. + + NOT YET DONE — finding C6 remains open on this transport: there is still no + GEK proof here, so a forged or stolen token reaches the node and can inject + chat without holding the group key. The challenge/response and mutual node + proof (quic_binding() is written and unit-tested for exactly this) are the + remaining work in 11.5.4/5/6. + """ try: - decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"]) - except Exception as e: - self._send(stream_id, {"type": "error", "detail": f"Invalid JWT: {e}"}) + peer = authorize_token( + msg.get("token", ""), + self._ctx["hub_pk_pem"], + group_id=msg.get("group_id", ""), + hosted_groups=self._ctx.get("groups"), + denylist=self._ctx.get("denylist"), + ) + except HandshakeError as refusal: + self._send(stream_id, {"type": "error", "detail": str(refusal)}) + self._quic.close() + return + + try: + self._nonce_client = base64.b64decode(msg.get("nonce", "")) + except Exception: + self._nonce_client = b"" + if len(self._nonce_client) < NONCE_LEN: + self._send(stream_id, {"type": "error", "detail": "Client nonce required"}) self._quic.close() return - denylist = self._ctx.get("denylist") - if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")): - self._send(stream_id, {"type": "error", "detail": "Token revoked"}) + gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx + if not gctx.get("gek"): + self._send(stream_id, { + "type": "error", + "detail": "Group encryption not initialized — contact node operator", + }) + self._quic.close() + return + + # Decoded but NOT authenticated: authentication is the GEK proof below. + self._pending = peer + self._gek_challenge = os.urandom(NONCE_LEN) + self._send(stream_id, { + "type": MNP.HANDSHAKE_CHALLENGE, + "v": MNP_VERSION, + "nonce": base64.b64encode(self._gek_challenge).decode(), + }) + + def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None: + """Verify the client's GEK proof, then prove the node in return (C6, C3).""" + if not self._gek_challenge or self._pending is None: + self._send(stream_id, {"type": "error", "detail": "No pending handshake challenge"}) + return + + peer = self._pending + gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx + gek = gctx.get("gek") + if not gek: + self._send(stream_id, {"type": "error", "detail": "Group encryption not initialized"}) self._quic.close() return - if group_id and group_id not in decoded.get("groups", []): - self._send(stream_id, {"type": "error", "detail": "Not a member of this group"}) + binding = self._ctx.get("server_cert_der") + if not binding: + # Refuse rather than fall back to an unbound proof (L4). + self._send(stream_id, {"type": "error", "detail": "Channel binding unavailable"}) self._quic.close() return + binding = quic_binding(binding) - if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]: - self._send(stream_id, {"type": "error", "detail": "Group not hosted on this node"}) + try: + proof = base64.b64decode(msg.get("proof", "")) + except Exception: + self._send(stream_id, {"type": "error", "detail": "Invalid proof encoding"}) + return + + if not verify_proof(gek, proof, ROLE_CLIENT, peer.group_id, + self._nonce_client, self._gek_challenge, binding): + self._send(stream_id, {"type": "error", "detail": "GEK proof failed"}) self._quic.close() return - self._user_id = decoded["sub"] - self._group_id = group_id + self._user_id = peer.user_id + self._group_id = peer.group_id peers = self._ctx.get("_peers") if peers is not None: peers[self._user_id] = self - log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], group_id[:8] if group_id else "none") + transcript = handshake_transcript( + ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding) + node_proof = make_proof( + gek, ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding) + + log.info("QUIC handshake OK — user=%s group=%s", + self._user_id[:8], self._group_id[:8]) self._send(stream_id, { "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(transcript)).decode(), }) + self._gek_challenge = None def _group_ctx(self) -> dict: """Resolve the active group context (multi-group or legacy single-group).""" @@ -408,6 +544,13 @@ class QuicChunkServer: generate_self_signed_cert(self._cert_path, self._key_path) config = QuicConfiguration(is_client=False, alpn_protocols=ALPN) config.load_cert_chain(str(self._cert_path), str(self._key_path)) + + # Channel-binding anchor for the handshake proof (11.5.6). Read from our own + # cert file — no aioquic internals needed on this side. + from cryptography import x509 + from cryptography.hazmat.primitives import serialization as _ser + self._ctx["server_cert_der"] = x509.load_pem_x509_certificate( + self._cert_path.read_bytes()).public_bytes(_ser.Encoding.DER) return config def _store_ticket(self, ticket: Any) -> None: |