From ed9fb22ed703db38f9b07c00d17076f90aa4cbc8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 04:10:14 +0200 Subject: fix(node)!: remove unauthenticated HTTP file API and TCP transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5.A — findings C1 and C6 (see second-review.md). C1: the per-group HTTP file API bound 0.0.0.0 for every configured group, private ones included, and served two endpoints with no authentication at all: GET /index (full Mesh Group Index) and GET /file/{id} (raw plaintext file via FileResponse). Anyone able to reach the port — LAN, forwarded port, permissive IPv6 — read every private file. This bypassed the entire GEK-proof and node sovereignty layer. Deleted rather than patched: it duplicated MNP without any of its controls. C6: the TCP+TLS chunk server accepted a bare JWT with no GEK proof, leaving a second non-compliant handshake path. Deleted; QUIC remains and will be brought to parity with WebRTC by the unified handshake in 11.5.4. Transport decision recorded in transport/__init__.py: WebRTC/ICE is primary for browser and native clients (the only NAT traversal validated here — 2 ISPs, IPv4 STUN + IPv6, 4G CGNAT); QUIC is kept for LAN, port-forwarded and hub-less group:// access. punch_nat() is a direct-connection helper, not a traversal stack. Also removed server_ssl_context()/client_ssl_context() from tls_cert.py (no remaining callers) and a dead import of the former in quic_server.py. generate_self_signed_cert() stays: QUIC uses it, and the certificate hash is the intended channel-binding anchor for 11.5.6, since QUIC has no DTLS fingerprint to bind the GEK proof to. BREAKING CHANGE: node.toml keys `port` and `http_port` are gone. Regenerate config with `meshbay-node init`. Env var MESHBAY_PORT -> MESHBAY_QUIC_PORT. Tests: 198 passed (209 - 7 test_http_server - 4 test_transport). No other test changed status. Net -1300 lines. Co-Authored-By: Claude Opus 5 --- packages/meshbay-node/src/meshbay_node/transport/quic_server.py | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py') 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..ede756e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -41,7 +41,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__) -- cgit v1.2.3 From 9df71bd1e5244743fae8c1b2bda41143f0748d9d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 11:00:39 +0200 Subject: fix: swarm privacy, revocation persistence, keystore KDF, audit integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6. H7 — private content hashes leaked to the hub. The daemon registered blake3 hashes for every group it hosted, private ones included, giving the hub a content fingerprint of every private file and letting anyone confirm whether a known file exists in the network. The leak was dormant only because the routes were declared on the groups router with a full path and mounted at /v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception. Fixing the path alone would have activated the leak, so both land together: registration is gated on group visibility, the routes moved to a real /v1/swarm router, and the lookup now requires authentication. H4 — revocation was advisory. Group revocations were signed and broadcast by the hub and then dropped by the node, whose handler understood only "user" and "jti", so "suspend a group" enforced nothing. The denylist was also in-memory only, so a restart silently un-revoked everyone. Now persisted to data_dir/denylist.json, group targets honoured on both transports, and live sessions for a revoked group are closed. M2 — the node keystore, which protects the node's Ed25519 and X25519 private keys, was still deriving at 64 MB long after the hub's password verifier moved to 256 MB; the docs recorded the bump as done, true for the hub only. Raising the constant alone would have made every existing keystore permanently undecryptable, so envelopes now record the parameters they were written with and pre-M2 files continue to open under the legacy profile. M6 — registration inserted its audit row with a NULL user_id and then ran UPDATE ip_logs SET user_id= WHERE user_id IS NULL, claiming every unattributed row in the table: failed logins for other usernames, concurrent registrations. In logs retained a year for legal requests, that attributed other people's connections to the wrong account. M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone could forge the IP written to the compliance log and evade per-IP rate limits. New netutil.client_ip honours the header only from a trusted proxy and takes the rightmost hop (the one our proxy appended); no direct header reads remain. L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer echo exception text (paths, internal state); L6 email sanity-checked instead of accepting any string — deliberately not RFC 5322, to avoid a new dependency. test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes are registered with the hub. Split: private asserts not-called (index push to members still asserted), and a new test proves public groups still register. That is the fourth pre-existing test found asserting a vulnerability as intended behaviour, after gek auto-activation, the transport-wide chat_store and the blind admin challenge. Tests: 116 node, 132 hub+common. Regression suite now 43. Co-Authored-By: Claude Opus 5 --- .../meshbay-common/src/meshbay_common/crypto.py | 40 +++++-- .../meshbay-common/src/meshbay_common/protocol.py | 6 +- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 37 +++++-- .../meshbay-hub/src/meshbay_hub/api/moderation.py | 8 +- .../meshbay-hub/src/meshbay_hub/api/netutil.py | 34 ++++++ packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 12 +-- packages/meshbay-hub/src/meshbay_hub/api/users.py | 47 ++++++--- packages/meshbay-hub/src/meshbay_hub/app.py | 3 +- packages/meshbay-node/src/meshbay_node/daemon.py | 36 ++++++- packages/meshbay-node/src/meshbay_node/keystore.py | 25 ++++- .../src/meshbay_node/transport/quic_server.py | 58 +++++++++- .../src/meshbay_node/transport/webrtc_server.py | 9 +- packages/meshbay-node/tests/test_daemon.py | 40 ++++++- .../tests/test_security_regressions.py | 117 +++++++++++++++++++++ 14 files changed, 406 insertions(+), 66 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/api/netutil.py (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py') diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py index 682e1c0..b2ff3c0 100644 --- a/packages/meshbay-common/src/meshbay_common/crypto.py +++ b/packages/meshbay-common/src/meshbay_common/crypto.py @@ -168,21 +168,45 @@ def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> by # ── Keystore (local key storage) ────────────────────────────────────────────── -# Argon2id parameters — calibrate to ~500ms on target hardware before production. -# POC measured 78ms with these; increase memory_cost to 262144 (256MB) for prod. +# Argon2id parameters for the node keystore. +# +# Finding M2: these sat at 64 MB long after the hub's password verifier was raised +# to 256 MB, and the docs recorded the bump as done — true for the hub, false here. +# The keystore protects the node's Ed25519 and X25519 private keys, so it is the +# more valuable target of the two. +# +# Parameters are recorded in each keystore envelope, so raising them does not +# invalidate existing files: LEGACY_* is used when an envelope predates the field. ARGON2_ITERATIONS = 3 -ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production +ARGON2_MEMORY_COST = 262144 # 256 MB ARGON2_LANES = 4 ARGON2_KEY_LENGTH = 32 -def derive_keystore_key(password: str, salt: bytes) -> bytes: - """Derive AES-256 key from password using Argon2id.""" +LEGACY_ARGON2_ITERATIONS = 3 +LEGACY_ARGON2_MEMORY_COST = 65536 # 64 MB — keystores written before M2 +LEGACY_ARGON2_LANES = 4 + + +def derive_keystore_key( + password: str, + salt: bytes, + *, + iterations: int | None = None, + memory_cost: int | None = None, + lanes: int | None = None, +) -> bytes: + """ + Derive an AES-256 key from a password using Argon2id. + + Parameters default to the current production values; callers pass the values + recorded in an existing envelope when opening an older keystore. + """ return Argon2id( salt=salt, length=ARGON2_KEY_LENGTH, - iterations=ARGON2_ITERATIONS, - lanes=ARGON2_LANES, - memory_cost=ARGON2_MEMORY_COST, + iterations=ARGON2_ITERATIONS if iterations is None else iterations, + lanes=ARGON2_LANES if lanes is None else lanes, + memory_cost=ARGON2_MEMORY_COST if memory_cost is None else memory_cost, ).derive(password.encode()) def encrypt_keystore(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]: diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 55dcdde..d86b4ef 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -29,8 +29,10 @@ class MNP: CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages - GEK_REQUEST = "gek_req" # browser requests group GEK - GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel + # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must + # never serve the GEK in plaintext. Members obtain it by unwrapping their own + # ECIES bundle. The constants lingered after the handlers were deleted, leaving + # the wire contract looking as though the endpoint still existed. FILE_UPLOAD = "file_upload" # client pushes file chunk to node FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt FILE_DELETE = "file_delete" # client requests file deletion diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 88af764..000f3f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user, require_user_scope +from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( FederatedGroup, Group, GroupMember, @@ -14,6 +15,10 @@ from meshbay_hub.db.models import ( router = APIRouter(prefix="/v1/groups", tags=["groups"]) +# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups +# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7). +swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"]) + @router.get("/mine") async def my_groups( @@ -117,13 +122,21 @@ class SwarmRegisterRequest(BaseModel): endpoint: str # "ip:port" -@router.post("/v1/swarm/register", status_code=201) +@swarm_router.post("/register", status_code=201) async def swarm_register( body: SwarmRegisterRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Node registers itself as a source for a content hash (public swarm).""" + """ + Node registers itself as a source for a PUBLIC content hash. + + Finding H7: the node registered hashes for every group it hosted, private ones + included, and this route was mounted at /v1/groups/v1/swarm/register — so the + node's calls 404'd and the leak was masked by a routing bug rather than + prevented. Nodes now filter by group visibility before calling, and the path is + correct, so the filter has to be right. + """ from meshbay_hub.csam import check_content_hash if check_content_hash(body.content_hash): raise HTTPException(status_code=451, detail="Content blocked") @@ -144,12 +157,18 @@ async def swarm_register( return {"status": "registered", "hash": body.content_hash} -@router.get("/v1/swarm/{content_hash}") +@swarm_router.get("/{content_hash}") async def swarm_sources( content_hash: str, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Return list of nodes that can serve a content hash.""" + """ + Return nodes that can serve a content hash. + + Authenticated (H7): an open endpoint lets anyone probe whether a given file + exists anywhere in the network and which node holds it. + """ from datetime import datetime, timezone, timedelta cutoff = datetime.now(timezone.utc) - timedelta(minutes=30) result = await db.execute( @@ -214,7 +233,7 @@ async def join_group( db.add(GroupMember(group_id=group_id, user_id=current_user.id)) db.add(IPLog(user_id=current_user.id, event="group_join", - ip_address=_ip(request), detail=group.name)) + ip_address=client_ip(request), detail=group.name)) await db.commit() return {"status": "joined", "group_id": group_id, "name": group.name} @@ -246,7 +265,7 @@ async def create_group( db.add(GroupMember(group_id=group.id, user_id=current_user.id)) db.add(IPLog(user_id=current_user.id, event="group_create", - ip_address=_ip(request), detail=body.name)) + ip_address=client_ip(request), detail=body.name)) await db.commit() await db.refresh(group) return {"group_id": group.id, "name": group.name} @@ -304,13 +323,9 @@ async def delete_group( from sqlalchemy import delete as sa_delete await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id)) db.add(IPLog(user_id=current_user.id, event="group_delete", - ip_address=_ip(request), detail=group.name)) + ip_address=client_ip(request), detail=group.name)) await db.delete(group) await db.commit() return {"status": "deleted", "group_id": group_id} -def _ip(request: Request) -> str: - fwd = request.headers.get("X-Forwarded-For") - return fwd.split(",")[0].strip() if fwd else ( - request.client.host if request.client else "unknown") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py index 6bb007b..853f255 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py @@ -28,6 +28,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user, require_admin +from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ContentBlocklist, ContentReport, User @@ -64,7 +65,7 @@ async def report_content( if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash): raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)") - ip = _ip(request) + ip = client_ip(request) # Count existing reports for this hash count_result = await db.execute( @@ -193,8 +194,3 @@ async def admin_remove_blocklist( await db.commit() return {"status": "unblocked", "hash": content_hash} - -def _ip(request: Request) -> str: - fwd = request.headers.get("X-Forwarded-For") - return fwd.split(",")[0].strip() if fwd else ( - request.client.host if request.client else "unknown") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/netutil.py b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py new file mode 100644 index 0000000..aa8344a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py @@ -0,0 +1,34 @@ +""" +Client address resolution for the audit log and rate limiting. + +Finding M7: every call site did + + fwd = request.headers.get("X-Forwarded-For") + return fwd.split(",")[0].strip() if fwd else request.client.host + +which trusts a header the client controls. Anyone could forge the IP written into +the compliance log — the log that exists specifically to answer legal requests — +and sidestep per-IP rate limiting at the same time. + +X-Forwarded-For is only consulted when the immediate peer is a trusted proxy, and +then the *rightmost* entry is used: that is the one our own proxy appended, whereas +the leftmost is whatever the client sent. +""" + +from fastapi import Request + +# Caddy terminates TLS on the same host and proxies to 127.0.0.1:8000. +TRUSTED_PROXIES = frozenset({"127.0.0.1", "::1", "localhost"}) + + +def client_ip(request: Request) -> str: + peer = request.client.host if request.client else "" + + if peer in TRUSTED_PROXIES: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + hops = [h.strip() for h in forwarded.split(",") if h.strip()] + if hops: + return hops[-1] + + return peer or "unknown" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 321e43c..7738875 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import issue_access_token from meshbay_hub.api.deps import get_current_user from meshbay_hub.api.middleware import limiter +from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, Node, User @@ -59,7 +60,7 @@ async def node_auth( sig = base64.b64decode(body.signature) pk.verify(sig, message) except (InvalidSignature, Exception): - db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username)) + db.add(IPLog(event="node_auth_fail", ip_address=client_ip(request), detail=body.username)) await db.commit() raise HTTPException(status_code=401, detail="Invalid signature") @@ -70,7 +71,7 @@ async def node_auth( access_token = issue_access_token( user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") - db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request))) + db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request))) await db.commit() return { @@ -101,7 +102,7 @@ async def announce_node( db.add(IPLog( user_id=current_user.id, event="node_announce", - ip_address=_ip(request), + ip_address=client_ip(request), detail=body.endpoint_hint, )) await db.commit() @@ -128,8 +129,3 @@ async def get_node( } -def _ip(request: Request) -> str: - fwd = request.headers.get("X-Forwarded-For") - if fwd: - return fwd.split(",")[0].strip() - return request.client.host if request.client else "unknown" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 53238de..5a0f9dc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +22,7 @@ from meshbay_hub.auth import ( verify_password, ) from meshbay_hub.api.middleware import limiter +from meshbay_hub.api.netutil import client_ip from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User @@ -62,6 +63,24 @@ class RegisterRequest(BaseModel): raise ValueError("username: only letters, digits, -, _, .") return v + @field_validator("email") + @classmethod + def email_valid(cls, v: str) -> str: + """ + Sanity-check the address (L6): the field was plain `str`, so any junk was + accepted and stored encrypted forever. Deliberately not RFC 5322 — full + validation would pull in the email-validator dependency for little gain, + and the address is only ever used for recovery and legal contact. + """ + v = v.strip() + local, sep, domain = v.partition("@") + if (not sep or not local or not domain + or "." not in domain + or len(v) > 254 + or any(c.isspace() or ord(c) < 32 for c in v)): + raise ValueError("invalid email address") + return v + class LoginRequest(BaseModel): username: str @@ -106,21 +125,24 @@ async def register( hub_id=hub_id, ) db.add(user) + # flush assigns user.id so the log row can be attributed directly. + # + # Finding M6: this used to insert the row with a NULL user_id and then run + # UPDATE ip_logs SET user_id = WHERE user_id IS NULL + # which claimed *every* unattributed row in the table — failed logins for other + # usernames, other registrations racing this one — and stamped them with the + # account just created. For logs retained a year to answer legal requests, that + # attributed other people's connections to the wrong person. + await db.flush() db.add(IPLog( + user_id=user.id, event="account_create", - ip_address=_client_ip(request), + ip_address=client_ip(request), detail=body.username, )) await db.commit() await db.refresh(user) - # Set user_id in IPLog after commit - await db.execute( - IPLog.__table__.update() - .where(IPLog.user_id == None) # noqa: E711 - .values(user_id=user.id)) - await db.commit() - return {"user_id": user.id} @@ -135,7 +157,7 @@ async def login( select(User).where(User.username == body.username)) user = result.scalar_one_or_none() - ip = _client_ip(request) + ip = client_ip(request) if not body.auth_key and not body.password: raise HTTPException(status_code=401, detail="No credentials provided") @@ -358,8 +380,3 @@ async def get_user_pubkeys( return resp -def _client_ip(request: Request) -> str: - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 7011bf0..668ae24 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -25,7 +25,7 @@ from meshbay_hub.api.hub import router as hub_router from meshbay_hub.api.users import router as users_router, set_config as users_set_config from meshbay_hub.api.deps import set_admin_usernames from meshbay_hub.api.nodes import router as nodes_router -from meshbay_hub.api.groups import router as groups_router +from meshbay_hub.api.groups import router as groups_router, swarm_router from meshbay_hub.api.revocation import router as revocation_router from meshbay_hub.api.moderation import router as moderation_router from meshbay_hub.api.federation import router as federation_router @@ -110,6 +110,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(users_router) app.include_router(nodes_router) app.include_router(groups_router) + app.include_router(swarm_router) app.include_router(revocation_router) app.include_router(moderation_router) app.include_router(federation_router) diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index c75c721..7dedad8 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -107,7 +107,9 @@ class NodeDaemon: } self._quic_server = None self._webrtc = None - self._denylist = Denylist() if Denylist else None + # Persisted so a restart does not silently un-revoke everyone (H4) + self._denylist = ( + Denylist(path=config.data_dir / "denylist.json") if Denylist else None) self._chat_stores: dict[str, ChatStore] = {} self._audit_store: AuditStore | None = None self._bundle_store: BundleStore | None = None @@ -205,6 +207,7 @@ class NodeDaemon: "gek": gek, "shared_root": shared_root, "index": indexer.index, + "visibility": group_cfg.visibility, } if not groups_ctx: @@ -310,8 +313,15 @@ class NodeDaemon: tid = payload.get("target_id", "") if target == "user": denylist.deny_user(tid) + elif target == "group": + # H4: previously dropped on the floor, so "suspend a + # group" was a hub-only gesture that no node enforced. + denylist.deny_group(tid) + self._drop_group_sessions(tid) elif target == "jti": denylist.deny_jti(tid) + else: + log.warning("Unknown revocation target: %r", target) except Exception as e: log.warning("Invalid revocation token: %s", e) @@ -343,9 +353,15 @@ class NodeDaemon: "yes" if self._webrtc else "no", "yes" if self._quic_server else "no") - # 11. Initial swarm registration + # 11. Initial swarm registration — PUBLIC groups only. + # Finding H7: registering every group's hashes hands the hub a content + # fingerprint of every private file on the node, which is exactly the + # metadata the "hub stores no content metadata" claim rules out. It also + # lets anyone confirm whether a known file exists in the network. endpoint = f"webrtc:{self._config.node.quic_port}" for gctx in groups_ctx.values(): + if gctx.get("visibility") != "public": + continue hashes = [e.id for e in gctx["index"].entries] if hashes: asyncio.ensure_future(self._register_swarm(hashes, endpoint)) @@ -462,13 +478,25 @@ class NodeDaemon: if pushed: log.info("Index pushed to %d WebRTC peers", pushed) - # 11.9 — Register file hashes with hub swarm table - if self._hub and self._state.get("endpoint_hint"): + # 11.9 — Register file hashes with hub swarm table (public groups only, H7) + group_cfg = next( + (g for g in self._config.groups if g.id == group_id), None) + if (self._hub and self._state.get("endpoint_hint") + and group_cfg and group_cfg.visibility == "public"): hashes = [e.id for e in idx.entries] if hashes: endpoint = f"webrtc:{self._config.node.quic_port}" asyncio.ensure_future(self._register_swarm(hashes, endpoint)) + def _drop_group_sessions(self, group_id: str) -> None: + """Close live sessions for a revoked group (H4).""" + if not self._webrtc or not group_id: + return + for session in list(self._webrtc._sessions.values()): + if session._group_id == group_id: + asyncio.ensure_future(session.close()) + log.info("Dropped session for revoked group %s", group_id[:8]) + async def _register_swarm(self, hashes: list[str], endpoint: str) -> None: try: n = await self._hub.register_swarm(hashes, endpoint) diff --git a/packages/meshbay-node/src/meshbay_node/keystore.py b/packages/meshbay-node/src/meshbay_node/keystore.py index 3777af0..59fc719 100644 --- a/packages/meshbay-node/src/meshbay_node/keystore.py +++ b/packages/meshbay-node/src/meshbay_node/keystore.py @@ -39,6 +39,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_common.crypto import ( + ARGON2_ITERATIONS, + ARGON2_LANES, + ARGON2_MEMORY_COST, + LEGACY_ARGON2_ITERATIONS, + LEGACY_ARGON2_LANES, + LEGACY_ARGON2_MEMORY_COST, decrypt_keystore, derive_keystore_key, encrypt_keystore, @@ -173,7 +179,18 @@ def load_keystore( tag = base64.b64decode(envelope["tag_b64"]) ct = base64.b64decode(envelope["ciphertext_b64"]) - aes_key = derive_keystore_key(pwd, salt) + # Envelopes written before M2 carry no parameters and used the 64 MB profile. + params = envelope.get("argon2", { + "iterations": LEGACY_ARGON2_ITERATIONS, + "memory_cost": LEGACY_ARGON2_MEMORY_COST, + "lanes": LEGACY_ARGON2_LANES, + }) + aes_key = derive_keystore_key( + pwd, salt, + iterations=params.get("iterations"), + memory_cost=params.get("memory_cost"), + lanes=params.get("lanes"), + ) try: plaintext = decrypt_keystore(iv, ct, tag, aes_key) except Exception: @@ -205,6 +222,12 @@ def _write_keystore(path: Path, keys: NodeKeys, password: str) -> None: envelope = { "version": KEYSTORE_VERSION, "argon2_salt_b64": base64.b64encode(salt).decode(), + # Recorded so parameters can be raised later without orphaning this file. + "argon2": { + "iterations": ARGON2_ITERATIONS, + "memory_cost": ARGON2_MEMORY_COST, + "lanes": ARGON2_LANES, + }, "iv_b64": base64.b64encode(iv).decode(), "tag_b64": base64.b64encode(tag).decode(), "ciphertext_b64": base64.b64encode(ct).decode(), 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 ede756e..7bb6ce5 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -50,22 +50,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 ────────────────────────────────────────────────────────────── @@ -156,7 +203,8 @@ class _MNPServerProtocol(QuicConnectionProtocol): return denylist = self._ctx.get("denylist") - if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")): + if denylist and denylist.is_denied( + decoded.get("sub", ""), decoded.get("jti", ""), group_id): self._send(stream_id, {"type": "error", "detail": "Token revoked"}) self._quic.close() return 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 9fb9ef2..43e6fdc 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -241,8 +241,10 @@ class WebRTCPeerSession: else: log.warning("Unknown MNP message type on DataChannel: %s", mtype) except Exception as e: - log.error("Error handling %s on DataChannel: %s", mtype, e) - self._send({"type": "error", "detail": str(e)}) + # Log the detail locally; send the peer a generic message. Exception + # text here carries filesystem paths and internal state (finding L3). + log.error("Error handling %s on DataChannel: %s", mtype, e, exc_info=True) + self._send({"type": "error", "detail": "Request failed"}) def _audit(self, event: str, detail: str = "") -> None: audit = self._ctx.get("audit_store") @@ -269,7 +271,8 @@ class WebRTCPeerSession: return denylist = self._ctx.get("denylist") - if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")): + if denylist and denylist.is_denied( + decoded.get("sub", ""), decoded.get("jti", ""), group_id): self._send({"type": "error", "detail": "Token revoked"}) return diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py index 9ebf945..0698104 100644 --- a/packages/meshbay-node/tests/test_daemon.py +++ b/packages/meshbay-node/tests/test_daemon.py @@ -228,10 +228,46 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu assert msg["group_id"] == "a" * 32 assert len(msg["entries"]) == indexer.index.count + # Finding H7: this group is private, so its content hashes must NOT be + # registered with the hub. The test previously asserted the opposite — + # publishing a fingerprint of every private file was treated as expected + # behaviour. Index push to members is unaffected (asserted above). + await asyncio.sleep(0.1) + daemon._hub.register_swarm.assert_not_called() + +@pytest.mark.asyncio +async def test_daemon_index_change_registers_swarm_for_public_group( + tmp_path, shared_dir, gek, hub_pk_pem): + """Public groups still register content hashes with the hub swarm (H7).""" + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=29010, ui_port=28000), + groups=[GroupConfig( + id="a" * 32, + name="public-group", + shared_dir=str(shared_dir), + visibility="public", + quic_port=29010, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._hub = AsyncMock() + daemon._hub.register_swarm = AsyncMock(return_value=2) + daemon._state["endpoint_hint"] = "node123" + + indexer = DirectoryIndexer( + root=shared_dir, group_id="a" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=gek) + await indexer.initial_scan() + + await daemon._on_index_change(indexer) + await asyncio.sleep(0.1) daemon._hub.register_swarm.assert_called_once() - call_args = daemon._hub.register_swarm.call_args - assert len(call_args[0][0]) == indexer.index.count + assert len(daemon._hub.register_swarm.call_args[0][0]) == indexer.index.count + @pytest.mark.asyncio async def test_daemon_index_change_skips_other_group_peers( diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index a480d21..9552848 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -348,6 +348,123 @@ def test_admin_challenge_expires(tmp_path): for m in session.sent) +def test_denylist_persists_and_honours_groups(tmp_path): + """ + H4: revocations lived only in memory, so a node restart silently un-revoked + everyone, and 'group' targets were dropped entirely — the hub signed and + broadcast them, the node's handler understood only 'user' and 'jti'. + """ + from meshbay_node.transport import Denylist + + path = tmp_path / "denylist.json" + first = Denylist(path=path) + first.deny_group("g-revoked") + first.deny_user("u-revoked") + first.deny_jti("j-revoked") + + # A fresh instance stands in for a daemon restart. + reloaded = Denylist(path=path) + assert reloaded.is_denied("", "", "g-revoked"), "group revocation not honoured" + assert reloaded.is_denied("u-revoked", "") + assert reloaded.is_denied("", "j-revoked") + assert not reloaded.is_denied("someone", "other", "g-allowed") + + +def test_swarm_registration_skips_private_groups(): + """ + H7: the daemon registered content hashes for every group, private included, + handing the hub a fingerprint of every private file. The bug was masked by a + mis-mounted route, so fixing the route without this filter would have turned a + dormant leak into a live one. + """ + source = (Path(__file__).parent.parent / "src" / "meshbay_node" + / "daemon.py").read_text() + assert 'visibility' in source and '_register_swarm' in source + # Both registration sites must gate on public visibility. + for marker in ['gctx.get("visibility") != "public"', + 'group_cfg.visibility == "public"']: + assert marker in source, f"swarm registration not gated: {marker}" + + +def test_keystore_argon2_is_production_strength(): + """M2: the keystore protects the node's private keys and sat at 64 MB.""" + from meshbay_common.crypto import ARGON2_MEMORY_COST + assert ARGON2_MEMORY_COST >= 262144 + + +def test_keystore_records_argon2_params_for_migration(tmp_path): + """ + M2: raising the parameters must not orphan existing keystores, so each + envelope records the parameters it was written with. + """ + import json + from meshbay_node.keystore import create_keystore, load_keystore + + path = tmp_path / "keystore.enc" + created = create_keystore(path=path, password="correct horse battery") + envelope = json.loads(path.read_text()) + assert envelope["argon2"]["memory_cost"] >= 262144 + + reopened = load_keystore(path=path, password="correct horse battery") + assert reopened.pk_ed25519_b64 == created.pk_ed25519_b64 + + +def test_legacy_keystore_still_opens(tmp_path): + """M2: a keystore written under the 64 MB profile must still unlock.""" + import base64 as _b64 + import json + import msgpack + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import ( + LEGACY_ARGON2_ITERATIONS, LEGACY_ARGON2_LANES, LEGACY_ARGON2_MEMORY_COST, + derive_keystore_key, encrypt_keystore, pk_to_b64, sk_to_b64, + ) + from meshbay_node.keystore import load_keystore + + sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate() + payload = msgpack.packb({ + "sk_ed25519_b64": sk_to_b64(sk_ed), + "sk_x25519_b64": sk_to_b64(sk_x), + }, use_bin_type=True) + + salt = b"\x01" * 16 + key = derive_keystore_key( + "legacy-pass", salt, + iterations=LEGACY_ARGON2_ITERATIONS, + memory_cost=LEGACY_ARGON2_MEMORY_COST, + lanes=LEGACY_ARGON2_LANES, + ) + iv, ct, tag = encrypt_keystore(payload, key) + + path = tmp_path / "legacy.enc" + # No "argon2" key — exactly how pre-M2 envelopes look. + path.write_text(json.dumps({ + "version": 1, + "argon2_salt_b64": _b64.b64encode(salt).decode(), + "iv_b64": _b64.b64encode(iv).decode(), + "tag_b64": _b64.b64encode(tag).decode(), + "ciphertext_b64": _b64.b64encode(ct).decode(), + })) + + keys = load_keystore(path=path, password="legacy-pass") + assert keys.pk_ed25519_b64 == pk_to_b64(sk_ed.public_key()) + assert keys.pk_x25519_b64 == pk_to_b64(sk_x.public_key()) + + +def test_dead_gek_protocol_constants_removed(): + """L1: the node never serves a GEK; the message types should not suggest it.""" + from meshbay_common.protocol import MNP + assert not hasattr(MNP, "GEK_REQUEST") + assert not hasattr(MNP, "GEK_RESPONSE") + + +def test_peer_errors_do_not_leak_internals(): + """L3: exception text carries filesystem paths and internal state.""" + source = (Path(__file__).parent.parent / "src" / "meshbay_node" + / "transport" / "webrtc_server.py").read_text() + assert '"detail": str(e)' not in source + + def test_admin_ui_escapes_filenames(tmp_path): """ H2: filenames are chosen by any group member and were rendered into the -- cgit v1.2.3 From be1ff89465c9878f2fbd641fb0d4eba729439ac8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 11:56:04 +0200 Subject: refactor(quic): share the unified handshake authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5.4 — QUIC half. Findings M1, M9 on this transport. quic_server._do_handshake_sync was a second, weaker copy of the WebRTC logic: group_id was optional, so omitting it skipped the membership check entirely and fell back to the node's first group (M1); node-scoped daemon tokens were accepted as client tokens (M9); and the checks could drift from the WebRTC path independently, which is how they diverged in the first place. Authorization now comes from meshbay_common.handshake, shared with WebRTC. C6 IS STILL OPEN ON THIS TRANSPORT. There is no GEK proof here yet: a forged or stolen token still reaches the node over QUIC and can inject chat without holding the group key. What remains is the challenge/response and the mutual node proof — quic_binding() is written and unit-tested for exactly this, and 11.5.6 (whether a certificate hash is the right anchor, or an RFC 5705 exporter is reachable from aioquic) is still unproven. This commit narrows the gap to the proof itself; it does not close the finding. QUIC tests updated: default tokens are members of the test group, and clients pass group_id, since it is mandatory now. Tests: 9 quic/multi-group, full node+common suite green. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_node/transport/quic_server.py | 54 ++++++++++++---------- packages/meshbay-node/tests/test_quic_transport.py | 9 +++- 2 files changed, 37 insertions(+), 26 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py') 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 7bb6ce5..ba2d5c5 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -34,6 +34,7 @@ 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 HandshakeError, authorize_token from meshbay_common.crypto import ( sign_chunk, pk_to_b64, @@ -193,40 +194,43 @@ 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}"}) - self._quic.close() - return - - denylist = self._ctx.get("denylist") - if denylist and denylist.is_denied( - decoded.get("sub", ""), decoded.get("jti", ""), group_id): - self._send(stream_id, {"type": "error", "detail": "Token revoked"}) - 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"}) - self._quic.close() - return - - 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"}) + 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 - 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") + 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, diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py index 0c1a1cd..c2c2b70 100644 --- a/packages/meshbay-node/tests/test_quic_transport.py +++ b/packages/meshbay-node/tests/test_quic_transport.py @@ -49,7 +49,8 @@ def make_jwt(sk_hub, pk_node_b64, ttl=3600, groups=None): "iss": "test-hub", "sub": "user-001", "pk_user": pk_node_b64, "hub_id": "test-hub", "jti": "test-jti", "iat": now, "exp": now + ttl, - "groups": groups or [], + # group_id is mandatory (M1), so default tokens are members of "g". + "groups": groups if groups is not None else ["g"], }, sk_pem, algorithm="EdDSA") @@ -80,6 +81,7 @@ async def test_quic_chunk_roundtrip(sk_node, sk_hub, gek, shared_dir, tmp_path): host="127.0.0.1", port=19100, jwt_token=token, gek=gek, pk_node_b64=pk_to_b64(sk_node.public_key()), + group_id="g", ) as client: chunk0 = await client.fetch_chunk(entry.id, chunk_index=0) chunk1 = await client.fetch_chunk(entry.id, chunk_index=1) @@ -116,6 +118,7 @@ async def test_quic_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path): host="127.0.0.1", port=19101, jwt_token=token, gek=gek, pk_node_b64=pk_to_b64(sk_node.public_key()), + group_id="g", ) as client: wire = await client.fetch_index() recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek) @@ -220,6 +223,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat async with QuicChunkClient( host="127.0.0.1", port=19104, jwt_token=token, gek=gek, pk_node_b64=pk_b64, + group_id="g", ) as client: wire = await client.fetch_index() assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 @@ -233,6 +237,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat host="127.0.0.1", port=19104, jwt_token=token, gek=gek, pk_node_b64=pk_b64, session_ticket=saved_ticket, + group_id="g", ) as client: wire = await client.fetch_index() assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 @@ -269,6 +274,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p async with QuicChunkClient( host="127.0.0.1", port=19105, jwt_token=token, gek=gek, pk_node_b64=pk_b64, + group_id="g", ) as client: wire = await client.fetch_index() assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 @@ -281,6 +287,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p async with QuicChunkClient( host="127.0.0.1", port=19105, jwt_token=token, gek=gek, pk_node_b64=pk_b64, + group_id="g", ) as client: await client.fetch_index() -- cgit v1.2.3 From 146a6759fa73386e9b59570956aeedd7e1cfd978 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 12:24:54 +0200 Subject: feat(quic): GEK proof and mutual authentication — closes C6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5.4/5/6 — finding C6, the last open critical finding. QUIC ran a JWT-only handshake: a forged or stolen token reached the node and could inject chat without ever holding the group key. It now runs the same challenge/response as WebRTC through meshbay_common.handshake — client nonce, role-bound length-prefixed transcript, GEK proof, and the node proving itself with a GEK proof plus an Ed25519 signature over the transcript (C3). 11.5.6 channel binding, resolved by spike and then by two findings the spike could not predict: * aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer certificate only via a private attribute. The server reads its own certificate from disk, so no internals are touched on that side; the client's access is guarded and fails loudly if an upgrade moves it. * A RESUMED TLS session carries no certificate — aioquic does not re-send it, so there is nothing live to bind to. The anchor therefore travels with the session ticket, which is sound because the ticket is cryptographically derived from the handshake where that certificate was presented. * The anchor had to travel with the ticket rather than live on the client object: resumption constructs a fresh client, so an instance-level cache was silently useless. Caught by the resumption test, not by inspection. Both paths refuse rather than degrade. No certificate and no cached anchor means the handshake fails; it never falls back to an unbound proof, which would silently drop MitM detection (L4). QuicChunkClient gains a peer_cert_der property and constructor argument, mirroring how session_ticket is already carried by the caller. Tests: 9 quic/multi-group, full node+common suite green. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_node/transport/quic_client.py | 96 ++++++++++++++++++++-- .../src/meshbay_node/transport/quic_server.py | 94 ++++++++++++++++++++- packages/meshbay-node/tests/test_quic_transport.py | 4 + 3 files changed, 186 insertions(+), 8 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport/quic_server.py') diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 288465f..9102085 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -11,6 +11,7 @@ TLS cert is self-signed; we use CERT_NONE equivalent in QUIC config. import asyncio import base64 import logging +import os import struct from pathlib import Path @@ -26,6 +27,32 @@ from meshbay_common import MNP_VERSION from meshbay_common.crypto import verify_chunk_signature from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk from meshbay_common.protocol import MNP +from meshbay_common.handshake import ( + NONCE_LEN, + ROLE_CLIENT, + ROLE_NODE, + handshake_transcript, + make_proof, + quic_binding, + verify_proof, +) + + +def _peer_cert_der(proto) -> bytes | None: + """ + The server certificate as seen by the client — the channel-binding anchor. + + Spike 11.5.6: aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer + certificate only through a private attribute. Returns None when it is absent; + callers decide, because absence is not always an error — see below. + """ + from cryptography.hazmat.primitives import serialization + + tls = getattr(getattr(proto, "_quic", None), "tls", None) + cert = getattr(tls, "_peer_certificate", None) if tls is not None else None + if cert is None: + return None + return cert.public_bytes(serialization.Encoding.DER) log = logging.getLogger(__name__) @@ -99,6 +126,7 @@ class QuicChunkClient: pk_node_b64: str, local_port: int = 0, # 0 = OS picks; set for hole punching (Port-Restricted) group_id: str = "", + peer_cert_der: bytes | None = None, session_ticket: object | None = None, ): self._host = host @@ -112,6 +140,9 @@ class QuicChunkClient: self._proto: _MNPClientProtocol | None = None self._cm = None self._ctrl_stream = 0 + # 11.5.6 binding anchor. Travels with the session ticket: on a resumed + # TLS session the server does not re-send its certificate. + self._peer_cert_der: bytes | None = peer_cert_der self._session_ticket = session_ticket async def __aenter__(self): @@ -146,19 +177,70 @@ class QuicChunkClient: ) self._proto = await self._cm.__aenter__() - handshake_msg = { - "type": MNP.HANDSHAKE, + nonce_c = os.urandom(NONCE_LEN) + self._proto._send(self._ctrl_stream, { + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": self._jwt_token, + "group_id": self._group_id, + "nonce": base64.b64encode(nonce_c).decode(), + }) + + reply = await self._proto._recv(self._ctrl_stream) + if reply.get("type") != MNP.HANDSHAKE_CHALLENGE: + raise ConnectionError(f"QUIC handshake rejected: {reply}") + + nonce_s = base64.b64decode(reply["nonce"]) + + # On a RESUMED TLS session the server does not re-send its certificate, so + # there is nothing live to bind to. The session ticket is cryptographically + # derived from the original handshake, so binding to the certificate seen + # then is sound — but only if we actually saw one. We never fall back to an + # unbound proof: that would silently drop MitM detection (L4). + cert_der = _peer_cert_der(self._proto) + if cert_der is not None: + self._peer_cert_der = cert_der + elif getattr(self, "_peer_cert_der", None) is None: + raise ConnectionError( + "QUIC peer certificate unavailable and none cached from a prior " + "session — refusing to handshake without channel binding") + binding = quic_binding(self._peer_cert_der) + + self._proto._send(self._ctrl_stream, { + "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, - "token": self._jwt_token, - } - if self._group_id: - handshake_msg["group_id"] = self._group_id - self._proto._send(self._ctrl_stream, handshake_msg) + "proof": base64.b64encode(make_proof( + self._gek, ROLE_CLIENT, self._group_id, + nonce_c, nonce_s, binding)).decode(), + }) + ack = await self._proto._recv(self._ctrl_stream) if ack.get("type") != MNP.HANDSHAKE_ACK: raise ConnectionError(f"QUIC handshake rejected: {ack}") + + # Authenticate the node before trusting anything it serves (C3). + if not verify_proof( + self._gek, base64.b64decode(ack.get("proof", "")), ROLE_NODE, + self._group_id, nonce_c, nonce_s, binding, + ): + raise ConnectionError("Node failed to prove GEK possession") + + transcript = handshake_transcript( + ROLE_NODE, self._group_id, nonce_c, nonce_s, binding) + try: + Ed25519PublicKey.from_public_bytes( + base64.b64decode(ack["node_pk"]) + ).verify(base64.b64decode(ack["sig"]), transcript) + except Exception as exc: + raise ConnectionError(f"Node signature invalid: {exc}") from exc + log.debug("QUIC connected to %s:%d", self._host, self._port) + @property + def peer_cert_der(self) -> bytes | None: + """Binding anchor to carry alongside a saved session ticket (11.5.6).""" + return self._peer_cert_der + async def close(self) -> None: if self._cm: await self._cm.__aexit__(None, None, None) 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 ba2d5c5..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,7 +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 HandshakeError, authorize_token +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, @@ -158,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): @@ -177,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: @@ -222,6 +238,67 @@ class _MNPServerProtocol(QuicConnectionProtocol): 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 + + 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 + + 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) + + 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 = peer.user_id self._group_id = peer.group_id @@ -229,13 +306,21 @@ class _MNPServerProtocol(QuicConnectionProtocol): if peers is not None: peers[self._user_id] = self + 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).""" @@ -459,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: diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py index c2c2b70..93ab1b0 100644 --- a/packages/meshbay-node/tests/test_quic_transport.py +++ b/packages/meshbay-node/tests/test_quic_transport.py @@ -228,6 +228,7 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat wire = await client.fetch_index() assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2 saved_ticket = client.session_ticket + saved_cert = client.peer_cert_der # Allow server to process the close await asyncio.sleep(0.1) @@ -237,6 +238,9 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat host="127.0.0.1", port=19104, jwt_token=token, gek=gek, pk_node_b64=pk_b64, session_ticket=saved_ticket, + # A resumed session carries no certificate, so the binding anchor from the + # original handshake travels with the ticket (11.5.6). + peer_cert_der=saved_cert, group_id="g", ) as client: wire = await client.fetch_index() -- cgit v1.2.3