diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 318 |
1 files changed, 225 insertions, 93 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 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() |