diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
| commit | 60c4570e72e36c2a9720593c8baec74ee2ab52d6 (patch) | |
| tree | e833e222a6a95e64e5b24a0813882636044198c2 | |
| parent | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (diff) | |
| download | meshbay-60c4570e72e36c2a9720593c8baec74ee2ab52d6.tar.gz | |
feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2P
Browser clients can now connect P2P to nodes behind residential NAT via
WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone
NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN).
No TURN relay needed. Hub serves only as signaling relay (<1 KB).
New files:
- webrtc_server.py: aiortc-based WebRTC transport (node side)
- signaling.py: SDP/ICE relay endpoint (hub side)
- transport.js: browser WebRTC client with msgpack framing
- webrtc-test.html: spike test page for browser→NAT→node validation
- test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard)
- meshbay-draft-v4.md: architecture spec updated for web client
Modified:
- hub_client.py: WebRTC offer handling via hub WebSocket
- revocation.py: node_id from WS auth + webrtc_answer routing
- pyproject.toml: aiortc>=1.9 dependency
123 tests passing (117 existing + 6 new).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | CLAUDE.md | 53 | ||||
| -rw-r--r-- | devel-phases-next.md | 419 | ||||
| -rw-r--r-- | docs/meshbay-draft-v4.md | 1155 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/signaling.py | 102 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 409 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html | 258 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 70 | ||||
| -rw-r--r-- | packages/meshbay-node/pyproject.toml | 1 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 16 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/__init__.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 373 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 326 |
15 files changed, 3045 insertions, 172 deletions
@@ -121,33 +121,48 @@ key hierarchy, on-the-fly encryption, transport abstraction. Existing v1 users (64 MB) are transparently rehashed on next successful login. CLI `calibrate` command still TODO for per-hardware tuning. -## NAT traversal — résultats empiriques (demo-v2) +## NAT traversal — empirical results -SFR résidentiel Fedora 44 → meshbay.org OVH VPS : -- **IPv6** : adresse publique présente MAIS entrant bloqué par la box → skippé -- **NAT type** : **Port-Restricted Cone** (pas Address-Restricted comme supposé en Spike 4) -- **Mécanisme validé** : `QuicChunkServer.punch_nat(peer_ip, peer_port)` envoie la probe - depuis le socket QUIC interne (`_transport.sendto()`). Le client DOIT se connecter - depuis le même port (local_port=QUIC_PORT dans QuicChunkClient). -- **UPnP** : désactivé sur box SFR → skippé -- **Handshake QUIC** : 12.7s (demo) → < 500ms attendu en prod (gap probe↔connect réduit + 0-RTT) -- **Scripts** : `QE/demo-v2/` — run_node.py / download.py / nat.py / setup_demo.py +### QUIC native clients (demo-v2) + +SFR residential Fedora 44 → meshbay.org OVH VPS: +- **NAT type**: Port-Restricted Cone +- **Mechanism**: `QuicChunkServer.punch_nat()` sends probe from QUIC server socket +- **Scripts**: `QE/demo-v2/` + +### WebRTC browser clients (Phase 9 spike, 2026-08-10) + +Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT 4G): + +| Test | ICE path | Result | +|---|---|---| +| WiFi LAN | IPv6 direct | OK, ~100ms | +| 4G + IPv6 | IPv6 inter-network | OK, ~600ms | +| 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms | + +- **No TURN relay needed** — ICE/STUN handles both NAT types automatically +- **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB) +- **Data path**: browser ↔ node P2P via WebRTC DataChannel +- **Scripts**: `QE/demo-v3/run_node_webrtc.py`, test page at `/webrtc-test.html` ## Key modules — où trouver quoi -| Besoin | Module | Fichier | +| Need | Module | File | |---|---|---| -| Chiffrement chunks (prod) | `meshbay_common.crypto` | `crypto.py` | -| Dérivation clés depuis password | `meshbay_common.keyderive` | `keyderive.py` | -| Bundle clés (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | +| Chunk encryption (prod) | `meshbay_common.crypto` | `crypto.py` | +| Key derivation from password | `meshbay_common.keyderive` | `keyderive.py` | +| Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | | GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` | | Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` | | Sender Keys (group chat) | `meshbay_common.senderkeys` | `senderkeys.py` (Phase 7.5) | -| AES-GCM (navigateur) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` | -| Keystore node | `meshbay_node.keystore` | `keystore.py` | -| NAT traversal | `QE/demo-v2/nat.py` | non versionné — résultats dans devel-phases.md | -| QUIC NAT punch | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` | -| Scripts de démo opérationnels | — | `QE/demo-v1/*.py` (non versionné) | +| AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` | +| Node keystore | `meshbay_node.keystore` | `keystore.py` | +| QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` | +| WebRTC transport (browser) | `meshbay_node.transport.webrtc_server` | Phase 9.3 — `aiortc` DataChannel | +| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | +| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | +| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router | +| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py` (not versioned) | ## meshbay.org server (état cible) diff --git a/devel-phases-next.md b/devel-phases-next.md index f4793df..09d07fa 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,209 +1,330 @@ # MeshBay — Next Implementation Phases -> Base: Phases 1-6 complete. demo-v2 NAT QUIC validated. -> Architecture reference: docs/meshbay-draft-v3.md +> Base: Phases 1–8 complete. 117 tests. demo-v3 validated against meshbay.org. +> Architecture reference: docs/meshbay-draft-v4.md > First security review: first-review.md (2026-08-10) --- -## Phase 7 — Node v2 : production, streaming, chat +## Phase 7 — Node v2 : production, streaming, chat ✅ DONE -**Objective:** a node usable for daily operations — multi-group, smooth streaming, -integrated chat, fast reconnection. +Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests. -### Prerequisites (from first security review, 2026-08-10) +| # | Component | Status | +|---|---|---| +| 7.0 | JWT group claims + node authz check | ✅ | +| 7.1 | QUIC 0-RTT session resumption | ✅ | +| 7.2 | Signaling `client_incoming`/`punch_ready` + jti denylist push | ✅ | +| 7.3 | Multi-group daemon (1-port multiplexing) | ✅ | +| 7.4 | HLS streaming via QUIC | ✅ | +| 7.5 | Chat: Sender Keys protocol + storage + MNP wire | ✅ | +| 7.6 | Chat: local web UI + WS push to members | ✅ | + +--- + +## Phase 8 — Hub v2: admin, federation, security ✅ DONE + +Commit: 46918ec — 20 files, +508/−90 lines, 117 tests. +Deployed to meshbay.org. Existing emails encrypted. DB schema migrated. + +| # | Component | Status | +|---|---|---| +| 8.1 | Admin roles — config-based `require_admin` | ✅ S1 resolved | +| 8.2 | Email encrypted at rest — AES-256-GCM, HKDF | ✅ S2 resolved | +| 8.3 | Refresh token rotation — family-based reuse detection | ✅ S5 resolved | +| 8.4 | Federation DB persistence (HubPeer model) | ✅ | +| 8.5 | Federation token verification async (DB-backed) | ✅ | +| 8.6 | CSAM hash check in swarm registration | ✅ | +| 8.7 | Rate limiting on auth endpoints (5/10/20 per min) | ✅ | +| 8.8 | Healthcheck endpoint (GET /v1/health) | ✅ | +| 8.9 | IP log cleanup background task (365-day retention) | ✅ | +| 8.10 | Argon2id bumped to 256 MB (pw_version=2, rehash on login) | ✅ | + +--- + +## Phase 9 — Web client: WebRTC transport + core SPA + +**Objective:** a web browser can connect P2P to a node behind residential NAT, +browse files, download, stream video, and chat — with zero data through the hub. + +**Architecture decisions (settled 2026-08-10):** + +### Transport: WebRTC DataChannel for browsers -Before writing Phase 7 production code, two critical design gaps must be -addressed — see `first-review.md` for full analysis: +Native clients (desktop, Android) use QUIC with `punch_nat()` — already validated +in demo-v2 on SFR residential (Port-Restricted Cone NAT). -1. **[C2] JWT must carry group membership claims.** Add `"groups": [group_ids]` - to `issue_access_token()`. Node MNP handshake must verify the requested - group_id is in the JWT's groups claim before serving any content. Without - this, any authenticated user can access any group on the node. - → Implement in 7.3 (multi-group daemon) before any other milestone. +Browsers cannot use QUIC for NAT traversal because WebTransport does not allow +the browser to choose its UDP source port. Port-Restricted Cone NAT requires the +client to connect from the exact port the node probed — impossible for browsers. -2. **[C1] Chat encryption: Sender Keys, not shared Double Ratchet.** The Double - Ratchet is a pairwise (1:1) protocol — sharing a single ratchet state across - N group members causes key/nonce reuse (AEAD catastrophic failure). The - architecture now uses **Sender Keys** (Signal Groups approach): each member - has their own sending chain key, O(N) state. - → Implement in 7.5. The existing `ratchet.py` is kept for future 1:1 DM. +**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC +stack handles NAT traversal automatically. The node uses `aiortc` (same author as +`aioquic`, already referenced in draft-v3 as [future]). -### Architectural decisions (settled) +ICE is strictly superior to our custom `punch_nat()` for this use case: +- Both sides send STUN binding requests simultaneously → mutual hole-punching +- No need for the client to pre-announce its port +- Handles both sides behind NAT +- Battle-tested by billions of users (Google Meet, Discord, etc.) -**Multi-group → multiplexing on a single QUIC port** -A node exposes a single QUIC port (e.g. 19010). All hosted groups share this -port. The group is identified in the MNP handshake by the `group_id` in the -JWT. Advantages: one NAT hole to maintain, one manual port forward if needed. -The QUIC server routes each connection to the appropriate IndexGroup/GEK -after JWT verification **and group membership authorization check**. +The MNP protocol (handshake, file_request, file_chunk, chat_message, etc.) runs +identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption. + +**Node dual transport:** +- QUIC (port 19000) — native clients, already in place +- WebRTC DataChannel — browsers, using `aiortc` + +### Signaling: hub WebSocket relay + +The hub relays WebRTC signaling (SDP offer/answer, ICE candidates) between +browser and node. This is the same role described in draft-v3 section 4.1.3: +"NAT traversal coordination [...] stateless [...] <1 KB per message." -**Signaling punch/connect (via hub WebSocket)** -Currently the node punches blindly at startup → 12.7s handshake (NAT hole ages -before the client arrives). Solution: ``` -Client → Hub (HTTPS) : "I'm about to connect to node X, I'm at IP:PORT" -Hub → Node (WS) : message "client_incoming: {peer_ip, peer_port}" -Node → NAT (UDP) : punch_nat(peer_ip, peer_port) immediately -Node → Hub (WS) : "punch_ready" -Hub → Client (HTTPS) : "connect now" -Client → Node (QUIC) : < 2s after probe → fresh NAT entry → < 200ms +Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates} +Hub → Node (WS) : {type: "webrtc_offer", sdp, ice_candidates, peer_id} +Node → Hub (WS) : {type: "webrtc_answer", sdp, ice_candidates, peer_id} +Hub → Browser (SSE) : {sdp, ice_candidates} ``` -The hub→node WebSocket channel already exists (`hub/api/revocation.py`). -Just add `client_incoming` / `punch_ready` message types. -This mechanism is a simplified ICE (Interactive Connectivity Establishment). -The same WebSocket channel also carries **jti denylist push** (security review -S3): when the hub revokes a token, it pushes the jti to all connected nodes. -Nodes maintain an in-memory set and check it during MNP handshake. +After signaling, the DataChannel is P2P. Hub is no longer involved. -**Chat — between a forum and Signal** -Not a real-time ephemeral chat (Signal) nor a heavy forum. -Model: **E2E encrypted discussion thread, persisted on the node**. -- Short messages + attachments (like Signal group) -- Optional threads/topics for structure (like a light forum) -- History stored on the node (not ephemeral) -- Push for connected members, pull for offline -- **Sender Keys** protocol for encryption (security review C1 — Double Ratchet - is pairwise only, cannot be shared across group members) -- Scope: per group (not per user pair) -- No automatic deletion (group admin manages retention) +### UI: Preact SPA -### Milestones +- **Framework:** Preact (~3 KB gzipped) + preact-router +- **Build:** esbuild (single binary, no node_modules bloat) for minification +- **Theming:** CSS `prefers-color-scheme` + localStorage toggle (dark/light) +- **i18n:** JSON translation files loaded client-side, English default +- **Responsive:** sidebar collapses to hamburger on mobile viewports +- **Crypto:** existing `crypto.js` (SubtleCrypto AES-GCM) for E2E decryption -| # | Component | File(s) | Priority | -|---|---|---|---| -| 7.0 | JWT group claims + node authz check | `hub/auth.py` + `node/transport/quic_server.py` | **Blocker** | -| 7.1 | QUIC 0-RTT session resumption | `transport/quic_server.py` + `quic_client.py` | High | -| 7.2 | Signaling `client_incoming`/`punch_ready` + jti denylist push | `hub/api/revocation.py` + `node/hub_client.py` | High | -| 7.3 | Multi-group daemon (1-port multiplexing) | `node/daemon.py` — N IndexGroups, 1 QuicChunkServer | High | -| 7.4 | HLS streaming via QUIC | `node/transport/hls.py` — segments as QUIC streams | Medium | -| 7.5 | Chat: Sender Keys protocol + storage + MNP wire | `common/senderkeys.py` + `node/chat/store.py` | Medium | -| 7.6 | Chat: local web UI + WS push to members | `node/ui/app.py` WebSocket for notifications | Medium | -| 7.7 | Argon2id calibration CLI | `node/daemon.py` — `meshbay-node calibrate-argon2` | Low | +### Hub role (reminder — fundamental constraint) -**Remaining open questions:** -- Do groups on the same node share the node's Ed25519 key? (likely yes) -- Multi-group UI at localhost:18000: tabs per group or unified list? +The hub is a registrar and signaling facilitator. It stores ONLY: +- User accounts (login, encrypted email, public keys, keypair bundle) +- Group metadata (name, admin, members, GEK bundles — no file indexes) +- Node registrations (endpoint hints, public keys) ---- +All data (files, streams, chat messages, directory indexes) lives on mesh nodes. +Clients (web or native) transfer data E2E with nodes. The hub never touches +content. This is non-negotiable. -## Phase 8 — Hub v2: admin, federation, production security +### Chat/forum storage -**Objective:** hub ready for public operation — admin roles, MHP network, -CSAM integrated, monitoring. +Chat messages are stored on the node(s) hosting the group, not on the hub. +The browser retrieves chat history from the node via DataChannel, same as files. +If no node in the group is online, the group (including chat) is unavailable. +This is inherent to the P2P model and acceptable. -| # | Component | File(s) | Priority | +### File search + +Content is not indexed on the hub. Search works client-side: +- Node provides a Mesh Group Index (file metadata: names, paths, sizes, hashes) +- For private groups, the index is GEK-encrypted — hub stores it opaque, client decrypts +- Browser caches decrypted indexes in IndexedDB (~50–100 MB quota, extensible) +- Search runs locally on cached indexes — instant, no network call, no hub involvement + +### Milestones + +| # | Component | Files | Priority | |---|---|---|---| -| 8.1 | Admin roles (hub_admin flag on User) + authz on revocation | `hub/db/models.py` + `hub/api/admin.py` + `hub/api/revocation.py` | **High — S1** | -| 8.2 | Email encryption at rest | `hub/db/models.py` — AES-256-GCM with config-derived key | **High — S2** | -| 8.3 | Refresh token rotation (one-time use) | `hub/api/users.py` — rotate on each use, detect reuse | **High — S5** | -| 8.4 | MHP inter-hub network (not just in-memory) | `hub/api/federation.py` + Alembic migration | High | -| 8.5 | federated_groups DB persistence | `hub/db/models.py` FederatedGroup already defined | High | -| 8.6 | CSAM real DB (import NCMEC/IWF) | `hub/csam.py` — import CLI + API update | High | -| 8.7 | Rate limiting on all authenticated endpoints | `hub/api/middleware.py` — extend slowapi | Medium — M3 | -| 8.8 | Metrics / healthcheck | `hub/api/health.py` | Medium | -| 8.9 | Cleanup IP logs (purge > 1 year) | `hub/tasks/cleanup.py` — APScheduler | Medium | -| 8.10 | Alembic migration Argon2id params | Bump migration + `hub/auth.py` | Low | +| 9.1 | **Spike: WebRTC DataChannel on node** | `aiortc` integration, 4 tests (handshake, file transfer, auth, guard) | ✅ | +| 9.2 | WebRTC signaling endpoints on hub | `hub/api/signaling.py` — relay SDP/ICE, 2 tests | ✅ | +| 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ | +| 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ | +| 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ | +| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/css/style.css` | High | +| 9.7 | Group list + file explorer UI | `static/components/GroupList.js`, `FileExplorer.js` | High | +| 9.8 | File download via DataChannel | `static/components/Download.js` — chunk reassembly | High | +| 9.9 | Video streaming via DataChannel | HLS segments → MediaSource API | Medium | +| 9.10 | Chat/forum UI via DataChannel | `static/components/ChatView.js` — Sender Keys | Medium | +| 9.11 | i18n framework + English strings | `static/i18n/en.json` | Medium | +| 9.12 | Settings UI (profile, theme, notifications) | `static/components/Settings.js` | Medium | +| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | High | + +**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel +works browser → node through SFR residential NAT, confirmed with three scenarios: + +| Test | ICE path | Result | +|---|---|---| +| WiFi LAN (same network) | IPv6 direct | OK, ~100ms | +| Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms | +| Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms | -Items 8.1-8.3 are from the first security review (S1, S2, S5). +Node behind SFR Port-Restricted Cone NAT + mobile behind SFR CGNAT 4G. +No TURN relay needed. ICE/STUN handles both NAT types automatically. -**Questions to clarify:** -- Who can be hub_admin? First registered user? Config toml? -- MHP: inter-hub authentication via JWT or mutual TLS? +**Dependencies added:** +- `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅ +- `esbuild` as a dev tool (single binary, not npm) — needed for 9.6+ +- `preact` + `preact-router` (ESM imports, no npm needed — CDN or vendored) --- -## Phase 9 — Android client MVP +## Phase 10 — meshbay.org site + admin/moderation UI -**Objectif :** app Android permettant de créer un compte, rejoindre un groupe, -télécharger des fichiers depuis un node. +**Objective:** meshbay.org becomes both a production hub and the project's public +website, with admin/moderation interfaces and user-facing features. -**Stack technique à décider :** -- **Kotlin natif** : plus de contrôle, accès direct aux APIs Android (WebRTC, QUIC via fork) -- **Flutter** : cross-platform (iOS futur), Dart, mais bindings aioquic inexistants -- **React Native** : JS, même problème de bindings natifs QUIC +### Site architecture -**Recommandation :** Kotlin natif. La partie critique (QUIC/UDP + crypto) est en C/Rust via -des bindings JNI. La couche UI peut être Jetpack Compose. +Two layers, cleanly separated: +- **Generic hub** (API + web app) — reusable by any hub operator +- **Site overlay** — meshbay.org-specific pages (landing, /downloads, /about) -| # | Composant | Tech | Priorité | -|---|---|---|---| -| 9.1 | Hub client (auth, groups, GEK) | Kotlin + Retrofit | Haute | -| 9.2 | Crypto (Ed25519, X25519, ChaCha20) | Bouncy Castle JVM | Haute | -| 9.3 | QUIC client | quiche (Cloudflare, Rust JNI) ou QUIC4J | Haute | -| 9.4 | NAT traversal (STUN + punch) | Kotlin native UDP | Haute | -| 9.5 | File browser + download | Kotlin + streaming IO | Haute | -| 9.6 | Chat UI | Jetpack Compose | Moyenne | -| 9.7 | Node UI pairing (QR code) | Android camera + hub API | Moyenne | +The site overlay is served by Caddy (static files) with priority over the hub. +The hub serves the SPA for authenticated users at `/app/`. -**Préalable à clarifier :** quels bindings QUIC existent sur Android ? -`quiche` de Cloudflare (en Rust, JNI) est le plus mature. +``` +site/ # meshbay.org-specific (not in generic hub package) +├── index.html # Landing page — project promotion +├── downloads.html # Package repos (Ubuntu, Fedora, Android APK) +├── about.html # Project info, GitHub link, contact +└── assets/ # Landing page CSS/images +``` ---- +### User roles -## Phase 10 — Web client v2 : groupes privés + streaming +| Role | Capabilities | +|---|---| +| `user` | Standard user — browse, download, chat, manage own profile | +| `moderator` | Review reports, suspend content/groups/users | +| `admin` | All moderator rights + hub management, user management, logs | -**Objectif :** navigateur peut décoder le contenu privé (AES-GCM) et streamer des vidéos. +Role stored on User model. `require_moderator` dependency (checks role ≥ moderator). +`require_admin` already exists (Phase 8.1 — config-based, extended to DB role). -| # | Composant | Fichier(s) | Priorité | -|---|---|---|---| -| 10.1 | Web client : décryptage privé (AES-GCM + SubtleCrypto) | `static/crypto.js` MeshBayCrypto | Haute | -| 10.2 | Web client : groupe-type "browser" (AES-GCM GEK) | Hub : `cipher` field sur Group | Haute | -| 10.3 | Player HLS dans browser (hls.js + déchiffrement) | `static/app.js` + hls.js | Haute | -| 10.4 | Chat browser (Sender Keys JS — AES-GCM via SubtleCrypto) | `static/senderkeys.js` | Moyenne | -| 10.5 | PWA / Service Worker | offline + cache | Basse | +### Milestones -**Question clé :** pour le streaming privé en browser, deux approches : -- **AES-GCM GEK** (actuel) : browser-native mais nécessite un groupe dédié -- **ChaCha20 via WASM** : même GEK que les clients natifs, plus complexe +| # | Component | Priority | +|---|---|---| +| 10.1 | Landing page + /downloads + /about | High | +| 10.2 | Moderator role + `require_moderator` dependency | High | +| 10.3 | Moderation UI (report list, suspend content/group/user) | High | +| 10.4 | Admin UI (hub management, user list, logs viewer) | High | +| 10.5 | Notification system (invitations, new content, maintenance) | Medium | +| 10.6 | User settings (profile, per-group options, privacy, mute) | Medium | +| 10.7 | Public group search (name + keyword in description) | Medium | +| 10.8 | Front page (notifications feed, prioritized: contacts → private → public) | Medium | +| 10.9 | Package repositories (APT for Ubuntu, DNF for Fedora) | Medium | +| 10.10 | Auto-update check endpoint for clients | Low | + +### Hub mirror (design only — implementation deferred) + +A mirror hub is a complete replica of the primary hub (same user DB, same groups, +same GEK bundles, same storage). Purpose: load distribution via DNS round-robin. + +**Design constraints:** +- Shared Ed25519 private key (transferred once at setup, securely) +- PostgreSQL logical replication for active-active read/write on both mirrors +- Both mirrors can issue JWTs (same signing key) +- DNS round-robin (2+ A records on meshbay.org) +- If one mirror goes down, the other continues serving + +**Not implemented now.** The design must not prevent future implementation: +- Hub config and private key paths must be externalizable +- No hub-specific state that can't be replicated +- JWT verification must not depend on hub-local state --- -## Phase 11 — Résilience réseau : TURN relay, 0-RTT, CGNAT +## Phase 11 — Android client MVP + +**Objective:** Android app for account creation, group browsing, file download, +chat. No node functionality on mobile (client-only). -**Objectif :** fonctionner même derrière les NAT les plus restrictifs (mobile 4G/5G CGNAT). +**Stack:** Kotlin native + Jetpack Compose. QUIC via `quiche` (Cloudflare, Rust +JNI binding). Crypto via Bouncy Castle JVM. Same NAT traversal as desktop native +clients (`punch_nat` + QUIC). -| # | Composant | Notes | Priorité | +| # | Component | Tech | Priority | |---|---|---|---| -| 11.1 | Mesh Relay TURN server | Node Python serveur UDP relay chiffré | Haute | -| 11.2 | Relay registration MHP | Hub : `/v1/relays/` + annonce aux nodes | Haute | -| 11.3 | Node : fallback automatique → relay | Après échec STUN dans discover_nat() | Haute | -| 11.4 | Punch coordination signaling | Hub WS → node punch → client connect < 2s | Haute | -| 11.5 | QUIC 0-RTT (aioquic session tickets) | Node stocke ticket → reconnexion < 50ms | Moyenne | -| 11.6 | Test CGNAT mobile 4G | Spike dédié : node mobile → node fixe | Moyenne | -| 11.7 | Connection pool (1 QUIC conn = N requêtes) | Node : réutilisation de stream par user | Moyenne | +| 11.1 | Hub client (auth, groups, GEK) | Kotlin + Retrofit | High | +| 11.2 | Crypto (Ed25519, X25519, ChaCha20) | Bouncy Castle JVM | High | +| 11.3 | QUIC client | quiche (Rust JNI) | High | +| 11.4 | NAT traversal (STUN + punch) | Kotlin native UDP | High | +| 11.5 | File browser + download | Kotlin + streaming IO | High | +| 11.6 | Chat UI | Jetpack Compose | Medium | +| 11.7 | Contact list integration | Android Contacts API (permission-gated) | Medium | +| 11.8 | Account creation from app | Registration flow + keypair bundle | High | + +**Cross-device compatibility:** the user may switch between web and Android. +The `keypair_bundle` (encrypted, stored on hub) enables this — same credentials, +same keys on both platforms. Notification state and read markers should sync +via hub (small encrypted blob per user, minimal storage). + +**Upload from mobile:** posting photos/videos to a group. The mobile uploads to +the group's node(s), not to the hub. The node stores it. MNP protocol extended +with an `upload` message type for client→node push. + +**Out of scope:** node functionality on mobile, Mac/iPhone support. --- -## Phase 12 — RPM/DEB packaging production + CI +## Phase 12 — Network resilience (optional, low priority) -**Objectif :** packages installables, CI qui tourne les tests, releases signées. +**Objective:** handle edge cases — symmetric NAT (CGNAT mobile), TURN relay, +0-RTT reconnection. Not needed for typical residential users. -| # | Composant | Notes | +| # | Component | Priority | |---|---|---| -| 12.1 | RPM build pipeline (Fedora, RHEL) | rpmbuild + spec files déjà écrits | -| 12.2 | DEB build pipeline (Ubuntu, Debian) | dpkg-deb + control déjà écrits | -| 12.3 | GitHub Actions CI | pytest + ruff sur PR | -| 12.4 | Release signing | GPG key pour les packages | -| 12.5 | Repo apt/dnf auto-hébergé | meshbay.org/packages/ | +| 12.1 | Mesh Relay TURN server | Low | +| 12.2 | Relay registration via MHP | Low | +| 12.3 | Node fallback to relay after ICE failure | Low | +| 12.4 | QUIC 0-RTT (session tickets) | Medium | +| 12.5 | Connection pool (1 QUIC conn = N requests) | Medium | +| 12.6 | Test CGNAT mobile 4G | Low | + +**Note:** enterprise users behind restrictive firewalls can configure port +forwarding themselves. This phase targets the ~15% of residential connections +where even ICE/STUN fails (symmetric NAT behind CGNAT). Not a priority — +the user explicitly deprioritized this. + +--- + +## Phase 13 — RPM/DEB packaging + CI + +| # | Component | +|---|---| +| 13.1 | RPM build pipeline (Fedora, RHEL) | +| 13.2 | DEB build pipeline (Ubuntu, Debian) | +| 13.3 | GitHub Actions CI (pytest + ruff on PR) | +| 13.4 | Release signing (GPG key) | +| 13.5 | Repo apt/dnf on meshbay.org/packages/ | +| 13.6 | Android APK distribution on meshbay.org/downloads/ | --- -## Ordre recommandé +## Recommended order ``` -Phase 7 (Node v2) ← débloque l'usage réel au quotidien -Phase 8 (Hub v2) ← stabilisation, admin, CSAM -Phase 11 (Relay+0-RTT)← résout le handshake 12.7s et CGNAT mobile -Phase 9 (Android) ← client mobile, long chantier -Phase 10 (Web v2) ← streaming privé browser -Phase 12 (Packaging) ← distribution +Phase 9 (Web client) ← core product: browser P2P to nodes +Phase 10 (Site + admin UI) ← public-facing site, moderation, admin +Phase 11 (Android) ← mobile client, long effort +Phase 13 (Packaging) ← distribution +Phase 12 (Resilience) ← optional, edge cases only ``` -**Next structural decisions (all resolved — see first-review.md):** -1. Multi-group on a single QUIC port ✅ (decided Phase 7) -2. Signaling punch/connect via existing hub WS ✅ (decided Phase 7) -3. Chat is a core feature, not a module ✅ (decided draft v3) -4. Chat encryption: Sender Keys ✅ (decided in security review) -5. JWT group claims required ✅ (decided in security review) +Phase 9 is the critical path. Milestone 9.5 (spike: browser → NAT → node file +transfer) is the single most important validation in the project. If it works, +the web client is viable. If not, the architecture needs fundamental rethinking. + +--- + +## Structural decisions (all resolved) + +1. Multi-group on a single QUIC port ✅ (Phase 7) +2. Signaling punch/connect via hub WS ✅ (Phase 7) +3. Chat is a core feature, not a module ✅ (draft v3) +4. Chat encryption: Sender Keys ✅ (security review) +5. JWT group claims required ✅ (security review) +6. Admin model: config-based ✅ (Phase 8) +7. Refresh token rotation: family-based ✅ (Phase 8) +8. Email encrypted at rest: AES-256-GCM ✅ (Phase 8) +9. Argon2id params: 256 MB, pw_version for migration ✅ (Phase 8) +10. **Browser transport: WebRTC DataChannel + ICE/STUN** ✅ (decided 2026-08-10) +11. **Hub role: registrar + signaling ONLY, never in data path** ✅ (reinforced 2026-08-10) +12. **Chat stored on nodes, not hub** ✅ (decided 2026-08-10) +13. **Web UI: Preact SPA, dark/light, responsive, i18n** ✅ (decided 2026-08-10) +14. **Site overlay: meshbay.org-specific pages separate from generic hub** ✅ (decided 2026-08-10) diff --git a/docs/meshbay-draft-v4.md b/docs/meshbay-draft-v4.md new file mode 100644 index 0000000..343fd26 --- /dev/null +++ b/docs/meshbay-draft-v4.md @@ -0,0 +1,1155 @@ +# MeshBay — Architecture Draft v4 + +> Status: active development — Phases 1–8 complete, Phase 9.1–9.5 validated, 123 tests. +> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved. + +--- + +## Changes from v3 + +The following items are **architectural decisions** driven by Phase 8 implementation and web client design (2026-08-10). They supersede the corresponding text in v3. + +| # | Category | What changed | Source | +|---|---|---|---| +| 1 | Browser transport | Web browsers use **WebRTC DataChannel** (with ICE/STUN) for P2P to nodes behind NAT. WebTransport cannot work because browsers cannot choose their UDP source port — Port-Restricted Cone NAT requires exact port matching. Native clients (desktop, Android) continue using QUIC with `punch_nat()`. | Web client design session | +| 2 | Hub signaling | Hub WebSocket extended to relay WebRTC signaling (SDP/ICE) between browser and node. <1 KB per message, stateless, no content. Same channel as jti denylist push and `client_incoming`. | Web client design session | +| 3 | Hub role | Reinforced: hub is registrar + signaling facilitator ONLY. Never proxies, stores, or touches content (files, streams, chat, indexes). All data lives on nodes. Clients connect E2E to nodes. | Design constraint | +| 4 | Chat storage | Chat messages stored on node(s) hosting the group, not on the hub. Browser retrieves chat from node via DataChannel. If no node is online, group is unavailable. | Web client design session | +| 5 | Web UI | Preact SPA (~3 KB gzipped), dark/light theme, responsive, i18n (JSON translations). ESM modules, esbuild for minification. No heavy frameworks. | Web client design session | +| 6 | Hub roles | Three roles: `user`, `moderator`, `admin`. Moderator can review reports and suspend content/groups/users. Admin has full hub management. | Web client design session | +| 7 | Site overlay | meshbay.org serves both generic hub functionality and site-specific pages (landing, /downloads, /about). Separated via Caddy static file priority. | Web client design session | +| 8 | Hub mirror | Design defined (future implementation): active-active with shared signing key, PostgreSQL logical replication, DNS round-robin. Not implemented yet. | Web client design session | +| 9 | Security items | S1 (admin authz), S2 (email encryption), S5 (refresh token rotation) resolved in Phase 8. Argon2id bumped to 256 MB with transparent rehash. | Phase 8 implementation | +| 10 | File search | Client-side search on cached indexes (IndexedDB). No hub involvement. Private group indexes are GEK-encrypted — hub stores opaque, client decrypts locally. | Web client design session | + +--- + +## Changes from v2 + +The following items are **mandatory corrections** driven by POC findings (spikes 1–6). They supersede the corresponding text in v2. + +| # | Category | What changed | Source | +|---|---|---|---| +| 1 | JWT | `jti` (UUID4) is now **required** in every access token — prevents replay and enables individual revocation. Without it, two tokens issued in the same second are bit-for-bit identical (Ed25519 is deterministic). | Spike 3 | +| 2 | Argon2id | Parameters updated: `iterations=4`, `memory_cost=262144` (256 MB). Previous params (iterations=3, 64 MB) gave 78 ms — too fast. Target is 500 ms on a home server. CLI calibration command added. | Spike 1 | +| 3 | NAT traversal | Order corrected: IPv6 → **STUN/hole-punching** → UPnP → TURN relay. UPnP moved to step 3 (disabled on tested SFR box). STUN is now priority 2, not UPnP. | Spike 4 | +| 4 | Transport | TCP + TLS 1.3 is now the **v1 implementation**. QUIC is the v2 target. The v2 architecture doc had this reversed (QUIC primary, TCP fallback). A `Transport` abstraction layer ensures the switch requires no protocol-layer changes. | Spike 5 | +| 5 | GEK wrapping | Exact protocol confirmed: ephemeral X25519 + `HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1")` + `ChaCha20-Poly1305(aad=pk_recipient)`. Hub stores opaque 48-byte blobs. | Spike 6 | +| 6 | Hub API | Four new endpoints validated in Spike 6: `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek`. Full table added as §4.1.5. | Spike 6 | +| 7 | Packages | Repository structure decided: 3 packages (`meshbay-common`, `meshbay-hub`, `meshbay-node`) in a uv workspace monorepo. RPM package names defined. | POC structure | +| 8 | Key persistence | X25519 keypairs **must be persisted** client-side before the first hub contact. Lesson from Spike 6 (`bob_state.json` fix). | Spike 6 | + +--- + +## 1. Project Overview + +MeshBay is a decentralized, peer-to-peer platform for file sharing, video streaming, and group messaging. It combines identity federation (via Mesh Hubs) with truly peer-to-peer data exchange (via Mesh Nodes), designed to be resilient, censorship-resistant, and user-friendly. + +**Core principles:** +- Data never transits through a central server — only identity and routing do +- End-to-end encryption for all private content (files, indexes, messages) +- The node operator is the legal host and is fully responsible for their content +- The hub is a lightweight registrar, not a content host or indexer +- Open source, self-hostable at every level + +**Domain:** meshbay.org (configurable at compile/deploy time throughout the codebase) + +--- + +## 2. Terminology + +| Term | Role | +|---|---| +| **Mesh Hub** | Identity authority and group registry server | +| **Mesh Node** | Local program on the host user's machine | +| **Mesh Client** | Web browser or Android app (end user) | +| **Mesh Relay** | Community-operated TURN fallback relay [future] | +| **MNP** | Mesh Node Protocol — P2P protocol between nodes and clients | +| **MHP** | Mesh Bay Hub Protocol — inter-hub federation protocol | +| **GEK** | Group Encryption Key — symmetric key for private group content | +| **Mesh Directory** | Public registry of groups (hub level) | +| **Mesh Group Index** | File listing for a group (node level, encrypted for private groups) | + +--- + +## 3. Protocol Versioning + +All protocols (MNP, MHP, hub REST API) carry explicit version information. + +**Format:** `MAJOR.MINOR` +- MAJOR bump: breaking change, backward incompatible +- MINOR bump: backward-compatible addition + +**Negotiation:** during handshake, both parties declare their supported version range. The highest mutually supported MINOR within the same MAJOR is used. If no common version exists, connection is refused with an explicit error. + +**Support policy:** a release supports the current MAJOR and at least the two previous MINOR versions (N-2). + +**Implementation:** a `version` field in every msgpack message header. Handshake step precedes all other exchanges. + +--- + +## 4. System Components + +### 4.1 Mesh Hub + +A lightweight server acting as a registrar. Intentionally minimal to limit legal exposure and operational cost. + +**What the hub stores:** +- User accounts: username, email (stored for account recovery — see §4.1.1), optional phone number, `PK_user` (Ed25519 + X25519), hub ID, status, creation timestamp +- Group registry: name, `PK_group`, hosting node identifier, visibility, join policy, member list with encrypted GEK bundles (private groups only) +- Mandatory connection logs (see §4.1.2) +- Revocation lists (users and groups) +- Registered peer hubs (explicit allowlist — no auto-discovery) + +**What the hub never stores:** +- File content or metadata +- Private group indexes +- Message content +- Node current IP (handled by ephemeral signaling — see §4.1.3) + +#### 4.1.1 Account Data + +Email is kept in full (not hashed) to support: +- Account recovery (password reset) +- Legal notifications +- Abuse contact + +Phone number: optional, associable after account creation. On Android, both collected at registration. Accounts are fusionable (email + phone pointing to same account). + +Email and phone are stored encrypted at rest in the database, using a server-side key derived from the hub's configuration secret (not the database). **[NOT YET IMPLEMENTED — currently stored in plaintext. Tracked as open question #10.]** + +#### 4.1.2 Mandatory IP Logging (Legal Compliance) + +Legal frameworks (LCEN in France, EU e-Commerce Directive, DSA) require service providers to retain connection logs. The hub logs the following with timestamp and IP address: + +| Event | Retention | +|---|---| +| Account creation | 1 year minimum | +| Login (success and failure) | 1 year minimum | +| Group creation | 1 year minimum | +| Group join / leave | 1 year minimum | +| Group deletion | 1 year minimum | +| Revocation actions | 1 year minimum | + +Logs are stored in a separate, access-controlled log table. They are not used for any purpose other than legal compliance and are not exposed to users or operators beyond legal requests. + +#### 4.1.3 Signaling Service + +NAT traversal coordination is handled by a lightweight WebSocket endpoint, logically separate from the main hub API. It is stateless: connection state is held in memory only and discarded after P2P connection establishment (typically within seconds). No persistent storage of node IP addresses. + +**Hub interaction summary:** + +| Event | Hub crypto load | Frequency | +|---|---|---| +| Account creation | Argon2 hash, store PK | Once | +| Login | Verify password, issue JWT (Ed25519 sign) | Per session | +| Group creation | Register metadata | Once per group | +| Member add/remove | Store/remove GEK bundle | On admin action | +| Group discovery | Return node address + PK_node + GEK bundle | Per initial access | +| NAT signaling | Relay WebSocket messages (<1 KB) | Per new P2P connection | +| Public search | Delegate to nodes, 60s in-memory cache | On demand | +| MHP federation sync | Exchange Mesh Directory | Background, periodic | +| Revocation | Ed25519-sign revocation token | Rare | + +**The hub is never in the data path after connection setup. JWT verification by nodes is local (Ed25519, no hub roundtrip). Confirmed at 884 µs in Spike 3.** + +#### 4.1.4 JWT Strategy + +Two tokens issued at login: + +**Access token** (JWT, signed Ed25519): +- Validity: 1 hour +- Payload: `jti` (UUID4, **mandatory** — unique per token, enables individual revocation and prevents replay), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, `groups` (list of group_ids the user is a member of — hub-signed membership claim) +- The `groups` claim is **mandatory** for node-side authorization: the node checks that the requested group_id appears in the JWT before serving any content. Without this claim, any authenticated user could access any group on the node. +- Presented to nodes for authentication and group access verification +- Verified locally by nodes using the hub's known public key — no hub roundtrip +- Compromise window: 1 hour maximum + +> **Why `jti` is mandatory:** Ed25519 signing is deterministic. Two tokens with identical payloads issued within the same second produce the same byte sequence. Without a `jti`, they are indistinguishable — a captured token is replayable forever within its validity window, and individual revocation is impossible. The `jti` also provides the revocation handle: hub stores `jti` of invalidated tokens in a server-side denylist. +> +> This bug was found and fixed during Spike 3. + +**Refresh token** (opaque, random 256-bit): +- Validity: 30–90 days [TBD exact duration] +- Stored securely on client only +- Used exclusively with the hub to obtain a new access token +- Revocable immediately by the hub (invalidates all future refreshes for this token) +- Stored server-side as a hashed value + +**Revocation flow:** hub invalidates the refresh token → next access token renewal fails → node access expires within 1 hour at most. For immediate revocation of an active access token: hub adds its `jti` to the token denylist; nodes that cache hub public key will periodically fetch the denylist. + +**Tech stack:** +- Language: Python +- Framework: FastAPI + Uvicorn +- Database: PostgreSQL + SQLAlchemy + Alembic +- Deployment: Apache reverse proxy (ProxyPass + SSL termination) +- Authentication: own system (Ed25519 JWT, Argon2id for password hashing) +- Hub accessible via domain and directly by IP (self-signed cert warning expected for IP access; documented) + +#### 4.1.5 Hub API Reference + +Complete table of validated and planned hub REST API endpoints. Endpoints marked ✓ were validated in the POC; endpoints marked [TBD] are designed but not yet implemented. + +**Hub metadata:** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| GET | `/v1/hub/info` | None | Hub metadata: hub_id, versions, counters | ✓ Spike 2 | +| GET | `/v1/hub/pubkey` | None | Hub Ed25519 public key (PEM) | ✓ Spike 2 | + +**User management:** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| POST | `/v1/users/register` | None | Create user account (username, email, password, pk_ed25519, pk_x25519) | ✓ Spike 2 | +| POST | `/v1/users/login` | None | Authenticate; returns access token + refresh token | ✓ Spike 2 | +| POST | `/v1/users/token/refresh` | Refresh token | Issue new access token | ✓ Spike 2 | +| GET | `/v1/users/{username}/pubkeys` | Access token | Fetch `pk_ed25519` and `pk_x25519` for a user (used for GEK wrapping) | ✓ Spike 6 | + +**Node management:** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| POST | `/v1/nodes/announce` | Access token | Register node with endpoint_hint; returns node_id | ✓ Spike 2 | +| GET | `/v1/nodes/{node_id}` | Access token | Retrieve node record (pk_node, endpoint_hint) | ✓ Spike 2 | + +**Group management:** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| POST | `/v1/groups` | Access token | Create group (name, visibility, join_policy, pk_group) | ✓ Spike 6 | +| GET | `/v1/groups` | None / Access token | List/search public groups; private groups require membership | [TBD] | +| GET | `/v1/groups/{group_id}` | None / Access token | Group metadata | [TBD] | +| DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke group | [TBD] | + +**GEK distribution (private groups):** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| POST | `/v1/groups/{group_id}/members/{username}/gek` | Access token (admin) | Upload opaque 48-byte GEK bundle for a member | ✓ Spike 6 | +| GET | `/v1/groups/{group_id}/gek` | Access token (member) | Retrieve caller's GEK bundle | ✓ Spike 6 | + +**Revocation:** + +| Method | Path | Auth | Description | Status | +|---|---|---|---|---| +| POST | `/v1/revoke/user/{user_id}` | Access token (admin) | Revoke a user account | [TBD] | +| POST | `/v1/revoke/group/{group_id}` | Access token (admin) | Revoke a group | [TBD] | +| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens | [TBD] | + +### 4.2 Mesh Node + +A local program running on the host user's machine. The node is the actual host of all content. + +**Responsibilities:** +- Watch and index shared directories (Mesh Group Index) — one directory per group +- Serve files, video streams, and group chat to members +- Manage all cryptographic keys locally (encrypted keystore) +- Handle P2P connections and NAT traversal (STUN + QUIC hole punching) +- Run the MNP protocol (QUIC v2, TCP+TLS v1) +- Host the Python extension module sandbox +- Serve the local web UI (localhost:18000) + +**Multi-group architecture (decided Phase 7):** +A node exposes **one QUIC port** for all groups it hosts. Groups are not isolated +by port — the MNP handshake identifies the target group via the `group_id` claim +in the client JWT. The server routes each connection to the appropriate +DirectoryIndexer and GEK after JWT verification. +Rationale: one NAT hole to maintain, one port to forward manually if needed. + +**Authorization invariant:** the node MUST verify that the JWT's `groups` claim +contains the requested group_id before serving any content. Without this check, +any authenticated user could access any group on the node. This is enforced at +the MNP handshake layer, not the transport layer. + +**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability. + +#### 4.2.1 Keystore and Unlock + +Private keys (user identity Ed25519, user exchange X25519, group identity Ed25519, GEK copies) are stored in a local encrypted keystore file. + +**Format:** msgpack container encrypted with AES-256-GCM, key derived from master password using Argon2id. + +**Argon2id parameters (production):** +- `iterations = 4` +- `memory_cost = 262144` (256 MB) +- `parallelism = 1` (or match CPU count — tune to target hardware) +- Target derivation time: ~500 ms on a home server + +> **Why these parameters:** Spike 1 measured iterations=3, memory=64 MB at 78 ms — far too fast. At 78 ms an attacker can attempt millions of guesses per second-equivalent with a GPU cluster. The target of 500 ms on a home server limits offline dictionary attacks to a tractable rate while remaining acceptable for a node that unlocks once at startup. + +**CLI calibration:** +``` +meshbay-node --calibrate-argon2 +``` +This command iterates through parameter combinations and reports the derivation time on the current hardware. The operator selects parameters meeting the 500 ms target and stores them in `~/.config/meshbay/node.toml`. Recommended starting point: `iterations=4, memory_cost=262144`. + +**Key persistence requirement:** All keypairs (Ed25519 + X25519) **must be written to the keystore before the first hub contact.** If keypairs are generated at registration time but not persisted before the hub call, subsequent runs will regenerate different keypairs, making all stored GEK bundles on the hub undecryptable. This was identified as a real failure mode in Spike 6 (`bob_state.json` fix). + +**Three unlock modes:** + +| Mode | How it works | Security level | +|---|---|---| +| **Secure (default)** | Password prompted at startup via terminal or local web UI | High | +| **Lazy file** | Password or derived key stored in `~/.config/meshbay/unlock.key` (chmod 600), read automatically at startup | Medium — acceptable for physically secure home machines. Risk documented at setup. | +| **Service (headless)** | `MESHBAY_UNLOCK_KEY` environment variable, set via systemd `EnvironmentFile=` pointing to a chmod 600 file | Medium-high — standard practice for server deployments | + +Future: OS keychain integration (libsecret/GNOME Keyring on Linux, Windows Credential Manager, macOS Keychain). + +#### 4.2.2 Hardware Sizing + +The main constraint is **upload bandwidth**, not CPU or RAM. + +| Scenario | Simultaneous users | Upload needed | CPU | RAM | +|---|---|---|---|---| +| Files + chat, minimal streaming | 10 | 20–50 Mbps | 2 cores | 512 MB | +| Active 1080p streaming (5–6 streams) | 10 | 50–80 Mbps | 2–4 cores | 1 GB | +| Mixed use | 50 | 200–300 Mbps | 4 cores | 2 GB | +| Active streaming | 50 | 400 Mbps | 4–8 cores | 2–4 GB | +| All use cases | 100 | 800 Mbps–1 Gbps | 8 cores | 4–8 GB | + +Beyond 20–30 active streaming users, a dedicated server is required. A home fiber connection (100–500 Mbps symmetric) is suitable for small groups. + +Crypto overhead is confirmed negligible: Spike 5 measured full encrypt+sign and verify+decrypt at under 10 ms for a 1 MB chunk. Network latency dominates. + +**Tech stack:** +- Language: Python (primary). Rust extension only if a specific hot path proves insufficient. +- Transport abstraction layer: `Transport` interface decouples TCP+TLS 1.3 (v1) from QUIC (v2). Application protocol is identical across both transports. +- v1 transport: **TCP + TLS 1.3** (`asyncio` + `ssl` module, standard library) +- v2 transport (future): **QUIC** (`aioquic`, Cloudflare-maintained) +- ICE/STUN: `aioice` (already a dependency) +- WebRTC: `aiortc` (browser P2P transport — Phase 9) +- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated AES-NI/ChaCha) +- Serialization: `msgpack` +- Compression: `zstandard` (zstd) +- File watching: `watchdog` +- Local DB: SQLite +- Local web UI: served by node on `localhost:18000` + +### 4.3 Mesh Client + +Web browser or Android app. Consumes content from nodes; manages account via hub. +The hub is never in the data path — clients connect E2E to nodes for all content. + +**Hub-side operations (HTTPS, lightweight):** +- Account creation, login, token refresh +- Public group search and discovery +- Group membership management, GEK bundle retrieval +- WebRTC signaling relay (SDP/ICE — <1 KB per connection, stateless) +- Notification metadata (invitations, new content indicators) + +**Node-side operations (direct P2P via QUIC or WebRTC DataChannel):** +- File browsing via Mesh Group Index +- File download (chunked, E2E encrypted) +- Video streaming (HLS segments via DataChannel or QUIC stream) +- Group chat (Sender Keys encrypted, stored on node) +- File/photo/video upload (client → node push) + +#### 4.3.1 Web Browser Client + +**Transport:** WebRTC DataChannel with ICE/STUN for NAT traversal. +WebTransport (HTTP/3) is not suitable because browsers cannot choose their UDP +source port — Port-Restricted Cone NAT (confirmed on SFR residential) requires +the client to connect from the exact port the node probed. WebRTC's ICE handles +this automatically via simultaneous STUN binding requests. + +**UI:** Preact SPA (~3 KB gzipped) served by the hub. +- Dark/light theme (CSS `prefers-color-scheme` + user toggle in localStorage) +- Responsive design (sidebar → hamburger menu on mobile) +- i18n: JSON translation files, English default +- Build: esbuild for minification (single binary, no npm dependency) +- Crypto: SubtleCrypto (AES-GCM) for E2E decryption in browser + +**Layout:** +- Left sidebar: group list (ordered by usage — private groups first), navigation +- Top bar: logo ("MeshBay") left, user menu right (settings, profile, language, logout) +- Main content area: file explorer, chat view, or settings depending on context + +**Client modes:** +- Explorer: file/folder browser for group content (read-only browse, download, stream) +- Chat/forum: per-group discussion thread with photo/video posting +- Settings: general, per-group, notifications, privacy, theme, language + +**Local storage:** +- IndexedDB: cached group indexes for instant local search (~50–100 MB quota) +- localStorage: theme preference, language, session state +- `keypair_bundle`: encrypted keypair retrieved from hub, decrypted locally with password + +**File search:** entirely client-side on cached indexes. No hub involvement. +Private group indexes are GEK-encrypted — stored opaque on the hub, decrypted +by the client locally. Search runs against the decrypted index in IndexedDB. + +#### 4.3.2 Android Client + +**Transport:** QUIC with `punch_nat()` — same as desktop native clients. +Android has full UDP access; no WebRTC needed. Uses `quiche` (Cloudflare, Rust +via JNI) for QUIC transport. + +**Stack:** Kotlin + Jetpack Compose. Bouncy Castle JVM for crypto. + +**Capabilities:** same as web browser (browse, download, stream, chat, upload). +Additional: contact list integration (Android Contacts API, permission-gated). +Account creation from app. No node functionality on mobile (client-only). + +**Cross-device compatibility:** the `keypair_bundle` (encrypted, stored on hub) +enables seamless switching between web and Android with the same credentials. +Notification state and read markers sync via hub (small encrypted blob per user). + +**Out of scope:** Mac/iPhone support. Node on mobile. + +### 4.4 Mesh Relay + +**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (symmetric NAT behind CGNAT, approximately 15–20% of connections in the worst case). Traffic is always E2E encrypted — the relay sees only opaque ciphertext. + +Not operated by meshbay.org. A relay registration protocol (hub-mediated) will be designed when this feature is introduced. It does not affect the current design. + +### 4.5 Package Structure and Repository + +**Repository layout:** monorepo managed with [uv workspaces](https://docs.astral.sh/uv/concepts/workspaces/). + +``` +meshbay/ +├── packages/ +│ ├── meshbay-common/ # Shared crypto, serialization, protocol types +│ ├── meshbay-hub/ # Hub server (FastAPI + Uvicorn) +│ └── meshbay-node/ # Node daemon + local web UI +├── poc/ # POC and spikes — reference implementation +│ ├── spike1_crypto/ +│ ├── spike2_hub/ +│ ├── spike3_node_reg/ +│ ├── spike4_nat/ +│ ├── spike5_transfer/ +│ ├── spike6_gek/ +│ └── spike-results.md +├── docs/ +│ └── meshbay-draft-v3.md +└── pyproject.toml # Workspace root +``` + +**Three packages:** + +| Package | RPM name | Contents | +|---|---|---| +| `meshbay-common` | `python3-meshbay-common` | Crypto primitives (Ed25519, X25519, ChaCha20, Argon2, HKDF), msgpack schemas, protocol constants, MNP message types | +| `meshbay-hub` | `python3-meshbay-hub` | FastAPI hub application, database models (SQLAlchemy), Alembic migrations, JWT issuance, GEK bundle storage | +| `meshbay-node` | `python3-meshbay-node` | Node daemon, keystore, file watcher, TCP+TLS transport, local web UI, extension module sandbox | + +**`meshbay-hub` and `meshbay-node` both depend on `meshbay-common`.** There is no runtime dependency between hub and node packages. + +**POC directory as reference implementation:** The `poc/` directory contains the working code from spikes 1–6. It is not production code and not packaged, but serves as the canonical reference for: +- Exact crypto parameter choices (Spike 1) +- GEK wrapping/unwrapping implementation (Spike 6) +- Hub API skeleton (Spike 2) +- NAT detection and STUN interaction (Spike 4) +- TCP file transfer pipeline (Spike 5) + +Developers implementing production features should read the corresponding spike before writing production code. + +--- + +## 5. Group Model + +Groups are the core organizational unit. + +| Parameter | Options | +|---|---| +| Visibility | Public / Private | +| Join policy | Open / On request / By invitation only | +| Admin | The hosting node operator (legal host) | + +A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join). + +A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members holding the GEK can decrypt anything. + +**Group addressing:** +``` +meshbay.org/u/username/groupname — public group via hub +meshbay.org/g/groupname — public group (shorthand) +group://<PK_group_fingerprint>@<node_addr> — hub-less direct access +``` +`meshbay.org` is fully configurable throughout the codebase (constant/config file). The hub is reachable via domain or IP (IP access requires self-signed cert; browsers will warn — expected and documented behavior). + +--- + +## 6. Cryptographic Architecture + +### 6.1 Key Hierarchy + +``` +User Identity Key Ed25519 Signing, authentication +User Exchange Key X25519 Key agreement (GEK wrapping, session ECDH) +Group Identity Key Ed25519 Group metadata signing (held by admin node) +Group Encryption Key ChaCha20 Private content and index encryption (symmetric, 256-bit) +Session Keys X25519/HKDF Perfect forward secrecy per P2P connection +``` + +All private keys stored exclusively on the node (or client device) in the encrypted keystore. The hub never sees any private key. + +Both `PK_ed25519` and `PK_x25519` are registered with the hub at account creation. The hub exposes them via `GET /v1/users/{username}/pubkeys` so that group admins can wrap GEK bundles for members without any direct contact between nodes. + +### 6.1.1 Key Generation Strategies + +Three strategies, depending on client type: + +**A — CLI / native node (Argon2id derivation)** +Keys are derived deterministically from `username + password`: +``` +salt = SHA-256("meshbay:v1:" + username) +seed = Argon2id(password, salt, length=64) +sk_ed25519 = Ed25519.from_private_bytes(seed[:32]) +sk_x25519 = X25519.from_private_bytes(seed[32:]) +``` +Same credentials → same keys on any machine. Password recovery = key recovery. +Implemented in `meshbay_common/keyderive.py::derive_keys_from_password()`. + +**B — Web browser (random keypairs + encrypted bundle)** +Browser generates random keypairs via WebCrypto `generateKey()`, encrypts them +with a PBKDF2-SHA512 derived key, and uploads the encrypted bundle to the hub +alongside the public keys. On subsequent logins, the hub returns the bundle +and the browser decrypts it locally with the password. + +The hub stores `keypair_bundle` (AES-256-GCM ciphertext) — opaque, cannot decrypt it. +Implemented in `static/keyderive.js`. Python side in `keyderive.py::encrypt_keypair_bundle()`. + +**C — Native node with keystore file** +Random keypairs generated once, stored in the Argon2id-encrypted keystore file +(`~/.config/meshbay/keystore.enc`). Standard operating mode for `meshbay-node`. + +**Algorithm mismatch note:** strategies A and B use different KDFs (Argon2id vs PBKDF2). +A user who registered via CLI (A) and later tries to recover via web (B) with the same +password will get different keypairs. This is by design: users pick one registration path. +Cross-path recovery requires the admin to issue new GEK bundles. + +### 6.2 GEK Management + +**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption). + +**GEK wrapping protocol (ECIES-like, confirmed in Spike 6):** + +``` +Admin side (wrap_gek): + sk_eph, pk_eph = X25519.generate() # fresh ephemeral keypair per bundle + shared = X25519(sk_eph, pk_recipient) + wrap_key = HKDF(shared, salt=pk_eph, + info="meshbay:gek_wrap:v1", + length=32) + nonce = random_bytes(12) + wrapped = ChaCha20-Poly1305(wrap_key).encrypt( + nonce, gek, aad=pk_recipient) # aad binds bundle to recipient + bundle = pk_eph || nonce || wrapped # 32 + 12 + 32+16 = 92 bytes on wire + # hub stores as opaque 48-byte blob + # (without pk_eph in compact form — see note) + +Member side (unwrap_gek): + shared = X25519(sk_recipient, pk_eph) + wrap_key = HKDF(shared, salt=pk_eph, + info="meshbay:gek_wrap:v1", + length=32) + gek = ChaCha20-Poly1305(wrap_key).decrypt( + nonce, wrapped, aad=pk_recipient) +``` + +> **Hub-stored blob size:** the hub stores the opaque bundle. Spike 6 confirmed the hub stores 48-byte blobs (nonce=12 + ciphertext=20 + tag=16 in the compact wire format used in the spike — `pk_eph` is stored separately in the bundle record). Production schema: hub bundle record = `{ pk_eph (32B), nonce (12B), ciphertext (32B), tag (16B) }` = 92 bytes total per member per group, stored as a single column. + +**Security properties confirmed in Spike 6:** +- Hub never sees the GEK in cleartext +- Ephemeral keypair is unique per bundle — same GEK and same recipient produce different ciphertext across calls +- AAD (`pk_recipient`) binds the bundle to its intended recipient — reuse for a different member is detected and rejected +- Wrong private key → AEAD authentication tag failure → immediate rejection + +**Group creation:** +1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG) +2. GEK wrapped for each initial member via the protocol above +3. Wrapped bundles uploaded to hub via `POST /v1/groups/{group_id}/members/{username}/gek` +4. Members retrieve their bundle via `GET /v1/groups/{group_id}/gek` + +**Member addition:** +- Admin fetches new member's `pk_x25519` from hub +- Wraps GEK for them and uploads bundle + +**Member revocation:** +- Admin node generates new GEK +- Re-encrypts for all remaining members, uploads new bundles +- New content encrypted with new GEK from this point +- Former member can still decrypt previously received content (acceptable trade-off — full retroactive re-encryption not planned) + +**Key persistence requirement:** before uploading a GEK bundle, the recipient's keypairs must already be registered on the hub and persisted locally. If a user registers, generates keypairs, but does not persist them before the first hub contact, subsequent sessions will regenerate different keypairs and all bundles will be undecryptable. The node initializes and persists all keypairs to the keystore before any hub API call. + +### 6.3 On-the-Fly Encryption for File Transfer + +Files are stored in plaintext on the host's disk. The node encrypts at read time. + +``` +Disk (plaintext) → zstd compress → GEK encrypt (per-chunk) → TCP+TLS 1.3 session → Client → TLS decrypt → GEK decrypt → plaintext +``` + +(In v2 transport: replace TCP+TLS 1.3 with QUIC — application pipeline is identical.) + +**Chunking:** +- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking) +- Per-chunk key derivation: `chunk_key = HKDF(GEK, salt=None, info="file:" || blake3(file) || ":chunk:" || index)` — salt is omitted because the GEK is a CSPRNG output (already uniform); the file/chunk context goes in `info` for domain separation, which is the correct HKDF usage per RFC 5869 +- Each chunk independently decryptable → enables VOD seeking +- Compress before encrypt (compression is ineffective on ciphertext) + +**Chunk authentication:** each chunk signed with the node's Ed25519 key. Client verifies before decryption. Prevents data injection by compromised relay. + +**Encryption performance (Spike 5, 1 MB chunk, TCP, Fedora → OVH VPS):** + +| Operation | Time | +|---|---| +| Encrypt + sign (node side) | 3.2 ms | +| Verify + decrypt (client side) | 3.9 ms | +| Total crypto overhead (1 MB) | < 10 ms | +| Network transfer | 99–234 ms (network-limited) | + +Encryption is not the bottleneck. Network latency and bandwidth dominate. + +**Pipeline optimization:** +- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops +- ChaCha20-Poly1305: ~1750 MB/s (Spike 1); AES-256-GCM: >2 GB/s with AES-NI +- asyncio pipeline (read → compress → encrypt → send) without loading full files into memory +- GEK-derived chunk keys computed in batch at transfer start, not per-chunk + +### 6.4 Transport Security + +**Implementation phases:** + +| Phase | Transport | Status | Notes | +|---|---|---|---| +| v1 | TCP + TLS 1.3 | Current implementation target | Standard library (`asyncio` + `ssl`), well-understood, works everywhere | +| v2 | QUIC (TLS 1.3 integrated, UDP, multiplexed streams) | Future upgrade | `aioquic`, no protocol changes needed — only transport layer | + +The `Transport` abstraction interface in `meshbay-node` decouples the application protocol from the underlying transport. Switching from TCP+TLS to QUIC requires implementing a new `Transport` backend with no changes to MNP message handling, GEK pipeline, or NAT traversal logic. + +**Per-connection session keys:** X25519 ECDH + HKDF, independent of the GEK layer. Provides forward secrecy per connection regardless of transport. + +**Rationale for TCP+TLS 1.3 first:** UDP hole-punching (required for QUIC in NAT scenarios) adds complexity in the early implementation. TCP outbound from behind NAT (as used in Spike 5) works without any NAT coordination. TLS 1.3 provides equivalent confidentiality guarantees to QUIC's integrated TLS. QUIC's benefits (0-RTT, multiplexing, no head-of-line blocking) are meaningful for performance but not for correctness — they belong in v2 once the application protocol is stable. + +### 6.5 TCP+TLS 1.3 Transport Implementation (v1) + +**Connection model:** +- Node listens on a configurable TCP port (default: 18000, same as local web UI port — separate socket) +- Clients connect outbound; nodes behind NAT connect outbound to other nodes via hole-punching signaling (see §7.1) +- TLS 1.3 mandatory; TLS 1.2 rejected +- Node presents a self-signed Ed25519 certificate pinned to its `PK_node` (registered on hub) +- Client validates certificate against `PK_node` retrieved from hub — not against a CA chain + +**Handshake sequence:** +``` +Client → Node: TCP SYN +Node → Client: TLS ServerHello (self-signed cert, PK_node) +Client: verify cert against hub-fetched PK_node +Client → Node: TLS ClientFinished +Node → Client: MNP handshake request (version negotiation) +Client → Node: MNP handshake response (JWT access token, version) +Node: verify JWT offline (Ed25519, hub public key) +Node → Client: session established +``` + +**Message framing over TCP:** +- Length-prefixed frames: `[4-byte big-endian length][msgpack payload]` +- Maximum frame size: 2 MB (prevents memory exhaustion; larger transfers use chunked `file_chunk` messages) +- Each frame carries the MNP `version` field in its header + +**QUIC migration path (v2):** +- Replace TCP length-framing with QUIC streams (one stream per logical exchange) +- MNP handshake maps 1:1 to a QUIC handshake stream +- File transfer maps to a dedicated QUIC stream per file (multiplexed, no head-of-line blocking) +- Chat messages map to a persistent QUIC stream +- No changes to JWT verification, GEK decryption, or Index sync logic + +**Port allocation:** +- `18000/tcp` — local web UI (loopback only, not exposed externally) +- `18001/tcp` — MNP P2P listener (exposed externally, TLS required) +- Configurable via `~/.config/meshbay/node.toml` + +### 6.6 Chat Encryption and Model + +Group chat is a **core feature** (not an extension module). + +**Model (decided):** between a forum and Signal. +- **Persistent:** messages stored on the node (not ephemeral like Signal by default) +- **Structured:** optional threads/topics for longer discussions, flat stream for quick messages +- **Scope:** per group (not per user pair) +- **Attachments:** files and images, shared like regular group files +- **Push/pull:** connected members get real-time push (WebSocket); offline members pull history on reconnect +- **Retention:** managed by the group admin (no automatic expiry) + +**Encryption — Sender Keys protocol (decided in first security review, 2026-08-10):** + +The Double Ratchet (implemented in `meshbay_common.ratchet`) is a **pairwise** (1:1) protocol. Using a shared ratchet state for N group members would cause chain key desynchronization and nonce/key reuse — a catastrophic AEAD failure. The architecture uses **Sender Keys** instead (same approach as Signal Groups): + +- Each group member generates a **sender key** (random symmetric chain key + signing keypair) +- On joining a group, the new member's sender key is distributed to all existing members via pairwise channels (GEK-wrapped or direct) +- Each existing member sends their current sender key to the new member +- Messages are encrypted with the sender's chain key (symmetric ratchet, one direction) +- Forward secrecy at **member rotation** granularity: when a member is removed, all remaining members rotate their sender keys +- O(N) state per member (one chain per group member), not O(N^2) +- The existing Double Ratchet implementation is kept for future 1:1 direct messaging + +Attachment files: encrypted with GEK-derived key (same as file chunks), hash referenced in the message. + +> **Why not MLS (RFC 9420)?** MLS provides O(log N) message overhead and per-message forward secrecy via tree-based ratcheting. It is the superior long-term choice, but its complexity is not justified for v1 group sizes (< 50 members). Sender Keys is proven at scale (Signal, WhatsApp) and simpler to implement. Migration to MLS is a v2 option if group sizes grow. + +--- + +## 7. Network and Connectivity + +### 7.1 NAT Traversal — Attempt Order + +``` +1. IPv6 available on both sides → direct connection (preferred) +2. STUN / ICE + UDP hole punching → ~80–85% success rate (Cone NAT confirmed in Spike 4) +3. UPnP / NAT-PMP on router → port mapping if available (NOT reliable — disabled on tested SFR box) +4. Mesh Relay (TURN) → [future feature] — symmetric NAT, CGNAT mobile +``` + +> **Correction from v2:** UPnP was listed as step 2 in v2. Spike 4 showed UPnP disabled on the tested SFR residential gateway. STUN + hole-punching (step 2) is more reliable and does not require router cooperation. UPnP is demoted to step 3 as a best-effort supplement, not a dependency. + +**Spike 4 findings:** +- Cone NAT confirmed on SFR residential (same external port 51250 for two different STUN servers) +- UDP hole punching functional: bidirectional echo received from OVH VPS +- STUN servers tested: `stun.cloudflare.com`, `stun.l.google.com` — both returned consistent results +- No CGNAT: stable public IPv4 (81.220.170.32) + +Without step 4 (Mesh Relay), approximately 15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented. + +**Signaling punch/connect (Phase 7.2 — reduces handshake from 12.7s to < 200ms):** +Currently the node punches blindly at startup; the client may connect 10-20s later +on an aging NAT entry, causing retransmissions. The coordinated flow uses the +existing hub→node WebSocket (revocation channel): +``` +Client → Hub : POST /v1/nodes/{id}/incoming {peer_ip, peer_port} +Hub → Node (WS) : {type: "client_incoming", peer_ip, peer_port} +Node : punch_nat(peer_ip, peer_port) immediately +Node → Hub (WS) : {type: "punch_ready"} +Hub → Client: 200 OK "connect now" +Client → QUIC: first packet < 2s after probe → fresh NAT entry +``` +demo-v2 finding: SFR residential is **Port-Restricted Cone NAT**. +The probe must come from the QUIC server's own socket (`punch_nat()` via +`_transport.sendto()`). The QUIC client must connect from the same port +as the probe's destination (`local_port=QUIC_PORT`). Handshake time +with proper signaling: < 200ms (vs 12.7s without). + +#### 7.1.1 Browser-Specific NAT Traversal (WebRTC DataChannel) + +Browsers cannot use the QUIC `punch_nat()` mechanism because WebTransport does +not allow the browser to choose its UDP source port. Port-Restricted Cone NAT +requires exact port matching on both IP and port — impossible for browsers. + +**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC +stack handles NAT traversal automatically: + +1. Browser gathers ICE candidates via STUN (discovers its external IP:port) +2. Node gathers ICE candidates via `aioice` (discovers its external IP:port) +3. Candidates exchanged via hub signaling (WebSocket relay, <1 KB) +4. ICE connectivity checks: both sides send STUN binding requests simultaneously +5. STUN binding requests serve as NAT hole-punching (both directions) +6. ICE finds a valid candidate pair — DataChannel established +7. MNP protocol runs over DataChannel (same messages, same E2E encryption) + +**Signaling flow:** +``` +Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates} +Hub → Node (WS) : {type: "webrtc_offer", sdp, ice_candidates, peer_id} +Node (aiortc) : creates PeerConnection, gathers answer candidates +Node → Hub (WS) : {type: "webrtc_answer", sdp, ice_candidates, peer_id} +Hub → Browser (SSE) : answer SDP + ICE candidates +DataChannel : P2P established — hub no longer involved +``` + +ICE is strictly superior to custom `punch_nat()` for browser use: +- No need for the client to pre-announce its port +- Handles both sides behind NAT simultaneously +- Automatic candidate prioritization and fallback +- Battle-tested by billions of daily users (Google Meet, Discord, Zoom) + +**Node dual transport:** the node listens on both: +- QUIC (UDP port 19000) — native clients (desktop, Android) +- WebRTC — browsers (via `aiortc`, separate UDP socket managed by ICE) + +The MNP application protocol is identical on both transports. Same handshake, +same file_request/file_chunk, same chat_message, same E2E encryption. + +### 7.2 MNP — Mesh Node Protocol + +Application-level protocol over TCP+TLS 1.3 (v1) or QUIC (v2). All messages carry a `version` field. The protocol is transport-agnostic — the `Transport` abstraction layer handles framing differences. + +**Defined message types:** + +| Type | Description | +|---|---| +| `handshake` | Key exchange, JWT presentation, version negotiation | +| `index_sync` | Encrypted Mesh Group Index delta | +| `file_request` | Request chunk(s) of a file by hash + chunk index | +| `file_chunk` | Chunk data + Ed25519 signature | +| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key | +| `chat_message` | Double Ratchet encrypted message frame | +| `chat_attachment` | Attachment metadata + key; data transferred as file chunks | +| `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata | + +### 7.3 Public Content Delivery — Swarm + +Public files identified by `blake3` hash. Multiple nodes can serve the same file: + +1. Any node that has a public file and chooses to mirror it registers: `{ hash → node_address }` with the hub +2. Hub maintains a source table: `{ blake3_hash → [node_A, node_B, ...] }` +3. Client requests file → hub returns source list → client fetches chunks in parallel from multiple nodes +4. Integrity verified by blake3 hash on each chunk + +**Transport:** TLS only for public content (no GEK). Content signed with the original node's Ed25519 key — clients verify authenticity even when served from a mirror. + +--- + +## 8. Indexes + +### 8.1 Mesh Directory (hub level) + +Public registry of groups, exchanged between hubs via MHP. + +Format: `msgpack`, signed with hub's Ed25519 key, carries `version` field. + +Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy, creation date. + +### 8.2 Mesh Group Index (node level) + +File listing for a group. Generated and maintained by the hosting node. + +Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + Ed25519 signature (public groups). + +Entry structure: +```python +{ + "version": 1, + "id": "<blake3_hash>", + "name": "filename.mkv", + "path": "Movies/2024/", + "size": 4294967296, + "type": "video", # video | audio | image | document | archive | other + "duration": 7245, # seconds, for media + "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted + "added_at": 1720000000 +} +``` + +Delta updates: `{ base_version, additions, deletions }` — no full re-encryption on each change. + +Transit: nodes push index deltas to connected members on change; members pull full index on first connection. Hub stores no index content. + +### 8.3 Search + +**Private groups:** entirely local on the client device. Client maintains a local encrypted cache of all group indexes it has received. No network call, no hub involvement, instant. + +**Public groups:** client queries nodes directly at request time. Hub provides routing only. + +**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this. In-memory micro-cache: **60-second TTL, RAM only, never persisted to disk, public content only.** Qualifies as technical caching under EU DSA Article 13 — not indexing. + +--- + +## 9. Web Client UI + +### 9.1 Architecture + +The web client is a Preact SPA served by the hub at `/app/`. It communicates +with the hub via HTTPS (auth, group management, signaling) and with nodes via +WebRTC DataChannel (file transfer, streaming, chat). The hub is never in the +data path. + +**Technology choices:** +- **Preact** (~3 KB gzipped): lightweight React-compatible framework +- **preact-router**: client-side routing (no server round-trips) +- **esbuild**: minification/bundling (single binary, no npm/node_modules) +- **SubtleCrypto**: browser-native AES-GCM for E2E decryption +- **IndexedDB**: local cache for group indexes (client-side search) + +No heavy frameworks (React, Vue, Angular). No build toolchain dependencies beyond +esbuild. ESM modules loaded natively by modern browsers. + +### 9.2 UI Structure + +``` +┌─────────────────────────────────────────────────────────┐ +│ [MeshBay] [User ▾] [⚙] │ +├──────────┬──────────────────────────────────────────────┤ +│ │ │ +│ Groups │ Main content area │ +│ │ │ +│ ● Private│ - File explorer (folders, files, download) │ +│ Group1 │ - Chat/forum view │ +│ Group2 │ - Video player (HLS via MediaSource API) │ +│ │ - Settings │ +│ ○ Public │ - Notifications feed │ +│ Group3 │ │ +│ │ │ +└──────────┴──────────────────────────────────────────────┘ +``` + +- **Left sidebar:** group list, ordered by usage frequency. Private groups first. + Collapses to hamburger menu on mobile viewports. +- **Top bar:** logo (left), user menu dropdown (right) — settings, profile, + language, online/offline status, logout. +- **Main area:** context-dependent content based on selected group and view. + +### 9.3 Views + +**Front page (no group selected):** +- Notification feed, prioritized: known contacts → private group activity → public +- System notifications (maintenance, updates) +- Quick access to recent groups + +**Group view — File Explorer:** +- Directory tree (folders, subfolders) — read-only browsing +- File metadata: name, size, type, date added +- Actions: download, stream (for media files) +- Files fetched directly from node via DataChannel +- Upload: photos/videos posted to the group's shared upload folder + (only the uploader or group admin can modify/delete) + +**Group view — Chat/Forum:** +- Sender Keys encrypted messages, fetched from node +- Post text, photos, videos (uploads go to node, not hub) +- Optional thread/topic structure for organized discussions +- Real-time push for connected members, pull history on reconnect + +**Group view — Video Player:** +- HLS segments fetched via DataChannel from node +- Decrypted client-side (GEK-derived key per segment) +- Played via MediaSource API (browser-native, no plugins) + +**Settings:** +- General: theme (dark/light/auto), language, notification preferences +- Per-group: notification mute, display options, filtering/blocking +- Privacy: online/offline status, profile visibility +- Profile: display name, avatar, account details + +### 9.4 Theming and i18n + +**Theme:** CSS custom properties for colors, toggled via: +1. `prefers-color-scheme` media query (OS default) +2. User override stored in localStorage +3. Toggle button in top bar or settings + +**i18n:** JSON translation files loaded client-side. +``` +static/i18n/ +├── en.json # English (default, always loaded) +├── fr.json # French (loaded on demand) +└── ... # Other languages added later +``` + +Keys are identifiers, not English text. Translation function: `t('group.join')`. + +### 9.5 meshbay.org Site Overlay + +meshbay.org serves both the generic hub application and site-specific pages: + +``` +site/ # meshbay.org-specific (not packaged with hub) +├── index.html # Landing page — project promotion, features +├── downloads.html # Package repos: Ubuntu, Fedora, Android APK +├── about.html # Project info, team, GitHub, contact +└── assets/ # Landing-specific CSS, images, icons +``` + +Caddy serves `site/` with priority. Requests not matching a static file fall +through to the hub FastAPI application. The hub serves `/app/` (SPA) and `/v1/` +(API). This separation ensures the hub package remains generic and deployable +by any operator, while meshbay.org has its own public-facing identity. + +### 9.6 Hub Mirror (future — design only) + +A mirror hub is a complete active-active replica of the primary hub. + +**Purpose:** load distribution for growing traffic. DNS round-robin (2+ A records). + +**Design:** +- Shared Ed25519 signing key (transferred once, securely) +- PostgreSQL logical replication for bidirectional read/write +- Both mirrors issue JWTs with the same key +- Both mirrors accept registrations, logins, and group operations +- If one mirror goes down, the other serves all traffic + +**Implementation constraints (must not violate in current development):** +- Hub config and key paths must be externalizable (already the case) +- No hub-instance-specific state that cannot be replicated +- JWT verification must not depend on hub-local state (already the case) +- Session state (refresh tokens, IP logs) must be in PostgreSQL (already the case) + +**Not implemented now.** Design documented to avoid blocking decisions. + +--- + +## 10. Hub Federation (MHP) <!-- was §9 in v3 --> + +### 9.1 Hub Hierarchy + +``` +Root Hub (meshbay.org) + ├── Full Hub (self-hosted, delegated CA) + │ └── issues user credentials, manages own groups + │ └── federates with other Full Hubs via MHP + └── Mirror Hub + └── hosts public Mesh Directory only (no user accounts, no key issuance) +``` + +A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub). Mirror Hubs can only replicate public directory data. Promotion/demotion is possible without breaking the protocol. + +### 9.2 MHP Design + +- Explicit peer selection: each hub maintains an allowlist of trusted peers +- No automatic hub discovery +- Exchanged: Mesh Directory (public groups), revocation lists, cross-hub user authentication data +- All MHP messages carry `version` field + +### 9.3 Cross-Hub Client Access + +1. Client (Hub A user) discovers a group on Hub B via Mesh Directory or direct link +2. Client presents Hub A JWT directly to Hub B +3. Hub B verifies JWT using Hub A's public key (fetched once, cached) +4. Hub B issues short-lived local session token +5. Client connects to node as normal + +--- + +## 11. Moderation <!-- was §10 in v3 --> + +### 10.1 Public Content + +``` +Report #1 → automatic suspension of public access + → node operator notified +One republication allowed +Report #2 → escalated to hub moderators +Confirmed → group revoked on local hub + → revocation propagated to federated hubs via MHP +``` + +Mechanism: `blake3` hash added to hub blocklist. Signed revocation token sent to node. + +### 10.2 CSAM + +Hash matching against NCMEC/IWF database on public content at registration time. No scanning of private/encrypted content. Participation is mandatory for hub operators and reduces legal exposure. + +### 10.3 Copyright + +DMCA/legal notice framework. Takedown on notification. No automated technical blocking (false positive risk, fair use). Hub can revoke on confirmed legal request. + +### 10.4 Private Content + +Not directly moderatable (E2E encrypted). Action available: revoke user or group at hub level on formal legal request. Hub issues Ed25519-signed revocation token verifiable by all member nodes offline. + +--- + +## 12. Python Extension Module System <!-- was §11 in v3 --> + +The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a core built-in feature, not a module.** + +**Module manifest:** +```python +{ + "name": "my-extension", + "version": "1.0.0", + "mnp_version": ">=1.0", + "permissions": ["read_index", "send_message", "receive_events"] +} +``` + +**Available APIs:** +- `read_index()` — read current group index (read-only) +- `send_message(content)` — post to group thread +- `receive_events(handler)` — subscribe to group events + +**Unavailable:** arbitrary network, filesystem access outside group context, system calls. + +--- + +## 13. Legal Framework <!-- was §12 in v3 --> + +**Node operator:** primary legal host of content. Fully responsible for what they share. Node setup communicates this explicitly. + +**Hub operator (meshbay.org):** registrar, not content host. Stores minimal data. Operates takedown mechanism. Participates in CSAM hash matching. Legal exposure analogous to a domain registrar. + +**Protocol/software author:** protected by substantial non-infringing uses. + +**Hub data:** +- Email and optional phone: kept for account recovery and legal compliance +- Password: Argon2id hash, never stored in cleartext +- Connection logs: retained per legal requirements (minimum 1 year) +- Content metadata: never stored +- Node current IP: not persisted (signaling is ephemeral) +- GEK bundles: opaque 48-byte ciphertext blobs; hub cannot decrypt them + +--- + +## 14. Future Features <!-- was §13 in v3 --> + +- **Mesh Relay:** community TURN relays, E2E encrypted traffic. Low priority — typical residential NAT works with ICE/STUN. Needed only for symmetric NAT (CGNAT mobile, ~15% of connections). +- ~~**QUIC transport (v2)**~~ ✅ DONE (Phase 5) — QUIC replaces TCP+TLS. +- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement +- **Hub mirror (load balancing):** design documented in §9.6. Active-active with shared key, PostgreSQL replication, DNS round-robin. Implementation deferred. +- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL. MNP `ephemeral_stream` type reserved. +- **Node–mobile pairing:** QR code from local web UI +- **Multi-source download:** parallel chunk fetching from swarm for public files +- **At-rest encryption on node:** optional for server-deployed nodes +- **OS keychain integration for keystore unlock** +- ~~**WebRTC**~~ ✅ Validated (Phase 9.1–9.5) — `aiortc` for browser-to-node P2P via DataChannel. Tested on SFR residential NAT (Port-Restricted Cone) + 4G CGNAT. No TURN needed. +- **Extension-triggered views:** local apps providing custom views for group content (gallery, kanban). MNP extension hook reserved. + +--- + +## 15. Open Questions [TBD] + +**Resolved by POC (no longer open):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R1 | Argon2id parameters: what values target ~500ms? | `iterations=3, memory_cost=262144` (256 MB). pw_version=2, transparent rehash on login. | Spike 1 + Phase 8.10 | +| R2 | JWT payload claims: what fields for offline node verification? | `jti` (UUID4), `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, `groups` claim. | Spike 3 + Phase 7 | +| R3 | GEK wrapping protocol: exact algorithm? | ECIES-like: ephemeral X25519 + HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1") + ChaCha20-Poly1305(aad=pk_recipient). | Spike 6 | +| R4 | NAT traversal: is STUN/hole-punching sufficient for residential users? | Yes for Cone NAT (SFR, Orange, Free). Relay needed only for symmetric NAT (CGNAT mobile). | Spike 4 | +| R5 | Transport: QUIC or TCP+TLS 1.3 for v1? | TCP+TLS 1.3 for v1, QUIC for v2. QUIC is now the active transport (Phase 5). | Spike 5 | +| R6 | Hub API: which endpoints for GEK distribution? | 4 endpoints confirmed. | Spike 6 | +| R7 | Package structure? | 3 packages: `meshbay-common`, `meshbay-hub`, `meshbay-node`. | POC | + +**Resolved by first security review (2026-08-10):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R8 | Group chat encryption model? | Sender Keys protocol. Double Ratchet kept for future 1:1 DM. | Security review C1 | +| R9 | Token denylist distribution? | Push via hub→node WebSocket. In-memory jti set on node. | Security review S3 | +| R10 | Chunk key HKDF: salt or info? | `info` (domain separation), `salt=None`. RFC 5869 compliant. | Security review M5 | +| R11 | AES-GCM keystore IV size? | 96-bit (12 bytes), per NIST SP 800-38D. | Security review S4 | + +**Resolved by Phase 8 implementation (2026-08-10):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R12 | Refresh token rotation? | One-time-use with family-based reuse detection. Old token reuse revokes entire family. | Phase 8.3 | +| R13 | Email encryption at rest? | AES-256-GCM, key derived from hub Ed25519 private key via HKDF(info="meshbay:email:v1"). | Phase 8.2 | +| R14 | Admin authorization model? | Config-based: `admin_usernames` in hub.toml + `MESHBAY_ADMIN_USERS` env var. | Phase 8.1 | +| R15 | QUIC migration timeline? | Done — QUIC is the active transport since Phase 5. | Phase 5 | + +**Resolved by web client design session (2026-08-10):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R16 | Browser transport for NAT traversal? | WebRTC DataChannel with ICE/STUN. WebTransport cannot work (port-restricted cone NAT). | Design session | +| R17 | Chat storage location? | On nodes, not hub. Hub never stores content. | Design session | +| R18 | Web UI framework? | Preact SPA (~3 KB), esbuild, dark/light theme, i18n, responsive. | Design session | +| R19 | Hub mirror design? | Active-active, shared signing key, PostgreSQL replication, DNS round-robin. | Design session | + +**Resolved by Phase 9 spike (2026-08-10):** + +| # | Question | Resolution | Source | +|---|---|---|---| +| R20 | WebRTC DataChannel validation? | Confirmed: browser→NAT→node file transfer works. Tested 3 scenarios on SFR residential (Port-Restricted Cone NAT) + 4G CGNAT: WiFi LAN (IPv6 direct, ~100ms), 4G IPv6 inter-network (~600ms), 4G IPv4 STUN hole-punch (~650ms). No TURN relay needed. | Phase 9.5 spike | + +**Still open:** + +1. **Refresh token validity:** 30 or 90 days? +2. **Group address scheme:** final URL format confirmation +3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? +4. **MHP federation sync frequency and conflict resolution** +5. **Chat attachment storage:** stored on node like regular files, or separate store? +6. **Relay registration protocol design** (when implemented) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index bbd1bc2..8f30745 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -119,7 +119,7 @@ async def node_websocket(ws: WebSocket): await ws.close(code=4001) return - node_id = decoded.get("sub", "unknown") + node_id = msg.get("node_id") or decoded.get("sub", "unknown") _connected_nodes[node_id] = ws log.info("Node WS connected: %s", node_id[:8]) await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id})) @@ -134,6 +134,9 @@ async def node_websocket(ws: WebSocket): event = _punch_events.get(node_id) if event: event.set() + elif msg.get("type") == "webrtc_answer": + from meshbay_hub.api.signaling import handle_webrtc_answer + handle_webrtc_answer(msg) except WebSocketDisconnect: log.info("Node WS disconnected: %s", (node_id or "unknown")[:8]) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py new file mode 100644 index 0000000..bd343c9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -0,0 +1,102 @@ +""" +WebRTC signaling — relay SDP/ICE between browser and node. + +The hub NEVER touches content. This is pure signaling: < 1 KB per message, +stateless relay. After the SDP exchange completes, the browser and node +communicate P2P via WebRTC DataChannel — hub is out of the loop. + +Flow: + Browser → Hub : POST /v1/nodes/{node_id}/webrtc/offer {sdp, ice_candidates} + Hub → Node : WS push {type: "webrtc_offer", sdp, ice_candidates, peer_id} + Node → Hub : WS reply {type: "webrtc_answer", sdp, ice_candidates, peer_id} + Hub → Browser : HTTP response {sdp, ice_candidates} +""" + +import asyncio +import json +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from meshbay_hub.api.deps import get_current_user +from meshbay_hub.db.models import User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/nodes", tags=["signaling"]) + +_webrtc_answers: dict[str, asyncio.Future] = {} + + +class WebRTCOfferRequest(BaseModel): + sdp: str + ice_candidates: list[dict] = [] + + +class WebRTCOfferResponse(BaseModel): + sdp: str + ice_candidates: list[dict] = [] + peer_id: str + + +@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse) +async def webrtc_offer( + node_id: str, + body: WebRTCOfferRequest, + current_user: User = Depends(get_current_user), +): + """ + Browser sends WebRTC SDP offer for a node. Hub relays via WebSocket. + Returns the node's SDP answer once received. + """ + from meshbay_hub.api.revocation import _connected_nodes + + ws = _connected_nodes.get(node_id) + if not ws: + raise HTTPException(status_code=404, detail="Node not connected") + + peer_id = str(uuid.uuid4()) + answer_future: asyncio.Future = asyncio.get_event_loop().create_future() + _webrtc_answers[peer_id] = answer_future + + try: + await ws.send_text(json.dumps({ + "type": "webrtc_offer", + "peer_id": peer_id, + "user_id": current_user.id, + "sdp": body.sdp, + "ice_candidates": body.ice_candidates, + })) + + try: + answer = await asyncio.wait_for(answer_future, timeout=15.0) + except asyncio.TimeoutError: + raise HTTPException( + status_code=504, detail="Node did not respond with WebRTC answer") + + return WebRTCOfferResponse( + sdp=answer["sdp"], + ice_candidates=answer.get("ice_candidates", []), + peer_id=peer_id, + ) + finally: + _webrtc_answers.pop(peer_id, None) + + +def handle_webrtc_answer(msg: dict) -> None: + """Called from the node WebSocket message loop when a webrtc_answer arrives.""" + peer_id = msg.get("peer_id") + if not peer_id: + log.warning("webrtc_answer without peer_id") + return + + future = _webrtc_answers.get(peer_id) + if future and not future.done(): + future.set_result({ + "sdp": msg.get("sdp", ""), + "ice_candidates": msg.get("ice_candidates", []), + }) + else: + log.warning("webrtc_answer for unknown peer_id: %s", peer_id) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 3d5e78b..6005927 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -27,6 +27,21 @@ async def app_js(): return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript") +@router.get("/transport.js") +async def transport_js(): + return FileResponse(STATIC_DIR / "transport.js", media_type="application/javascript") + + +@router.get("/crypto.js") +async def crypto_js(): + return FileResponse(STATIC_DIR / "crypto.js", media_type="application/javascript") + + +@router.get("/webrtc-test.html") +async def webrtc_test(): + return FileResponse(STATIC_DIR / "webrtc-test.html", media_type="text/html") + + @router.get("/", response_class=HTMLResponse) async def index(): return HTMLResponse(_HTML) diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index c80da28..7bd5ee3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -32,6 +32,7 @@ from meshbay_hub.api.federation import router as federation_router from meshbay_hub.csam import csam_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.relay import router as relay_router +from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.webapp import router as webapp_router from meshbay_hub.api.middleware import limiter @@ -93,6 +94,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(csam_router) app.include_router(health_router) app.include_router(relay_router) + app.include_router(signaling_router) app.include_router(webapp_router) return app diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js new file mode 100644 index 0000000..9112734 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -0,0 +1,409 @@ +/** + * MeshBay Browser Transport — WebRTC DataChannel client. + * + * Connects to a MeshBay node via WebRTC DataChannel (P2P, E2E). + * The hub is only used for signaling (SDP/ICE relay) — after connection, + * all data flows directly between browser and node. + * + * Wire format: length-prefixed msgpack (4-byte big-endian + msgpack payload). + * Same format as QUIC and TCP+TLS transports on the node side. + * + * Usage: + * const transport = new MeshBayTransport(hubUrl, accessToken); + * await transport.connect(nodeId, jwtToken, groupId); + * const index = await transport.fetchIndex(); + * const chunk = await transport.fetchChunk(fileId, 0); + * transport.close(); + */ + +class MeshBayTransport { + constructor(hubUrl, accessToken) { + this._hubUrl = hubUrl; + this._accessToken = accessToken; + this._pc = null; + this._channel = null; + this._pending = new Map(); + this._seqId = 0; + this._recvBuf = new Uint8Array(0); + this._connected = false; + this._onChat = null; + } + + get connected() { return this._connected; } + + set onChat(fn) { this._onChat = fn; } + + async connect(nodeId, jwtToken, groupId) { + this._pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + + this._channel = this._pc.createDataChannel('mnp', { ordered: true }); + this._channel.binaryType = 'arraybuffer'; + + const channelReady = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); + this._channel.onopen = () => { + clearTimeout(timeout); + this._connected = true; + resolve(); + }; + this._channel.onerror = (e) => { + clearTimeout(timeout); + reject(new Error('DataChannel error: ' + e.message)); + }; + }); + + this._channel.onmessage = (event) => this._onMessage(event.data); + this._channel.onclose = () => { this._connected = false; }; + + const offer = await this._pc.createOffer(); + await this._pc.setLocalDescription(offer); + + await new Promise((resolve) => { + if (this._pc.iceGatheringState === 'complete') return resolve(); + this._pc.onicegatheringstatechange = () => { + if (this._pc.iceGatheringState === 'complete') resolve(); + }; + }); + + const resp = await fetch(`${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this._accessToken}`, + }, + body: JSON.stringify({ + sdp: this._pc.localDescription.sdp, + ice_candidates: [], + }), + }); + + if (!resp.ok) { + const detail = await resp.json().catch(() => ({})); + throw new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); + } + + const answer = await resp.json(); + await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); + + await channelReady; + + const ack = await this._sendAndWait({ + type: 'handshake', + v: '0.1', + token: jwtToken, + group_id: groupId || '', + }); + + if (ack.type !== 'handshake_ack') { + throw new Error('MNP handshake rejected: ' + (ack.detail || JSON.stringify(ack))); + } + + return ack; + } + + async fetchIndex() { + const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return _b64decode(msg.index_b64); + } + + async fetchChunk(fileId, chunkIndex) { + const msg = await this._sendAndWait({ + type: 'file_req', + v: '0.1', + file_id: fileId, + chunk_index: chunkIndex, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async fetchStreamSegment(fileId, segmentIndex, segmentDuration) { + const msg = await this._sendAndWait({ + type: 'stream_seg', + v: '0.1', + file_id: fileId, + segment_index: segmentIndex, + segment_duration: segmentDuration || 4, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return _b64decode(msg.data_b64); + } + + async sendChat(payload, iteration, threadId) { + const msg = await this._sendAndWait({ + type: 'chat_msg', + v: '0.1', + payload: payload, + iteration: iteration || 0, + thread_id: threadId || null, + }); + return msg; + } + + close() { + if (this._channel) this._channel.close(); + if (this._pc) this._pc.close(); + this._connected = false; + for (const [, p] of this._pending) p.reject(new Error('Transport closed')); + this._pending.clear(); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + _sendAndWait(obj) { + return new Promise((resolve, reject) => { + const id = this._seqId++; + const timeout = setTimeout(() => { + this._pending.delete(id); + reject(new Error('Response timeout')); + }, 30000); + this._pending.set(id, { + resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, + reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, + }); + this._send(obj); + }); + } + + _send(obj) { + const encoded = msgpack_encode(obj); + const header = new Uint8Array(4); + new DataView(header.buffer).setUint32(0, encoded.byteLength, false); + const frame = new Uint8Array(4 + encoded.byteLength); + frame.set(header); + frame.set(encoded, 4); + this._channel.send(frame); + } + + _onMessage(data) { + const incoming = new Uint8Array(data); + const combined = new Uint8Array(this._recvBuf.length + incoming.length); + combined.set(this._recvBuf); + combined.set(incoming, this._recvBuf.length); + this._recvBuf = combined; + + while (this._recvBuf.length >= 4) { + const len = new DataView(this._recvBuf.buffer, this._recvBuf.byteOffset).getUint32(0, false); + if (this._recvBuf.length < 4 + len) break; + const msgBytes = this._recvBuf.slice(4, 4 + len); + this._recvBuf = this._recvBuf.slice(4 + len); + + const msg = msgpack_decode(msgBytes); + this._dispatch(msg); + } + } + + _dispatch(msg) { + if (msg.type === 'chat_msg' && this._onChat) { + this._onChat(msg); + return; + } + + const oldest = this._pending.entries().next(); + if (!oldest.done) { + const [, handler] = oldest.value; + handler.resolve(msg); + } + } +} + +// ── Minimal msgpack encode/decode ──────────────────────────────────────────── +// Covers the subset used by MNP: maps, strings, integers, binary, arrays, null. + +function msgpack_encode(obj) { + const parts = []; + _encodeValue(obj, parts); + const total = parts.reduce((s, p) => s + p.length, 0); + const result = new Uint8Array(total); + let off = 0; + for (const p of parts) { result.set(p, off); off += p.length; } + return result; +} + +function _encodeValue(val, parts) { + if (val === null || val === undefined) { + parts.push(new Uint8Array([0xc0])); + } else if (typeof val === 'boolean') { + parts.push(new Uint8Array([val ? 0xc3 : 0xc2])); + } else if (typeof val === 'number') { + if (Number.isInteger(val)) { + if (val >= 0 && val <= 127) { + parts.push(new Uint8Array([val])); + } else if (val >= 0 && val <= 0xff) { + parts.push(new Uint8Array([0xcc, val])); + } else if (val >= 0 && val <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xcd; + new DataView(b.buffer).setUint16(1, val, false); + parts.push(b); + } else if (val >= 0 && val <= 0xffffffff) { + const b = new Uint8Array(5); b[0] = 0xce; + new DataView(b.buffer).setUint32(1, val, false); + parts.push(b); + } else if (val >= -32 && val < 0) { + parts.push(new Uint8Array([val & 0xff])); + } else if (val >= -128 && val < 0) { + const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff; + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xd2; + new DataView(b.buffer).setInt32(1, val, false); + parts.push(b); + } + } else { + const b = new Uint8Array(9); b[0] = 0xcb; + new DataView(b.buffer).setFloat64(1, val, false); + parts.push(b); + } + } else if (typeof val === 'string') { + const encoded = new TextEncoder().encode(val); + if (encoded.length <= 31) { + parts.push(new Uint8Array([0xa0 | encoded.length])); + } else if (encoded.length <= 0xff) { + parts.push(new Uint8Array([0xd9, encoded.length])); + } else if (encoded.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xda; + new DataView(b.buffer).setUint16(1, encoded.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdb; + new DataView(b.buffer).setUint32(1, encoded.length, false); + parts.push(b); + } + parts.push(encoded); + } else if (val instanceof Uint8Array) { + if (val.length <= 0xff) { + parts.push(new Uint8Array([0xc4, val.length])); + } else if (val.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xc5; + new DataView(b.buffer).setUint16(1, val.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xc6; + new DataView(b.buffer).setUint32(1, val.length, false); + parts.push(b); + } + parts.push(val); + } else if (Array.isArray(val)) { + if (val.length <= 15) { + parts.push(new Uint8Array([0x90 | val.length])); + } else if (val.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xdc; + new DataView(b.buffer).setUint16(1, val.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdd; + new DataView(b.buffer).setUint32(1, val.length, false); + parts.push(b); + } + for (const item of val) _encodeValue(item, parts); + } else if (typeof val === 'object') { + const keys = Object.keys(val); + if (keys.length <= 15) { + parts.push(new Uint8Array([0x80 | keys.length])); + } else if (keys.length <= 0xffff) { + const b = new Uint8Array(3); b[0] = 0xde; + new DataView(b.buffer).setUint16(1, keys.length, false); + parts.push(b); + } else { + const b = new Uint8Array(5); b[0] = 0xdf; + new DataView(b.buffer).setUint32(1, keys.length, false); + parts.push(b); + } + for (const k of keys) { + _encodeValue(k, parts); + _encodeValue(val[k], parts); + } + } +} + +function msgpack_decode(buf) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const [val] = _decodeValue(buf, view, 0); + return val; +} + +function _decodeValue(buf, view, offset) { + const byte = buf[offset]; + + if (byte <= 0x7f) return [byte, offset + 1]; + if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1]; + if ((byte & 0xa0) === 0xa0) { + const len = byte & 0x1f; + return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len]; + } + if ((byte & 0xf0) === 0x90) { + const len = byte & 0x0f; + return _decodeArray(buf, view, offset + 1, len); + } + if ((byte & 0xf0) === 0x80) { + const len = byte & 0x0f; + return _decodeMap(buf, view, offset + 1, len); + } + + switch (byte) { + case 0xc0: return [null, offset + 1]; + case 0xc2: return [false, offset + 1]; + case 0xc3: return [true, offset + 1]; + case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; } + case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; } + case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; } + case 0xcc: return [buf[offset + 1], offset + 2]; + case 0xcd: return [view.getUint16(offset + 1, false), offset + 3]; + case 0xce: return [view.getUint32(offset + 1, false), offset + 5]; + case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9]; + case 0xd0: return [view.getInt8(offset + 1), offset + 2]; + case 0xd1: return [view.getInt16(offset + 1, false), offset + 3]; + case 0xd2: return [view.getInt32(offset + 1, false), offset + 5]; + case 0xd9: { + const len = buf[offset + 1]; + return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len]; + } + case 0xda: { + const len = view.getUint16(offset + 1, false); + return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len]; + } + case 0xdb: { + const len = view.getUint32(offset + 1, false); + return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len]; + } + case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); } + case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); } + case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); } + case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); } + default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`); + } +} + +function _decodeArray(buf, view, offset, count) { + const arr = []; + for (let i = 0; i < count; i++) { + const [val, newOff] = _decodeValue(buf, view, offset); + arr.push(val); + offset = newOff; + } + return [arr, offset]; +} + +function _decodeMap(buf, view, offset, count) { + const obj = {}; + for (let i = 0; i < count; i++) { + const [key, off1] = _decodeValue(buf, view, offset); + const [val, off2] = _decodeValue(buf, view, off1); + obj[key] = val; + offset = off2; + } + return [obj, offset]; +} + +function _b64decode(b64) { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +// Export +window.MeshBayTransport = MeshBayTransport; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html new file mode 100644 index 0000000..0d5750b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html @@ -0,0 +1,258 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>MeshBay — WebRTC Spike Test</title> + <style> + *, *::before, *::after { box-sizing: border-box; } + body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } + .container { max-width: 800px; margin: 32px auto; padding: 0 16px; } + h1 { color: #38bdf8; font-size: 1.4em; } + h2 { color: #94a3b8; font-size: 1.1em; margin-top: 2em; } + .step { background: #1e293b; border: 1px solid #334155; border-radius: 8px; + padding: 16px; margin: 12px 0; } + .step.done { border-color: #22c55e; } + .step.fail { border-color: #ef4444; } + .step.active { border-color: #38bdf8; } + input { padding: 8px 12px; border: 1px solid #475569; border-radius: 6px; + background: #0f172a; color: #e2e8f0; font-size: 0.95em; margin: 4px; width: 240px; } + button { padding: 8px 20px; background: #0ea5e9; color: #fff; border: none; + border-radius: 6px; cursor: pointer; font-size: 0.95em; margin: 4px; } + button:hover { background: #0284c7; } + button:disabled { background: #475569; cursor: not-allowed; } + #log { background: #020617; border: 1px solid #1e293b; border-radius: 8px; + padding: 12px; font-family: monospace; font-size: 0.85em; line-height: 1.6; + max-height: 400px; overflow-y: auto; white-space: pre-wrap; } + .ok { color: #22c55e; } + .err { color: #ef4444; } + .info { color: #38bdf8; } + .warn { color: #f59e0b; } + .dim { color: #64748b; } + .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 2px 8px; + border-radius: 4px; font-size: 0.8em; font-weight: bold; margin-left: 8px; } + .badge.fail { background: #ef4444; color: #fff; } + </style> +</head> +<body> +<div class="container"> + <h1>MeshBay — WebRTC DataChannel Spike Test</h1> + <p class="dim">Phase 9.5 — E2E browser → NAT → node file transfer via WebRTC</p> + + <div class="step" id="step-login"> + <h2>1. Login to Hub</h2> + <input id="username" placeholder="Username" value="bob"> + <input id="password" placeholder="Password" type="password" value="bob"> + <button id="btn-login" onclick="doLogin()">Login</button> + <span id="login-status"></span> + </div> + + <div class="step" id="step-connect"> + <h2>2. Connect to Node via WebRTC</h2> + <input id="node-id" placeholder="Node ID"> + <input id="group-id" placeholder="Group ID (optional)"> + <button id="btn-connect" onclick="doConnect()" disabled>Connect</button> + <span id="connect-status"></span> + </div> + + <div class="step" id="step-transfer"> + <h2>3. File Transfer Test</h2> + <button id="btn-index" onclick="doFetchIndex()" disabled>Fetch Index</button> + <br> + <input id="file-id" placeholder="File ID (blake3 hex, from node log)"> + <button id="btn-chunk" onclick="doFetchChunk()" disabled>Fetch Chunk</button> + <span id="transfer-status"></span> + </div> + + <h2>Log</h2> + <div id="log"></div> +</div> + +<script src="/transport.js"></script> +<script> +const HUB_URL = window.location.origin; +const params = new URLSearchParams(window.location.search); +let accessToken = null; +let jwtToken = null; +let transport = null; +let fileIndex = null; + +// Pre-fill from URL params +if (params.get('user')) document.getElementById('username').value = params.get('user'); +if (params.get('pass')) document.getElementById('password').value = params.get('pass'); +if (params.get('node')) document.getElementById('node-id').value = params.get('node'); +if (params.get('group')) document.getElementById('group-id').value = params.get('group'); +if (params.get('file')) document.getElementById('file-id').value = params.get('file'); + +// Auto-run if all params provided +if (params.get('auto')) { + setTimeout(async () => { + await doLogin(); + if (accessToken) await doConnect(); + if (transport && transport.connected) { + await doFetchIndex(); + if (document.getElementById('file-id').value) await doFetchChunk(); + } + }, 500); +} + +function logMsg(cls, text) { + const el = document.getElementById('log'); + const line = document.createElement('span'); + line.className = cls; + line.textContent = text + '\n'; + el.appendChild(line); + el.scrollTop = el.scrollHeight; +} + +function setStep(id, state) { + const el = document.getElementById(id); + el.className = 'step ' + state; +} + +async function doLogin() { + const user = document.getElementById('username').value; + const pass = document.getElementById('password').value; + logMsg('info', `Logging in as ${user}...`); + setStep('step-login', 'active'); + + try { + const resp = await fetch(`${HUB_URL}/v1/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass }), + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.detail || resp.statusText); + } + + const data = await resp.json(); + accessToken = data.access_token; + jwtToken = data.access_token; + logMsg('ok', `Login OK — token: ${accessToken.substring(0, 20)}...`); + setStep('step-login', 'done'); + document.getElementById('login-status').innerHTML = '<span class="badge">OK</span>'; + document.getElementById('btn-connect').disabled = false; + } catch (e) { + logMsg('err', `Login FAILED: ${e.message}`); + setStep('step-login', 'fail'); + document.getElementById('login-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} + +async function doConnect() { + const nodeId = document.getElementById('node-id').value; + const groupId = document.getElementById('group-id').value; + if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; } + + logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`); + setStep('step-connect', 'active'); + + try { + transport = new MeshBayTransport(HUB_URL, accessToken); + + logMsg('dim', ' Creating RTCPeerConnection...'); + logMsg('dim', ' Creating DataChannel "mnp"...'); + logMsg('dim', ' Gathering ICE candidates...'); + logMsg('dim', ' Sending SDP offer to hub...'); + + const t0 = performance.now(); + const ack = await transport.connect(nodeId, jwtToken, groupId); + const elapsed = (performance.now() - t0).toFixed(0); + + logMsg('ok', `WebRTC connected in ${elapsed}ms`); + logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`); + logMsg('ok', ` DataChannel: open, ordered, reliable`); + setStep('step-connect', 'done'); + document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>'; + document.getElementById('btn-index').disabled = false; + document.getElementById('btn-chunk').disabled = false; + } catch (e) { + logMsg('err', `Connection FAILED: ${e.message}`); + setStep('step-connect', 'fail'); + document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} + +async function doFetchIndex() { + logMsg('info', 'Fetching Mesh Group Index via DataChannel...'); + try { + const t0 = performance.now(); + const indexBytes = await transport.fetchIndex(); + const elapsed = (performance.now() - t0).toFixed(0); + + logMsg('ok', `Index received: ${indexBytes.byteLength} bytes in ${elapsed}ms`); + + try { + const envelope = msgpack_decode(indexBytes); + logMsg('dim', ` type: ${envelope.type}, encrypted: ${envelope.encrypted}, version: ${envelope.version}`); + logMsg('dim', ` group_id: ${envelope.group_id}`); + + if (envelope.encrypted) { + logMsg('warn', ` Index is GEK-encrypted — browser decryption not implemented in spike`); + logMsg('dim', ` ct_b64 length: ${envelope.ct_b64?.length || 0} chars`); + logMsg('info', ` Spike workaround: enter a file_id manually or use Fetch First Chunk`); + // Store envelope so chunk test can proceed with manual file_id + fileIndex = { entries: [], envelope }; + } else { + // Public group: decompress and parse + logMsg('dim', ` Public index — data_b64 length: ${envelope.data_b64?.length || 0}`); + fileIndex = { entries: [], envelope }; + } + } catch (pe) { + logMsg('warn', ` Could not parse index envelope: ${pe.message}`); + } + } catch (e) { + logMsg('err', `Index fetch FAILED: ${e.message}`); + } +} + +async function doFetchChunk() { + let fileId = document.getElementById('file-id').value.trim(); + + if (!fileId) { + logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)'); + logMsg('dim', ' Look for "Initial scan complete" in the node terminal'); + logMsg('dim', ' Or run: python -c "import blake3; print(blake3.blake3(open(\'QE/demo-v3/shared_media/sample.txt\',\'rb\').read()).hexdigest())"'); + return; + } + + logMsg('info', `Fetching chunk 0 of file ${fileId.substring(0, 16)}... via DataChannel...`); + + try { + const t0 = performance.now(); + const chunkMsg = await transport.fetchChunk(fileId, 0); + const elapsed = (performance.now() - t0).toFixed(0); + + if (chunkMsg.type === 'error') { + logMsg('err', `Chunk fetch error: ${chunkMsg.detail}`); + return; + } + + logMsg('ok', `Chunk received in ${elapsed}ms:`); + logMsg('ok', ` type: ${chunkMsg.type}`); + logMsg('ok', ` chunk_index: ${chunkMsg.chunk_index}`); + logMsg('ok', ` plaintext_size: ${chunkMsg.plaintext_size} bytes`); + logMsg('ok', ` ct_b64 length: ${chunkMsg.ct_b64?.length || 0} chars`); + logMsg('ok', ` nonce_b64: ${chunkMsg.nonce_b64?.substring(0, 16)}...`); + logMsg('ok', ` sig_b64: ${chunkMsg.sig_b64?.substring(0, 16)}...`); + + logMsg('', ''); + logMsg('ok', '=== SPIKE TEST PASSED ==='); + logMsg('ok', 'Browser connected to node via WebRTC DataChannel.'); + logMsg('ok', 'MNP handshake, index sync, and file chunk transfer all work.'); + logMsg('ok', 'Data flowed P2P — hub was only used for signaling.'); + + setStep('step-transfer', 'done'); + document.getElementById('transfer-status').innerHTML = '<span class="badge">E2E OK</span>'; + } catch (e) { + logMsg('err', `Chunk fetch FAILED: ${e.message}`); + setStep('step-transfer', 'fail'); + document.getElementById('transfer-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} +</script> +</body> +</html> diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 327158f..a9c97a3 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -483,6 +483,76 @@ async def test_password_rehash_on_login(client, app): assert r.status_code == 200 +# ── WebRTC signaling (9.2) ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_webrtc_offer_no_node(client): + """WebRTC offer to a non-connected node returns 404.""" + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "sig_user", "email": "sig@x.com", "password": "sigpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "sig_user", "password": "sigpass99"}) + token = r.json()["access_token"] + + r = await client.post("/v1/nodes/fake-node-id/webrtc/offer", + json={"sdp": "v=0\r\n...", "ice_candidates": []}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 404 + assert "not connected" in r.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_webrtc_signaling_roundtrip(client, app): + """WebRTC signaling: offer relayed to node via WS, answer returned to browser.""" + import asyncio + import json + + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "sig_user2", "email": "sig2@x.com", "password": "sigpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "sig_user2", "password": "sigpass99"}) + token = r.json()["access_token"] + + from meshbay_hub.api.revocation import _connected_nodes + from meshbay_hub.api.signaling import handle_webrtc_answer + + class FakeWS: + def __init__(self): + self.sent = [] + + async def send_text(self, text): + self.sent.append(json.loads(text)) + msg = self.sent[-1] + if msg.get("type") == "webrtc_offer": + await asyncio.sleep(0.01) + handle_webrtc_answer({ + "type": "webrtc_answer", + "peer_id": msg["peer_id"], + "sdp": "v=0\r\nanswer-sdp", + "ice_candidates": [{"candidate": "test"}], + }) + + fake_ws = FakeWS() + node_id = "test-node-sig" + _connected_nodes[node_id] = fake_ws + + try: + r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer", + json={"sdp": "v=0\r\noffer-sdp", "ice_candidates": []}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + data = r.json() + assert "answer-sdp" in data["sdp"] + assert len(data["ice_candidates"]) == 1 + assert "peer_id" in data + finally: + _connected_nodes.pop(node_id, None) + + # ── IP log cleanup (8.9) ──────────────────────────────────────────────────── @pytest.mark.asyncio diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index 805871e..592de54 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "aioice>=0.9", # ICE/STUN for NAT traversal "aioquic>=1.0", # QUIC transport (MNP v2) — implemented in Phase 5 "websockets>=12.0", # hub→node revocation push + "aiortc>=1.9", # WebRTC DataChannel for browser P2P (Phase 9) ] [project.optional-dependencies] diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 74851c1..3e77ed0 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -250,10 +250,11 @@ class HubClient: self, on_incoming: Any = None, on_revocation: Any = None, + on_webrtc_offer: Any = None, ) -> None: """ Maintain a persistent WebSocket connection to the hub. - Receives NAT punch requests and revocation tokens. + Receives NAT punch requests, revocation tokens, and WebRTC offers. Runs until cancelled. """ import websockets @@ -270,6 +271,7 @@ class HubClient: await ws.send(json.dumps({ "type": "auth", "token": self._session.access_token, + "node_id": self._session.node_id, })) auth_resp = json.loads(await ws.recv()) if auth_resp.get("type") != "auth_ok": @@ -289,6 +291,18 @@ class HubClient: elif mtype == "revocation" and on_revocation: on_revocation(msg.get("token", "")) + elif mtype == "webrtc_offer" and on_webrtc_offer: + answer = await on_webrtc_offer( + msg["sdp"], msg["peer_id"], + msg.get("ice_candidates", [])) + if answer: + await ws.send(json.dumps({ + "type": "webrtc_answer", + "peer_id": msg["peer_id"], + "sdp": answer[0], + "ice_candidates": answer[1], + })) + elif mtype == "pong": pass diff --git a/packages/meshbay-node/src/meshbay_node/transport/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/__init__.py index 5a1b8d7..df9c209 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/transport/__init__.py @@ -1,10 +1,9 @@ -"""MeshBay Node transport layer — TCP+TLS (MNP v1) and QUIC (MNP v2).""" +"""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 # QUIC transport (MNP v2) — requires aioquic>=1.0 -# Falls back gracefully if not installed; node still works via TCP+TLS and HTTP. try: from .quic_server import QuicChunkServer, Denylist from .quic_client import QuicChunkClient @@ -15,7 +14,17 @@ except ImportError: Denylist = None # type: ignore[assignment,misc] QUIC_AVAILABLE = False +# WebRTC transport (browsers) — requires aiortc>=1.9 +try: + from .webrtc_server import WebRTCTransport, WebRTCPeerSession + WEBRTC_AVAILABLE = True +except ImportError: + WebRTCTransport = None # type: ignore[assignment,misc] + WebRTCPeerSession = None # type: ignore[assignment,misc] + 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/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py new file mode 100644 index 0000000..89391b9 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -0,0 +1,373 @@ +""" +MeshBay Node — WebRTC DataChannel server for browser clients. + +Browsers cannot use QUIC for NAT traversal (WebTransport doesn't allow choosing +the UDP source port — Port-Restricted Cone NAT requires exact port matching). +WebRTC DataChannel with ICE/STUN handles this automatically. + +The MNP protocol (handshake, file_request, file_chunk, chat, etc.) runs +identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption, +same message types, same msgpack wire format. + +Wire format on the DataChannel: + - Each message is length-prefixed msgpack (4-byte big-endian + msgpack payload) + - Same as QUIC streams and TCP+TLS + - DataChannel is ordered and reliable (SCTP over DTLS) + +Signaling flow (handled externally by the hub): + Browser → Hub : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates} + Hub → Node : WS push {type: "webrtc_offer", sdp, ice_candidates, peer_id} + Node → Hub : WS push {type: "webrtc_answer", sdp, ice_candidates, peer_id} + Hub → Browser : SSE/response {sdp, ice_candidates} + After signaling, DataChannel is P2P — hub is out of the loop. +""" + +import asyncio +import base64 +import logging +import struct +from pathlib import Path +from typing import Any + +import blake3 +import jwt +import msgpack +from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel +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 + +log = logging.getLogger(__name__) + +CHUNK_SIZE = 1024 * 1024 +MAX_MSG = 64 * 1024 * 1024 + + +def _pack(obj: dict) -> bytes: + data = msgpack.packb(obj, use_bin_type=True) + return struct.pack(">I", len(data)) + data + + +class _DataChannelBuffer: + """Accumulate DataChannel messages and extract length-prefixed msgpack.""" + + def __init__(self): + self._buf = bytearray() + + def feed(self, data: bytes): + self._buf.extend(data) + + def messages(self): + while len(self._buf) >= 4: + length = struct.unpack(">I", self._buf[:4])[0] + if length > MAX_MSG: + raise ValueError(f"Message too large: {length}") + if len(self._buf) < 4 + length: + break + msg_bytes = bytes(self._buf[4:4 + length]) + del self._buf[:4 + length] + yield msgpack.unpackb(msg_bytes, raw=False) + + +class WebRTCPeerSession: + """One WebRTC peer connection, handling MNP over a DataChannel.""" + + def __init__(self, pc: RTCPeerConnection, node_ctx: dict): + self._pc = pc + self._ctx = node_ctx + self._channel: RTCDataChannel | None = None + self._buffer = _DataChannelBuffer() + self._user_id: str | None = None + self._group_id: str | None = None + + def _setup_channel(self, channel: RTCDataChannel) -> None: + self._channel = channel + + @channel.on("message") + def on_message(message): + if isinstance(message, str): + message = message.encode() + self._buffer.feed(message) + for msg in self._buffer.messages(): + self._handle_message(msg) + + def _handle_message(self, msg: dict) -> None: + mtype = msg.get("type") + try: + if mtype == MNP.HANDSHAKE: + self._do_handshake(msg) + elif self._user_id is None: + self._send({"type": "error", "detail": "Handshake required"}) + elif mtype == MNP.INDEX_SYNC: + self._do_index_sync() + elif mtype == MNP.FILE_REQUEST: + self._do_file_request(msg) + elif mtype == MNP.STREAM_SEGMENT: + self._do_stream_segment(msg) + elif mtype == MNP.CHAT_MESSAGE: + self._do_chat_message(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)}) + + 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}"}) + 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"}) + 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"}) + return + + self._user_id = decoded["sub"] + self._group_id = group_id + + log.info("WebRTC handshake OK — user=%s group=%s", + self._user_id[:8], group_id[:8] if group_id else "none") + self._send({ + "type": MNP.HANDSHAKE_ACK, + "v": MNP_VERSION, + "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), + }) + + def _group_ctx(self) -> dict: + if "groups" in self._ctx and self._group_id: + return self._ctx["groups"][self._group_id] + return self._ctx + + def _do_index_sync(self) -> None: + ctx = self._group_ctx() + wire = ctx["index"].serialize() + self._send({ + "type": MNP.INDEX_SYNC, + "v": MNP_VERSION, + "index_b64": base64.b64encode(wire).decode(), + }) + + def _do_file_request(self, msg: dict) -> None: + ctx = self._group_ctx() + file_id = msg["file_id"] + chunk_index = msg["chunk_index"] + entry = ctx["index"].get_entry(file_id) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + + file_path = ctx["shared_root"] / entry.path / entry.name + if not file_path.exists(): + self._send({"type": "error", "detail": "File not on disk"}) + return + + chunk_data = _read_and_encrypt( + self._ctx["sk_node"], + ctx["gek"], + file_path, + chunk_index, + ) + self._send(chunk_data) + + def _do_stream_segment(self, msg: dict) -> None: + ctx = self._group_ctx() + file_id = msg["file_id"] + segment_index = msg["segment_index"] + segment_duration = msg.get("segment_duration", 4) + + entry = ctx["index"].get_entry(file_id) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + + file_path = ctx["shared_root"] / entry.path / entry.name + if not file_path.exists(): + self._send({"type": "error", "detail": "File not on disk"}) + return + + import subprocess + 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: + self._send({"type": "error", "detail": "Segment extraction failed"}) + return + segment_data = result.stdout + except Exception: + self._send({"type": "error", "detail": "Segment extraction failed"}) + return + + self._send({ + "type": MNP.STREAM_SEGMENT, + "v": MNP_VERSION, + "file_id": file_id, + "segment_index": segment_index, + "data_b64": base64.b64encode(segment_data).decode(), + "size": len(segment_data), + }) + + def _do_chat_message(self, msg: dict) -> None: + chat_store = self._ctx.get("chat_store") + if chat_store: + asyncio.ensure_future(chat_store.save_message( + sender_id=msg.get("sender_id", self._user_id), + iteration=msg.get("iteration", 0), + payload=msg.get("payload", b"").encode() + if isinstance(msg.get("payload"), str) else msg.get("payload", b""), + thread_id=msg.get("thread_id"), + )) + self._send({"type": "ack", "v": MNP_VERSION}) + + def _send(self, obj: dict) -> None: + if self._channel and self._channel.readyState == "open": + self._channel.send(_pack(obj)) + + async def close(self) -> None: + await self._pc.close() + + +def _read_and_encrypt( + sk_node: Ed25519PrivateKey, + gek: bytes, + file_path: Path, + chunk_index: int, +) -> dict: + with open(file_path, "rb") as f: + f.seek(chunk_index * CHUNK_SIZE) + plaintext = f.read(CHUNK_SIZE) + + file_hash = blake3.blake3(file_path.read_bytes()).digest() + 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(), + } + + +class WebRTCTransport: + """ + Manages WebRTC peer connections for browser clients. + + Usage: + transport = WebRTCTransport(sk_node, hub_pk_pem, gek, shared_root, index) + answer_sdp = await transport.handle_offer(offer_sdp, peer_id) + # Return answer_sdp to the browser via hub signaling + """ + + def __init__( + self, + sk_node: Ed25519PrivateKey, + hub_pk_pem: bytes, + gek: bytes, + shared_root: Path, + index: GroupIndex, + groups: dict[str, dict] | None = None, + denylist: Any | None = None, + stun_servers: list[str] | None = None, + ): + self._ctx: dict[str, Any] = { + "sk_node": sk_node, + "hub_pk_pem": hub_pk_pem, + "gek": gek, + "shared_root": shared_root, + "index": index, + } + if groups: + self._ctx["groups"] = groups + if denylist: + self._ctx["denylist"] = denylist + self._stun = stun_servers or ["stun:stun.l.google.com:19302"] + self._sessions: dict[str, WebRTCPeerSession] = {} + + async def handle_offer( + self, offer_sdp: str, peer_id: str, + ) -> tuple[str, list[dict]]: + """ + Process a WebRTC SDP offer from a browser client. + + Returns (answer_sdp, ice_candidates) to relay back via hub signaling. + ICE candidates are embedded in the SDP (aiortc gathers before returning). + """ + from aiortc import RTCIceServer, RTCConfiguration + + config = RTCConfiguration( + iceServers=[RTCIceServer(urls=s) for s in self._stun] if self._stun else [] + ) + pc = RTCPeerConnection(configuration=config) + session = WebRTCPeerSession(pc, self._ctx) + self._sessions[peer_id] = session + + @pc.on("datachannel") + def on_datachannel(channel: RTCDataChannel): + log.info("WebRTC DataChannel opened: %s (peer=%s)", channel.label, peer_id) + session._setup_channel(channel) + + @pc.on("connectionstatechange") + async def on_state_change(): + state = pc.connectionState + log.info("WebRTC connection state: %s (peer=%s)", state, peer_id) + if state in ("failed", "closed"): + self._sessions.pop(peer_id, None) + + offer = RTCSessionDescription(sdp=offer_sdp, type="offer") + await pc.setRemoteDescription(offer) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + + log.info("WebRTC answer ready for peer=%s", peer_id) + return pc.localDescription.sdp, [] + + async def close_peer(self, peer_id: str) -> None: + session = self._sessions.pop(peer_id, None) + if session: + await session.close() + + async def close_all(self) -> None: + for session in self._sessions.values(): + await session.close() + self._sessions.clear() + + @property + def active_peers(self) -> int: + return len(self._sessions) diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py new file mode 100644 index 0000000..4c0fdbf --- /dev/null +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -0,0 +1,326 @@ +""" +Integration test: WebRTC DataChannel transport for browser clients. + +Phase 9 milestone 9.1 — spike: validate aiortc WebRTC DataChannel works +for MNP protocol exchange (handshake, index_sync, file_request, file_chunk). + +Uses local loopback (no STUN/ICE needed for localhost). +""" + +import asyncio +import base64 +import os +import struct +import time + +import blake3 +import jwt +import msgpack +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from aiortc import RTCPeerConnection, RTCSessionDescription + +from meshbay_common import MNP_VERSION +from meshbay_common.crypto import ( + generate_gek, + pk_to_b64, + chunk_key as derive_chunk_key, + decrypt_chunk, + verify_chunk_signature, +) +from meshbay_common.protocol import MNP +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.transport.webrtc_server import WebRTCTransport + + +@pytest.fixture +def sk_node(): + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def sk_hub(): + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def gek(): + return generate_gek() + + +@pytest.fixture +def shared_dir(tmp_path): + d = tmp_path / "shared" + d.mkdir() + (d / "test.bin").write_bytes(os.urandom(2048)) + (d / "hello.txt").write_bytes(b"hello webrtc " * 50) + return d + + +def _hub_pk_pem(sk_hub): + return sk_hub.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + +def _make_jwt(sk_hub, groups=None): + sk_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + now = int(time.time()) + return jwt.encode({ + "iss": "test-hub", "sub": "user-001", + "pk_user": "test", "hub_id": "test-hub", + "jti": "test-jti-webrtc", "iat": now, "exp": now + 3600, + "groups": groups or [], + }, sk_pem, algorithm="EdDSA") + + +def _pack(obj: dict) -> bytes: + data = msgpack.packb(obj, use_bin_type=True) + return struct.pack(">I", len(data)) + data + + +def _unpack(raw: bytes) -> dict: + length = struct.unpack(">I", raw[:4])[0] + return msgpack.unpackb(raw[4:4 + length], raw=False) + + +@pytest.mark.asyncio +async def test_webrtc_datachannel_handshake(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: browser sends MNP handshake, node responds with handshake_ack.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, ice_candidates = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-001") + + answer = RTCSessionDescription(sdp=answer_sdp, type="answer") + await browser_pc.setRemoteDescription(answer) + + await asyncio.sleep(0.5) + + token = _make_jwt(sk_hub) + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": token, + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == MNP.HANDSHAKE_ACK + assert msg["v"] == MNP_VERSION + assert "node_pk" in msg + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: full file transfer — handshake, index, fetch chunk, decrypt.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + token = _make_jwt(sk_hub) + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": token, + })) + + buf = bytearray() + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-002") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + # 1) Handshake ack + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + # 2) Request index + channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) + idx_msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert idx_msg["type"] == MNP.INDEX_SYNC + assert "index_b64" in idx_msg + + # 3) Request file chunk + entry = next(e for e in indexer.index.entries if e.name == "test.bin") + channel.send(_pack({ + "type": MNP.FILE_REQUEST, + "v": MNP_VERSION, + "file_id": entry.id, + "chunk_index": 0, + })) + + chunk_msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert chunk_msg["type"] == MNP.FILE_CHUNK + + # 4) Verify and decrypt + ct = base64.b64decode(chunk_msg["ct_b64"]) + nonce = base64.b64decode(chunk_msg["nonce_b64"]) + ct_hash = base64.b64decode(chunk_msg["ct_hash_b64"]) + pt_hash = base64.b64decode(chunk_msg["pt_hash_b64"]) + sig = base64.b64decode(chunk_msg["sig_b64"]) + file_hash = base64.b64decode(chunk_msg["file_hash_b64"]) + + pk_node = sk_node.public_key() + verify_chunk_signature(pk_node, 0, nonce, ct_hash, sig) + assert blake3.blake3(ct).digest() == ct_hash + + ckey = derive_chunk_key(gek, file_hash, 0) + plaintext = decrypt_chunk(ckey, nonce, ct) + assert blake3.blake3(plaintext).digest() == pt_hash + + original = (shared_dir / "test.bin").read_bytes() + assert plaintext == original + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: invalid JWT is rejected with error.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-003") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + await asyncio.sleep(0.5) + + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": "invalid.jwt.token", + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "JWT" in msg["detail"] or "Invalid" in msg["detail"] + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: request without handshake is rejected.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-004") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + await asyncio.sleep(0.5) + + channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "Handshake required" in msg["detail"] + + await browser_pc.close() + await transport.close_all() |