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/daemon.py | 61 ++++-------------------- 1 file changed, 8 insertions(+), 53 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index fe12909..c930e54 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -8,9 +8,9 @@ Startup sequence: 4. Fetch GEK bundle from hub (if group configured) 5. Start directory indexer (watchdog) 6. Create chat stores (one SQLite DB per group) - 7. Create WebRTC transport (browser clients via DataChannel) - 8. Start QUIC+TCP chunk servers (native clients) - 9. Start HTTP file API (public content) + 7. Create WebRTC transport (browser + native clients via DataChannel) + 8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access) + 9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed) 10. Start hub WebSocket (signaling, revocations, WebRTC offers) 11. Start local web UI on node.ui_port (localhost only) 12. Run until SIGINT/SIGTERM @@ -43,11 +43,9 @@ from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer from meshbay_node.keystore import NodeKeys, load_or_create_keystore from meshbay_node.transport import ( - ChunkServer, Denylist, QUIC_AVAILABLE, WEBRTC_AVAILABLE, - create_http_app, ) if QUIC_AVAILABLE: @@ -103,12 +101,10 @@ class NodeDaemon: "hub_url": config.hub.url, "username": config.hub.username, "groups": [g.name for g in config.groups], - "node_port": config.node.port, "quic_port": config.node.quic_port, "endpoint_hint": None, "indexes": {}, } - self._tcp_server: ChunkServer | None = None self._quic_server = None self._webrtc = None self._denylist = Denylist() if Denylist else None @@ -118,7 +114,6 @@ class NodeDaemon: self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] self._hub: HubClient | None = None - self._http_servers: list[uvicorn.Server] = [] async def run(self) -> None: log.info("MeshBay Node starting up") @@ -265,7 +260,7 @@ class NodeDaemon: else: log.warning("WebRTC not available (aiortc not installed)") - # 7. QUIC + TCP chunk servers + # 7. QUIC chunk server (LAN / port-forwarded / hub-less direct access) if QUIC_AVAILABLE: self._quic_server = QuicChunkServer( sk_node=keys.sk_ed25519, @@ -282,19 +277,6 @@ class NodeDaemon: log.info("QUIC server on port %d (%d groups)", self._config.node.quic_port, len(groups_ctx)) - self._tcp_server = ChunkServer( - sk_node=keys.sk_ed25519, - hub_pk_pem=session.hub_pk_pem, - gek=first["gek"], - shared_root=first["shared_root"], - index=first["index"], - host="0.0.0.0", - port=self._config.node.port, - groups=groups_ctx, - ) - await self._tcp_server.start() - log.info("TCP+TLS server on port %d", self._config.node.port) - # 8. Hub WebSocket (signaling + revocations + WebRTC offers) async def on_webrtc_offer(sdp, peer_id, ice_candidates): if not self._webrtc: @@ -338,32 +320,10 @@ class NodeDaemon: self._tasks.append(ws_task) log.info("Hub WS task started") - # 9. HTTP file API (one per group) - for gid, gctx in groups_ctx.items(): - group_cfg = next( - (g for g in self._config.groups if g.id == gid), None) - if not group_cfg: - continue - http_app = create_http_app( - sk_node=keys.sk_ed25519, - hub_pk_pem=session.hub_pk_pem, - shared_root=gctx["shared_root"], - index=gctx["index"], - group_id=gid, - group_name=group_cfg.name, - gek=gctx.get("gek"), - ) - http_cfg = uvicorn.Config( - http_app, - host="0.0.0.0", - port=group_cfg.http_port, - log_level="warning", - ) - http_server = uvicorn.Server(http_cfg) - self._http_servers.append(http_server) - self._tasks.append(asyncio.create_task(http_server.serve())) - log.info("HTTP API on port %d for group %s", - group_cfg.http_port, group_cfg.name) + # 9. (removed in Phase 11.5) The per-group HTTP file API used to start here. + # It served the Mesh Group Index and raw plaintext files on 0.0.0.0 with no + # authentication, for private groups too — finding C1. Every client path now + # goes through the MNP handshake (JWT + group claim + GEK proof). # 10. Update admin UI state (UI already running from step 2) self._state["groups_ctx"] = groups_ctx @@ -541,11 +501,6 @@ class NodeDaemon: if self._quic_server: await self._quic_server.stop() - if self._tcp_server: - await self._tcp_server.stop() - - for server in self._http_servers: - server.should_exit = True log.info("Node stopped") -- cgit v1.2.3 From 3ce051e134a432417fcaca4e8b5775d98f614a31 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 10:42:20 +0200 Subject: fix(node): group isolation, upload confinement, GEK seizure, admin challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11.5 — findings H1, C5a, H2, C5b, H5 (see second-review.md). Batched together because the node-side changes share webrtc_server.py and cannot be separated into working commits. H1 — cross-group chat leak. chat_store, the peer registry and the display-name cache were read from the shared transport context, and daemon.py hoisted the FIRST group's chat store onto it. On a node hosting several groups every group's messages went to one database, chat_history served them back to members of every other group, and chat broadcast reached all peers regardless of group. All three now resolve through _group_ctx(). C5a — upload confinement. Uploads landed in the shared root under a client-chosen name and overwrote whatever was there. Any member could destroy the operator's files, and by becoming the recorded uploader of the replaced file could then delete it through the uploader path, bypassing the Ed25519 admin challenge. Uploads now go to a per-user quarantine (.uploads/{user_id}/), refuse to overwrite, and enforce chunk ordering, a filename allowlist and a size cap. H2 — stored XSS in the node admin UI. Filenames chosen by any group member were interpolated raw into the localhost UI, which has no authentication, so script execution there equals control of the node admin API. Now html.escape() throughout, textContent in the audit table, plus CSP/nosniff/no-referrer. The CSP contains exfiltration but cannot stop injected inline script — escaping is the fix. C5b — group key seizure. gek_bundle_store wrote whatever any member sent and auto-activated bundles addressed to the node operator. The operator's X25519 public key is public (the node publishes it in handshake_ack), so any member could wrap a key of their choosing for it and take over the group, locking every legitimate member out. Storing now requires an operator signature and _try_activate_gek is removed: nothing arriving over MNP can set a live GEK. H5 — unbound signing oracle. The node challenged with 32 raw random bytes and the client signed them blind, so a signature named no operation, subject, node or time. New meshbay_common/adminop.py defines a length-prefixed, domain-separated transcript; both sides build it independently and the client refuses to sign when the announced op/subject do not match its request. BREAKING: a group admin who does not operate the node can no longer store GEK bundles on it. Invites must be performed by the node operator. Adds tests/test_security_regressions.py. Verified against pre-fix source via git stash. Three pre-existing tests asserted the vulnerable behaviour as a feature and were inverted: gek auto-activation, and the transport-wide chat_store in test_daemon. Tests: 109 node, 132 hub+common. Co-Authored-By: Claude Opus 5 --- .../meshbay-common/src/meshbay_common/adminop.py | 70 ++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 15 +- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 38 ++- .../src/meshbay_hub/static/keyderive.js | 14 +- .../src/meshbay_hub/static/transport.js | 57 +++- packages/meshbay-node/src/meshbay_node/daemon.py | 6 +- .../src/meshbay_node/transport/webrtc_server.py | 318 ++++++++++++------ packages/meshbay-node/src/meshbay_node/ui/app.py | 79 ++++- packages/meshbay-node/tests/test_daemon.py | 9 +- .../tests/test_security_regressions.py | 372 +++++++++++++++++++++ .../meshbay-node/tests/test_webrtc_transport.py | 96 ++++-- 11 files changed, 919 insertions(+), 155 deletions(-) create mode 100644 packages/meshbay-common/src/meshbay_common/adminop.py create mode 100644 packages/meshbay-node/tests/test_security_regressions.py (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py new file mode 100644 index 0000000..71446ca --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -0,0 +1,70 @@ +""" +Admin operation challenge transcripts (MNP). + +Destructive and privileged node operations are authorized by an Ed25519 signature +from the node operator, not by a JWT — the hub controls JWT issuance, so a JWT can +never establish node-level authority (see draft-v4 §4.2.x). + +Finding H5: the node used to challenge the client with 32 raw random bytes and the +client signed them blind. That is an unbound signing oracle — the signed message +named no operation, no subject, no node and no time, so a signature obtained for one +purpose was structurally valid for any other, and a malicious node could ask a user +to sign bytes meaningful in a different protocol. + +The transcript below fixes that: + + - a fixed domain-separation prefix, so these signatures can never collide with + node_auth, revocation tokens, chunk signatures or anything added later; + - the operation and its subject, so the client can display and verify what it is + authorizing before signing; + - the node's public key, so a signature for node A is not valid on node B; + - the group, so authority does not leak across groups on a multi-group node; + - a node-chosen nonce, so signatures cannot be replayed; + - a timestamp, so stale challenges can be rejected. + +Every field is length-prefixed. Plain concatenation would let a crafted subject +impersonate a following field (finding L4 applies the same rule to the GEK proof). + +Both sides MUST build the transcript with this function — the client from the +fields it received, the node from the state it stored. They are compared by +producing the same bytes, never by trusting a value off the wire. +""" + +ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1" + +# Operations that require node-operator authority. +OP_FILE_DELETE = "file_delete" +OP_GEK_BUNDLE_STORE = "gek_bundle_store" + +# A challenge older than this is refused, so a signature captured from a stale +# exchange cannot be replayed later. +ADMIN_CHALLENGE_TTL = 120 # seconds + + +def admin_transcript( + op: str, + node_pk_b64: str, + group_id: str, + subject: str, + nonce: bytes, + ts: int, +) -> bytes: + """ + Build the exact byte string signed for an admin operation. + + `subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the + target user_id for OP_GEK_BUNDLE_STORE. + """ + fields = [ + op.encode(), + node_pk_b64.encode(), + group_id.encode(), + subject.encode(), + nonce, + str(ts).encode(), + ] + out = bytearray(ADMIN_TRANSCRIPT_PREFIX) + for field in fields: + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ddff928..dee47ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -979,8 +979,10 @@ function GroupPage({ groupId, group, token, username, userId }) { const transport = transportRef.current; if (!transport || !transport.connected) return; try { + // Signs an explicit transcript built by transport.js, not opaque bytes from + // the node — see MeshBayCrypto.adminTranscript and finding H5. const signFn = (_sessionKeys && window.MeshBayKeys) - ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge) + ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) : null; await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); @@ -1405,9 +1407,16 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { } const gekBytes = transport.gekRaw; - // Wrap GEK for invitee and store on node via P2P + // Wrap GEK for invitee and store on node via P2P. + // The node requires the operator's Ed25519 signature to accept the bundle + // (C5b), so inviting from a browser that is not the node operator's will be + // refused by the node — deliberately: only the operator decides what is + // stored on their machine. + const signFn = (_sessionKeys && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) + : null; const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes); - await transport.storeGekBundle(pubkeys.user_id, groupId, bundle); + await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn); // Add member on hub (membership management only) await hubFetch(`/v1/groups/${groupId}/members/${username}`, { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 5ebf624..2346fa2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -230,6 +230,42 @@ function b64encode(bytes) { return btoa(String.fromCharCode(...bytes)); } +// ── Admin operation transcript ─────────────────────────────────────────────── +// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these +// bytes independently; they are never taken off the wire. +// +// Finding H5: the client used to sign 32 raw random bytes chosen by the node — a +// blind signing oracle. It now reconstructs a domain-separated, length-prefixed +// transcript naming the operation, subject, node and group, so the UI can show the +// user what they are authorizing and a signature cannot be reused elsewhere. + +const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1'); + +function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) { + const enc = new TextEncoder(); + const fields = [ + enc.encode(op), + enc.encode(nodePkB64), + enc.encode(groupId), + enc.encode(subject), + b64decode(nonceB64), + enc.encode(String(ts)), + ]; + let total = ADMIN_TRANSCRIPT_PREFIX.length; + for (const f of fields) total += 4 + f.length; + + const out = new Uint8Array(total); + out.set(ADMIN_TRANSCRIPT_PREFIX, 0); + let off = ADMIN_TRANSCRIPT_PREFIX.length; + for (const f of fields) { + new DataView(out.buffer).setUint32(off, f.length, false); + off += 4; + out.set(f, off); + off += f.length; + } + return out; +} + // ── GEK proof (HMAC-SHA256 for handshake challenge) ───────────────────────── async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { @@ -249,5 +285,5 @@ async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, - hmacGEK, + hmacGEK, adminTranscript, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index ff3da33..3d9c3c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -246,16 +246,22 @@ async function regenerateKeys(token, username, password) { }; } -async function signChallenge(skEdPkcs8B64, challengeB64) { +/** + * Sign an explicit byte string with the user's Ed25519 identity key. + * + * Takes bytes rather than a base64 blob from the wire: callers are expected to + * build the message themselves (see MeshBayCrypto.adminTranscript) so that the + * user's identity key is never applied to content the peer chose. Finding H5. + */ +async function signBytes(skEdPkcs8B64, message) { const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey( 'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']); - const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0)); - const sig = await crypto.subtle.sign('Ed25519', sk, challenge); + const sig = await crypto.subtle.sign('Ed25519', sk, message); return btoa(String.fromCharCode(...new Uint8Array(sig))); } window.MeshBayKeys = { - registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge, + registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes, deriveAuthKey, decryptBundleWithKey, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index ca9c60e..d636085 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -266,6 +266,39 @@ class MeshBayTransport { return msg; } + /** + * Authorize a privileged node operation with the user's Ed25519 identity key. + * + * The client rebuilds the signed transcript from the challenge fields and refuses + * to sign unless the operation and subject match what the user actually asked for. + * Previously the node sent 32 opaque random bytes and the client signed them + * blind, which let any peer obtain a signature over content of its choosing + * (finding H5). + */ + async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) { + if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) { + throw new Error( + `Refusing to sign: node asked to authorize "${challenge.op}" on ` + + `"${challenge.subject}", but the requested action was "${expectedOp}" ` + + `on "${expectedSubject}"`); + } + if (!signFn) throw new Error('Admin challenge received but no signing key available'); + + const transcript = window.MeshBayCrypto.adminTranscript( + challenge.op, challenge.node_pk, challenge.group_id, + challenge.subject, challenge.nonce, challenge.ts); + + const signature = await signFn(transcript); + const ack = await this._sendAndWait({ + type: 'admin_response', + v: '0.1', + op_id: challenge.op_id, + signature, + }); + if (ack.type === 'error') throw new Error(ack.detail); + return ack; + } + async deleteFile(fileId, signFn) { const msg = await this._sendAndWait({ type: 'file_delete', @@ -274,16 +307,7 @@ class MeshBayTransport { }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { - if (!signFn) throw new Error('Admin challenge received but no signing key available'); - const signature = await signFn(msg.challenge); - const ack = await this._sendAndWait({ - type: 'admin_response', - v: '0.1', - file_id: fileId, - signature, - }); - if (ack.type === 'error') throw new Error(ack.detail); - return ack; + return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn); } return msg; } @@ -304,7 +328,15 @@ class MeshBayTransport { return msg; } - async storeGekBundle(userId, groupId, bundle) { + /** + * Store a wrapped GEK bundle on the node for a member. + * + * Node-operator operation: the node answers with an admin challenge and only the + * pinned operator key is accepted. Any member used to be able to write bundles — + * including one addressed to the operator, which the node then auto-adopted as the + * live group key (finding C5b). + */ + async storeGekBundle(userId, groupId, bundle, signFn) { const msg = await this._sendAndWait({ type: 'gek_bundle_store', v: '0.1', @@ -315,6 +347,9 @@ class MeshBayTransport { wrapped_b64: bundle.wrapped_b64, }); if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'gek_bundle_store', userId, signFn); + } return msg; } diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index c930e54..c75c721 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -241,7 +241,11 @@ class NodeDaemon: groups=groups_ctx, denylist=denylist, ) - self._webrtc._ctx["chat_store"] = first.get("chat_store") + # No global chat_store here: each group's store lives in + # groups_ctx[gid]["chat_store"] and is resolved per session via + # _group_ctx(). Assigning the first group's store transport-wide + # sent every group's chat to one database and served it back to + # members of every other group (finding H1). self._webrtc._ctx["hub_ws"] = _WsSender(hub) self._webrtc._ctx["node_user_id"] = session.user_id self._webrtc._ctx["audit_store"] = self._audit_store 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 13e90c8..9fb9ef2 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -28,7 +28,9 @@ import hashlib import hmac import logging import os +import re import struct +import time from pathlib import Path from typing import Any @@ -41,6 +43,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( ) from meshbay_common import MNP_VERSION +from meshbay_common.adminop import ( + ADMIN_CHALLENGE_TTL, + OP_FILE_DELETE, + OP_GEK_BUNDLE_STORE, + admin_transcript, +) from meshbay_common.crypto import pk_to_b64 from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP @@ -51,6 +59,16 @@ log = logging.getLogger(__name__) CHUNK_SIZE = 1024 * 1024 MAX_MSG = 64 * 1024 * 1024 +# Upload limits (finding C5a). Uploads used to land directly in the shared root under +# a name the client chose, overwriting whatever was already there — which both violated +# node sovereignty and defeated the delete authorization (overwrite a file, become its +# recorded uploader, then delete it legitimately). +MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file +UPLOAD_DIR_NAME = ".uploads" +# Conservative allowlist: also what keeps markup out of filenames, which the node admin +# UI used to render unescaped (finding H2). +SAFE_UPLOAD_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}$") + def _extract_dtls_fingerprint(sdp: str) -> bytes: """Extract the DTLS SHA-256 fingerprint from SDP as raw 32 bytes.""" @@ -170,7 +188,8 @@ class WebRTCPeerSession: self._username: str = "" self._pk_user: str = "" self._gek_challenge: bytes | None = None - self._admin_challenges: dict[str, bytes] = {} + self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation + self._uploads: dict[str, dict] = {} # filename → {next_index, bytes} def _setup_channel(self, channel: RTCDataChannel) -> None: self._channel = channel @@ -214,7 +233,7 @@ class WebRTCPeerSession: elif mtype == MNP.ADMIN_RESPONSE: self._do_admin_response(msg) elif mtype == MNP.GEK_BUNDLE_STORE: - asyncio.ensure_future(self._do_gek_bundle_store(msg)) + self._do_gek_bundle_store(msg) elif mtype == MNP.KEYPAIR_BUNDLE_STORE: asyncio.ensure_future(self._do_keypair_bundle_store(msg)) elif mtype == MNP.STREAM_REQUEST: @@ -340,9 +359,7 @@ class WebRTCPeerSession: self._username = self._pending_username self._pk_user = self._pending_pk_user - peers = self._ctx.get("_peers") - if peers is not None: - peers[self._user_id] = self + self._peer_registry()[self._user_id] = self node_user_id = self._ctx.get("node_user_id") log.info("WebRTC handshake OK — user=%s group=%s", @@ -388,8 +405,21 @@ class WebRTCPeerSession: else: self._send({"type": MNP.GEK_BUNDLE_RESP, "v": MNP_VERSION, "found": False}) - async def _do_gek_bundle_store(self, msg: dict) -> None: - """Store a wrapped GEK bundle for a target user (admin operation).""" + def _do_gek_bundle_store(self, msg: dict) -> None: + """ + Request to store a wrapped GEK bundle for a target user. + + Finding C5b: this used to write whatever any authenticated member sent, with + INSERT OR REPLACE semantics, and then auto-activate the bundle if it was + addressed to the node operator. Since the operator's X25519 public key is + public — the node even hands it out in handshake_ack — any member could wrap + a GEK of their own choosing for the operator and make the node adopt it, + locking every legitimate member out of the group and taking over the key. + + Storing a bundle is now a node-operator operation gated by an Ed25519 + challenge, and nothing arriving over MNP can activate a GEK: activation + happens only through the local admin UI or the CLI. + """ bundle_store = self._ctx.get("bundle_store") if not bundle_store: self._send({"type": "error", "detail": "Bundle store not available"}) @@ -405,49 +435,21 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Missing bundle fields"}) return - await bundle_store.store(group_id, target_user_id, pk_eph, nonce, wrapped) - log.info("GEK bundle stored: group=%s user=%s", group_id[:8], target_user_id[:8]) - self._audit("gek_bundle_store", f"target={target_user_id[:8]}") + if not self._ctx.get("admin_pk_ed25519"): + self._send({ + "type": "error", + "detail": "No admin key pinned — bundle storage refused", + }) + return - self._send({ - "type": "ack", "v": MNP_VERSION, - "detail": "gek_bundle_stored", + self._issue_admin_challenge(OP_GEK_BUNDLE_STORE, target_user_id, { + "group_id": group_id, "user_id": target_user_id, + "pk_eph_b64": pk_eph, + "nonce_b64": nonce, + "wrapped_b64": wrapped, }) - # Auto-activate GEK if the bundle is for the node operator - node_user_id = self._ctx.get("node_user_id") - if node_user_id and target_user_id == node_user_id and group_id: - await self._try_activate_gek(group_id, target_user_id) - - async def _try_activate_gek(self, group_id: str, user_id: str) -> None: - """Unwrap and activate GEK for the node when the operator's bundle arrives.""" - from meshbay_common.crypto import unwrap_gek_aes - - bundle_store = self._ctx.get("bundle_store") - sk_x_raw = self._ctx.get("sk_x25519_raw") - pk_x_raw = self._ctx.get("pk_x25519_raw") - if not bundle_store or not sk_x_raw or not pk_x_raw: - return - - bundle = await bundle_store.fetch(group_id, user_id) - if not bundle: - return - - try: - gek = unwrap_gek_aes(bundle, sk_x_raw, pk_x_raw) - except Exception as e: - log.warning("Failed to unwrap GEK for auto-activation: %s", e) - return - - groups = self._ctx.get("groups") - if groups and group_id in groups: - groups[group_id]["gek"] = gek - log.info("GEK auto-activated for group %s", group_id[:8]) - elif "gek" in self._ctx: - self._ctx["gek"] = gek - log.info("GEK auto-activated (single-group mode)") - async def _do_keypair_bundle_fetch(self) -> None: """Serve the caller's encrypted keypair bundle during the handshake window.""" bundle_store = self._ctx.get("bundle_store") @@ -508,6 +510,20 @@ class WebRTCPeerSession: return self._ctx["groups"][self._group_id] return self._ctx + def _peer_registry(self) -> dict: + """ + Connected peers for THIS group only. + + Finding H1: this used to live on the shared transport context, so a chat + message was broadcast to every peer on the node regardless of which group + they had authenticated to. + """ + return self._group_ctx().setdefault("_peers", {}) + + def _user_names(self) -> dict: + """Display-name cache, per group — same leak as _peer_registry (H1).""" + return self._group_ctx().setdefault("_user_names", {}) + def _do_index_sync(self) -> None: ctx = self._group_ctx() idx = ctx["index"] @@ -599,11 +615,14 @@ class WebRTCPeerSession: }) def _do_chat_message(self, msg: dict) -> None: - chat_store = self._ctx.get("chat_store") + # Per-group store — see _peer_registry() and finding H1. Reading chat_store + # off the shared transport context sent every group's messages to the first + # group's database, and served them back to anyone on the node. + chat_store = self._group_ctx().get("chat_store") payload = msg.get("payload", "") sender_name = msg.get("sender_name", "") if sender_name: - self._ctx.setdefault("_user_names", {})[self._user_id] = sender_name + self._user_names()[self._user_id] = sender_name if chat_store: raw = payload.encode() if isinstance(payload, str) else payload asyncio.ensure_future(chat_store.save_message( @@ -614,7 +633,7 @@ class WebRTCPeerSession: sender_name=sender_name, )) - peers = self._ctx.get("_peers", {}) + peers = self._peer_registry() broadcast = { "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, @@ -647,7 +666,7 @@ class WebRTCPeerSession: self._audit("chat_message") def _do_chat_history(self, msg: dict) -> None: - chat_store = self._ctx.get("chat_store") + chat_store = self._group_ctx().get("chat_store") if not chat_store: self._send({ "type": MNP.CHAT_HISTORY_RESPONSE, @@ -662,7 +681,7 @@ class WebRTCPeerSession: async def _send_chat_history(self, chat_store, since: float, limit: int) -> None: msgs = await chat_store.get_messages(since=since, limit=limit) - names = self._ctx.get("_user_names", {}) + names = self._user_names() self._send({ "type": MNP.CHAT_HISTORY_RESPONSE, "v": MNP_VERSION, @@ -691,24 +710,55 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Missing filename or data"}) return + if not SAFE_UPLOAD_NAME.match(filename): + self._send({"type": "error", "detail": "Invalid filename"}) + return + shared_root = ctx.get("shared_root") if not shared_root: self._send({"type": "error", "detail": "No shared directory"}) return - upload_dir = shared_root / ".uploads" - upload_dir.mkdir(exist_ok=True) - safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_") - tmp_path = upload_dir / f"{safe_name}.part" + # Per-user quarantine: a member can only ever write inside their own directory, + # so they cannot overwrite the operator's files or another member's (C5a). + rel_dir = f"{UPLOAD_DIR_NAME}/{self._user_id}" + user_dir = shared_root / UPLOAD_DIR_NAME / self._user_id + user_dir.mkdir(parents=True, exist_ok=True) + tmp_path = user_dir / f"{filename}.part" + final_path = user_dir / filename + + state = self._uploads.get(filename) + if chunk_index == 0: + if final_path.exists(): + self._send({"type": "error", "detail": "File already exists"}) + return + state = {"next_index": 0, "bytes": 0} + self._uploads[filename] = state + elif state is None: + self._send({"type": "error", "detail": "Upload not started"}) + return + + # Reject out-of-order or replayed chunks — otherwise chunk_index>0 appends + # blindly to whatever .part file is already on disk. + if chunk_index != state["next_index"]: + self._send({"type": "error", "detail": "Unexpected chunk index"}) + return if isinstance(data, str): chunk_bytes = base64.b64decode(data) else: chunk_bytes = bytes(data) - mode = "ab" if chunk_index > 0 else "wb" - with open(tmp_path, mode) as f: + if state["bytes"] + len(chunk_bytes) > MAX_UPLOAD_BYTES: + self._uploads.pop(filename, None) + tmp_path.unlink(missing_ok=True) + self._send({"type": "error", "detail": "Upload exceeds size limit"}) + return + + with open(tmp_path, "wb" if chunk_index == 0 else "ab") as f: f.write(chunk_bytes) + state["next_index"] = chunk_index + 1 + state["bytes"] += len(chunk_bytes) self._send({ "type": MNP.FILE_UPLOAD_ACK, @@ -718,19 +768,20 @@ class WebRTCPeerSession: }) if chunk_index + 1 >= total_chunks: - final_path = shared_root / safe_name + self._uploads.pop(filename, None) tmp_path.rename(final_path) - log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks) - self._audit("file_upload", safe_name) - self._register_uploader(ctx, safe_name) + log.info("Upload complete: %s (%d chunks, %d bytes)", + filename, total_chunks, state["bytes"]) + self._audit("file_upload", f"{rel_dir}/{filename}") + self._register_uploader(ctx, rel_dir, filename) - def _register_uploader(self, ctx: dict, filename: str) -> None: - """Tag the index entry with the uploader's user_id after upload completes.""" + def _register_uploader(self, ctx: dict, rel_dir: str, filename: str) -> None: + """Tag the index entry with the uploader's identity after upload completes.""" idx = ctx.get("index") if not idx: return for entry in idx.entries: - if entry.name == filename and entry.path == "": + if entry.name == filename and entry.path == rel_dir: entry.uploader_id = self._user_id entry.uploader_pk = self._pk_user return @@ -753,22 +804,64 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "No authorized key for deletion"}) return - challenge = os.urandom(32) - self._admin_challenges[file_id] = challenge + self._issue_admin_challenge(OP_FILE_DELETE, file_id) + + # ── Admin operation challenge/response (finding H5) ────────────────────── + + def _node_pk_b64(self) -> str: + return pk_to_b64(self._ctx["sk_node"].public_key()) + + def _issue_admin_challenge( + self, op: str, subject: str, payload: dict | None = None, + ) -> None: + """ + Ask the client to authorize `op` on `subject` with its Ed25519 identity key. + + The client is sent the transcript *fields*, not opaque bytes, so it can + rebuild and inspect what it signs. The node keeps the authoritative copy and + rebuilds the transcript itself at verification time — nothing signed is ever + taken from the response message. + """ + nonce = os.urandom(32) + ts = int(time.time()) + op_id = base64.b64encode(os.urandom(16)).decode() + self._admin_ops[op_id] = { + "op": op, "subject": subject, "nonce": nonce, "ts": ts, + "payload": payload or {}, + } self._send({ "type": MNP.ADMIN_CHALLENGE, "v": MNP_VERSION, - "challenge": base64.b64encode(challenge).decode(), - "file_id": file_id, + "op_id": op_id, + "op": op, + "subject": subject, + "nonce": base64.b64encode(nonce).decode(), + "ts": ts, + "node_pk": self._node_pk_b64(), + "group_id": self._group_id or "", }) + @staticmethod + def _verify_sig(pk: Ed25519PublicKey | None, transcript: bytes, sig: bytes) -> bool: + if pk is None: + return False + try: + pk.verify(sig, transcript) + return True + except Exception: + return False + def _do_admin_response(self, msg: dict) -> None: - file_id = msg.get("file_id", "") + op_id = msg.get("op_id", "") sig_b64 = msg.get("signature", "") - challenge = self._admin_challenges.pop(file_id, None) - if not challenge: - self._send({"type": "error", "detail": "No pending admin challenge"}) + pending = self._admin_ops.pop(op_id, None) + if not pending: + self._send({"type": "error", "detail": "No pending admin operation"}) + return + + if time.time() - pending["ts"] > ADMIN_CHALLENGE_TTL: + self._send({"type": "error", "detail": "Admin challenge expired"}) return try: @@ -777,40 +870,80 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Invalid signature encoding"}) return + transcript = admin_transcript( + op=pending["op"], + node_pk_b64=self._node_pk_b64(), + group_id=self._group_id or "", + subject=pending["subject"], + nonce=pending["nonce"], + ts=pending["ts"], + ) + + if pending["op"] == OP_FILE_DELETE: + self._admin_exec_file_delete(pending, transcript, sig_bytes) + elif pending["op"] == OP_GEK_BUNDLE_STORE: + asyncio.ensure_future( + self._admin_exec_bundle_store(pending, transcript, sig_bytes)) + else: + self._send({"type": "error", "detail": "Unknown admin operation"}) + + def _admin_exec_file_delete( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + file_id = pending["subject"] ctx = self._group_ctx() entry = ctx["index"].get_entry(file_id) if not entry: self._send({"type": "error", "detail": "File not found"}) return - verified = False - - # Try admin key (locally pinned) - admin_pk = self._ctx.get("admin_pk_ed25519") - if admin_pk: - try: - admin_pk.verify(sig_bytes, challenge) - verified = True - except Exception: - pass - - # Try uploader key (stored at upload time) - if not verified and entry.uploader_pk: + uploader_pk = None + if entry.uploader_pk: try: - uploader_key = Ed25519PublicKey.from_public_bytes( + uploader_pk = Ed25519PublicKey.from_public_bytes( base64.b64decode(entry.uploader_pk)) - uploader_key.verify(sig_bytes, challenge) - verified = True except Exception: - pass + uploader_pk = None - if not verified: + # Node operator, or the user who uploaded this file — verified by the key + # recorded at upload time, never by a JWT claim (the hub controls those). + if not (self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig) + or self._verify_sig(uploader_pk, transcript, sig)): self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"file_delete:{file_id[:16]}") return self._exec_file_delete(ctx, file_id, entry) + async def _admin_exec_bundle_store( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + # Node operator only. A group admin who does not run the node has no + # authority over what this node stores (draft-v4 §4.2.x, deny by default). + if not self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"gek_bundle_store:{pending['subject'][:16]}") + return + + payload = pending["payload"] + bundle_store = self._ctx.get("bundle_store") + if not bundle_store: + self._send({"type": "error", "detail": "Bundle store not available"}) + return + + await bundle_store.store( + payload["group_id"], payload["user_id"], + payload["pk_eph_b64"], payload["nonce_b64"], payload["wrapped_b64"], + ) + log.info("GEK bundle stored: group=%s user=%s", + payload["group_id"][:8], payload["user_id"][:8]) + self._audit("gek_bundle_store", f"target={payload['user_id'][:8]}") + self._send({ + "type": "ack", "v": MNP_VERSION, + "detail": "gek_bundle_stored", + "user_id": payload["user_id"], + }) + def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: file_path = ctx["shared_root"] / entry.path / entry.name if file_path.exists(): @@ -914,9 +1047,8 @@ class WebRTCPeerSession: async def close(self) -> None: self._audit("disconnect") - peers = self._ctx.get("_peers") - if peers and self._user_id: - peers.pop(self._user_id, None) + if self._user_id: + self._peer_registry().pop(self._user_id, None) await self._pc.close() diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 31deb66..d2c3429 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -16,6 +16,7 @@ import base64 import json import logging import time +from html import escape from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query @@ -35,6 +36,33 @@ def create_ui_app(state: dict) -> FastAPI: redoc_url=None, ) + @app.middleware("http") + async def _security_headers(request, call_next): + """ + Defence in depth behind the escaping fixes for H2. This UI is unauthenticated + on loopback, so script execution here equals full control of the node admin API. + + Note what this does and does not do: the page relies on inline