# MeshBay — Project Conventions ## What this project is MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging. See `docs/meshbay-draft-v5.md` for the architecture specification (v3/v4 superseded). ## Repository structure ``` meshbay/ ├── packages/ │ ├── meshbay-common/ # Shared crypto + protocol — python3-meshbay-common RPM │ ├── meshbay-hub/ # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM │ └── meshbay-node/ # Node daemon + local UI — meshbay-node RPM ├── poc/ # POC spike scripts (reference, not production) ├── docs/ # Architecture drafts and POC plans ├── packaging/ # RPM spec files, DEB control files, systemd units └── QE/ # NOT versioned (.gitignore) — test artefacts, credentials, demos ├── demo-v1/ # Scripts démo opérationnels (setup_demo.py, run_node.py, download.py) ├── spikes/ # Expérimentations futures (remplace ~/draft/) └── server-state/ # Inventaire de ce qui tourne sur meshbay.org ``` **Règle QE/** : tout test sur meshbay.org doit ouvrir le port UFW, tester, et fermer le port + tuer les processus dans le MÊME bloc de commandes. Jamais de processus orphelins ni de ports ouverts après un test. ## Python environment - **Minimum Python:** 3.12 - **Build backend:** hatchling (per package `pyproject.toml`) ```bash # Créer le venv (--clear si recréation sur une autre machine/OS) python3 -m venv .venv --clear source .venv/bin/activate # Toutes les dépendances sont déclarées dans les pyproject.toml — un seul pip install suffit pip install -e packages/meshbay-common -e packages/meshbay-hub -e packages/meshbay-node pip install pytest pytest-asyncio aiosqlite # extras dev ``` Les deps clés (aioquic, watchdog, fastapi, blake3, etc.) sont dans les `pyproject.toml` et installées automatiquement. Ne pas ajouter manuellement des packages sans les déclarer dans le bon `pyproject.toml`. > **Ne jamais copier `.venv/` entre machines d'OS différents.** Si rsync depuis Fedora vers Ubuntu, > exclure `.venv/` et recréer sur la cible avec `python3 -m venv .venv --clear`. > Sans `--clear`, `certifi.where()` pointe vers un chemin Fedora inexistant sur Ubuntu → `FileNotFoundError`. ```bash # Lancer les tests .venv/bin/pytest ``` ## Code conventions - **Linter/formatter:** ruff (`uv run ruff check .` / `uv run ruff format .`) - **Line length:** 100 - **Type hints:** required on all public functions - **Comments:** only when the WHY is non-obvious; no docstrings restating the function name - **No prints in library code** — use `logging` module ## Versioning ### Package versions (SemVer) - Format: `MAJOR.MINOR.PATCH` - Pre-1.0: breaking changes bump MINOR, not MAJOR - All three packages share the same version number (released together) ### Protocol versions (independent) - MNP: `0.1` → bumped independently of package version - MHP: `0.1` → bumped independently of package version - Every wire message carries a `v` field - Breaking change → MAJOR bump; backward-compatible → MINOR bump - N-2 MINOR backward compatibility guaranteed ## Commit messages (Conventional Commits) ``` feat(node): add directory watcher with watchdog fix(hub): include jti in all JWT tokens chore(common): add Argon2id calibration to crypto.py docs: update draft v3 with POC findings test(common): add wrap/unwrap GEK round-trip test ``` Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf` Scope: `hub`, `node`, `common`, or omitted for cross-cutting ## Security rules - **Never commit private keys** (hub_private.pem, *.key, unlock.key, keystore.enc) - **Never commit QE/** — credentials, test keys, demo data go there - **Never log GEK, private keys, or plaintext passwords** — even at DEBUG level - **meshbay.org is internet-facing** — open port → test → close port + kill processes in same block ## First security review (2026-08-10) — see `first-review.md` **Critical (before Phase 7):** - **C1** Chat: Sender Keys protocol, NOT shared Double Ratchet (pairwise protocol would cause key/nonce reuse in group context). `ratchet.py` kept for future 1:1 DM. - **C2** JWT must carry `"groups": [group_ids]` claim. Node MNP handshake must verify group membership before serving content. Without this, any authenticated user accesses any group. **Significant (Phase 7-8):** - **S1** Admin revocation endpoint has no authz check ✅ DONE (Phase 8.1 — config-based require_admin) - **S2** Email stored in plaintext (spec says encrypted at rest) ✅ DONE (Phase 8.2 — AES-256-GCM, HKDF from hub key) - **S3** jti denylist push via hub→node WebSocket → Phase 7.2 - **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE - **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection) **Node sovereignty (2026-08-12):** - **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE - **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE - **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE - **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The keystore key is not the key the browser signs with, so every admin operation failed closed. Authority now comes from the node's roster — `meshbay-node operator pair` (2026-08-14). `admin_pk_ed25519` is still read as a legacy form; never auto-pin again, and never resolve the operator's key through the hub - **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE - **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE - **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE - **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE **Known remaining trust assumptions (Phase 12 — all actionable items done):** - **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login. - **T2** ✅ **CLOSED 2026-08-14** (the finding is H3). Not by safety numbers: the invite path stopped reading the directory. The node holds the GEK and wraps it for a key the recipient proves possession of; identities are bound to accounts by one-time codes the hub never sees. See `docs/invite-pairing-v1.md` - **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension **T3 attack surface reduction (2026-08-12, all phases complete):** - **Phase 1** ✅ DONE: GEK bundles moved from hub to node P2P (WebRTC DataChannel). No hub fallback. - **Phase 2** ✅ DONE: Keypair bundles moved from hub to node P2P. Registration stores locally, pushed to node on first connect. Hub never stores keypair bundles. - **Phase 3** ✅ DONE: Hub GEK cleanup — `GET /gek` endpoint removed, `GEKBundle` model removed, `gek_bundles` table dropped, `keypair_bundle` column removed, member-add URL cleaned (`/gek` suffix removed), Alembic migrations updated. **Browser crypto hardening (2026-08-13):** - `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh) - `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close) - `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch) - Removed auto-`regenerateKeys()` on login (was silently rotating hub keys, breaking GEK unwrap) - Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP) - Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead) **Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model, key hierarchy, on-the-fly encryption, transport abstraction, DTLS channel binding. ## Second security review (2026-08-13) — see `second-review.md` **6 critical, 7 high findings. Phase 11.5 is BLOCKING — see `devel-phases-next.md`.** The current build must not host real private data. The claims above about node sovereignty and P2P crypto material were **overstated**. The GEK-HMAC proof, Ed25519 admin challenge and channel binding are real, but they are enforced on the WebRTC path only, and three other paths into the node were left behind. - **C1** Node HTTP API (`http_server.py`) serves private group **index and plaintext files with no authentication**, on `0.0.0.0`, for every group — bypasses the entire sovereignty layer - **C2** `/v1/nodes/ws` trusts a client-supplied `node_id` → any user hijacks a node's signaling identity and impersonates it to browsers - **C3** The node never authenticates itself to the client (`node_pk` is never verified, no proof of possession) - **C4** Keypair bundles are served pre-proof and pushed to every node joined; PBKDF2-only → offline password attack - **C5** Any member can overwrite arbitrary shared files (upload) and seize the group GEK (`gek_bundle_store` + auto-activation) - **C6** GEK proof exists on WebRTC only — QUIC and TCP accept a bare JWT (chat injection) - **H1** Multi-group nodes share one `chat_store` and one peer registry → cross-group chat leak - **H2** Stored XSS in the node admin UI via uploaded filename → node takeover - **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable even by the hub" is true against a *passive* hub only ## Invite redesign (2026-08-14) — closes H3 and M3 See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or `gek_bundle_store`. - **The node wraps the group key**, on every connection, for the X25519 key the joiner signed with their pinned Ed25519 identity. **Nothing fetches a public key from the hub to wrap for** — not the SPA, not `gek-init`. That lookup *was* H3 - **`gek_bundle_store` is deleted**, not gated. No member hands the node key material - **The node's roster decides who gets the key**, not hub membership: a hub that invents an account and mints it a token gets `not_authorized_for_group` - **One-time codes** bind a key to an account without the directory. 40 bits, single use, one account, node-wide lockout. 7 days for invitations, 24 h for operator pairing, both in `[node]` of node.toml - **`join_policy`** (`invite`|`open`) is read from **node.toml, never the hub** — a hub able to declare a group open would be handed its key. Unknown group ⇒ `invite` - Operator surface over SSH: `operator pair`, `member list|invite|revoke|unpin`. Deleting a file is the last browser-only operation - Revocation now works for key delivery (nothing stored survives it) — but **still rotate the GEK**, the ex-member holds the current one ## Keypair bundles and the browser KDF (2026-08-14) - The bundle key is **Argon2id 128 MB / t=3 / p=1**, WebAssembly vendored under `static/vendor/` (CSP forbids external hosts; 12.2 must keep `wasm-unsafe-eval`). **Do not change the parameters in one place**: `keyderive.js`, the QE harness and `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch presents as an account nobody can open - Bundles carry an `MBK2` marker; the PBKDF2 form is still readable and is re-encrypted on the next backup. Both keys are derived at sign-in because the passphrase is deliberately not retained - Cost is paid **once per sign-in** (650 ms bundle + 239 ms auth_key); reloading a page derives nothing — the key lives in IndexedDB - The bundle is stored on **every node its owner joins**. That is what makes a second browser work, and it is C4: cracking one yields identity keys, hence content on *other* nodes and the ability to sign as that user. Draft-v5 §7.1 has the measured numbers. **The passphrase is the wall; the KDF is a speed bump** - Floor: 12 characters and ~60 estimated bits, enforced client-side only — with the password split (T1) the hub never sees a passphrase ## Two lessons that cost four rounds of live testing - **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the client, written in the right order by construction: it proves the protocol and nothing about the SPA. Three ordering bugs passed it and failed in a browser. `test_spa_ordering.py` exists for that class and is worth extending - **An unbounded `await` on the hub socket makes a node silently unreachable.** Three instances found in `maintain_ws`: the offer handler awaited inside the read loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending the task for good. Symptom is always the same — daemon running, logging nothing, `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first **Corrections to remember:** - `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native clients too (via `aiortc` in Python) - Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB - Sender keys must be distributed **pairwise to identity keys**, never GEK-derived - Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path ## Known calibration TODOs - Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2. Existing v1 users (64 MB) are transparently rehashed on next successful login. CLI `calibrate` command still TODO for per-hardware tuning. ## NAT traversal — empirical results ### 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) **SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (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 | **Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT: | Test | ICE path | Result | |---|---|---| | Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms | | Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms | | Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms | - **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN - **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 | Need | Module | File | |---|---|---| | 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 (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 | | File download (large) | `static/app.js` | File System Access API (`showSaveFilePicker`) — stream to disk | | i18n (browser) | `static/i18n.js` | `t()` lookup, ESM, localStorage lang selection | | Admin API (hub) | `meshbay_hub.api.admin` | Phase 10.2 — user/group mgmt, audit logs, stats | | Admin UI (browser) | `static/app.js` | Phase 10.3–10.4 — AdminPage component, 5 tabs | | Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user`, `require_user_scope` | | Node auth (hub) | `meshbay_hub.api.nodes` | `POST /v1/nodes/auth` — Ed25519 challenge-response, node-scoped JWT | | Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) | | Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions | | Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` | | Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members (GEK exchange is P2P) | | File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler | | GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto | | GEK HMAC proof (browser) | `static/crypto.js` | `hmacGEK()` — HMAC-SHA256 with DTLS channel binding | | DTLS fp extraction (browser) | `static/transport.js` | `_extractDtlsFingerprint()` — SDP fingerprint for channel binding | | DTLS fp extraction (node) | `meshbay_node.transport.webrtc_server` | `_extract_dtls_fingerprint()` — SDP fingerprint for channel binding | | Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response | | Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password | | GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` | | IndexedDB cache (browser) | `static/app.js` | Phase 10b.5 — group index caching | | Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side | | MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments | | MSE video streaming (browser) | `static/app.js` | Phase 10c — MediaSource + SourceBuffer progressive playback | | Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings | | Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired | | Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs | | Hub WS client | `meshbay_node.hub_client` | `login()` (Ed25519) + `maintain_ws()` + `send_ws()` — no auth_key on node | | Chat store | `meshbay_node.chat.store` | SQLite per-group, `data_dir/{group_id}/chat.db` | | Audit store | `meshbay_node.audit` | SQLite IP/action log, `data_dir/audit.db` (legal compliance) | | Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto | | P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types | | Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect | | Key persistence (browser) | `static/app.js` | `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage | | pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key | | Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only | | JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | | Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000) | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | ## meshbay.org server (état cible) - OS: Ubuntu 26.04 LTS, Python 3.14.4 - SSH: `ssh cbesson@meshbay.org` - Caddy : reverse proxy HTTPS sur 80/443 - UFW rules: **22/tcp, 80/tcp, 443/tcp uniquement** - Services légitimes : `meshbay-hub.service`, Caddy, PostgreSQL (local) - Inventaire détaillé : `QE/server-state/meshbay.org.md` - Deploy hub : voir `QE/server-state/meshbay.org.md` ## new rules, from now Documents and demo/comments are written in english unless requested in french.