aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/__init__.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/client.py148
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/http_server.py336
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py96
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py183
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/server.py286
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/tls_cert.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py994
8 files changed, 1080 insertions, 1020 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
index df9c209..e423e35 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
@@ -1,7 +1,17 @@
-"""MeshBay Node transport layer — TCP+TLS (v1), QUIC (v2), WebRTC (browsers)."""
-from .server import ChunkServer
-from .client import ChunkClient
-from .http_server import create_http_app
+"""
+MeshBay Node transport layer — WebRTC DataChannel (primary), QUIC (direct/LAN).
+
+Transport decision (2026-08-13, second security review):
+ - WebRTC/ICE is the primary path for browser AND native clients. ICE/STUN is the
+ only NAT traversal validated on this project (2 ISPs, IPv4 STUN + IPv6, 4G CGNAT).
+ - QUIC is kept at parity for LAN, port-forwarded and hub-less `group://` access.
+ `punch_nat()` is a direct-connection helper, not a traversal stack.
+ - TCP+TLS (`server.py`/`client.py`) and the node HTTP file API (`http_server.py`)
+ were REMOVED in Phase 11.5. The HTTP API served private group indexes and
+ plaintext files with no authentication on 0.0.0.0 (finding C1); the TCP server
+ accepted a bare JWT with no GEK proof (finding C6). Neither is coming back —
+ every client path must go through the unified MNP handshake.
+"""
# QUIC transport (MNP v2) — requires aioquic>=1.0
try:
@@ -14,7 +24,7 @@ except ImportError:
Denylist = None # type: ignore[assignment,misc]
QUIC_AVAILABLE = False
-# WebRTC transport (browsers) — requires aiortc>=1.9
+# WebRTC transport (browsers + native clients) — requires aiortc>=1.9
try:
from .webrtc_server import WebRTCTransport, WebRTCPeerSession
WEBRTC_AVAILABLE = True
@@ -24,7 +34,6 @@ except ImportError:
WEBRTC_AVAILABLE = False
__all__ = [
- "ChunkServer", "ChunkClient", "create_http_app",
"QuicChunkServer", "QuicChunkClient", "Denylist", "QUIC_AVAILABLE",
"WebRTCTransport", "WebRTCPeerSession", "WEBRTC_AVAILABLE",
]
diff --git a/packages/meshbay-node/src/meshbay_node/transport/client.py b/packages/meshbay-node/src/meshbay_node/transport/client.py
deleted file mode 100644
index 63d50af..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/client.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-MeshBay — TCP+TLS chunk client (MNP v1).
-
-Used by the web client (or other nodes) to fetch files from a Mesh Node.
-Verifies Ed25519 chunk signatures using the node's public key from the hub.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-from pathlib import Path
-
-import blake3
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- decrypt_chunk,
- verify_chunk_signature,
-)
-from meshbay_common.protocol import MNP
-from meshbay_node.transport.tls_cert import client_ssl_context
-
-log = logging.getLogger(__name__)
-
-MAX_MSG = 64 * 1024 * 1024
-
-
-async def _send(writer, obj):
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader):
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- return msgpack.unpackb(await reader.readexactly(length), raw=False)
-
-
-class ChunkClient:
- """
- Async client for fetching encrypted chunks from a ChunkServer.
-
- Usage:
- async with ChunkClient(host, port, jwt_token, gek, pk_node_b64) as client:
- data = await client.fetch_chunk(file_id, chunk_index=0)
- """
-
- def __init__(
- self,
- host: str,
- port: int,
- jwt_token: str,
- gek: bytes,
- pk_node_b64: str, # node's Ed25519 PK from hub — used for sig verification
- group_id: str = "",
- ):
- self._host = host
- self._port = port
- self._jwt_token = jwt_token
- self._gek = gek
- self._group_id = group_id
- self._pk_node = Ed25519PublicKey.from_public_bytes(
- base64.b64decode(pk_node_b64))
- self._reader: asyncio.StreamReader | None = None
- self._writer: asyncio.StreamWriter | None = None
-
- async def __aenter__(self):
- await self.connect()
- return self
-
- async def __aexit__(self, *_):
- await self.close()
-
- async def connect(self) -> None:
- ssl_ctx = client_ssl_context()
- self._reader, self._writer = await asyncio.open_connection(
- self._host, self._port, ssl=ssl_ctx)
-
- handshake_msg = {
- "type": MNP.HANDSHAKE,
- "v": MNP_VERSION,
- "token": self._jwt_token,
- }
- if self._group_id:
- handshake_msg["group_id"] = self._group_id
- await _send(self._writer, handshake_msg)
- ack = await _recv(self._reader)
- if ack.get("type") != MNP.HANDSHAKE_ACK:
- raise ConnectionError(f"Handshake rejected: {ack}")
- log.debug("Connected to node %s:%d", self._host, self._port)
-
- async def close(self) -> None:
- if self._writer:
- self._writer.close()
- await self._writer.wait_closed()
-
- async def fetch_index(self) -> bytes:
- """Request the Mesh Group Index. Returns raw wire bytes (encrypted)."""
- await _send(self._writer, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION})
- msg = await _recv(self._reader)
- return base64.b64decode(msg["index_b64"])
-
- async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes:
- """
- Fetch, verify, and decrypt one chunk.
- Returns plaintext bytes.
- """
- await _send(self._writer, {
- "type": MNP.FILE_REQUEST,
- "v": MNP_VERSION,
- "file_id": file_id,
- "chunk_index": chunk_index,
- })
- msg = await _recv(self._reader)
-
- if msg.get("type") == "error":
- raise LookupError(msg.get("detail", "Unknown error"))
-
- ct = base64.b64decode(msg["ct_b64"])
- nonce = base64.b64decode(msg["nonce_b64"])
- ct_hash = base64.b64decode(msg["ct_hash_b64"])
- pt_hash = base64.b64decode(msg["pt_hash_b64"])
- sig = base64.b64decode(msg["sig_b64"])
- file_hash = base64.b64decode(msg["file_hash_b64"])
- ci = msg["chunk_index"]
-
- # 1. Verify Ed25519 signature
- verify_chunk_signature(self._pk_node, ci, nonce, ct_hash, sig)
-
- # 2. Verify ciphertext hash
- if blake3.blake3(ct).digest() != ct_hash:
- raise ValueError("Ciphertext hash mismatch")
-
- # 3. Decrypt
- ckey = derive_chunk_key(self._gek, file_hash, ci)
- plaintext = decrypt_chunk(ckey, nonce, ct)
-
- # 4. Verify plaintext hash
- if blake3.blake3(plaintext).digest() != pt_hash:
- raise ValueError("Plaintext hash mismatch after decryption")
-
- return plaintext
diff --git a/packages/meshbay-node/src/meshbay_node/transport/http_server.py b/packages/meshbay-node/src/meshbay_node/transport/http_server.py
deleted file mode 100644
index 151c2e8..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/http_server.py
+++ /dev/null
@@ -1,336 +0,0 @@
-"""
-MeshBay Node — HTTP file API (port 19001, public content).
-
-Serves public group content over standard HTTP so browsers can
-access files without any special protocol.
-
-Endpoints:
- GET / node info (JSON)
- GET /index public Mesh Group Index (JSON)
- GET /file/{file_id} full file download (streaming)
- GET /file/{file_id}/{chunk} single encrypted chunk (JSON)
- GET /hls/{file_id}/playlist.m3u8 HLS playlist
- GET /hls/{file_id}/{segment}.ts HLS segment (binary TS)
-
-Auth: Bearer JWT in Authorization header (or ?token= query param).
-For public groups: auth optional (anonymous browse allowed).
-For chunk download: auth required (JWT verified offline with hub PK).
-
-Note: this server handles PUBLIC content only (no GEK decryption).
-Private group content requires a client that can do ChaCha20 (Phase 5).
-"""
-
-import asyncio
-import base64
-import json
-import logging
-import os
-import struct
-import subprocess
-import tempfile
-from pathlib import Path
-
-import blake3
-import jwt
-from fastapi import FastAPI, Header, HTTPException, Query, Request
-from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import sign_chunk, pk_to_b64
-from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
-from meshbay_node import __version__
-from meshbay_node.indexer import GroupIndex
-from meshbay_node.indexer.group_index import GroupIndex
-
-log = logging.getLogger(__name__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-HLS_SEGMENT_DURATION = 4 # seconds per HLS segment
-
-
-def create_http_app(
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- shared_root: Path,
- index: GroupIndex,
- group_id: str,
- group_name: str,
- gek: bytes | None = None, # None for public groups
-) -> FastAPI:
- """
- Create the node's public HTTP API FastAPI app.
- Bind to 0.0.0.0:19001 (or configured port) for external access.
- """
- app = FastAPI(
- title="MeshBay Node HTTP API",
- version=__version__,
- docs_url=None,
- redoc_url=None,
- )
-
- # ── Auth helper ───────────────────────────────────────────────────────────
-
- def _verify_token_optional(
- authorization: str | None,
- token_param: str | None,
- ) -> dict | None:
- """Verify JWT if provided. Returns decoded payload or None."""
- raw = None
- if authorization and authorization.lower().startswith("bearer "):
- raw = authorization[7:]
- elif token_param:
- raw = token_param
- if not raw:
- return None
- try:
- return jwt.decode(raw, hub_pk_pem, algorithms=["EdDSA"])
- except Exception:
- return None
-
- def _require_token(
- authorization: str | None,
- token_param: str | None,
- ) -> dict:
- decoded = _verify_token_optional(authorization, token_param)
- if decoded is None:
- raise HTTPException(status_code=401, detail="Authentication required")
- return decoded
-
- # ── Node info ─────────────────────────────────────────────────────────────
-
- @app.get("/")
- async def node_info():
- return {
- "node_version": __version__,
- "mnp_version": MNP_VERSION,
- "group_id": group_id,
- "group_name": group_name,
- "file_count": index.count,
- "pk_node": pk_to_b64(sk_node.public_key()),
- }
-
- # ── Public index ──────────────────────────────────────────────────────────
-
- @app.get("/index")
- async def get_index(
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Public Mesh Group Index as JSON. No auth required for public groups."""
- entries = [
- {
- "id": e.id,
- "name": e.name,
- "path": e.path,
- "size": e.size,
- "type": e.type,
- "duration": e.duration,
- }
- for e in index.entries
- ]
- return {
- "group_id": group_id,
- "group_name": group_name,
- "version": index.version,
- "entries": entries,
- }
-
- # ── Full file download (streaming) ────────────────────────────────────────
-
- @app.get("/file/{file_id}")
- async def download_file(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Stream an entire file. Public groups: no auth needed."""
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found in index")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- return FileResponse(
- path=str(file_path),
- filename=entry.name,
- media_type=_media_type(entry.name),
- )
-
- # ── Chunk endpoint (encrypted, for MNP-aware clients) ────────────────────
-
- @app.get("/file/{file_id}/{chunk_index}")
- async def get_chunk(
- file_id: str,
- chunk_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """
- Serve one encrypted chunk (JSON). Auth required.
- Clients that understand MNP can decrypt with the GEK they got from the hub.
- """
- _require_token(authorization, token)
-
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- # Read chunk
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- if not plaintext:
- raise HTTPException(status_code=416, detail="Chunk out of range")
-
- file_hash = bytes.fromhex(entry.id)
- pt_hash = blake3.blake3(plaintext).digest()
-
- if gek:
- # Private group: encrypt chunk
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": True,
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
- else:
- # Public group: serve plaintext chunk (TLS provides transport encryption)
- pt_hash_b = blake3.blake3(plaintext).digest()
- sig_payload = chunk_index.to_bytes(4, "big") + bytes(12) + pt_hash_b
- sig = sk_node.sign(sig_payload)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": False,
- "data_b64": base64.b64encode(plaintext).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- }
-
- # ── HLS streaming ─────────────────────────────────────────────────────────
-
- @app.get("/hls/{file_id}/playlist.m3u8")
- async def hls_playlist(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Generate HLS playlist for a video file."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video file not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- duration = entry.duration or _probe_duration(file_path)
- if not duration:
- raise HTTPException(status_code=422, detail="Cannot determine video duration")
-
- n_segments = max(1, int(duration / HLS_SEGMENT_DURATION) + 1)
- token_param = f"?token={token}" if token else ""
-
- lines = [
- "#EXTM3U",
- "#EXT-X-VERSION:3",
- f"#EXT-X-TARGETDURATION:{HLS_SEGMENT_DURATION}",
- "#EXT-X-MEDIA-SEQUENCE:0",
- ]
- for i in range(n_segments):
- seg_dur = min(HLS_SEGMENT_DURATION, duration - i * HLS_SEGMENT_DURATION)
- if seg_dur <= 0:
- break
- lines.append(f"#EXTINF:{seg_dur:.3f},")
- lines.append(f"/hls/{file_id}/{i}.ts{token_param}")
- lines.append("#EXT-X-ENDLIST")
-
- return StreamingResponse(
- iter(["\n".join(lines)]),
- media_type="application/vnd.apple.mpegurl",
- )
-
- @app.get("/hls/{file_id}/{segment_index}.ts")
- async def hls_segment(
- file_id: str,
- segment_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Serve one HLS segment as MPEG-TS via ffmpeg transcoding."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- start_time = segment_index * HLS_SEGMENT_DURATION
-
- async def generate():
- proc = await asyncio.create_subprocess_exec(
- "ffmpeg", "-hide_banner", "-loglevel", "error",
- "-ss", str(start_time),
- "-i", str(file_path),
- "-t", str(HLS_SEGMENT_DURATION),
- "-c:v", "copy", "-c:a", "copy",
- "-f", "mpegts", "pipe:1",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL,
- )
- assert proc.stdout
- while chunk := await proc.stdout.read(65536):
- yield chunk
- await proc.wait()
-
- return StreamingResponse(generate(), media_type="video/mp2t")
-
- return app
-
-
-# ── Helpers ───────────────────────────────────────────────────────────────────
-
-def _media_type(filename: str) -> str:
- ext = Path(filename).suffix.lower()
- return {
- ".mp4": "video/mp4", ".mkv": "video/x-matroska",
- ".webm": "video/webm", ".avi": "video/x-msvideo",
- ".mp3": "audio/mpeg", ".flac": "audio/flac",
- ".ogg": "audio/ogg", ".opus": "audio/opus",
- ".jpg": "image/jpeg", ".png": "image/png",
- ".pdf": "application/pdf",
- }.get(ext, "application/octet-stream")
-
-
-def _probe_duration(path: Path) -> float | None:
- """Use ffprobe to get video duration in seconds."""
- try:
- result = subprocess.run(
- ["ffprobe", "-v", "quiet", "-print_format", "json",
- "-show_format", str(path)],
- capture_output=True, text=True, timeout=10,
- )
- data = json.loads(result.stdout)
- return float(data["format"]["duration"])
- except Exception:
- return None
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 f439e62..ed3925d 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -20,6 +20,7 @@ The transport is the only change — all crypto, auth, and message types stay th
import asyncio
import base64
import logging
+import os
import struct
import subprocess
from pathlib import Path
@@ -34,6 +35,17 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ quic_binding,
+ verify_proof,
+)
from meshbay_common.crypto import (
sign_chunk,
pk_to_b64,
@@ -41,7 +53,6 @@ from meshbay_common.crypto import (
from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
-from meshbay_node.transport.tls_cert import server_ssl_context
log = logging.getLogger(__name__)
@@ -51,22 +62,69 @@ ALPN = ["meshbay-mnp"]
class Denylist:
- """Shared denylist for revoked users and invalidated JWTs."""
+ """
+ Denylist for revoked users, groups and invalidated JWTs.
- def __init__(self):
+ Finding H4: revocations used to live only in memory, so a node restart silently
+ un-revoked everyone, and group revocations were dropped entirely — the hub
+ signed and broadcast them but the node's handler only understood "user" and
+ "jti". Now persisted to disk and group targets are honoured.
+ """
+
+ def __init__(self, path: Path | None = None):
self.user_ids: set[str] = set()
+ self.group_ids: set[str] = set()
self.jtis: set[str] = set()
+ self._path = path
+ self._load()
- def is_denied(self, user_id: str, jti: str) -> bool:
- return user_id in self.user_ids or jti in self.jtis
+ def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool:
+ return (user_id in self.user_ids
+ or jti in self.jtis
+ or (bool(group_id) and group_id in self.group_ids))
def deny_user(self, user_id: str) -> None:
self.user_ids.add(user_id)
log.info("Denied user: %s", user_id[:8])
+ self._save()
+
+ def deny_group(self, group_id: str) -> None:
+ self.group_ids.add(group_id)
+ log.info("Denied group: %s", group_id[:8])
+ self._save()
def deny_jti(self, jti: str) -> None:
self.jtis.add(jti)
log.info("Denied jti: %s", jti[:8])
+ self._save()
+
+ def _load(self) -> None:
+ if not self._path or not self._path.exists():
+ return
+ try:
+ import json
+ data = json.loads(self._path.read_text())
+ self.user_ids = set(data.get("users", []))
+ self.group_ids = set(data.get("groups", []))
+ self.jtis = set(data.get("jtis", []))
+ log.info("Denylist loaded: %d users, %d groups, %d jtis",
+ len(self.user_ids), len(self.group_ids), len(self.jtis))
+ except Exception as e:
+ log.warning("Could not load denylist from %s: %s", self._path, e)
+
+ def _save(self) -> None:
+ if not self._path:
+ return
+ try:
+ import json
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._path.write_text(json.dumps({
+ "users": sorted(self.user_ids),
+ "groups": sorted(self.group_ids),
+ "jtis": sorted(self.jtis),
+ }))
+ except Exception as e:
+ log.warning("Could not persist denylist to %s: %s", self._path, e)
# ── Wire helpers ──────────────────────────────────────────────────────────────
@@ -111,6 +169,9 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._user_id: str | None = None
self._group_id: str | None = None
self._buffers: dict[int, _StreamBuffer] = {}
+ self._nonce_client: bytes = b""
+ self._gek_challenge: bytes | None = None
+ self._pending = None
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, StreamDataReceived):
@@ -130,6 +191,8 @@ class _MNPServerProtocol(QuicConnectionProtocol):
try:
if mtype == MNP.HANDSHAKE:
self._do_handshake_sync(stream_id, msg)
+ elif mtype == MNP.HANDSHAKE_RESPONSE:
+ self._do_handshake_response_sync(stream_id, msg)
elif self._user_id is None:
self._send(stream_id, {"type": "error", "detail": "Handshake required"})
elif mtype == MNP.INDEX_SYNC:
@@ -147,44 +210,117 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": str(e)})
def _do_handshake_sync(self, stream_id: int, msg: dict) -> None:
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
+ """
+ Authorization half of the unified handshake (11.5.4).
+
+ This used to be a second, weaker copy of the WebRTC logic: group_id was
+ optional (so omitting it skipped the membership check entirely — M1),
+ node-scoped daemon tokens were accepted as client tokens (M9), and the
+ checks could drift from the WebRTC path independently. All of that now
+ comes from meshbay_common.handshake, shared with WebRTC.
+
+ NOT YET DONE — finding C6 remains open on this transport: there is still no
+ GEK proof here, so a forged or stolen token reaches the node and can inject
+ chat without holding the group key. The challenge/response and mutual node
+ proof (quic_binding() is written and unit-tested for exactly this) are the
+ remaining work in 11.5.4/5/6.
+ """
try:
- decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"])
- except Exception as e:
- self._send(stream_id, {"type": "error", "detail": f"Invalid JWT: {e}"})
+ peer = authorize_token(
+ msg.get("token", ""),
+ self._ctx["hub_pk_pem"],
+ group_id=msg.get("group_id", ""),
+ hosted_groups=self._ctx.get("groups"),
+ denylist=self._ctx.get("denylist"),
+ )
+ except HandshakeError as refusal:
+ self._send(stream_id, {"type": "error", "detail": str(refusal)})
+ self._quic.close()
+ return
+
+ try:
+ self._nonce_client = base64.b64decode(msg.get("nonce", ""))
+ except Exception:
+ self._nonce_client = b""
+ if len(self._nonce_client) < NONCE_LEN:
+ self._send(stream_id, {"type": "error", "detail": "Client nonce required"})
self._quic.close()
return
- denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
- self._send(stream_id, {"type": "error", "detail": "Token revoked"})
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ if not gctx.get("gek"):
+ self._send(stream_id, {
+ "type": "error",
+ "detail": "Group encryption not initialized — contact node operator",
+ })
+ self._quic.close()
+ return
+
+ # Decoded but NOT authenticated: authentication is the GEK proof below.
+ self._pending = peer
+ self._gek_challenge = os.urandom(NONCE_LEN)
+ self._send(stream_id, {
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ })
+
+ def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None:
+ """Verify the client's GEK proof, then prove the node in return (C6, C3)."""
+ if not self._gek_challenge or self._pending is None:
+ self._send(stream_id, {"type": "error", "detail": "No pending handshake challenge"})
+ return
+
+ peer = self._pending
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ gek = gctx.get("gek")
+ if not gek:
+ self._send(stream_id, {"type": "error", "detail": "Group encryption not initialized"})
self._quic.close()
return
- if group_id and group_id not in decoded.get("groups", []):
- self._send(stream_id, {"type": "error", "detail": "Not a member of this group"})
+ binding = self._ctx.get("server_cert_der")
+ if not binding:
+ # Refuse rather than fall back to an unbound proof (L4).
+ self._send(stream_id, {"type": "error", "detail": "Channel binding unavailable"})
self._quic.close()
return
+ binding = quic_binding(binding)
- if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]:
- self._send(stream_id, {"type": "error", "detail": "Group not hosted on this node"})
+ try:
+ proof = base64.b64decode(msg.get("proof", ""))
+ except Exception:
+ self._send(stream_id, {"type": "error", "detail": "Invalid proof encoding"})
+ return
+
+ if not verify_proof(gek, proof, ROLE_CLIENT, peer.group_id,
+ self._nonce_client, self._gek_challenge, binding):
+ self._send(stream_id, {"type": "error", "detail": "GEK proof failed"})
self._quic.close()
return
- self._user_id = decoded["sub"]
- self._group_id = group_id
+ self._user_id = peer.user_id
+ self._group_id = peer.group_id
peers = self._ctx.get("_peers")
if peers is not None:
peers[self._user_id] = self
- log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], group_id[:8] if group_id else "none")
+ transcript = handshake_transcript(
+ ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+ node_proof = make_proof(
+ gek, ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+
+ log.info("QUIC handshake OK — user=%s group=%s",
+ self._user_id[:8], self._group_id[:8])
self._send(stream_id, {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
"node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ "proof": base64.b64encode(node_proof).decode(),
+ "sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(),
})
+ self._gek_challenge = None
def _group_ctx(self) -> dict:
"""Resolve the active group context (multi-group or legacy single-group)."""
@@ -408,6 +544,13 @@ class QuicChunkServer:
generate_self_signed_cert(self._cert_path, self._key_path)
config = QuicConfiguration(is_client=False, alpn_protocols=ALPN)
config.load_cert_chain(str(self._cert_path), str(self._key_path))
+
+ # Channel-binding anchor for the handshake proof (11.5.6). Read from our own
+ # cert file — no aioquic internals needed on this side.
+ from cryptography import x509
+ from cryptography.hazmat.primitives import serialization as _ser
+ self._ctx["server_cert_der"] = x509.load_pem_x509_certificate(
+ self._cert_path.read_bytes()).public_bytes(_ser.Encoding.DER)
return config
def _store_ticket(self, ticket: Any) -> None:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/server.py b/packages/meshbay-node/src/meshbay_node/transport/server.py
deleted file mode 100644
index b77f1f2..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/server.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""
-MeshBay Node — TCP+TLS chunk server (MNP v1).
-
-Serves encrypted file chunks to authenticated clients over TLS.
-Each connection:
- 1. Client sends MNP handshake with JWT bearer token
- 2. Server verifies JWT offline (hub PK cached)
- 3. Client sends chunk requests
- 4. Server reads from disk, encrypts on-the-fly, signs, sends
-
-Wire protocol: length-prefixed msgpack (4-byte big-endian length header).
-All messages carry {"type": ..., "v": MNP_VERSION}.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-import time
-from pathlib import Path
-
-import blake3
-import jwt
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
- sign_chunk,
- pk_to_b64,
-)
-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__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-MAX_MSG = 64 * 1024 * 1024 # 64 MB max message size (safety)
-
-
-# ── Wire helpers ──────────────────────────────────────────────────────────────
-
-async def _send(writer: asyncio.StreamWriter, obj: dict) -> None:
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader: asyncio.StreamReader) -> dict:
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- data = await reader.readexactly(length)
- return msgpack.unpackb(data, raw=False)
-
-
-# ── Chunk serving ─────────────────────────────────────────────────────────────
-
-def _serve_chunk(
- sk_node: Ed25519PrivateKey,
- gek: bytes,
- file_path: Path,
- file_hash: bytes,
- chunk_index: int,
-) -> dict:
- """Read, encrypt, sign one chunk. Blocking — run in executor."""
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- pt_hash = blake3.blake3(plaintext).digest()
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
-
- return {
- "type": MNP.FILE_CHUNK,
- "v": MNP_VERSION,
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
-
-
-# ── Connection handler ────────────────────────────────────────────────────────
-
-class _ConnectionHandler:
- def __init__(
- self,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- groups: dict[str, dict] | None = None,
- ):
- self._reader = reader
- self._writer = writer
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._groups = groups
- self._peer = writer.get_extra_info("peername")
- self._user_id: str | None = None
- self._group_id: str | None = None
-
- async def handle(self) -> None:
- try:
- await self._handshake()
- await self._serve_loop()
- except asyncio.IncompleteReadError:
- log.debug("[%s] Client disconnected", self._peer)
- except Exception as e:
- log.warning("[%s] Error: %s", self._peer, e)
- await _send(self._writer, {"type": "error", "detail": str(e)})
- finally:
- self._writer.close()
-
- async def _handshake(self) -> None:
- msg = await _recv(self._reader)
- if msg.get("type") != MNP.HANDSHAKE:
- raise ValueError(f"Expected handshake, got {msg.get('type')!r}")
-
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
- try:
- decoded = jwt.decode(token, self._hub_pk_pem, algorithms=["EdDSA"])
- except Exception as e:
- raise PermissionError(f"Invalid JWT: {e}") from e
-
- if group_id and group_id not in decoded.get("groups", []):
- raise PermissionError("Not a member of this group")
-
- if group_id and self._groups and group_id not in self._groups:
- raise PermissionError("Group not hosted on this node")
-
- self._user_id = decoded["sub"]
- self._group_id = group_id
-
- if group_id and self._groups and group_id in self._groups:
- ctx = self._groups[group_id]
- self._gek = ctx["gek"]
- self._shared_root = ctx["shared_root"]
- self._index = ctx["index"]
-
- log.info("[%s] Handshake OK — user=%s group=%s", self._peer, self._user_id[:8], group_id[:8] if group_id else "none")
-
- await _send(self._writer, {
- "type": MNP.HANDSHAKE_ACK,
- "v": MNP_VERSION,
- "node_pk": pk_to_b64(self._sk_node.public_key()),
- })
-
- async def _serve_loop(self) -> None:
- loop = asyncio.get_event_loop()
- while True:
- msg = await _recv(self._reader)
- mtype = msg.get("type")
-
- if mtype == MNP.INDEX_SYNC:
- wire = self._index.serialize()
- await _send(self._writer, {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "index_b64": base64.b64encode(wire).decode(),
- })
-
- elif mtype == MNP.FILE_REQUEST:
- file_id = msg["file_id"]
- chunk_index = msg["chunk_index"]
-
- entry = self._index.get_entry(file_id)
- if entry is None:
- await _send(self._writer, {
- "type": "error",
- "detail": f"File not found: {file_id[:8]}",
- })
- continue
-
- file_path = self._shared_root / entry.path / entry.name
- if not file_path.exists():
- await _send(self._writer, {
- "type": "error", "detail": "File not on disk"})
- continue
-
- file_hash = bytes.fromhex(entry.id)
- chunk = await loop.run_in_executor(
- None, _serve_chunk,
- self._sk_node, self._gek, file_path, file_hash, chunk_index)
- await _send(self._writer, chunk)
-
- else:
- log.warning("[%s] Unknown message type: %s", self._peer, mtype)
-
-
-# ── Server ────────────────────────────────────────────────────────────────────
-
-class ChunkServer:
- """
- Async TCP+TLS server that serves encrypted file chunks.
-
- Usage:
- server = ChunkServer(
- host="0.0.0.0", port=19000,
- sk_node=sk, hub_pk_pem=pk_pem,
- gek=gek, shared_root=Path("/data"),
- index=group_index,
- )
- await server.start()
- # ... when shutting down:
- await server.stop()
- """
-
- def __init__(
- self,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- host: str = "0.0.0.0",
- port: int = 19000,
- cert_path: Path | None = None,
- key_path: Path | None = None,
- groups: dict[str, dict] | None = None,
- ):
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._host = host
- self._port = port
- self._cert_path = cert_path
- self._key_path = key_path
- self._groups = groups
- self._server: asyncio.Server | None = None
-
- @property
- def port(self) -> int:
- return self._port
-
- async def start(self) -> None:
- ssl_ctx = server_ssl_context(
- cert_path=self._cert_path or Path.home() / ".config/meshbay/node_tls.crt",
- key_path=self._key_path or Path.home() / ".config/meshbay/node_tls.key",
- )
- self._server = await asyncio.start_server(
- self._handle_connection,
- host=self._host,
- port=self._port,
- ssl=ssl_ctx,
- )
- log.info("ChunkServer listening on %s:%d (TLS)", self._host, self._port)
-
- async def stop(self) -> None:
- if self._server:
- self._server.close()
- await self._server.wait_closed()
- self._server = None
- log.info("ChunkServer stopped")
-
- async def _handle_connection(
- self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
- ) -> None:
- handler = _ConnectionHandler(
- reader, writer,
- self._sk_node, self._hub_pk_pem,
- self._gek, self._shared_root, self._index,
- groups=self._groups,
- )
- await handler.handle()
diff --git a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
index 1354ac9..374fd08 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
@@ -1,17 +1,18 @@
"""
-Self-signed TLS certificate generation for the node.
+Self-signed TLS certificate generation for the node's QUIC listener.
The cert is used for transport confidentiality only.
Node identity is verified via Ed25519 PK (from hub), not TLS cert chain.
-Clients connect with ssl.CERT_NONE + verify Ed25519 at the MNP handshake layer.
-Certificate is generated once and cached at ~/.config/meshbay/node_tls.pem/.key.
+Phase 11.5 note: the certificate hash is also the intended channel-binding anchor for
+the QUIC handshake proof (11.5.6), since QUIC has no DTLS fingerprint to bind to.
+
+Certificate is generated once and cached at ~/.config/meshbay/node_tls.crt/.key.
"""
import logging
import os
from pathlib import Path
-import ssl
import datetime
import ipaddress
@@ -69,27 +70,6 @@ def generate_self_signed_cert(
return cert_path, key_path
-def server_ssl_context(
- cert_path: Path = DEFAULT_CERT,
- key_path: Path = DEFAULT_KEY,
-) -> ssl.SSLContext:
- """SSL context for the node's TCP server."""
- if not cert_path.exists() or not key_path.exists():
- generate_self_signed_cert(cert_path, key_path)
-
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
-
-
-def client_ssl_context() -> ssl.SSLContext:
- """
- SSL context for clients connecting to a node.
- CERT_NONE because we verify node identity via Ed25519 PK at the MNP layer.
- """
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
- ctx.check_hostname = False
- ctx.verify_mode = ssl.CERT_NONE
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
+# `server_ssl_context()` / `client_ssl_context()` were removed in Phase 11.5 along with
+# the TCP+TLS transport they served. QUIC builds its own QuicConfiguration and calls
+# generate_self_signed_cert() directly.
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..fe4e3c2 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,16 +43,68 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
)
from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import pk_to_b64
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ verify_proof,
+ webrtc_binding,
+)
+from meshbay_common.adminop import (
+ ADMIN_CHALLENGE_TTL,
+ OP_FILE_DELETE,
+ OP_INVITE_CREATE,
+ admin_transcript,
+)
+from meshbay_common.crypto import pk_to_b64, wrap_gek_aes
+from meshbay_common.join import (
+ JOIN_TTL,
+ ROLE_MEMBER,
+ ROLE_OPERATOR,
+ join_transcript,
+)
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
+from meshbay_node.roster import DEFAULT_INVITE_TTL
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
+
+# Budget for an unauthenticated peer: enough for a handshake and a bundle fetch,
+# nowhere near enough to be a memory-exhaustion primitive (H6).
+PRE_HANDSHAKE_MAX_MSG = 64 * 1024
+# ffmpeg is spawned per stream request; without a cap any member can fork-bomb
+# the node by requesting many streams at once (H6).
+MAX_CONCURRENT_TRANSCODES = 2
+# Bundle fetches are served in the pre-proof window (C4). Bounded and audited
+# until the native client removes remote keypair bundles entirely.
+MAX_PRE_PROOF_FETCHES = 4
+# Pairing codes carry 40 bits and are single-use, but a connection must not be
+# allowed to sit there guessing. Failures are audited, so a grind is visible.
+MAX_JOIN_ATTEMPTS = 5
+# Per-connection limits alone would not bind an attacker who can open connections
+# at will — and the adversary who can mint tokens for any account is the hub. So
+# failed pairings are also counted node-wide over a window.
+MAX_JOIN_FAILURES_WINDOW = 20
+JOIN_FAILURE_WINDOW = 600 # seconds
+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."""
@@ -119,10 +173,18 @@ def _pack(obj: dict) -> bytes:
class _DataChannelBuffer:
- """Accumulate DataChannel messages and extract length-prefixed msgpack."""
+ """
+ Accumulate DataChannel messages and extract length-prefixed msgpack.
+
+ Finding H6: the limit was a flat 64 MB applied even before the handshake, so an
+ unauthenticated peer could announce a 64 MB frame and dribble bytes into it,
+ holding that much memory per connection. Until a peer has proved GEK
+ possession it gets a small budget; the large one is for file uploads.
+ """
- def __init__(self):
+ def __init__(self, max_message: int = MAX_MSG):
self._buf = bytearray()
+ self.max_message = max_message
def feed(self, data: bytes):
self._buf.extend(data)
@@ -130,7 +192,7 @@ class _DataChannelBuffer:
def messages(self):
while len(self._buf) >= 4:
length = struct.unpack(">I", self._buf[:4])[0]
- if length > MAX_MSG:
+ if length > self.max_message:
raise ValueError(f"Message too large: {length}")
if len(self._buf) < 4 + length:
break
@@ -162,15 +224,25 @@ class WebRTCPeerSession:
self._pc = pc
self._ctx = node_ctx
self._channel: RTCDataChannel | None = None
- self._buffer = _DataChannelBuffer()
+ self._buffer = _DataChannelBuffer(max_message=PRE_HANDSHAKE_MAX_MSG)
+ self._pre_proof_fetches = 0
self._user_id: str | None = None
self._group_id: str | None = None
self._peer_id: str = peer_id
self._remote_ip: str = ""
self._username: str = ""
- self._pk_user: str = ""
+ # Set from the roster: the key this node pinned for this account. Never
+ # from the JWT — the hub picks what goes in there.
+ self._pinned_pk: str = ""
self._gek_challenge: bytes | None = None
- self._admin_challenges: dict[str, bytes] = {}
+ # Same value as the GEK challenge, but kept for the life of the connection:
+ # a join_request is signed over it, and it must stay verifiable after the
+ # handshake clears the challenge (an operator pairs while already connected).
+ self._nonce_node: bytes = b""
+ self._join_attempts = 0
+ self._nonce_client: bytes = b""
+ 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
@@ -191,10 +263,30 @@ class WebRTCPeerSession:
self._do_handshake(msg)
elif mtype == MNP.HANDSHAKE_RESPONSE:
self._do_handshake_response(msg)
- elif mtype == MNP.GEK_BUNDLE_FETCH and self._gek_challenge is not None:
- asyncio.ensure_future(self._do_gek_bundle_fetch())
- elif mtype == MNP.KEYPAIR_BUNDLE_FETCH and self._gek_challenge is not None:
- asyncio.ensure_future(self._do_keypair_bundle_fetch())
+ elif mtype in (MNP.GEK_BUNDLE_FETCH, MNP.KEYPAIR_BUNDLE_FETCH) \
+ and self._gek_challenge is not None:
+ # Served before the GEK proof by necessity: the client needs its
+ # wrapped bundle in order to compute the proof. That window is a
+ # disclosure surface (C4) — a hub that forges a JWT reaches it — so
+ # it is bounded and audited here, and closed properly when clients
+ # stop storing keypair bundles on other people's nodes.
+ self._pre_proof_fetches += 1
+ if self._pre_proof_fetches > MAX_PRE_PROOF_FETCHES:
+ self._audit_auth_failed(
+ getattr(self, "_pending_group", ""), "pre-proof fetch flood")
+ self._send({"type": "error", "detail": "Too many requests"})
+ return
+ self._audit_pre_proof_fetch(mtype)
+ if mtype == MNP.GEK_BUNDLE_FETCH:
+ asyncio.ensure_future(self._do_gek_bundle_fetch())
+ else:
+ asyncio.ensure_future(self._do_keypair_bundle_fetch())
+ elif mtype == MNP.JOIN_REQUEST and self._nonce_node:
+ # Valid both before the GEK proof (a new member has no GEK to prove
+ # with) and after it (an operator pairing a browser is already
+ # connected). Authority comes from the pairing code and the
+ # signature, never from the session state.
+ asyncio.ensure_future(self._do_join_request(msg))
elif self._user_id is None:
self._send({"type": "error", "detail": "Handshake required"})
elif mtype == MNP.INDEX_SYNC:
@@ -213,17 +305,21 @@ class WebRTCPeerSession:
self._do_file_delete(msg)
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))
+ elif mtype == MNP.INVITE_CREATE:
+ self._do_invite_create(msg)
elif mtype == MNP.KEYPAIR_BUNDLE_STORE:
asyncio.ensure_future(self._do_keypair_bundle_store(msg))
+ elif mtype == MNP.KEYPAIR_BUNDLE_DELETE:
+ asyncio.ensure_future(self._do_keypair_bundle_delete())
elif mtype == MNP.STREAM_REQUEST:
asyncio.ensure_future(self._stream_video(msg))
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")
@@ -239,57 +335,73 @@ class WebRTCPeerSession:
detail=detail,
))
+ def _channel_binding(self) -> bytes:
+ """Both DTLS fingerprints, so a proof is valid on this connection only."""
+ offer_fp = b""
+ answer_fp = b""
+ if self._pc.remoteDescription:
+ offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp)
+ if self._pc.localDescription:
+ answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp)
+ if not offer_fp or not answer_fp:
+ return b""
+ return webrtc_binding(offer_fp, answer_fp)
+
def _do_handshake(self, msg: dict) -> None:
- token = msg.get("token", "")
group_id = msg.get("group_id", "")
try:
- decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"])
- except Exception as e:
- self._send({"type": "error", "detail": f"Invalid JWT: {e}"})
- self._audit_auth_failed(group_id, str(e))
- return
-
- denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
- self._send({"type": "error", "detail": "Token revoked"})
- return
-
- if group_id and group_id not in decoded.get("groups", []):
- self._send({"type": "error", "detail": "Not a member of this group"})
+ peer = authorize_token(
+ msg.get("token", ""),
+ self._ctx["hub_pk_pem"],
+ group_id=group_id,
+ hosted_groups=self._ctx.get("groups"),
+ denylist=self._ctx.get("denylist"),
+ )
+ except HandshakeError as refusal:
+ # HandshakeError messages are authored to be peer-safe, unlike arbitrary
+ # exception text (L3) — the client needs to know *why* it was refused.
+ self._send({"type": "error", "detail": str(refusal),
+ "code": getattr(refusal, "code", "")})
+ self._audit_auth_failed(group_id, str(refusal))
return
- if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]:
- self._send({"type": "error", "detail": "Group not hosted on this node"})
+ try:
+ self._nonce_client = base64.b64decode(msg.get("nonce", ""))
+ except Exception:
+ self._nonce_client = b""
+ if len(self._nonce_client) < NONCE_LEN:
+ # The client nonce is what makes the NODE's proof fresh (C3). Without
+ # it a recorded ack could be replayed by an impersonating peer.
+ self._send({"type": "error", "detail": "Client nonce required"})
return
- # Store decoded JWT data but DO NOT set self._user_id yet —
- # the user is not authenticated until they prove GEK possession.
- self._pending_sub = decoded["sub"]
- self._pending_group = group_id
- self._pending_username = decoded.get("username", "")
- self._pending_pk_user = decoded.get("pk_user", "")
+ # Decoded, but NOT authenticated: that happens on the GEK proof.
+ self._pending_sub = peer.user_id
+ self._pending_group = peer.group_id
+ self._pending_username = peer.username
- ctx = self._ctx
- if "groups" in ctx and group_id:
- gctx = ctx["groups"].get(group_id, ctx)
- else:
- gctx = ctx
- gek = gctx.get("gek")
-
- nonce = os.urandom(32)
- self._gek_challenge = nonce
- challenge = {
- "type": MNP.HANDSHAKE_CHALLENGE,
- "v": MNP_VERSION,
- "nonce": base64.b64encode(nonce).decode(),
- }
- if not gek:
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ if not gctx.get("gek"):
self._send({
"type": "error",
"detail": "Group encryption not initialized — contact node operator",
})
return
- self._send(challenge)
+
+ self._gek_challenge = os.urandom(NONCE_LEN)
+ self._nonce_node = self._gek_challenge
+ self._send({
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ # Announced here because a first-time joiner needs it *before* the
+ # ack: join_request signs a transcript naming this node, and someone
+ # who has never held the GEK cannot complete the handshake to learn
+ # it. Unverified at this point — the ack proves it, the client checks
+ # the two match, and a wrong value only makes our own verification
+ # fail. It is never a substitute for the ack's proof and signature.
+ "node_pk": self._node_pk_b64(),
+ })
def _do_handshake_response(self, msg: dict) -> None:
if not self._gek_challenge or not hasattr(self, "_pending_sub"):
@@ -297,61 +409,71 @@ class WebRTCPeerSession:
return
group_id = self._pending_group
- ctx = self._ctx
- if "groups" in ctx and group_id:
- gctx = ctx["groups"].get(group_id, ctx)
- else:
- gctx = ctx
+ gctx = self._ctx["groups"][group_id] if "groups" in self._ctx else self._ctx
gek = gctx.get("gek")
-
if not gek:
self._send({"type": "error", "detail": "Group encryption not initialized"})
self._gek_challenge = None
return
- proof = msg.get("proof", "")
try:
- proof_bytes = base64.b64decode(proof)
+ proof_bytes = base64.b64decode(msg.get("proof", ""))
except Exception:
self._send({"type": "error", "detail": "Invalid proof encoding"})
return
- offer_fp = b""
- answer_fp = b""
- if self._pc.remoteDescription:
- offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp)
- if self._pc.localDescription:
- answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp)
+ binding = self._channel_binding()
+ if not binding:
+ # Refuse rather than fall back to an unbound proof (L4).
+ self._send({"type": "error", "detail": "Channel binding unavailable"})
+ self._gek_challenge = None
+ self._audit_auth_failed(group_id, "no channel binding")
+ return
- data = self._gek_challenge + offer_fp + answer_fp
- expected = hmac.new(gek, data, hashlib.sha256).digest()
- if not hmac.compare_digest(proof_bytes, expected):
+ if not verify_proof(gek, proof_bytes, ROLE_CLIENT, group_id,
+ self._nonce_client, self._gek_challenge, binding):
self._send({"type": "error", "detail": "GEK proof failed"})
self._gek_challenge = None
self._audit_auth_failed(group_id, "GEK HMAC mismatch")
return
+ self._complete_handshake(gek, binding)
self._gek_challenge = None
- self._complete_handshake()
- def _complete_handshake(self) -> None:
+ def _complete_handshake(self, gek: bytes, binding: bytes) -> None:
+ # Authenticated peers may send large frames (file uploads); unauthenticated
+ # ones may not (H6).
+ self._buffer.max_message = MAX_MSG
self._user_id = self._pending_sub
self._group_id = self._pending_group
self._username = self._pending_username
- self._pk_user = self._pending_pk_user
+ asyncio.ensure_future(self._load_pinned_pk())
- 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",
self._user_id[:8],
self._group_id[:8] if self._group_id else "none")
+ # The node proves itself too (C3): possession of the GEK over the client's
+ # nonce, plus a signature over the same transcript with its long-term key.
+ # Previously the client received an unverifiable node_pk and trusted
+ # is_node_admin from whoever answered — so a peer that had hijacked
+ # signaling could serve a forged index, chat history and permissions.
+ node_transcript = handshake_transcript(
+ ROLE_NODE, self._group_id or "", self._nonce_client,
+ self._gek_challenge or b"", binding)
+ node_proof = make_proof(
+ gek, ROLE_NODE, self._group_id or "", self._nonce_client,
+ self._gek_challenge or b"", binding)
+
ack = {
"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(node_transcript)).decode(),
"is_node_admin": bool(node_user_id and self._user_id == node_user_id),
}
if node_user_id:
@@ -388,65 +510,41 @@ 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)."""
- bundle_store = self._ctx.get("bundle_store")
- if not bundle_store:
- self._send({"type": "error", "detail": "Bundle store not available"})
- return
-
- target_user_id = msg.get("user_id", "")
- group_id = msg.get("group_id") or self._group_id
- pk_eph = msg.get("pk_eph_b64", "")
- nonce = msg.get("nonce_b64", "")
- wrapped = msg.get("wrapped_b64", "")
+ def _do_invite_create(self, msg: dict) -> None:
+ """
+ Issue a one-time pairing code for someone the operator wants to admit.
- if not target_user_id or not pk_eph or not nonce or not wrapped or not group_id:
- self._send({"type": "error", "detail": "Missing bundle fields"})
+ Replaces the old invite path, where the inviter fetched the invitee's
+ public key from the hub and wrapped the group key for whatever came back
+ (H3). The node now needs nothing but a name: it will wrap the key itself,
+ later, for a key the invitee proves they hold.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
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]}")
-
- self._send({
- "type": "ack", "v": MNP_VERSION,
- "detail": "gek_bundle_stored",
- "user_id": target_user_id,
- })
-
- # 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:
+ invitee_id = msg.get("user_id", "")
+ group_id = msg.get("group_id") or self._group_id
+ if not invitee_id or not group_id:
+ self._send({"type": "error", "detail": "Missing user_id or group_id"})
return
-
- bundle = await bundle_store.fetch(group_id, user_id)
- if not bundle:
+ if group_id != self._group_id:
+ self._send({"type": "error", "detail": "Wrong group for this session"})
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)
+ if not self._has_admin_authority():
+ self._send({
+ "type": "error",
+ "detail": "No operator paired — run `meshbay-node operator pair`",
+ })
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)")
+ self._issue_admin_challenge(OP_INVITE_CREATE, invitee_id, {
+ "group_id": group_id,
+ "user_id": invitee_id,
+ "username": str(msg.get("username", ""))[:64],
+ })
async def _do_keypair_bundle_fetch(self) -> None:
"""Serve the caller's encrypted keypair bundle during the handshake window."""
@@ -491,6 +589,287 @@ class WebRTCPeerSession:
"detail": "keypair_bundle_stored",
})
+ # ── Pairing and join (H3, M3) ────────────────────────────────────────────
+
+ def _join_refuse(self, reason: str, audit_detail: str = "") -> None:
+ self._join_attempts += 1
+ # Node-wide window, shared across connections: reconnecting must not reset
+ # the budget.
+ now = time.time()
+ failures = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ failures.append(now)
+ self._ctx["join_failures"] = failures
+ self._audit_join("join_refused", audit_detail or reason)
+ self._send({
+ "type": MNP.JOIN_RESULT,
+ "v": MNP_VERSION,
+ "ok": False,
+ "reason": reason,
+ })
+
+ def _audit_join(self, event: str, detail: str) -> None:
+ audit = self._ctx.get("audit_store")
+ if not audit:
+ return
+ self._remote_ip = self._remote_ip or _get_remote_ip(self._pc)
+ asyncio.ensure_future(audit.log_event(
+ user_id=self._user_id or getattr(self, "_pending_sub", "unknown"),
+ event=event,
+ ip=self._remote_ip,
+ username=self._username or getattr(self, "_pending_username", ""),
+ group_id=self._group_id or getattr(self, "_pending_group", "") or "",
+ detail=detail,
+ ))
+
+ async def _do_join_request(self, msg: dict) -> None:
+ """
+ Pin an identity, or recognise one already pinned.
+
+ The client signs its own Ed25519 and X25519 keys together with the node's
+ nonce, so the identity key vouches for the encryption key — that is what
+ will make it safe for the node to wrap the GEK for a key that arrived over
+ the wire instead of one fetched from the hub's directory (H3).
+
+ A first pairing needs a one-time code, which the hub never sees. Afterwards
+ the pin is the credential and a changed key is refused outright, the same
+ rule the client applies to `pk_node` (11.5.8).
+ """
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ if self._join_attempts >= MAX_JOIN_ATTEMPTS:
+ self._send({"type": "error", "detail": "Too many attempts"})
+ return
+
+ now = time.time()
+ recent = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ if len(recent) >= MAX_JOIN_FAILURES_WINDOW:
+ self._audit_join("join_throttled", f"{len(recent)} failures in window")
+ self._send({"type": "error", "detail": "Pairing temporarily locked"})
+ return
+
+ user_id = self._user_id or getattr(self, "_pending_sub", "")
+ username = self._username or getattr(self, "_pending_username", "")
+ if not user_id:
+ self._send({"type": "error", "detail": "Handshake required"})
+ return
+
+ pk_ed_b64 = msg.get("pk_ed25519", "")
+ pk_x_b64 = msg.get("pk_x25519", "")
+ code = msg.get("code", "")
+ ts = msg.get("ts", 0)
+
+ try:
+ pk_ed_raw = base64.b64decode(pk_ed_b64)
+ pk_x_raw = base64.b64decode(pk_x_b64)
+ if len(pk_ed_raw) != 32 or len(pk_x_raw) != 32:
+ raise ValueError
+ pk_ed = Ed25519PublicKey.from_public_bytes(pk_ed_raw)
+ except Exception:
+ self._join_refuse("invalid_keys")
+ return
+
+ if not isinstance(ts, int) or abs(time.time() - ts) > JOIN_TTL:
+ self._join_refuse("stale_request")
+ return
+
+ # An empty group_id means operator pairing, which is node-wide. Anything
+ # else must be the group this connection authenticated to — a signature
+ # obtained for one group must not name another.
+ group_id = msg.get("group_id", "") or ""
+ session_group = self._group_id or getattr(self, "_pending_group", "") or ""
+ if group_id and group_id != session_group:
+ self._join_refuse("group_mismatch")
+ return
+
+ transcript = join_transcript(
+ node_pk_b64=self._node_pk_b64(),
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed_b64,
+ pk_x25519_b64=pk_x_b64,
+ nonce_node=self._nonce_node,
+ ts=ts,
+ )
+ try:
+ sig = base64.b64decode(msg.get("sig", ""))
+ except Exception:
+ self._join_refuse("invalid_signature_encoding")
+ return
+ if not self._verify_sig(pk_ed, transcript, sig):
+ self._join_refuse("signature_invalid")
+ return
+
+ known = await roster.get_identity(user_id)
+ if known:
+ if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64:
+ # The blocking warning, raised where it matters: whoever this is
+ # holds a different key than the person the operator paired.
+ self._join_refuse(
+ "key_changed",
+ f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}")
+ return
+ # An operator's row is node-wide (empty group), so a lookup for the
+ # group they happen to be opening finds nothing. Fall back to it, or
+ # the client is told it has no role on a node it administers.
+ member = (await roster.get_member(group_id, user_id)
+ or await roster.get_member("", user_id))
+ await self._join_ok(
+ user_id, pk_x_raw, session_group,
+ role=member["role"] if member else "",
+ recognised=True,
+ )
+ return
+
+ if not code:
+ if self._group_join_policy(session_group) == "open":
+ # An open-join group admits anyone the hub calls a member, so a
+ # code would protect nothing — the hub can walk in through the
+ # front door. Pin what turns up and say so in the audit log.
+ await self._pin_and_admit(
+ roster, user_id, username, pk_ed_b64, pk_x_b64,
+ group_id=session_group, role=ROLE_MEMBER,
+ approved_by="open-join", via="tofu")
+ await self._join_ok(user_id, pk_x_raw, session_group,
+ role=ROLE_MEMBER, recognised=False)
+ return
+ self._join_refuse("code_required")
+ return
+
+ invite = await roster.consume_invite(code, user_id)
+ if not invite:
+ self._join_refuse("code_invalid")
+ return
+
+ await self._pin_and_admit(
+ # The name comes from the invitation, not from the token: the hub does
+ # not put a username claim in a JWT, so pinning from the session alone
+ # left the roster nameless and `member revoke <name>` unable to match.
+ roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64,
+ group_id=invite["group_id"], role=invite["role"],
+ approved_by=invite["created_by"], via="code")
+ # The roster row comes from the invitation; the key comes from the
+ # connection. An operator pairing is node-wide (empty group), but they
+ # redeemed the code while opening a group and expect to read it — and
+ # is_authorized() already grants an operator every group on this node.
+ await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"],
+ role=invite["role"], recognised=False)
+
+ def _group_join_policy(self, group_id: str) -> str:
+ """
+ Admission policy for a group, read from the node's own configuration.
+
+ Never from the hub: a hub that could declare a group open would be handed
+ the key to it (§3.4 of docs/invite-pairing-v1.md).
+ """
+ gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
+ return gctx.get("join_policy", "invite")
+
+ async def _pin_and_admit(
+ self, roster, user_id: str, username: str, pk_ed_b64: str, pk_x_b64: str,
+ *, group_id: str, role: str, approved_by: str, via: str,
+ ) -> None:
+ await roster.pin_identity(
+ user_id=user_id, username=username,
+ pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via=via,
+ )
+ await roster.set_member(
+ group_id=group_id, user_id=user_id, role=role,
+ status="active", approved_by=approved_by,
+ )
+ if role == ROLE_OPERATOR:
+ self._ctx["has_admin_authority"] = True
+
+ log.info("Identity pinned (%s): user=%s role=%s", via, user_id[:8], role)
+ self._audit_join("join_pinned", f"role={role} via={via}")
+
+ async def _join_ok(
+ self, user_id: str, pk_x_raw: bytes, group_id: str,
+ *, role: str, recognised: bool,
+ ) -> None:
+ """
+ Answer a join, wrapping the group key for the key the caller just proved.
+
+ This is the H3 fix. The inviter used to fetch the invitee's public key from
+ the hub and wrap the GEK for whatever came back, so a hub that answered
+ with its own key was handed the group key by an honest member following the
+ protocol exactly. The node now wraps for a key that arrived from its owner
+ over an authenticated channel, bound to a pinned identity.
+ """
+ reply = {
+ "type": MNP.JOIN_RESULT,
+ "v": MNP_VERSION,
+ "ok": True,
+ "recognised": recognised,
+ "role": role,
+ }
+
+ roster = self._ctx["roster"]
+ if group_id and not await roster.is_authorized(group_id, user_id):
+ # Pinned on this node, but not admitted to this group. Hub membership
+ # alone must not produce a key.
+ reply["gek"] = False
+ reply["reason"] = "not_authorized_for_group"
+ self._send(reply)
+ self._audit_join("join_no_gek", f"group={group_id[:8]} not authorized")
+ return
+
+ gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
+ gek = gctx.get("gek")
+ if not gek:
+ reply["gek"] = False
+ reply["reason"] = "no_gek"
+ self._send(reply)
+ return
+
+ bundle = wrap_gek_aes(gek, pk_x_raw)
+ reply["gek"] = True
+ reply["pk_eph_b64"] = bundle["pk_eph_b64"]
+ reply["nonce_b64"] = bundle["nonce_b64"]
+ reply["wrapped_b64"] = bundle["wrapped_b64"]
+ self._send(reply)
+ self._audit_join("gek_wrapped", f"group={group_id[:8]}")
+
+ async def _do_keypair_bundle_delete(self) -> None:
+ """
+ Withdraw our own key backup from this node.
+
+ Only ever our own: the user_id comes from the authenticated session, never
+ from the message. Someone who does not want a second browser should not be
+ leaving a PBKDF2-protected blob on every node they have ever joined (C4),
+ and turning the setting off has to remove what is already there — not just
+ stop adding to it.
+ """
+ bundle_store = self._ctx.get("bundle_store")
+ if not bundle_store:
+ self._send({"type": "error", "detail": "Bundle store not available"})
+ return
+
+ removed = await bundle_store.delete_keypair(self._user_id)
+ if removed:
+ log.info("Keypair bundle withdrawn by user=%s", self._user_id[:8])
+ self._audit("keypair_bundle_delete")
+ self._send({"type": "ack", "v": MNP_VERSION,
+ "detail": "keypair_bundle_deleted", "removed": removed})
+
+ def _audit_pre_proof_fetch(self, mtype: str) -> None:
+ """Record bundle access made before the GEK proof (C4)."""
+ audit = self._ctx.get("audit_store")
+ if not audit:
+ return
+ self._remote_ip = self._remote_ip or _get_remote_ip(self._pc)
+ asyncio.ensure_future(audit.log_event(
+ user_id=getattr(self, "_pending_sub", "unknown"),
+ event="pre_proof_fetch",
+ ip=self._remote_ip,
+ group_id=getattr(self, "_pending_group", "") or "",
+ detail=mtype,
+ ))
+
def _audit_auth_failed(self, group_id: str, reason: str) -> None:
audit = self._ctx.get("audit_store")
if audit:
@@ -508,6 +887,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"]
@@ -555,6 +948,17 @@ class WebRTCPeerSession:
self._audit("file_download", entry.name)
def _do_stream_segment(self, msg: dict) -> None:
+ asyncio.ensure_future(self._do_stream_segment_async(msg))
+
+ async def _do_stream_segment_async(self, msg: dict) -> None:
+ """
+ Legacy HLS segment extraction (superseded by stream_req/MSE).
+
+ Finding H6: this ran subprocess.run(..., timeout=30) directly inside the
+ event loop, so a single request stalled the whole daemon — every peer,
+ every group — for up to thirty seconds. Now async and under the same
+ transcode semaphore as _stream_video.
+ """
ctx = self._group_ctx()
file_id = msg["file_id"]
segment_index = msg["segment_index"]
@@ -570,21 +974,34 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not on disk"})
return
- import subprocess
+ sem = self._ctx.get("_transcode_sem")
+ if sem is None:
+ sem = asyncio.Semaphore(MAX_CONCURRENT_TRANSCODES)
+ self._ctx["_transcode_sem"] = sem
+
try:
- result = subprocess.run(
- ["ffmpeg", "-hide_banner", "-loglevel", "error",
- "-ss", str(segment_index * segment_duration),
- "-i", str(file_path),
- "-t", str(segment_duration),
- "-c:v", "copy", "-c:a", "copy",
- "-f", "mpegts", "pipe:1"],
- capture_output=True, timeout=30,
- )
- if result.returncode != 0 or not result.stdout:
+ async with sem:
+ proc = await asyncio.create_subprocess_exec(
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
+ "-ss", str(segment_index * segment_duration),
+ "-i", str(file_path),
+ "-t", str(segment_duration),
+ "-c:v", "copy", "-c:a", "copy",
+ "-f", "mpegts", "pipe:1",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+ try:
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.wait()
+ self._send({"type": "error", "detail": "Segment extraction timed out"})
+ return
+ if proc.returncode != 0 or not stdout:
self._send({"type": "error", "detail": "Segment extraction failed"})
return
- segment_data = result.stdout
+ segment_data = stdout
except Exception:
self._send({"type": "error", "detail": "Segment extraction failed"})
return
@@ -599,11 +1016,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 +1034,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 +1067,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 +1082,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 +1111,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,21 +1169,30 @@ 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, rel_dir: str, filename: str) -> None:
+ """
+ Tag the index entry with the uploader's identity after upload completes.
- def _register_uploader(self, ctx: dict, filename: str) -> None:
- """Tag the index entry with the uploader's user_id after upload completes."""
+ The key recorded here is the one this node pinned, not the one the token
+ carried. `pk_user` was a hub-chosen claim, and it decided who could later
+ delete the file: a hub issuing a token naming its own key could delete
+ anyone's uploads on any node. Deletion is supposed to be authorized by the
+ node, and this closes the last place where it was not.
+ """
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
+ entry.uploader_pk = self._pinned_pk
return
def _do_file_delete(self, msg: dict) -> None:
@@ -747,28 +1207,115 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not found"})
return
- admin_pk = self._ctx.get("admin_pk_ed25519")
has_uploader_pk = bool(entry.uploader_pk)
- if not admin_pk and not has_uploader_pk:
+ if not self._has_admin_authority() and not has_uploader_pk:
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
+
+ async def _load_pinned_pk(self) -> None:
+ """Remember which key this node pinned for the peer we just authenticated."""
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id:
+ return
+ ident = await roster.get_identity(self._user_id)
+ if ident:
+ self._pinned_pk = ident["pk_ed25519"]
+
+ def _has_admin_authority(self) -> bool:
+ """
+ Cheap synchronous pre-check: is there anyone who could authorize this?
+
+ Only decides whether to issue a challenge at all — the gate is
+ `_verify_admin_sig`. The flag is set at startup and refreshed in-process
+ when an operator pairs.
+ """
+ return bool(self._ctx.get("admin_pk_ed25519")
+ or self._ctx.get("has_admin_authority"))
+
+ async def _verify_admin_sig(self, transcript: bytes, sig: bytes) -> bool:
+ """
+ Check a signature against every key holding node-operator authority.
+
+ Read from the roster on each call rather than cached: revoking a paired
+ browser must take effect immediately, and admin operations are rare enough
+ that a SQLite read costs nothing. `admin_pk_ed25519` in node.toml is still
+ honoured so an existing deployment keeps working until its operator pairs
+ (M3) — it is the legacy form of the same statement.
+ """
+ legacy = self._ctx.get("admin_pk_ed25519")
+ if self._verify_sig(legacy, transcript, sig):
+ return True
+
+ roster = self._ctx.get("roster")
+ if roster is None:
+ return False
+ for pk_b64 in await roster.operator_pks():
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64))
+ except Exception:
+ continue
+ if self._verify_sig(pk, transcript, sig):
+ return True
+ 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 +1324,96 @@ 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:
+ asyncio.ensure_future(
+ self._admin_exec_file_delete(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_CREATE:
+ asyncio.ensure_future(
+ self._admin_exec_invite_create(pending, transcript, sig_bytes))
+ else:
+ self._send({"type": "error", "detail": "Unknown admin operation"})
+
+ async 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 (await self._verify_admin_sig(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_invite_create(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ # Node operator only. A group admin who does not run the node has no
+ # authority over who this node admits (deny by default). Delegation is
+ # designed but deferred — see §6.2 of docs/invite-pairing-v1.md.
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
+ return
+
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ payload = pending["payload"]
+ code = await roster.create_invite(
+ group_id=payload["group_id"],
+ user_id=payload["user_id"],
+ role=ROLE_MEMBER,
+ created_by=self._user_id or "",
+ ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL),
+ username=payload.get("username", ""),
+ )
+ invites = await roster.list_invites()
+ expires = next(
+ (i["expires_at"] for i in invites
+ if i["user_id"] == payload["user_id"]
+ and i["group_id"] == payload["group_id"]), "")
+
+ log.info("Invite created: group=%s user=%s",
+ payload["group_id"][:8], payload["user_id"][:8])
+ self._audit("invite_create", f"target={payload['user_id'][:8]}")
+ # The code exists in the clear exactly here and in the operator's hands.
+ self._send({
+ "type": MNP.INVITE_RESULT,
+ "v": MNP_VERSION,
+ "code": code,
+ "expires_at": expires,
+ "user_id": payload["user_id"],
+ "username": payload.get("username", ""),
+ })
+
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():
@@ -827,6 +1430,20 @@ class WebRTCPeerSession:
async def _stream_video(self, msg: dict) -> None:
"""Stream a video file as fMP4 segments via MSE-compatible output."""
+ # One ffmpeg per request with no cap lets any member exhaust the node's
+ # CPU and process table (H6). The semaphore lives on the transport context
+ # so it is shared across all peers, not per-session.
+ sem = self._ctx.get("_transcode_sem")
+ if sem is None:
+ sem = asyncio.Semaphore(MAX_CONCURRENT_TRANSCODES)
+ self._ctx["_transcode_sem"] = sem
+ if sem.locked() and sem._value <= 0:
+ self._send({"type": "error", "detail": "Server busy, retry shortly"})
+ return
+ async with sem:
+ await self._stream_video_inner(msg)
+
+ async def _stream_video_inner(self, msg: dict) -> None:
ctx = self._group_ctx()
file_id = msg.get("file_id", "")
entry = ctx["index"].get_entry(file_id)
@@ -914,9 +1531,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()