aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/quic_server.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-node/src/meshbay_node/transport/quic_server.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-node/src/meshbay_node/transport/quic_server.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py183
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: