aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 16:05:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 16:05:16 +0200
commit8a6294b0412a86f378c6e2e937c28de64a903c91 (patch)
tree20d1977d9148a2c86c62925c5be7e571815c9057 /docs
parent854a9441ccb734c7fbb1e0ff8570b9ef659c09b0 (diff)
downloadmeshbay-8a6294b0412a86f378c6e2e937c28de64a903c91.tar.gz
docs: move root docs into docs/ and archive superseded drafts
Move the remaining root-level .md files (except CLAUDE.md) into docs/: devel-phases.md, devel-phases-next.md, first-review.md, second-review.md, tmp-decisions.md. Update all inbound references in CLAUDE.md (now docs/-prefixed) and strip the now-redundant docs/ prefix from links inside the moved files. Consolidate the superseded material into docs/old-draft.md: architecture drafts v1-v4, POC v1, and the Phase 1-12 development log, each under an ARCHIVED banner with a preamble pointing at the current specs. Delete the merged originals plus the unreferenced French translations (v1-fr, v2-fr, poc-v1-fr). Repoint the surviving file-links in first-review.md, second-review.md and meshbay-draft-v5.md at old-draft.md; prose "draft-v3 §x" mentions are left as-is since the content now lives in the archive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J74kj44q6REczub8XR3DRy
Diffstat (limited to 'docs')
-rw-r--r--docs/devel-phases-next.md1343
-rw-r--r--docs/first-review.md372
-rw-r--r--docs/meshbay-draft-v1-fr.md442
-rw-r--r--docs/meshbay-draft-v1.md442
-rw-r--r--docs/meshbay-draft-v2-fr.md550
-rw-r--r--docs/meshbay-draft-v2.md550
-rw-r--r--docs/meshbay-draft-v3.md893
-rw-r--r--docs/meshbay-draft-v4.md1368
-rw-r--r--docs/meshbay-draft-v5.md2
-rw-r--r--docs/old-draft.md4497
-rw-r--r--docs/poc-v1-fr.md432
-rw-r--r--docs/poc-v1.md767
-rw-r--r--docs/second-review.md870
-rw-r--r--docs/tmp-decisions.md181
14 files changed, 7264 insertions, 5445 deletions
diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md
new file mode 100644
index 0000000..6730ca0
--- /dev/null
+++ b/docs/devel-phases-next.md
@@ -0,0 +1,1343 @@
+# MeshBay — Next Implementation Phases
+
+> Base: Phases 1–12 complete (except 10.9 → Phase 18). Web SPA + admin panel + self-service UI + MSE video streaming live on meshbay.org. Node daemon is production-ready (WebRTC, WS, chat, HTTP, index push, swarm all wired).
+> Architecture reference: **meshbay-draft-v6.md** (2026-08-17; v5 remains
+> authoritative for everything v6 does not restate, v4 superseded 2026-08-13).
+> Desktop client, roots, device linking: **desktop-client-v1.md**
+> First security review: first-review.md (2026-08-10)
+> **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.**
+>
+> ⛔ **Phase 11.5 is BLOCKING.** No feature phase starts until C1–C6 and H1–H7 are closed.
+> The current build must not host real private data: the node's HTTP API serves private
+> group content unauthenticated (C1), any user can hijack a node's signaling identity (C2),
+> and an active hub can obtain any group key through the key directory it controls (H3).
+>
+> **Phases renumbered 2026-08-13** (old → new): 12→14, 13→15, 14→16, 15→17, 16→18, 17→19.
+> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13,
+> hub minimization deferred by operator decision), 13 (native desktop client).
+
+---
+
+## Phase 7 — Node v2 : production, streaming, chat ✅ DONE
+
+Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
+
+| # | 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 ✅ DONE
+
+Commit: ab4d389 — 27 files, +3053/−330 lines, 132 tests.
+Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs.
+
+**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
+
+Native clients (desktop, Android) use QUIC with `punch_nat()` — already validated
+in demo-v2 on SFR residential (Port-Restricted Cone NAT).
+
+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.
+
+**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]).
+
+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.)
+
+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."
+
+```
+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}
+```
+
+After signaling, the DataChannel is P2P. Hub is no longer involved.
+
+### UI: Preact SPA
+
+- **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
+
+### Hub role (reminder — fundamental constraint)
+
+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.
+
+### Chat/forum storage
+
+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.
+
+### 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 |
+|---|---|---|---|
+| 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/style.css`, `static/vendor/htm-preact.js` | ✅ |
+| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ |
+| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ |
+| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ |
+| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ |
+| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ |
+| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ |
+| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ |
+| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ |
+| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ |
+| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ |
+| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ |
+
+**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel
+works browser → node through two different ISP residential NATs:
+
+**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT):
+
+| 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 |
+
+**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site):
+
+| 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, both Chrome and Firefox. No TURN relay needed.
+ICE/STUN handles all tested NAT types automatically.
+
+**Performance optimizations (2026-08-11):**
+- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O)
+- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file)
+- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests)
+- After binary wire format (9.15): eliminated 33% base64 inflation + removed
+ redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already
+ authenticates ciphertext, DTLS authenticates transport
+- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted
+ chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari
+ fall back to Blob-in-RAM approach.
+
+**Indexer debounce (2026-08-11):**
+- File copy triggers multiple watchdog events at different file sizes → duplicate
+ index entries with different blake3 hashes. Fixed with 2-second debounce +
+ path-based dedup (remove old entry before adding new).
+
+**Known remaining items for future phases:**
+- ~~True video streaming (MSE or Service Worker)~~ → Phase 10c (2026-08-11)
+- Multiple shared directories per node (UI + config)
+- Multi-node per user support
+
+**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 10 — meshbay.org site + admin/moderation UI
+
+Commit: 8fa298e (10.1–10.4), 022da76 (10.5–10.10) — 155 tests.
+
+**Objective:** meshbay.org becomes both a production hub and the project's public
+website, with admin/moderation interfaces and user-facing features.
+
+### Site architecture
+
+Two layers, cleanly separated:
+- **Generic hub** (API + web app) — reusable by any hub operator
+- **Site overlay** — meshbay.org-specific pages (landing, /downloads, /about)
+
+The site overlay is served by Caddy (static files) with priority over the hub.
+The hub serves the SPA for authenticated users at `/app/`.
+
+```
+site/ # meshbay.org-specific (not in generic hub package)
+├── index.html # Landing page — project promotion
+├── downloads.html # Package repos (placeholder, Phase 13)
+├── about.html # Project info, GitHub link, contact
+└── assets/
+ └── site.css # Landing page styles (dark/light aware)
+```
+
+### User roles
+
+| Role | Capabilities |
+|---|---|
+| `user` | Standard user — browse, download, chat, manage own profile |
+| `moderator` | Review reports, suspend content/groups/users |
+| `admin` | All moderator rights + hub management (same as moderator for now, distinction reserved for future federation/mirror) |
+
+Role stored as `role` column on User model (`user` | `moderator` | `admin`).
+`require_moderator` dependency (checks role ≥ moderator OR config allowlist).
+`require_admin` checks role = admin OR config allowlist (backward compat).
+Config-listed admin usernames are synced to `role = "admin"` in DB at startup.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 10.1 | Landing page + /downloads + /about | ✅ |
+| 10.2 | Moderator role + `require_moderator` dependency + admin API | ✅ |
+| 10.3 | Moderation UI (user/group suspend, blocklist management) | ✅ |
+| 10.4 | Admin UI (stats, user list, group list, audit logs viewer, blocklist) | ✅ |
+| 10.5 | Notification system (invitations, role changes, account status) | ✅ |
+| 10.6 | User settings (profile, role display, per-group notification mute) | ✅ |
+| 10.7 | Public group search (name keyword filtering) | ✅ |
+| 10.8 | Front page (notification feed with unread badge) | ✅ |
+| 10.9 | Package repositories (APT/DNF) | Deferred to Phase 13 |
+| 10.10 | Auto-update check endpoint (`GET /v1/hub/version`) | ✅ |
+
+### API endpoints (10.2, 10.5, 10.7, 10.10)
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) |
+| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) |
+| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable) |
+| GET | `/v1/admin/users/{id}` | Moderator+ | User detail (email decrypted, group count) |
+| PATCH | `/v1/admin/users/{id}` | Moderator+ | Update role or status (triggers notification) |
+| GET | `/v1/admin/groups` | Moderator+ | List groups (with member count) |
+| PATCH | `/v1/admin/groups/{id}` | Moderator+ | Update group status |
+| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user) |
+| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) |
+| POST | `/v1/notifications/{id}/read` | Access token | Mark single notification read |
+| POST | `/v1/notifications/read-all` | Access token | Mark all notifications read |
+| GET | `/v1/groups?q=` | None | Search public groups by name |
+| GET | `/v1/hub/version` | None | Version check (hub, MNP, MHP versions) |
+
+### Admin UI (10.3–10.4)
+
+Admin page at `#/admin` in SPA, accessible to moderators and admins.
+Five tabs: Stats, Users, Groups, Logs, Blocklist.
+
+- **Stats:** card grid (users, groups, nodes, online nodes)
+- **Users:** searchable table, inline role dropdown, suspend/unsuspend buttons, detail overlay
+- **Groups:** table with member count, suspend/unsuspend
+- **Logs:** filterable IP audit log table, paginated (50/page, load more)
+- **Blocklist:** existing `/v1/admin/blocklist` endpoints, add/remove hashes
+
+### SPA route change
+
+SPA now also served at `/app/` and `/app/{path}` (in addition to `/`).
+With Caddy site overlay, Caddy serves `site/index.html` at `/`,
+and requests to `/app/` fall through to the hub.
+
+### Caddy integration
+
+**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added
+2026-08-17). Use it, not the snippet this section used to carry.
+
+The snippet that was here served `site/` from the root with `try_files` and proxied
+`/v1/*`, `/app*`, `/style.css` and `/*.js` to the hub. It predates asset versioning and
+**would have broken the SPA**: the module graph is served under `/a/<hash>/`, which
+`handle /*.js` does not match, and neither does `/locales/*.js`. Worse, `/sw.js` would
+have 404ed — the service worker has to stay at the root or its scope stops covering the
+pages it intercepts downloads for, which breaks streamed downloads on Firefox and Safari
+without any visible error.
+
+The rule is inverted: an **allowlist** of site paths served statically, everything else
+proxied to the hub. The hub mounts its whole static directory at `/` (`app.py`), so it
+owns the root namespace by default and the site takes only what it names.
+
+**Consequence to be aware of:** with the site overlay in front, `/` is the landing page,
+so `webapp.py`'s `GET /` (which returns the SPA shell) is unreachable on meshbay.org. That
+route stays — a **generic** hub with no site overlay should serve the application at its
+root. The overlay is meshbay.org-specific by design.
+
+`site/` is **not** pushed by the hub deploy procedure; it syncs separately to
+`/srv/meshbay/site`.
+
+### 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 10b — Self-service UI + client-side features
+
+Pending commit — 166 tests.
+
+**Objective:** make the web SPA fully self-service — users can create groups,
+manage members, join open groups, upload files, and search across all cached
+group file indexes. No admin intervention needed for basic operations.
+
+### Self-service features
+
+| # | Component | Status |
+|---|---|---|
+| 10b.1 | Group creation UI (CreateGroupPage) | ✅ |
+| 10b.2 | Member management + invite (MembersPanel) | ✅ |
+| 10b.3 | Group join flow (open groups self-join) | ✅ |
+| 10b.4 | File upload (client → node via MNP FILE_UPLOAD) | ✅ |
+| 10b.5 | IndexedDB caching (group file indexes cached locally) | ✅ |
+| 10b.6 | Cross-group file search (SearchPage — client-side, no hub) | ✅ |
+
+### New API endpoints (10b.1–10b.3)
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| POST | `/v1/groups` | Access token | Create a new group (name, visibility, join_policy) |
+| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) |
+| POST | `/v1/groups/{id}/join` | Access token | Self-join open group (checks join_policy) |
+| POST | `/v1/groups/{id}/members/{username}/gek` | Access token | Store GEK bundle for invitee |
+| GET | `/v1/groups/{id}/gek` | Access token | Get own GEK bundle (for wrapping) |
+
+### New MNP message types (10b.4)
+
+| Type | Direction | Description |
+|---|---|---|
+| `file_upload` | client → node | Push encrypted file chunk (filename, chunk_index, total_chunks, data) |
+| `file_upload_ack` | node → client | Acknowledge chunk receipt |
+
+Node stores uploads in `shared_root/.uploads/` as `.part` files during transfer,
+renames to final location on last chunk. Filename sanitized (no path traversal).
+
+### Browser crypto additions (10b.2)
+
+AES-256-GCM ECIES variant for GEK wrapping in browsers. WebCrypto does not
+support ChaCha20-Poly1305, so a parallel ECIES scheme uses AES-256-GCM with
+a distinct HKDF info string (`meshbay:gek_wrap:v1:aes` vs `meshbay:gek_wrap:v1`).
+Both Python and browser implement the AES variant for interop.
+
+Functions added to `crypto.js`: `generateGEK()`, `wrapGEK()`, `unwrapGEK()`,
+`encryptChunk()`, `b64encode()`.
+
+Functions added to `crypto.py`: `wrap_gek_aes()`, `unwrap_gek_aes()`.
+
+### IndexedDB caching (10b.5)
+
+When a group's file index is fetched from a node, it is cached in IndexedDB
+(`meshbay` database, `group_indexes` store). On subsequent visits, cached
+entries are shown immediately while the live connection is established. This
+gives instant file list display even before WebRTC connects.
+
+Cache key: `groupId`. Stored: `{ groupId, groupName, entries[], cachedAt }`.
+Best-effort — failures are silently ignored.
+
+### Cross-group file search (10b.6)
+
+SearchPage component at `#/search`. Searches file names and paths across ALL
+cached group indexes in IndexedDB. Pure client-side — no hub involvement.
+Results link back to the group page. Accessible from sidebar.
+
+### Tests added
+
+- 8 tests: group self-service (create, join open, join invite rejected, join already member, members list, non-member denied, search, join triggers notification)
+- 3 tests: AES GEK wrap/unwrap (round-trip, wrong key rejected, differs from ChaCha20 wrap)
+
+---
+
+## Phase 10c — MSE video streaming (real-time playback)
+
+Pending commit — 167 tests.
+
+**Objective:** replace the download-then-play video player with real-time
+MSE (MediaSource Extensions) streaming. Playback starts within seconds
+instead of waiting for the full file download.
+
+### Architecture
+
+```
+Browser Node
+ │ │
+ ├── stream_req {file_id} ──────►│
+ │ ├── ffprobe → codec info
+ │◄──── stream_init {codec,dur} ──┤
+ │ ├── ffmpeg -c copy → fMP4 pipe
+ │◄──── stream_data {seg 0, ct} ──┤ (256 KB encrypted segments)
+ │◄──── stream_data {seg 1, ct} ──┤
+ │ ... │
+ │◄──── stream_end ───────────────┤
+ │ │
+ MediaSource → SourceBuffer │
+ ├── appendBuffer(decrypted) │
+ ├── video.play() after ~2-3s │
+```
+
+**Key design decisions:**
+
+1. **Node-side remux via ffmpeg** — `ffmpeg -c copy -movflags frag_keyframe+empty_moov+default_base_moof -f mp4 pipe:1` remuxes any video format (MP4, MKV, AVI, WebM, MOV) into fragmented MP4 (fMP4) that MSE can consume. No transcoding — just remuxing. Near-zero CPU overhead.
+
+2. **Codec detection via ffprobe** — the node probes the video to determine the exact codec string for MSE SourceBuffer creation (e.g., `avc1.640028,mp4a.40.2` for H.264 High@4.0 + AAC-LC). This ensures the browser creates the correct decoder.
+
+3. **Same encryption model** — each 256 KB fMP4 segment is encrypted with AES-256-GCM using the same key derivation as file downloads (GEK + file_hash + segment_index → HKDF → chunk_key). E2E encryption is maintained.
+
+4. **Progressive SourceBuffer append** — the browser creates a MediaSource, opens a SourceBuffer with the probed codec, and appends decrypted segments as they arrive. SourceBuffer handles partial MP4 boxes internally. Playback starts after ~2-3 segments (~512 KB buffered).
+
+### Supported codecs
+
+| Codec | MSE string | Browser support |
+|---|---|---|
+| H.264 (AVC) | `avc1.PPCCLL` | Chrome, Firefox, Safari, Edge |
+| H.265 (HEVC) | `hev1.1.6.L93.B0` | Safari, Chrome (partial) |
+| VP9 | `vp09.00.10.08` | Chrome, Firefox |
+| AV1 | `av01.0.01M.08` | Chrome, Firefox |
+| AAC | `mp4a.40.2` | All |
+| MP3 | `mp4a.6b` | All |
+| Opus | `opus` | Chrome, Firefox |
+| AC-3 | `ac-3` | Safari, Chrome |
+
+### New MNP message types
+
+| Type | Direction | Description |
+|---|---|---|
+| `stream_req` | client → node | Request MSE video stream for file_id |
+| `stream_init` | node → client | Codec string + duration (probed via ffprobe) |
+| `stream_data` | node → client | Encrypted fMP4 segment (256 KB, AES-GCM) |
+| `stream_end` | node → client | End of stream signal |
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 10c.1 | MNP protocol: STREAM_REQUEST/INIT/DATA/END message types | ✅ |
+| 10c.2 | Node: ffprobe codec detection + MSE codec string derivation | ✅ |
+| 10c.3 | Node: ffmpeg fMP4 remux + encrypted segment streaming | ✅ |
+| 10c.4 | Transport: event-based stream message dispatch | ✅ |
+| 10c.5 | Browser: MSE VideoPlayer (MediaSource + SourceBuffer) | ✅ |
+| 10c.6 | Tests: stream_request error handling | ✅ |
+
+### File changes
+
+**Modified:**
+- `packages/meshbay-common/src/meshbay_common/protocol.py` — STREAM_REQUEST/INIT/DATA/END
+- `packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py` — `_probe_video()`, `_stream_video()` handler
+- `packages/meshbay-hub/src/meshbay_hub/static/transport.js` — `requestStream()`, stream event handlers
+- `packages/meshbay-hub/src/meshbay_hub/static/app.js` — MSE-based VideoPlayer component
+- `packages/meshbay-hub/src/meshbay_hub/static/style.css` — streaming progress bar
+- `packages/meshbay-hub/src/meshbay_hub/static/i18n.js` — buffering/MSE error strings
+- `packages/meshbay-node/tests/test_webrtc_transport.py` — stream_request error test
+
+### Known limitations (future work)
+
+- No seeking beyond buffered range (user must wait for data to arrive)
+- No adaptive bitrate (single quality stream)
+- Requires ffmpeg/ffprobe on the node (already a dependency for the old STREAM_SEGMENT handler)
+
+---
+
+## Phase 11 — Node daemon: production-ready ✅ DONE
+
+Pending commit — 171 tests.
+
+**Objective:** the node daemon (`meshbay-node`) runs as a complete, self-contained
+service. Previously the daemon only started QUIC/TCP servers and the local web UI;
+everything browser-facing (WebRTC, hub WS, chat store, HTTP API) was only wired
+in QE demo scripts. This phase moved all that logic into the daemon.
+
+### What changed
+
+**`daemon.py` — complete rewrite.** The daemon now starts all transports and
+services in a single process:
+
+1. Keystore + hub login (unchanged)
+2. Per-group directory indexers (unchanged)
+3. **ChatStore** per group (new) — SQLite DB in `~/.local/share/meshbay/{group_id}/chat.db`
+4. **WebRTC transport** (new) — browser clients via DataChannel, wired as
+ `on_webrtc_offer` callback on the hub WS
+5. QUIC + TCP servers (unchanged)
+6. **Hub WebSocket** (new) — `maintain_ws()` as asyncio task, receives signaling,
+ revocation tokens, WebRTC offers. Auto-reconnect on disconnect.
+7. **HTTP file API** (new) — one `create_http_app()` per group on configured port
+8. Local web UI (unchanged)
+9. **Graceful shutdown** (enhanced) — cancels WS task, closes WebRTC peers, closes
+ chat stores, stops HTTP/QUIC/TCP servers, stops indexers
+
+**`hub_client.py`** — added `_ws` tracking, `send_ws()` for chat notifications,
+and `register_swarm()` for file hash registration with the hub.
+
+**`config.py`** — added `data_dir` field (default `~/.local/share/meshbay/`)
+for chat DBs and other persistent state.
+
+**`meshbay-node.service`** — updated systemd unit with `StateDirectory=meshbay`,
+`ProtectSystem=strict`, `ReadWritePaths` for config and data directories.
+
+**Index push (11.5):** when watchdog detects file changes, the debounced
+`on_change` callback fires `_on_index_change` on the daemon, which pushes a
+full `INDEX_SYNC` to all WebRTC peers in that group. Only peers whose
+`_group_id` matches receive the push.
+
+**Swarm registration (11.9):** on startup and on each index change, the daemon
+registers all file hashes with the hub's `/v1/swarm/register` endpoint. This
+allows other nodes/clients to discover which nodes host which content.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 11.1 | Daemon: hub WS integration | ✅ |
+| 11.2 | Daemon: WebRTC transport | ✅ |
+| 11.3 | Daemon: chat store | ✅ |
+| 11.4 | Daemon: HTTP file API | ✅ |
+| 11.5 | Daemon: index push on change | ✅ |
+| 11.6 | Daemon: node_user_id + hub_ws context | ✅ |
+| 11.7 | Daemon: graceful shutdown | ✅ |
+| 11.8 | Systemd unit file | ✅ |
+| 11.9 | Swarm registration | ✅ |
+| 11.10 | Integration test | ✅ (4 tests: lifecycle, no-groups, index push, group filtering) |
+
+### File changes
+
+**Modified:**
+- `packages/meshbay-node/src/meshbay_node/daemon.py` — complete rewrite
+- `packages/meshbay-node/src/meshbay_node/hub_client.py` — `_ws` tracking, `send_ws()`
+- `packages/meshbay-node/src/meshbay_node/config.py` — `data_dir` field
+- `packaging/systemd/meshbay-node.service` — hardening, StateDirectory
+
+**Added:**
+- `packages/meshbay-node/tests/test_daemon.py` — 2 integration tests
+
+---
+
+## Phase 11.5 — Security remediation ⛔ BLOCKING
+
+> Source: `second-review.md` (2026-08-13). Finding IDs in brackets.
+> **No other phase starts until section J acceptance criteria pass.**
+
+**Objective:** close the gap between what the documents describe and what the code
+enforces. The Phase 12 sovereignty work (GEK-HMAC proof, DTLS channel binding, Ed25519
+admin challenge) is sound but was implemented on one of four paths into the node. This
+phase reduces the node to two paths and brings both to the same standard.
+
+### Transport decision (settled 2026-08-13)
+
+| Listener | Fate | Reason |
+|---|---|---|
+| WebRTC DataChannel (aiortc) | **Primary** — browser + native | ICE/STUN is the only NAT traversal validated here (2 ISPs, 2 browsers, IPv4 STUN + IPv6, 4G CGNAT) |
+| QUIC 19000 | **Kept, brought to parity** | LAN, port-forwarded, and hub-less `group://` direct access |
+| TCP+TLS 18001 | **Removed** | Superseded; no GEK proof; nothing uses it |
+| HTTP 19001 | **Removed** | Source of C1; duplicates MNP without any of its controls |
+
+> `punch_nat()` is a single UDP probe (`quic_server.py:446`) with no STUN client, no
+> candidate gathering and no dual-stack fallback — `aioice` is pulled in by `aiortc` only.
+> It is a direct-connection helper, **not** a traversal stack. ICE remains the primary path.
+
+### A — Reduce the node's exposed surface
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.1 | Delete `transport/http_server.py` + daemon wiring (`daemon.py:341-366`) | **C1** | No listener on `0.0.0.0` other than QUIC; no endpoint serves file bytes or an index without a completed handshake |
+| 11.5.2 | Delete `transport/server.py` + `transport/client.py` (TCP+TLS) | C6 scope | `ChunkServer` gone from `daemon.py`; port 18001 unbound |
+| 11.5.3 | Node admin UI stays loopback + gains a session token in the URL | H2 | UI unreachable without the token printed at daemon startup |
+
+### B — One handshake, two transports
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.4 | Extract `meshbay_common/handshake.py`: JWT verify → `scope == "user"` → denylist → **mandatory** `group_id` in claims → group hosted → GEK challenge → proof verify → ack | **C6**, M1, M9 | Single implementation; `webrtc_server.py` and `quic_server.py` contain no JWT logic of their own |
+| 11.5.5 | Both transports call it; test parametrized over `[webrtc, quic]` | C6 | A test that adds a step to the handshake fails for any transport that skips it |
+| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections |
+
+#### 11.5.6 spike results (aioquic 1.3.0)
+
+**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the
+preferred anchor is unavailable.
+
+**Certificate access is asymmetric and partly private:**
+
+| Side | Path to the server certificate | API status |
+|---|---|---|
+| Server | `tls.certificate` | public attribute |
+| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` |
+
+`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is
+`protocol._quic.tls._peer_certificate` — two levels of private API.
+
+**The risk this creates.** Binding a security check to a private attribute means an
+aioquic upgrade can remove it silently. A channel binding that silently becomes
+unavailable is the worst failure mode: 11.5.21 already established that the handshake
+must *refuse* rather than degrade, so a rename would turn every QUIC connection into a
+hard failure — noisy, but only if the refusal path is right. If it were ever made
+tolerant, it would turn into a silent loss of MitM detection.
+
+**Options for the implementer, in order of preference:**
+
+1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in
+ `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and
+ non-None on a live connection — so an upgrade fails CI rather than production. Keep
+ `make_proof()` refusing an empty binding.
+2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from
+ WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the
+ hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient
+ connection authentication on its own — but note the QUIC client currently does **not**
+ verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer),
+ so this option must be paired with pinning, or the TLS layer authenticates nobody.
+3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5.
+
+Recommendation: option 1 with the guard test, and open option 3 upstream.
+
+### C — Mutual authentication
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.7 | Node proves GEK possession over a client nonce **and** signs the transcript with `sk_node`: `Ed25519(sk_node, "meshbay:node_proof:v1" ‖ nonce_c ‖ binding)` | **C3** | Client rejects a peer that cannot produce both |
+| 11.5.8 | Client pins `pk_node` (TOFU on first connect, persisted); key change raises a blocking warning | C3 | Swapping the node's key surfaces to the user instead of silently succeeding |
+| 11.5.9 | Node WS registration: require `scope == "node"`, verify `Node.user_id == payload["sub"]`, derive `group_ids` **from the DB**, refuse to overwrite a live registration | **C2** | A user-scoped token, or a mismatched `node_id`, is rejected at `/v1/nodes/ws` |
+| 11.5.10 | `POST /v1/nodes/announce` requires proof of possession of `sk_node`; one active record per user | M8 | Announcing someone else's `pk_node` fails |
+
+### D — MNP authorization
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.11 | `gek_bundle_store` requires an Ed25519 admin challenge; **delete `_try_activate_gek`** — GEK activation is local-UI/CLI only | **C5b** | A member cannot change the group's active GEK |
+| 11.5.12 | Upload: per-user quarantine `.uploads/{user_id}/`, refuse to overwrite an existing index entry, size cap + per-user quota, filename allowlist (`[A-Za-z0-9._-]`) | **C5a**, H2 | A member cannot replace another member's file, and cannot inject markup via a filename |
+| 11.5.13 | Admin challenge becomes a structured transcript: `"meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ ts`; client displays what it signs | **H5** | No path exists where a peer obtains a signature over bytes it fully chose |
+| 11.5.14 | `gek_bundle_fetch` / `keypair_bundle_fetch` move **after** proof verification; interim rate-limit + audit on the pre-proof window | C4 (partial) | Pre-proof window serves nothing; full fix lands in 13.3 |
+
+### E — Isolation
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.15 | `chat_store` and `_peers` resolve from `_group_ctx()`, one peer registry per group (`daemon.py:249`, `webrtc_server.py:602,617,650`) | **H1** | Two-group / two-user test proves neither history nor broadcast crosses groups |
+
+### F — Node admin UI
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.16 | `html.escape()` on every interpolated value (`ui/app.py:363`), `textContent` in the audit page (`:632`), CSP header | **H2** | A file named `<img src=x onerror=...>` renders as text |
+
+### G — Revocation
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.17 | Handle `target == "group"` on the node; persist the denylist to `data_dir`; check group status in `webrtc_offer` | **H4** | Revoking a group drops live sessions and blocks new signaling |
+
+### H — Privacy
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.18 | Swarm registers hashes for `visibility == "public"` groups only; fix the mis-mounted route (`/v1/groups/v1/swarm/...`); authenticate the lookup | **H7** | No private-group content hash ever reaches the hub |
+
+### I — Resource limits
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.19 | Pre-handshake buffer cap (a few KB, not 64 MB); `asyncio.Semaphore` around ffmpeg; delete the synchronous `subprocess.run` in `_do_stream_segment`; per-user signaling rate limit + membership check before relaying an offer; validate `peer_ip` against the request source | **H6** | One client cannot stall the daemon's event loop or exhaust its memory/CPU |
+
+### J — Crypto hygiene, hub fixes, acceptance
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.20 | Keystore Argon2id → 256 MB, parameters stored per-node in `node.toml` (not a `meshbay_common` constant); raise the password minimum | M2 | `calibrate-argon2` writes usable config; `crypto.py:173` no longer hardcodes 64 MB |
+| 11.5.21 | Length-prefix every field in the HMAC transcript; **reject** empty DTLS fingerprints instead of proceeding | L4 | A missing fingerprint fails the handshake rather than degrading it to nonce-only |
+| 11.5.22 | Hub: fix IPLog backfill (`users.py:118-122`), trusted-proxy XFF, scrub `str(e)` from peer-visible errors, drop `GEK_REQUEST`/`GEK_RESPONSE` constants, validate email | M6, M7, L3, L1, L6 | Compliance log attributes each row to the right account |
+| 11.5.23 | Regression suite | all | See below |
+
+**Required regression tests (all must exist and fail on reintroduction):**
+
+```
+test_no_unauthenticated_content — every node listener refuses index/chunks pre-handshake
+test_handshake_parity[webrtc,quic] — identical checks on both transports
+test_group_isolation — 2 groups × 2 users: chat + peers never cross
+test_upload_cannot_overwrite — member B cannot replace member A's file
+test_gek_store_requires_admin — member cannot store/activate a GEK
+test_ws_node_identity — user token / foreign node_id rejected
+test_node_proof_required — client aborts when the node cannot prove GEK + sk_node
+test_ui_escapes_filenames — markup in a filename renders inert
+test_swarm_public_only — private hashes never registered
+```
+
+**Acceptance criteria for the phase:** with a hub whose signing key is in the attacker's
+hands, an attacker who is not a group member obtains **no** index entry, **no** file byte,
+**no** chat message, and cannot write to any node. A member who is not the node operator
+cannot delete or overwrite another member's file, and cannot change the group key.
+
+---
+
+## Phase 12 — Client key verification + served-SPA integrity
+
+> **Reworked 2026-08-13 by operator decision.** This phase was "Hub minimization:
+> registrar and nothing more". That work is **deferred and may be dropped** — see
+> decisions D1/D2 in `tmp-decisions.md`. The hub will keep serving the web UI, and a
+> native client will be offered *in addition to* it, not as a replacement.
+>
+> Two items are kept here because the decision makes them *more* relevant, not less:
+> the hub stays in the trusted path, so what it can substitute and what code it serves
+> both still matter. Everything else from the old Phase 12 (route blindness test,
+> opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped
+> from the plan; the swarm item already shipped in 11.5.18.
+
+**Objective:** make the hub's remaining power over confidentiality *detectable*, given
+that it stays in the trusted path by choice.
+
+### 12.1 is DONE — and not as it was written
+
+**H3 is closed (2026-08-14), by removing the lookup rather than by verifying it.** The
+plan here was key transparency and safety numbers: keep fetching the invitee's key from
+the hub, and give humans a way to notice a substitution. What shipped instead is the
+invite redesign in `invite-pairing-v1.md` — the node holds the GEK and wraps it
+itself, for a key the recipient proves possession of, and identities are bound to
+accounts by one-time codes the hub never sees.
+
+Why that is better than what was planned: safety numbers make a substitution *detectable
+by a human who bothers to check*, at the single worst moment (first contact, when there
+is no previous key to compare against). Removing the directory read from the invite path
+makes the substitution impossible instead, and costs the user one code to pass along
+rather than a fingerprint comparison ritual.
+
+It also closed **M3** as a side effect, and absorbed **14.3/14.4** (CLI invite, member
+management), which had to exist for a headless operator to admit anyone.
+
+Safety numbers may still return later as defence in depth for *identity* verification —
+"is this really Bob's account" — which is a different question from "which key gets the
+group key". They are no longer load-bearing.
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `invite-pairing-v1.md` |
+| 12.2 | Served-SPA integrity | Strict CSP, Subresource Integrity on the bundle, and a signed digest of the served bundle published by the hub so a native client or extension can verify what the browser was given. **Now the highest-value item here**: T3 is the only remaining way an active hub reads content, and it can also lift a pairing code out of the page it served. **The CSP must keep `wasm-unsafe-eval` in `script-src`** — the bundle KDF is Argon2id in WebAssembly since 2026-08-14, and a policy that forbids it locks every user out of their keys |
+| 12.3 | Honest labelling | `/app/` states plainly that the hub serves this code and what that implies. Docs stop claiming end-to-end integrity for the hub-served path — the claim that holds is "the hub cannot read your content unless it ships you malicious client code" |
+| 12.4 | Written threat model | One page: passive hub, active hub, malicious node operator, malicious member, network attacker, local attacker — and for each claim, which adversary it holds against. This is what stops the overclaiming pattern the second review kept finding |
+
+**Dropped from the old Phase 12** (recorded so the intent is not lost if it returns):
+route-inventory blindness test, opaque private-group name/description, chat_notify
+metadata minimization, residual schema cleanup.
+
+---
+
+## Phase 13 — Native desktop client (Electron + optional Python sidecar)
+
+> **Reworked 2026-08-17 by operator decision. `desktop-client-v1.md` is
+> authoritative for this phase** — shell, device linking, account creation, node
+> management, packaging and the open items. The milestone table below is the summary;
+> the design and its rationale are there.
+>
+> **The shell changed: pywebview → Electron.** Structural decision 18 is reversed. The
+> reason is measured, not aesthetic: the SPA depends on Chromium-class APIs (WebRTC,
+> WebCrypto X25519/Ed25519, MSE, Service Workers, File System Access), so keeping
+> Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and `sw.js`
+> unchanged, while a system webview meant reimplementing ~2500–3000 lines plus a loopback
+> media server and native dialogs. The old "69 % reused" figure was measured against an
+> `app.js` of ~2600 lines; it is **4586** as of 2026-08-17, and `app.js` now holds 2 direct
+> `crypto.subtle` calls — the three-globals seam had already leaked.
+>
+> **One blocking addition: device linking (13.3b).** Identity keys are per node, so a
+> native client holding its own keys is refused by `webrtc_server.py:886-894` where a
+> browser is not. Without linking, an account created natively could never be opened in a
+> browser without an operator code per node — the "native must not prevent web use"
+> objective fails outright. See `desktop-client-v1.md` §4.
+
+**Objective:** ship a desktop application whose UI is not served by the hub, with durable
+local key storage, at feature parity with the web for a standard user, that does not
+prevent web use, and that can manage locally installed nodes.
+
+> ⚠️ **Do not justify this phase as "the fix for T3".** An earlier draft of
+> `second-review.md` claimed a native client makes code integrity independent of the hub.
+> That was wrong: a binary downloaded from `meshbay.org` and signed with a key the hub
+> operator holds relocates the trust rather than removing it. What native actually changes is
+> **detectability** — an attack must ship as an artifact that can be hashed and compared
+> instead of a one-off HTTP response — and that value is realised only by **18.7 reproducible
+> builds** plus published hashes.
+>
+> **Cost line revised 2026-08-17.** Two of the three costs recorded here were pywebview's,
+> not native's. Electron with `sandbox` and `contextIsolation` **keeps** the Chromium
+> renderer sandbox — the strongest available — so "native costs the browser sandbox" is
+> false for this shell, and the D2 table in `tmp-decisions.md` is wrong on that row. The
+> loopback media server is deleted (13.6). What remains, and is real: **we own Chromium's
+> patch cadence**, the renderer parses attacker-controlled content from nodes, npm enters
+> the supply chain, and the updater is new surface.
+>
+> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified
+> on product grounds. It closes **C4** for native devices, but **not for accounts that also
+> use a browser** — see `desktop-client-v1.md` §5.1. Reproducible builds are unusually
+> tractable here: the UI has no bundler or minifier, and Electron's own binaries have
+> published upstream hashes.
+
+### Why this is cheap (re-measured 2026-08-17)
+
+| Asset | Lines | Under Electron | Under a system webview |
+|---|---|---|---|
+| `style.css`, `i18n.js`, `vendor/`, `zipstream.js`, `transfers.js` | ~4200 | reuse | reuse |
+| `app.js` | 4586 | reuse, minus the hub base URL | reuse, minus storage glue + MSE player |
+| `transport.js`, `crypto.js`, `keyderive.js` | 1907 | **reuse** | delete and reimplement |
+| `downloads.js`, `sw.js` | 363 | **reuse** | delete |
+
+`HUB = ''` (`app.js:12`) becoming a configurable absolute base URL is the one structural
+change. That is what 13.1 exists for, and under Electron it is nearly all of it.
+
+The old plan also budgeted a loopback media server (WebKitGTK MSE is unreliable), a SQLite
+index cache (IndexedDB is restricted under `file://`) and native file dialogs. Chromium
+provides all three, so those milestones are deleted rather than rewritten.
+
+### Non-negotiable
+
+**UI assets ship inside the package and load from disk.** A shell that points its WebView at
+`https://meshbay.org/app/` is a browser with a different icon and fixes nothing. The hub is
+used for the API only, and the bundle is covered by 13.9 signing.
+
+Shell hardening is part of that and is not optional: `contextIsolation` and `sandbox` on,
+`nodeIntegration` off, a custom `app://` protocol rather than `file://`, navigation to
+non-local targets refused, and a strict in-package CSP that **keeps `wasm-unsafe-eval`** —
+the bundle KDF is Argon2id in WebAssembly, and a policy forbidding it locks every user out
+of their keys.
+
+### Milestones
+
+| # | Component | Prio | Description |
+|---|---|---|---|
+| 13.1 | Hub base URL + storage/save-file adapter | 1 | `HUB = ''` becomes configurable; a thin adapter for storage and saving. **Acceptance: the browser SPA behaves identically.** Much smaller than the old platform split — Chromium stays Chromium |
+| 13.2 | Electron shell | 1 | `app://` via `protocol.handle`, CSP, preload with an enumerated API, sandbox, single instance, tray, window state |
+| 13.3 | Local key storage + device auth | 1 | `safeStorage` (OS keychain) with an explicit fallback where no keyring exists; device Ed25519 auth on the `POST /v1/nodes/auth` pattern. **`POST /v1/users/auth` does not exist yet and must be written** |
+| **13.3b** | **Device linking** | 1 | **Blocking.** One-time code generated by the new device, countersigned by an already-pinned key. `identities` gains a device dimension; `pin_identity`'s `INSERT OR REPLACE` must go. `desktop-client-v1.md` §4 |
+| 13.3c | Hybrid registration | 1 | Register with a passphrase-derived `auth_key` (existing endpoint, browser-compatible at once, and the only account recovery path), device key thereafter |
+| ~~13.4~~ | aiortc client transport | — | **Deleted** — Chromium provides WebRTC. `transport.js` is kept as the client |
+| ~~13.5~~ | SQLite index cache | — | **Deleted** — IndexedDB works under `app://` |
+| ~~13.6~~ | Loopback media server | — | **Deleted** — Chromium MSE works. Removes the C1-shaped surface this milestone would have added |
+| 13.7 | Native save dialog | 2 | `dialog.showSaveDialog` + streamed write from the main process; the service-worker path already works, so this is an improvement, not a prerequisite |
+| 13.8 | Safety-number UI | 3 | Consumes 12.2. **No longer load-bearing for device linking** — the code binding replaced digit comparison |
+| 13.9 | Release key + verified updates | 3 | Prefer the signed apt/dnf repo (18.5) over a bespoke updater. **The key must exist before the first public package**; without it the update channel is the new T3 |
+| 13.10 | Packaging | 1 / 3 | `.deb` (Ubuntu 24+) and `.rpm` (Fedora 44+) first; MSI per-user (Windows) later; AppImage/Flatpak optional |
+| ~~13.11~~ | "Retire the SPA?" | — | **Settled**: the web stays. A native client must not prevent web use |
+| 13.11b | Per-root `unavailable` index state | 2 | Freeze the subtree instead of emptying it when a volume goes away. **Prerequisite for root selection** — otherwise unplugging a USB drive propagates deletions for a whole library |
+| 13.11c | Named roots per group | 2 | `shared_dir` (one string) → a list of `{name, path, kind}`; the name is the directory's basename, derived once and stored. Unique names (case-insensitive), no nesting, per-root availability, operator-designated upload target |
+| 13.11d | Filesystem portability | 1 | Case folding, NFC normalization, Windows reserved names, `\\?\` paths, reconciliation scans. Case folding is for index identity, collision reporting and root names. **Not** the no-overwrite check: `Path.exists()` is already case-insensitive on NTFS/exFAT, so C5a is not reachable that way (verified 2026-08-18) |
+| 13.12 | Node management over signed MNP ops | 2 | Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers, root selection, **`gek_rotate`**. The loopback admin API is never exposed to the network |
+| 13.13 | First-run wizard | 2 | Detect a local node, `systemctl --user enable --now` (no privilege), link to the hub account, create/attach a group, `gek-init`, `operator pair` — all on loopback, no terminal |
+| 13.14 | Python sidecar — `group://` over QUIC | 2 | Reuses `quic_client.py`. Also the only local management path on a LAN with no internet, since MNP setup needs the hub's signaling |
+
+### Deletions enabled once native is the recommended client
+
+`webcrypto.py` + the `:aes` HKDF variant · `deriveAuthKey`/`deriveEncryptionKey` +
+`pw_version` 3 + legacy migration · keypair bundle MNP messages + `keypair_bundles` table ·
+`_bundleKey` in IndexedDB + `_sessionKeys` in sessionStorage + `_pkFromSk`.
+
+**Revised 2026-08-17:** `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js`
+and the MSE path are **no longer on this list** — under Electron they are the client, not
+browser workarounds. The keypair-bundle deletions still apply, but only for accounts that
+opt out of browser use (`desktop-client-v1.md` §5.1); the browser path needs them.
+
+**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding.
+These carry NAT traversal and are not browser workarounds.
+
+---
+
+## Phase 14 — Node CLI + management
+
+> Was Phase 12 before the 2026-08-13 renumbering.
+
+**Objective:** `meshbay-node` CLI becomes a full management tool, not just a
+daemon launcher.
+
+**Partially delivered early (2026-08-13), forced by the first real deployment.**
+Every operator action lived behind a web UI on the node's own loopback interface,
+so a node on a server reached over SSH could not be operated at all without
+port-forwarding a browser session — and 11.5.3 added a token that had to be
+copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock
+that.
+
+**Member management landed 2026-08-14** with the invite redesign, for the same
+reason: a node admits people from its own roster, and a headless operator had no
+way to put anyone on it. `operator pair`, `member list|invite|revoke|unpin` all
+work over SSH. **Deleting a file is now the only operator action that still needs
+a browser.**
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped |
+| 14.1b | ~~`meshbay-node ui`~~ | **Removed 2026-09-01** (`refactor-node-ui.md` phase 5). The server-rendered admin page it opened is gone; the CLI and the desktop client's Node page use the loopback control API directly |
+| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine |
+| 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts |
+| 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK |
+| 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config |
+| 14.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced |
+| 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one |
+| 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset |
+| 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add |
+| 14.8 | Config reload (SIGHUP) | ✅ **DONE 2026-08-18** — `meshbay-node reload`. Deliberately narrow: it re-roots **groups already hosted**, which is what an operator adjusts day to day, and reports a changed group *set* as needing a restart. Adding a group live means new indexers, chat stores, GEK loads and transport contexts, and that is how a half-built group ends up serving content. No connection is dropped |
+| 14.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted |
+| 14.10 | `meshbay-node denylist` | ✅ **DONE 2026-08-18** — `denylist show|clear [identifier]`. Clearing asks for confirmation and reports the count, because it re-admits whoever it was keeping out |
+| 14.11 | `meshbay-node file rm` | ✅ **DONE 2026-08-18** — `file list|rm <id>`. **No operator action now requires a browser.** Refuses a file whose root is unavailable: it is frozen, not gone |
+
+### Sequencing and factoring (added 2026-08-17)
+
+**Phase 14 finishes before 13.12** (node management from the desktop client), and the
+remaining commands are written against a single internal module rather than beside one.
+
+- The CLI is the only interface that works with the daemon stopped, with no GEK, or with
+ no operator paired — exactly the states the desktop client cannot reach, and the ones
+ decision E5 sends back to the local machine. Holes here have no fallback.
+- 13.12 would add MNP handlers for operations the CLI already performs through the
+ loopback API. **Two paths to one operation with different authorization is the shape of
+ C1 and C6.** Factor each operation into `meshbay_node/ops.py`, with the CLI, the
+ loopback API and the MNP handler as three thin adapters. Parity becomes structural,
+ authorization lives in one place, and 13.12 is adapter code.
+
+The refactor is cheaper now, with six commands left, than after 13.12 exists.
+See `desktop-client-v1.md` §6.6.
+
+**Done 2026-08-18.** `meshbay_node/ops.py` holds each operation once; the loopback API is
+a one-line adapter per endpoint (`_op()` translates `OpError` into a JSON response) and the
+MNP handlers call the same functions through `_run_op`. `test_ops.py` asserts the shape
+rather than trusting it: every operation takes `state` first, `ops` imports nothing
+web-shaped, and no loopback handler performs an operation itself.
+
+**Signed MNP ops shipped with it:** `gek_rotate` and `member_unpin`, both operator-signed
+over a structured transcript like every other destructive operation. Rotation is the half
+of revocation that revocation cannot do — the ex-member holds the current key — and the
+node generates the replacement with its own CSPRNG, so no key material crosses the wire.
+
+### Architecture
+
+CLI commands talk to the running daemon over its **loopback admin API**, authenticated
+with the per-run session token (11.5.3) — `_daemon_api()` in `daemon.py`. The Unix-socket
+design below was the original plan; the loopback API already existed for the admin UI,
+carries the same authorization, and avoided a second control plane. A socket would still
+be an improvement (no port, file permissions instead of a token file) if the admin UI
+ever goes away.
+
+`status` deliberately does *not* use it: it reads the keystore, the config and the roster
+directly, so it works while the daemon is stopped — which is when an operator most needs
+to know why.
+
+---
+
+## Phase 15 — Chat encryption (Sender Keys) + retention
+
+> Was Phase 13 before the 2026-08-13 renumbering.
+
+**Objective:** implement spec section 6.6 — group chat messages are encrypted
+with the Sender Keys protocol. Currently, chat messages are stored and
+transmitted as plaintext payloads (relying on transport encryption only).
+
+### Background
+
+`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol, but nothing
+in production imports it — `grep` finds it only in its own tests. The node chat flow
+(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
+derivation, symmetric message encryption, and a distribution format.
+
+### 15.0 — Decide the distribution channel FIRST (blocking sub-milestone)
+
+`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
+direct)". **GEK-wrapped is the wrong choice** and must not be implemented: it makes every
+sender key a function of the GEK, so anyone who holds the GEK — including an attacker who
+obtained it via H3 key substitution, or a former member who kept it — recovers every sender
+key. The encryption would then be decorative.
+
+Distribution must be **pairwise to identity keys**: wrap each sender key with ECIES to the
+recipient's `pk_x25519` (the existing `wrap_gek_aes` primitive), or run the existing
+`ratchet.py` Double Ratchet per member pair. Decide and record before writing 15.1.
+
+### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17)
+
+**This phase predates device linking (`desktop-client-v1.md` §4) and is wrong as
+written.** One person now holds several identity keys on one node — a browser and a
+desktop client, up to the device cap. Two consequences, and the first is the whole
+decision:
+
+**A shared per-person chain reintroduces C1, one level down.** If Alice's two devices share
+one sending chain, both advance it, and concurrent sends produce **key and nonce reuse** —
+which is precisely why `first-review.md` C1 rejected a shared Double Ratchet for groups.
+Per-device chains have no shared mutable state and no reuse. There is no third option worth
+weighing.
+
+**The code already fails this, silently.** `senderkeys.py` keys everything by
+`sender_id: str`, and `GroupSenderKeyStore.add_sender` does
+`self._states[dist.sender_id] = ...` — so a second device registering under the same
+`sender_id` **overwrites the first, dropping its chain**. Same shape as `pin_identity`'s
+`INSERT OR REPLACE`, same fix: `sender_id` becomes a **device** identifier (account plus
+device key fingerprint), not a `user_id`. The module needs its identifier redefined, not
+restructuring — and the class docstring, which says "one chain per member", needs to say
+per device.
+
+**What follows from per-device chains:**
+
+- **Fan-out is O(devices), not O(members)** — bounded by the per-user device cap (5 by
+ default), so up to 5× the distribution messages. Acceptable, but size the distribution
+ path for it rather than discovering it.
+- **A new device cannot read history until every sender redistributes.** Nobody but the
+ senders holds their chain keys — that is the point — so a freshly linked device sees an
+ unreadable backlog until each sender is next online. Either accept and surface it
+ ("history before this device was added is unavailable"), or have the **linking device
+ hand over its own accumulated state as a blob sealed to the new device's key**, relayed
+ by the node, which cannot read it. Decide in 15.0.
+- **Revoking a device must rotate**, exactly like revoking a member: a lost laptop holds
+ every sender key it ever received. 15.4 only knows about members today and must cover
+ `device revoke` and `member unpin`.
+
+### Honest threat delta (state this in the docs, not just here)
+
+Sender Keys protects chat against **someone who holds the node's disk but is not a group
+member** — a seized machine, a hosting provider, a compromised node. It does **not** protect
+chat from the node operator, because on this platform the operator is a group member and
+therefore a legitimate sender-key recipient. Claiming more than that would repeat the
+overstatement pattern `second-review.md` §7 flags.
+
+Three additions once devices exist, all of which belong in the user-facing docs:
+
+- **It does not protect against anyone holding any one device of any member.** With
+ several devices per person, that surface is larger than it was.
+- **C4's blast radius reaches chat history.** A browser recovers its identity key from the
+ keypair bundle on the node; cracking that bundle yields every sender key ever wrapped to
+ it, because the distribution channel has no forward secrecy. Not a regression — chat is
+ plaintext at rest today — but it means Sender Keys is worth measurably less to a
+ browser-using account than to a native one, which is the same asymmetry as everywhere
+ else in `desktop-client-v1.md` §5.1.
+- **Sender authentication is now a requirement, not an accepted limitation**
+ (operator decision, 2026-08-17). A sender key proves *a device*; it does not prove which
+ account that device belongs to, and NS6's enforcement of `sender_id` from the session is
+ the node's word. The design is in `desktop-client-v1.md` §4.8: **sign every message
+ with the sender's device key** (independent of encryption, so it can land before this
+ phase), **pin `account → device keys` client-side** using the device-add
+ countersignatures as evidence, and optionally have the **operator sign a roster
+ attestation** to close first contact. What survives: an operator who turns malicious
+ *later* cannot forge an account a member has already seen — forgery is limited to
+ accounts the victim has never encountered.
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking |
+| 15.0b | **Per-device chains** | `sender_id` becomes a device identifier; fix `GroupSenderKeyStore`'s silent overwrite; decide the history-handover question. **Blocking, and depends on device linking (Stage C) landing first** |
+| 15.1 | Node: sender key init | Generate a sender key **per device** on group join, distribute to **every device of every member** |
+| 15.2 | Node: encrypt chat on send | Encrypt payload with that device's chain key before broadcast |
+| 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order |
+| 15.4 | Key rotation on removal | Member removed **or device revoked or unpinned** → all remaining devices rotate |
+| 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore |
+| 15.6 | MNP version negotiation | Handshake declares a supported version range, not a single `v` field (L2 — today `v` is sent by everyone and checked by no one). **Move this earlier**: it pairs with the minimum-client-version check, and version skew begins the day the desktop client ships (`desktop-client-v1.md` §2.6), not when chat is encrypted |
+| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly. Note they now land in the **operator-designated upload root** (§6.7 of the desktop-client doc) |
+
+---
+
+## Phase 16 — Android client MVP
+
+> Was Phase 14 before the 2026-08-13 renumbering.
+>
+> **Rewritten 2026-08-17.** The previous text described an architecture that no longer
+> exists and, in two places, one that was deliberately dismantled. Corrections are listed
+> below rather than silently applied, because the same mistakes are easy to make twice.
+>
+> **Shares the desktop design** (`desktop-client-v1.md`): keys generated and kept
+> locally, device Ed25519 authentication, no keypair bundles, and **an Android client is
+> simply another device** under device linking. Do not re-derive a second crypto, auth or
+> admission model here.
+
+**Objective:** Android app for account creation, group browsing, file download, streaming
+and chat. **Client only — no node functionality on mobile**, and that is structural: an
+app cannot freely read the phone's folders (the user grants access to one tree at a time,
+revocably), background processes are killed, and a long-lived listening socket is not
+guaranteed. All three are things a node must have.
+
+### What the previous text got wrong
+
+| It said | Reality |
+|---|---|
+| "The `keypair_bundle` (encrypted, stored on hub) enables cross-device" | **The hub has stored no keypair bundle since 2026-08-12**, and since 2026-08-14 identity keys are **per node** — there is no single identity to carry between platforms. Cross-device is **device linking**, not a shared bundle |
+| "Notification state and read markers sync via hub (small encrypted blob per user)" | Violates the rule that **group-related server state lives on the node** (draft-v6 §2.5). Even encrypted, a per-user blob the hub stores gives it update timing and frequency — who reads which group, when. Node-side or not synced |
+| "Hub client (auth, groups, **GEK**)" | The hub does not serve GEKs. `GET /gek` and the `gek_bundles` table were removed in the T3 work; the node wraps the key on every connection |
+| "NAT traversal (`punch_nat` + QUIC)" | `punch_nat()` is **not** a traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated (structural decision 17). **ICE/STUN is the traversal path**, and Android has a native WebRTC stack |
+| "MNP extended with an `upload` message type" | Already shipped — `FILE_UPLOAD`, Phase 10b.4 |
+| "Account creation … + keypair bundle" | Hybrid registration (draft-v6 §1 item 7): passphrase-derived `auth_key`, then a device Ed25519 key. No bundle anywhere |
+| Milestones numbered 14.x inside Phase 16 | Leftover from the renumbering; they are 16.x below |
+
+**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal
+path, same as every other client. Crypto via Bouncy Castle JVM.
+
+**QUIC is deferred.** It exists for LAN and hub-less `group://`, which is marginal on a
+phone, and it would drag a Rust JNI dependency (`quiche`) into an MVP. Add it if a real
+use case appears.
+
+### Milestones
+
+| # | Component | Priority |
+|---|---|---|
+| 16.1 | Hub client — auth, groups, notifications (Retrofit) | High |
+| 16.2 | Crypto — Ed25519, X25519, ChaCha20, and **`auth_key` derivation byte-identical to `keyderive.js`/`keyderive.py`** | High |
+| 16.3 | WebRTC DataChannel transport + the unified handshake (11.5.4) | High |
+| 16.4 | **Device linking** — the app generates its own keys and is approved by an already-paired device (§4 of the desktop-client doc) | High |
+| 16.5 | Hybrid registration from the app | High |
+| 16.6 | File browser + download, **root-aware paths**, per-root "unavailable" state | High |
+| 16.7 | Upload from mobile via the existing `FILE_UPLOAD` handler; photo picker, no broad storage permission | Medium |
+| 16.8 | Chat UI | Medium |
+| 16.9 | Video streaming (native player, MSE not required) | Medium |
+| 16.10 | Contact list integration (permission-gated) | Low |
+
+### Consequences carried from the other phases
+
+- **Device linking is a prerequisite** (Stage C), exactly as for the desktop client. Without
+ it, installing the app on a phone would need an operator code per node.
+- **A fourth consumer of the KDF parity test.** `auth_key` is PBKDF2-SHA512 600 000 in
+ `keyderive.js`, `keyderive.py`, the QE harness and now Kotlin. The standing warning
+ applies and matters more each time: **never change those parameters in one place** — a
+ mismatch does not look like an error, it looks like an account nobody can open.
+- **Sender Keys**: a phone is a device, so it gets its own chain (§15.0b). The "no history
+ until every sender redistributes" property is **most visible here** — people install an
+ app and expect their backlog — which argues for the sealed state handover rather than
+ the accept-and-explain option.
+- **Version skew is worse than on desktop.** An installed client meets a newer hub
+ (`desktop-client-v1.md` §2.6), and store review latency means a fix cannot be
+ pushed quickly. The minimum-client-version check is not optional here.
+- **Multi-root** falls out for free if the app is built after Stage A; it must not assume a
+ group is one directory.
+
+### Open, and worth deciding before 16.8
+
+**Chat delivery on a phone has no answer today.** Android will not let an app hold a
+WebRTC DataChannel open in the background, so a message arriving while the app is closed
+reaches nobody. The obvious mechanism is a push service, and the obvious push service is
+FCM — which would mean **Google learning the timing of your group activity, and the hub
+sending it**, against the whole metadata posture (H7, draft-v6 §2.5). Alternatives
+(a self-hosted UnifiedPush distributor, a foreground service the user opts into, polling
+on open) each cost something different. **Decide it explicitly; do not let FCM arrive as
+an implementation detail.**
+
+**Out of scope:** node functionality on mobile, Mac and iPhone support.
+
+---
+
+## Phase 17 — Network resilience (optional, low priority)
+
+> Was Phase 15 before the 2026-08-13 renumbering.
+
+**Objective:** handle edge cases — symmetric NAT (CGNAT mobile), TURN relay,
+0-RTT reconnection. Not needed for typical residential users.
+
+| # | Component | Priority |
+|---|---|---|
+| 15.1 | Mesh Relay TURN server | Low |
+| 15.2 | Relay registration via MHP | Low |
+| 15.3 | Node fallback to relay after ICE failure | Low |
+| 15.4 | QUIC 0-RTT (session tickets) | Medium |
+| 15.5 | Connection pool (1 QUIC conn = N requests) | Medium |
+| 15.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 18 — Packaging, repositories, CI, supply chain
+
+> Was Phase 16 before the 2026-08-13 renumbering.
+> Release **signing** is not here — it moved into 13.9, because a desktop application
+> cannot ship without a verified update channel. This phase covers distro packaging and CI.
+
+| # | Component |
+|---|---|
+| 18.1 | RPM build pipeline (Fedora, RHEL) |
+| 18.2 | DEB build pipeline (Ubuntu, Debian) |
+| 18.3 | GitHub Actions CI (pytest + ruff on PR) |
+| 18.4 | **Security CI**: the 11.5.23 regression suite + the 12.1 hub-blindness test run on every PR; dependency audit (`pip-audit`); static analysis (`bandit`/`semgrep`) |
+| 18.5 | Repo apt/dnf on meshbay.org/packages/, signed with the 13.9 key |
+| 18.6 | Android APK distribution on meshbay.org/downloads/ |
+| 18.7 | Reproducible builds for the desktop client (stretch) — lets third parties verify the shipped bundle matches the source, the last piece of the T3 answer |
+
+---
+
+## Phase 19 — Extension module sandbox (future)
+
+> Was Phase 17 before the 2026-08-13 renumbering.
+> Adds a large new attack surface (arbitrary code near group data). Requires its own
+> security review before any code is written. Must stay last.
+
+**Objective:** implement spec section 12 — Python extension modules that can
+react to group events, access the file index, and send messages, running in
+a sandboxed subprocess with limited permissions.
+
+| # | Component | Description |
+|---|---|---|
+| 17.1 | Module manifest loader | Parse `module.toml`, validate permissions |
+| 17.2 | Sandboxed subprocess | `read_index()`, `send_message()`, `receive_events()` API |
+| 17.3 | Permission enforcement | No filesystem/network beyond group context |
+| 17.4 | Module marketplace on hub | List/install/rate extension modules |
+
+**Low priority.** This is an extensibility feature for power users and
+community developers. Core functionality must be complete and stable first.
+
+---
+
+## Recommended order
+
+```
+Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
+Phase 13.1 (Hub base URL + adapters)← free refactor, prerequisite for the desktop client
+Phase 12 (Key verification) ← H3 safety numbers + served-SPA integrity
+Phase 14 (Node CLI) ← best security-per-effort answer to T3
+Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first
+Phase 13.2+ (Desktop client) ← Electron; offered alongside the browser SPA
+Phase 16 (Android) ← reuses the Phase 13 design
+Phase 17 (Resilience) ← optional, edge cases only
+Phase 18 (Packaging + CI) ← distro repos; 18.7 gates 13's security argument
+Phase 19 (Extensions) ← last, needs its own security review
+```
+
+**Reordered 2026-08-13.** The desktop client was originally placed third on the strength of
+"it removes T3". That claim was corrected (see the Phase 13 banner), so the client is now
+sequenced after the work that closes actual findings, and behind decision D2 in
+`tmp-decisions.md`. Security-per-effort: **11.5 ≫ 12 ≫ 14 ≫ 13**.
+
+Phase 14 (CLI) moved ahead of the client work for a specific reason: the node operator holds
+the GEK and is the content authority, yet today must use hub-served JS to initialize GEKs and
+invite members. The CLI removes that dependency for the highest-value target at a fraction of
+any client's cost.
+
+**Phase 11.5 is blocking and not negotiable.** The current build serves private group
+content over an unauthenticated HTTP port (C1), lets any user hijack a node's signaling
+identity (C2), and lets any member seize the group key (C5b). No feature work lands on top
+of that.
+
+**One task can run in parallel:** 13.1 (hub base URL + storage/save-file adapter) is pure
+refactoring with the acceptance criterion "the browser SPA is unchanged in behaviour". It
+de-risks Phase 13 and touches none of the security surface.
+
+**One task must not be deferred inside Phase 13:** 13.3b (device linking). It is a protocol
+and schema change, it gates the "native must not prevent web use" objective, and the
+roster's `pin_identity` currently does `INSERT OR REPLACE` on a `user_id` primary key — a
+silent overwrite that becomes a hole the moment more than one key per person is legitimate.
+
+**Renumbering map (2026-08-13):**
+
+| Old | New | Phase |
+|---|---|---|
+| — | 11.5 | Security remediation (new) |
+| — | 12 | Hub minimization (new) |
+| — | 13 | Native desktop client (new) |
+| 12 | 14 | Node CLI + management |
+| 13 | 15 | Chat encryption (Sender Keys) |
+| 14 | 16 | Android client |
+| 15 | 17 | Network resilience |
+| 16 | 18 | Packaging, repos, CI |
+| 17 | 19 | Extension module sandbox |
+
+---
+
+## 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)
+15. **MSE streaming: ffmpeg fMP4 remux on node, SourceBuffer on browser** ✅ (Phase 10c)
+16. **Transport: aiortc/ICE is primary for browser AND native. QUIC kept at parity for LAN,
+ port-forwarded and hub-less `group://` access. TCP+TLS and the node HTTP API are
+ removed.** ✅ (decided 2026-08-13, second review)
+17. **`punch_nat()` is a direct-connection helper, not a NAT traversal stack** — no STUN, no
+ candidate gathering, no dual-stack fallback, validated on one ISP. ICE/STUN (validated on
+ two ISPs, two browsers, IPv4 + IPv6 + 4G CGNAT) is the traversal path. ✅ (2026-08-13)
+18. ~~**Native desktop shell: pywebview**~~ → **Electron**, with an optional Python sidecar
+ for hub-less `group://` over QUIC. **Reversed 2026-08-17** — the SPA depends on
+ Chromium-class APIs, so a system webview meant reimplementing ~2500–3000 lines and
+ losing the renderer sandbox. What is unchanged and non-negotiable: **UI assets ship
+ inside the package and load from disk**, never fetched from the hub, or T3 is not
+ fixed. See `desktop-client-v1.md` §2.
+18b. **A second device is admitted by device linking, not by an operator code.** The
+ already-pinned key countersigns; the binding is a one-time code the new device
+ generates and displays, never a human comparing digits. The hub cannot produce that
+ countersignature. ✅ (2026-08-17)
+19. **Private keys never leave the device on native clients.** Keypair bundles are retired
+ rather than relocated; Phase 12's move of bundles from hub to node was the wrong
+ destination (C4). ✅ (2026-08-13). **Qualified 2026-08-17:** this holds for native
+ devices. A browser has no durable storage of its own and still needs a bundle on each
+ node, so C4 closes for an account only when it opts out of browser use.
+20. **Sender keys are distributed pairwise to identity keys, never derived from or wrapped
+ under the GEK.** ✅ (2026-08-13)
+21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
+ must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)
diff --git a/docs/first-review.md b/docs/first-review.md
new file mode 100644
index 0000000..fffac4c
--- /dev/null
+++ b/docs/first-review.md
@@ -0,0 +1,372 @@
+# MeshBay — First Architecture & Security Review
+
+> Date: 2026-08-10
+> Scope: design-level review of the cryptographic architecture, trust model, and
+> security properties as specified in draft v3 (archived in `old-draft.md`) and implemented
+> through Phases 1-6 (81 tests, demo-v2 validated).
+>
+> This review does NOT assess the security of the demo/test deployment. It evaluates
+> whether the architecture, as designed, provides a robust foundation for a secure
+> decentralized platform.
+
+---
+
+## Executive Summary
+
+The cryptographic architecture is **strong and well-designed**. The algorithm
+choices are modern and correct, the trust model is sound, and the key hierarchy
+is properly separated. The six POC spikes were genuinely useful — the jti fix
+(Spike 3), the Argon2id recalibration (Spike 1), and the GEK wrapping protocol
+confirmation (Spike 6) are exactly the kind of findings that save projects from
+shipping real vulnerabilities.
+
+There are **no fatal design flaws**. The issues found are fixable before Phase 7,
+and the most important one (Double Ratchet group model) should be resolved
+before writing production chat code.
+
+Classification: **Critical** (must fix before production), **Significant** (design
+gap, fix before Phase 8), **Minor** (improvement, can schedule), **Note** (observation,
+no action required).
+
+---
+
+## What Is Solid
+
+These design decisions are correct and represent genuine security engineering:
+
+**1. Hub-blind GEK wrapping (ECIES-like)**
+The wrapping protocol (ephemeral X25519 + HKDF + ChaCha20-Poly1305 with AAD) is
+textbook ECIES done right. The hub stores opaque blobs, the ephemeral keypair
+ensures each wrapping produces different ciphertext, and the AAD binding to
+`pk_recipient` prevents bundle swapping attacks. This is the most important
+crypto decision in the system and it's correct.
+
+**2. Ed25519 identity verification independent of TLS**
+Nodes use self-signed TLS certs for transport confidentiality only. Client
+verifies the node's Ed25519 public key (from hub) at the MNP handshake layer.
+This decouples transport security from identity — the right design for a system
+where nodes can't get CA-signed certificates.
+
+**3. Mandatory jti in JWT**
+The Spike 3 finding was critical. Ed25519 is deterministic — without jti, two
+tokens issued in the same second are bit-identical. Adding UUID4 jti to every
+token was the correct fix. The architecture now enables per-token revocation.
+
+**4. On-the-fly encryption model**
+Files stored in plaintext on the node, encrypted at read time with per-chunk keys
+derived from the GEK via HKDF. This avoids the double-storage problem
+(encrypted + plaintext) and makes GEK rotation feasible without re-encrypting
+terabytes on disk.
+
+**5. Domain separation in HKDF**
+Every key derivation uses a distinct `info` string (`meshbay:gek_wrap:v1`,
+`meshbay:ratchet:root:v1`, etc.). The AES variant adds `:aes` suffix to chunk
+key derivation. This is a small detail that prevents cross-protocol key reuse
+and shows mature crypto engineering.
+
+**6. Transport abstraction layer**
+The `Transport` interface allowing TCP+TLS v1 → QUIC v2 migration without
+protocol changes was a good architectural decision confirmed by the successful
+demo-v2 QUIC validation.
+
+**7. Refresh token stored as blake3 hash**
+Server never stores the raw refresh token — only its hash. Correct pattern.
+Database breach doesn't leak usable refresh tokens.
+
+---
+
+## Critical Issues
+
+### C1 — Double Ratchet is not suitable for group chat as described
+
+**Location:** `meshbay_common/ratchet.py`, draft v3 section 6.6
+
+**Problem:** The draft states "all members share the same ratchet state seeded
+from the group GEK." The Signal Double Ratchet is designed for **pairwise**
+(1:1) communication. It fundamentally cannot work as a shared group state:
+
+- If all N members share a single ratchet state, each member advancing the
+ sending chain desynchronizes all other members. Message 5 from Alice and
+ message 5 from Bob would use the same chain key, producing a nonce/key reuse
+ — a catastrophic failure for ChaCha20-Poly1305 and AES-GCM.
+- The current `RatchetState` class has one `CKs` (sending chain) and one `CKr`
+ (receiving chain), confirming it's a pairwise protocol.
+
+**What Signal actually does for groups:** Signal uses a different protocol called
+**Sender Keys** (described in their "Group Protocol" specification). Each member
+has their own symmetric sending chain key. When a member joins a group, all
+existing members send their current sender key to the new member via pairwise
+Double Ratchet channels. This gives forward secrecy per member, not per message.
+
+**Impact:** If implemented as described, group chat will either:
+- Silently corrupt messages (if state is truly shared), or
+- Require N*(N-1)/2 pairwise ratchet sessions (O(N^2) state, impractical for
+ groups > 10 members)
+
+**Recommendation:** Before Phase 7.5 (chat), decide between:
+1. **Sender Keys** (Signal Groups approach): each member maintains one symmetric
+ sending chain. Forward secrecy at member rotation granularity. O(N) state.
+ Simpler to implement, good enough for most threat models.
+2. **Pairwise Double Ratchet**: keep the current implementation but use it for
+ 1:1 messages only. Group messages would be encrypted N-1 times. O(N^2) cost
+ per message — only feasible for small groups (<20).
+3. **MLS (Message Layer Security, RFC 9420)**: the modern standard for group
+ messaging. Tree-based ratcheting, O(log N) state and messages. More complex
+ but future-proof. Python implementations exist (`openmls` bindings, or
+ `mls-protocol`).
+
+Recommendation: **Sender Keys** for v1 (pragmatic, Signal-proven), with the
+option to migrate to MLS later if group sizes grow.
+
+### C2 — No group membership verification in MNP handshake
+
+**Location:** `quic_server.py:125-146`, `server.py:130-151`
+
+**Problem:** The MNP handshake verifies the JWT signature and expiration, but does
+NOT check whether the authenticated user is a member of the group being accessed.
+Any valid JWT holder can request any file from any group served by the node.
+
+The draft says the JWT carries "hub-signed groups membership claim" (section 4.1.4),
+but the actual `issue_access_token()` in `auth.py:104-125` does not include any
+group membership claims. The JWT contains only `sub`, `pk_user`, `hub_id`, `jti`,
+`iat`, `exp`.
+
+**Impact in Phase 7 (multi-group):** A user authenticated for group A can request
+files from group B on the same node. Since all groups share one QUIC port, this
+becomes an authorization bypass.
+
+**Recommendation:**
+- Add group membership claims to the JWT: `"groups": ["group_id_1", "group_id_2"]`
+- Node verifies the requested group_id is in the JWT's groups claim
+- This is a simple change to `issue_access_token()` + handshake verification
+- The JWT is already verified offline with the hub's Ed25519 key — adding claims
+ doesn't change the verification flow
+
+---
+
+## Significant Issues
+
+### S1 — Admin revocation endpoint has no authorization check
+
+**Location:** `revocation.py:149-194`
+
+**Problem:** The `admin_revoke` endpoint requires authentication (`get_current_user`)
+but does NOT verify that the current user is a hub admin. Any authenticated user
+can revoke any other user or any group. The docstring says "Admin only (user must
+be hub admin — user_id in config)" but no such check is implemented.
+
+**Impact:** Any registered user can revoke any other user or group on the hub.
+This is a privilege escalation vulnerability.
+
+**Recommendation:** Phase 8 plans admin roles (8.1: `hub_admin` flag on User). This
+check must be added before the revocation endpoint is used in any non-demo context.
+For now, the endpoint exists but is only callable by someone who knows the API —
+acceptable for a test deployment, not for production.
+
+### S2 — Email stored in plaintext in the database
+
+**Location:** `models.py:42`, draft v3 section 4.1.1
+
+**Problem:** The spec says "Email and phone are stored encrypted at rest in the
+database." The actual `User` model stores email as `String(256)` — plaintext.
+A database breach would expose all user emails.
+
+**Recommendation:** Encrypt email (and future phone field) with a server-side key
+derived from a secret not stored in the database (e.g., from the hub config file).
+Use AES-256-GCM with a deterministic IV derived from user_id (for lookups) or
+accept that encrypted email cannot be searched by value.
+
+### S3 — No jti denylist distribution to nodes
+
+**Location:** draft v3 section 4.1.4, open question #9
+
+**Problem:** The architecture describes a jti denylist for immediate token revocation,
+but:
+- The hub has no `GET /v1/revoke/denylist` endpoint (marked [TBD])
+- Nodes don't check any denylist during JWT verification
+- The revocation WebSocket pushes revocation tokens to nodes, but nodes don't
+ persist or check them during MNP handshake
+
+**Impact:** A revoked user's JWT remains valid for up to 1 hour (until natural
+expiration). The revocation WebSocket can close active connections, but new
+connections with the same JWT will succeed.
+
+**Recommendation:** Two options:
+1. **Push + local cache** (recommended): when the node receives a revocation via
+ WebSocket, it adds the jti to an in-memory set. MNP handshake checks this set.
+ Simple, real-time, no polling.
+2. **Pull**: node periodically fetches the denylist from the hub. Adds latency
+ between revocation and enforcement.
+
+Option 1 is simpler and already half-built (the WebSocket channel exists).
+
+### S4 — AES-GCM keystore uses non-standard 128-bit IV
+
+**Location:** `crypto.py:148` — `iv = os.urandom(16)`
+
+**Problem:** AES-GCM is specified for 96-bit (12-byte) nonces (NIST SP 800-38D).
+The keystore encryption uses a 128-bit (16-byte) IV. The `cryptography` library
+accepts this and processes it through GHASH to derive the internal counter, which
+is secure — but it's a deviation from the standard.
+
+**Impact:** No direct vulnerability. AES-GCM with >96-bit IVs has a slightly
+different security proof (birthday bound applies to the GHASH reduction). For a
+keystore that's encrypted once and rarely re-encrypted, the practical risk is zero.
+
+**Recommendation:** Change to `os.urandom(12)` for standard compliance. Simple
+one-line fix. The existing keystore files would need re-encryption on next save
+(which happens naturally when the user updates their keystore).
+
+### S5 — Refresh token not rotated on use
+
+**Location:** `users.py:158-179`
+
+**Problem:** When a refresh token is used to obtain a new access token, the same
+refresh token remains valid. If an attacker intercepts a refresh token, they can
+use it repeatedly alongside the legitimate user, and neither party detects the
+theft.
+
+**Recommendation:** Implement refresh token rotation: each use of a refresh token
+issues a new refresh token and invalidates the old one. If the old token is used
+again (by the attacker), the hub detects the reuse and revokes all tokens for
+that user (indicating theft). This is the OAuth 2.0 Security BCP recommendation
+(RFC 6819, section 5.2.2.3).
+
+---
+
+## Minor Issues
+
+### M1 — Username enumeration via registration and pubkeys endpoints
+
+The registration endpoint returns "Username already taken" (409), and
+`GET /v1/users/{username}/pubkeys` returns 404 vs a valid response. Both allow
+enumerating valid usernames. For a decentralized platform where users have public
+identities, this may be acceptable by design, but it should be a conscious
+decision.
+
+### M2 — TLS self-signed certificate uses RSA-2048
+
+**Location:** `tls_cert.py:36`
+
+The TLS cert uses RSA-2048 while the rest of the system uses Ed25519. Since the
+cert is only for transport confidentiality (identity is verified via Ed25519),
+this is acceptable. However, using an Ed25519 TLS certificate would be more
+consistent and is supported by modern TLS 1.3 stacks. RSA-2048 is ~112-bit
+security; Ed25519 is ~128-bit.
+
+### M3 — No rate limiting on GEK retrieval and pubkeys endpoints
+
+Only `/register` and `/login` have rate limiting. An attacker could enumerate
+pubkeys or attempt to retrieve GEK bundles at high frequency. While GEK bundles
+are opaque (no direct attack), rate limiting on all authenticated endpoints is
+good hygiene.
+
+### M4 — Single admin per group with no delegation or recovery
+
+If the admin's node goes offline, the group becomes inaccessible: no new members
+can be added, no GEK rotation, no moderation. There's no mechanism for admin
+delegation or recovery. For a personal file-sharing platform this may be
+acceptable, but for any group with more than a few members, this is a
+single-point-of-failure.
+
+### M5 — Chunk key derivation uses HKDF salt=None
+
+**Location:** `crypto.py:46-51`
+
+The code uses `salt=None` and puts the file context in `info`. This is actually
+correct HKDF usage (salt is for randomizing extraction when IKM might be
+non-uniform; GEK is from CSPRNG so salt isn't needed; info is for domain
+separation). However, the draft v3 spec describes it as using `salt`, which
+creates a spec/code discrepancy. Update the spec to match the code, since the
+code is correct.
+
+### M6 — Argon2id production parameters not yet applied
+
+**Location:** `crypto.py:131-133`, `auth.py:24-26`, `keyderive.py:33-35`
+
+All three Argon2id usage sites still use the dev parameters (iterations=3,
+memory=64MB, ~78ms). Production target is iterations=4, memory=256MB, ~500ms.
+Phase 7.7 plans a calibration CLI command. This must be done before any
+real-world deployment. The comments document this correctly.
+
+---
+
+## Notes (No Action Required)
+
+### N1 — Forward secrecy model is appropriate
+
+File encryption uses GEK-derived symmetric keys — no forward secrecy at the
+application layer. If GEK is compromised, past files are decryptable. This is
+documented and accepted: the alternative (per-session file encryption keys)
+would break seeking, caching, and multi-source download. The transport layer
+(TLS 1.3 / QUIC) provides forward secrecy for data in transit.
+
+### N2 — Error messages in login are correct
+
+`login()` returns the same "Invalid credentials" for both user-not-found and
+wrong-password. This is the correct behavior to prevent user enumeration through
+the login flow (even though registration and pubkeys endpoints allow it — see M1).
+
+### N3 — Hub legal exposure model is well-positioned
+
+The hub stores no content, no metadata, no node IPs (beyond ephemeral signaling).
+GEK bundles are opaque. The hub's legal exposure is analogous to a domain
+registrar or email provider — it knows who registered but not what they share.
+LCEN/DSA compliance is addressed through IP logging with 1-year retention.
+
+### N4 — QUIC NAT probe content is fine
+
+`punch_nat()` sends `b'MESHBAY:NAT:PUNCH'` as a fixed probe. Some NAT
+implementations might filter constant payloads, but in practice this works
+(demo-v2 confirmed). The content of the probe packet doesn't matter for NAT
+entry creation — only the 5-tuple (src_ip, src_port, dst_ip, dst_port, proto)
+matters.
+
+### N5 — Web/CLI key derivation mismatch is by design
+
+Strategy A (Argon2id) and Strategy B (PBKDF2-SHA512 in browser) produce
+different keys from the same password. The code and docs correctly explain this:
+users pick one registration path. The web client uses random keypairs stored
+encrypted on the hub, not password-derived keys. This avoids the mismatch
+entirely.
+
+---
+
+## Prioritized Action Plan
+
+| # | Issue | Severity | When to fix |
+|---|---|---|---|
+| C1 | Double Ratchet group model | Critical | Before Phase 7.5 (chat) |
+| C2 | No group membership in JWT/handshake | Critical | Phase 7.3 (multi-group) |
+| S1 | Admin revocation has no authz check | Significant | Phase 8.1 (admin roles) |
+| S2 | Email stored in plaintext | Significant | Phase 8 |
+| S3 | No jti denylist on nodes | Significant | Phase 7.2 (signaling) |
+| S4 | AES-GCM 128-bit IV | Significant | Any time (1 line) |
+| S5 | Refresh token rotation | Significant | Phase 8 |
+| M1 | Username enumeration | Minor | Accept or Phase 8 |
+| M2 | RSA-2048 TLS cert | Minor | Phase 7 or later |
+| M3 | Rate limiting gaps | Minor | Phase 8.6 |
+| M4 | Single admin SPOF | Minor | Phase 8+ |
+| M5 | Spec/code HKDF discrepancy | Minor | Update spec |
+| M6 | Argon2id prod params | Minor | Phase 7.7 |
+
+---
+
+## Conclusion
+
+MeshBay's security architecture is built on solid foundations. The cryptographic
+primitive choices are modern and correct. The trust model (hub-blind, node-hosted,
+E2E encrypted) is well-designed and consistently applied. The POC spikes caught
+real issues (jti, Argon2id calibration, NAT behavior) that would have been
+difficult to fix post-deployment.
+
+The two critical issues (C1: group ratchet model, C2: group membership
+authorization) are both design decisions that need to be made before Phase 7
+produces production chat and multi-group code. They are not retroactive problems
+— they are forward-looking decisions that the architecture leaves room for.
+
+The significant issues (S1-S5) are implementation gaps that should be addressed
+during Phases 7-8, in the natural course of hardening the hub and node.
+
+Overall assessment: **good foundations, ready for Phase 7** after deciding the
+group chat encryption model (C1) and adding group claims to the JWT (C2).
diff --git a/docs/meshbay-draft-v1-fr.md b/docs/meshbay-draft-v1-fr.md
deleted file mode 100644
index 5f8ea6a..0000000
--- a/docs/meshbay-draft-v1-fr.md
+++ /dev/null
@@ -1,442 +0,0 @@
-# MeshBay — Brouillon d'Architecture v1
-
-> Statut : brouillon préliminaire — de nombreux points restent ouverts, marqués [TBD]
-
----
-
-## 1. Présentation du projet
-
-MeshBay est une plateforme décentralisée, pair-à-pair, pour le partage de fichiers, le streaming vidéo et la messagerie de groupe. Elle combine une fédération d'identité (via les Mesh Hubs) avec un échange de données véritablement pair-à-pair (via les Mesh Nodes), dans l'objectif d'être résiliente, résistante à la censure et accessible aux utilisateurs.
-
-**Principes fondamentaux :**
-- Les données ne transitent jamais par un serveur central — seuls l'identité et le routage le font
-- Chiffrement de bout en bout pour tout contenu privé (fichiers, index, messages)
-- L'opérateur du node est l'hébergeur légal et porte l'entière responsabilité de son contenu
-- Le hub est un registrar léger, pas un hébergeur ni un indexeur de contenu
-- Open source, auto-hébergeable à chaque niveau
-
-**Domaine :** meshbay.org
-
----
-
-## 2. Terminologie
-
-| Terme | Rôle |
-|---|---|
-| **Mesh Hub** | Serveur d'autorité d'identité et registre de groupes |
-| **Mesh Node** | Programme local sur la machine de l'utilisateur hébergeur |
-| **Mesh Client** | Navigateur web ou application Android (utilisateur final) |
-| **Mesh Relay** | Relais TURN de secours opéré par la communauté |
-| **MNP** | Mesh Node Protocol — protocole P2P entre nodes et clients |
-| **MHP** | Mesh Bay Hub Protocol — protocole de fédération inter-hubs |
-| **GEK** | Group Encryption Key — clé symétrique de chiffrement du groupe |
-| **Mesh Directory** | Registre public des groupes (niveau hub) |
-| **Mesh Group Index** | Listing chiffré des fichiers d'un groupe (niveau node) |
-
----
-
-## 3. Composants du système
-
-### 3.1 Mesh Hub
-
-Serveur léger agissant comme un registrar. Il est intentionnellement maintenu minimal pour réduire l'exposition légale et la charge opérationnelle.
-
-**Ce que le hub stocke :**
-- Comptes utilisateurs : nom d'utilisateur, email haché, `PK_user` (empreinte de clé publique), ID du hub, statut
-- Registre de groupes : nom, `PK_group`, adresse du node hébergeur, visibilité, liste des membres avec bundles GEK chiffrés
-- Listes de révocation (utilisateurs et groupes)
-- Hubs pairs enregistrés (liste d'autorisation explicite — pas de découverte automatique)
-
-**Ce que le hub ne stocke jamais :**
-- Contenu de fichiers ou métadonnées
-- Index de groupes privés
-- Contenu de messages
-- Adresses IP des nodes (gérées par le service de signaling éphémère)
-
-**Interactions hub — quand est-il sollicité ?**
-
-| Événement | Charge hub | Fréquence |
-|---|---|---|
-| Création de compte | Hash du credential, stockage PK | Une fois |
-| Login | Vérification credentials, émission JWT signé | Par session (~30j de validité) |
-| Création de groupe | Enregistrement nom, PK_group, node | Une fois par groupe |
-| Ajout/suppression membre | Stockage/suppression bundle GEK chiffré | Sur action admin |
-| Discovery d'un groupe | Retour adresse node + PK_node + bundle GEK | Par accès initial |
-| Signaling NAT | Relais de quelques messages WebSocket (<1 Ko) | Par nouvelle connexion P2P |
-| Recherche publique | Délégation de requête aux nodes à la demande | Sur demande |
-| Sync fédération MHP | Échange mises à jour du Mesh Directory | Background, périodique |
-| Révocation | Émission token de révocation signé | Rare |
-
-**Le hub n'est jamais dans le chemin des données après l'établissement de la connexion initiale.**
-
-**Le JWT comme passeport hors-ligne :**
-Le hub émet un JWT signé avec sa clé privée Ed25519. Les nodes vérifient ce JWT localement en utilisant la clé publique connue du hub — aucun aller-retour hub requis par requête. Validité JWT : ~30 jours.
-
-**Stack technique :**
-- Langage : Python
-- Framework : FastAPI + Uvicorn
-- Base de données : PostgreSQL + SQLAlchemy + Alembic
-- Déploiement : derrière un reverse proxy Apache (ProxyPass)
-- Authentification : système propre (JWT signé Ed25519, sans dépendance OAuth)
-
-**Création de compte :** [TBD] — email seul dans un premier temps, numéro de téléphone associable par la suite. Via l'app Android, les deux collectés par défaut. Comptes fusionnables.
-
-### 3.2 Mesh Node
-
-Programme local tournant sur la machine de l'utilisateur hébergeur. Le node est l'hébergeur effectif de tout le contenu.
-
-**Responsabilités :**
-- Surveiller et indexer les répertoires partagés (Mesh Group Index)
-- Servir fichiers et flux vidéo aux membres du groupe
-- Gérer toutes les clés cryptographiques localement (keystore, protégé par mot de passe)
-- Gérer les connexions P2P et la traversée NAT
-- Exécuter le protocole MNP
-- Héberger le sandbox de modules Python
-- Servir l'interface web locale (localhost)
-- [Futur] Recevoir et redistribuer une vidéo éphémère depuis mobile
-
-**Plateforme :** Linux en priorité, cross-platform dès le départ (Windows/macOS). Python assure la portabilité.
-
-**Stack technique :**
-- Langage : Python (principal), extensions Rust uniquement si strictement nécessaire pour les parties critiques en performance
-- QUIC : `aioquic`
-- ICE/STUN : `aioice`
-- WebRTC (futur) : `aiortc`
-- Crypto : `cryptography` (PyCA, backed OpenSSL, accélération matérielle)
-- Sérialisation : `msgpack`
-- Compression : `zstandard` (zstd)
-- Surveillance fichiers : `watchdog`
-- BDD locale : SQLite
-- Interface web locale : servie par le node sur localhost (port [TBD])
-
-**Appairage node avec mobile :** QR code depuis l'interface web locale [futur].
-
-### 3.3 Mesh Client
-
-Navigateur web ou application Android. Consomme le contenu depuis le node ; gère le compte via le hub.
-
-**Opérations côté hub :**
-- Création de compte et login
-- Recherche et découverte de groupes publics
-- Gestion de l'appartenance aux groupes
-
-**Opérations côté node (P2P direct) :**
-- Navigation dans les fichiers (Mesh Group Index)
-- Lecture du fil de messages (avec pièces jointes, façon Signal)
-- Téléchargement de fichiers
-- Streaming vidéo (VOD)
-- [Futur] Flux vidéo éphémère
-
-**Modes client** [à concevoir] :
-- Mode explorateur : navigation dans les fichiers d'un groupe
-- Mode flux : fil de messages avec pièces jointes
-- Articulation UI hub/node à définir
-
-### 3.4 Mesh Relay
-
-Relais TURN opéré par la communauté. Utilisé uniquement en dernier recours quand toutes les méthodes de connexion P2P échouent. Le trafic est toujours chiffré E2E — le relais ne voit que des paquets QUIC opaques et ne peut pas lire le contenu.
-
-Non opéré par meshbay.org. Un protocole d'enregistrement des relais auprès des hubs est [TBD].
-
----
-
-## 4. Modèle de groupe
-
-Les groupes sont l'unité organisationnelle centrale.
-
-| Paramètre | Options |
-|---|---|
-| Visibilité | Public / Privé |
-| Politique d'adhésion | Libre / Sur demande / Sur invitation uniquement |
-| Admin | L'opérateur du node hébergeur (hébergeur légal) |
-
-Un groupe public fonctionne comme un forum thématique : fichiers partagés, fil de discussion, liste de membres. Il peut être à entrée libre, sur demande ou sur invitation, indépendamment de sa visibilité publique.
-
-Le contenu d'un groupe privé (fichiers, index, messages) est toujours chiffré E2E avec la GEK. Seuls les membres possédant la GEK peuvent déchiffrer quoi que ce soit.
-
-**Adressage des groupes** [TBD] :
-```
-meshbay.org/u/username/groupname — groupe public via hub
-meshbay.org/g/groupname — groupe public direct
-group://<PK_group_fingerprint>@<node_addr> — accès direct sans hub
-```
-
----
-
-## 5. Architecture cryptographique
-
-### 5.1 Hiérarchie de clés
-
-```
-Clé d'identité utilisateur Ed25519 Signature, authentification
-Clé d'échange utilisateur X25519 Accord de clé
-Clé d'identité groupe Ed25519 Signature métadonnées groupe (tenue par le node admin)
-Clé de chiffrement groupe ChaCha20 Chiffrement contenu et index (symétrique, 256 bits)
-Clés de session X25519/HKDF Perfect forward secrecy par connexion P2P
-```
-
-Toutes les clés privées sont stockées exclusivement sur le node (ou l'appareil client), dans un keystore local protégé par mot de passe. Le hub ne voit jamais aucune clé privée.
-
-### 5.2 Gestion de la GEK
-
-**Création de groupe :**
-1. Le node admin génère la GEK (ChaCha20-Poly1305, 256 bits, CSPRNG)
-2. La GEK est chiffrée pour chaque membre via accord de clé X25519 + HKDF
-3. Les bundles GEK chiffrés sont stockés sur le hub (ou sur le node — [TBD])
-
-**Ajout de membre :**
-- GEK chiffrée avec la `PK_user` du nouveau membre et distribuée
-
-**Révocation de membre :**
-- Le node admin génère une nouvelle GEK
-- Re-chiffrement pour tous les membres restants
-- Les nouveaux contenus sont chiffrés avec la nouvelle GEK
-- L'ancien membre conserve la capacité de déchiffrer le contenu précédemment reçu (compromis acceptable — re-chiffrement complet non prévu)
-
-### 5.3 Chiffrement à la volée pour le transfert de fichiers
-
-Les fichiers sont stockés en clair sur le disque de l'hébergeur. Le node chiffre à la lecture avant transmission.
-
-```
-Disque (clair) → [Node] → compression zstd → chiffrement GEK (par chunk) → session QUIC → [Client] → déchiffrement QUIC → déchiffrement GEK → clair
-```
-
-**Stratégie de chunking :**
-- Taille de chunk : 1 Mo (amortit l'overhead AEAD, permet le seek)
-- Dérivation de clé par chunk :
- `chunk_key = HKDF(GEK, "file:" || blake3(fichier) || "chunk:" || index)`
-- Chaque chunk déchiffrable indépendamment (permet le seek vidéo)
-- Compresser avant chiffrer (la compression zstd est inutile après chiffrement)
-
-**Authentification des chunks :**
-Chaque chunk (ou lot) est signé avec la clé Ed25519 du node. Le client vérifie avant déchiffrement. Prévient l'injection de données par un relais compromis.
-
-### 5.4 Sécurité du transport
-
-- Protocole principal : **QUIC** (TLS 1.3 intégré, UDP, multiplexé)
-- Clés de session par connexion via X25519 ECDH + HKDF
-- La couche QUIC est indépendante de la couche applicative GEK — deux couches de chiffrement indépendantes
-
-### 5.5 Chiffrement du chat
-
-La messagerie de groupe utilise l'algorithme **Double Ratchet** (comme Signal) :
-- Forward secrecy et break-in recovery par message
-- Chaque message chiffré indépendamment
-- Implémentation : bibliothèque Python ou Rust existante [TBD]
-
----
-
-## 6. Réseau et connectivité
-
-### 6.1 Traversée NAT — ordre des tentatives
-
-```
-1. IPv6 disponible des deux côtés → connexion directe, aucun problème NAT
-2. UPnP / NAT-PMP sur le routeur → le node ouvre un port automatiquement
-3. ICE + STUN / UDP hole punching → fonctionne pour ~80-85% des cas
-4. Mesh Relay (fallback TURN) → opéré par la communauté, trafic E2E chiffré
-```
-
-**Signaling** (étapes 3/4) : coordonné via WebSocket du hub, <1 Ko par tentative, sans état après connexion établie.
-
-**Couverture étape 4 :** ~15-20% des connexions (NAT symétrique des deux côtés, CGNAT). Le relais ne voit que des paquets QUIC chiffrés.
-
-### 6.2 MNP — Mesh Node Protocol
-
-Protocole applicatif sur QUIC. Blocs définis :
-
-- **Handshake** : échange de clés, vérification d'appartenance au groupe (présentation JWT)
-- **Sync d'index** : delta de Mesh Group Index chiffré à la connexion
-- **Transfert de fichiers** : requête/réponse par chunk avec vérification de hash
-- **Streaming VOD** : segments HLS/DASH, chiffrés par segment avec des clés dérivées de la GEK
-- **Messagerie** : messages Double Ratchet encapsulés dans des frames MNP
-- **[Futur] Flux éphémère** : type `ephemeral_stream` avec métadonnées TTL
-
-### 6.3 Diffusion de contenu public
-
-Les fichiers publics sont identifiés par leur hash `blake3`. Plusieurs nodes peuvent servir le même fichier :
-
-1. Le Node A possède le fichier public X (hash H)
-2. Tout node qui obtient X et choisit de le mirrorer s'enregistre auprès du hub : "je sers le hash H"
-3. Le hub maintient : `{ blake3_hash → [node_A, node_B, ...] }`
-4. Un client demande X → le hub retourne la liste des sources → le client récupère des chunks en parallèle depuis plusieurs nodes
-
-**Transport contenu public :** TLS uniquement (pas de GEK). Contenu signé avec la clé Ed25519 du node original pour vérification d'authenticité par les clients, même servi depuis un miroir. Possibilité laissée ouverte d'ajouter une GEK pour des groupes "publics réservés aux inscrits" dans une révision future.
-
----
-
-## 7. Index
-
-### 7.1 Mesh Directory (niveau hub)
-
-Registre public des groupes. Échangé entre hubs via MHP.
-
-Format : msgpack, signé par la clé Ed25519 du hub.
-
-Champs par entrée : nom de groupe, `PK_group`, hub hébergeur, description, tags de type de contenu, politique d'adhésion.
-
-### 7.2 Mesh Group Index (niveau node)
-
-Listing des fichiers d'un groupe. Généré et maintenu par le node hébergeur.
-
-Format : msgpack → compressé zstd → chiffré GEK (groupes privés) ou signé en clair (groupes publics).
-
-Structure d'une entrée :
-```python
-{
- "id": "<blake3_hash>",
- "name": "fichier.mkv",
- "path": "Films/2024/", # relatif au répertoire partagé
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # secondes, pour les médias
- "thumb_hash":"<blake3>", # hash de la miniature (miniature aussi chiffrée GEK)
- "added_at": 1720000000
-}
-```
-
-**Mises à jour delta :** chaque mise à jour porte `{base_version, additions, deletions}` — pas de re-chiffrement complet à chaque changement.
-
-**Transit :** les nodes poussent les deltas d'index aux membres connectés sur modification. Les membres tirent l'index complet à la première connexion. Le hub ne stocke aucun contenu d'index — seulement l'adresse du node pour le routage.
-
-### 7.3 Recherche
-
-**Groupes privés :** la recherche est entièrement locale sur l'appareil du client. Le client maintient un cache local chiffré de tous les index des groupes dont il est membre. Aucun appel réseau, aucune implication du hub, résultats instantanés.
-
-**Groupes publics :** le client interroge les nodes directement à la demande. Le hub fournit le routage (quel node héberge quel groupe) mais n'effectue aucune recherche de contenu lui-même.
-
-**Interface web du hub — recherche :** délègue la requête aux nodes concernés à la demande. Le hub ne stocke rien de cette interaction. Micro-cache en mémoire des résultats : **TTL 60 secondes maximum, RAM uniquement, jamais écrit sur disque, contenu public uniquement.** Ceci relève du caching technique (DSA EU Article 13) et ne constitue pas de l'indexation.
-
----
-
-## 8. Fédération inter-hubs (MHP)
-
-### 8.1 Hiérarchie des hubs
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (auto-hébergé, CA déléguée)
- │ └── émet des credentials utilisateurs, gère ses propres groupes
- │ └── peut se fédérer avec d'autres Full Hubs via MHP
- └── Mirror Hub
- └── héberge uniquement le Mesh Directory public (pas de comptes utilisateurs)
-```
-
-Un Full Hub reçoit un certificat signé par le Root Hub (ou un Full Hub parent) prouvant son autorité. Les clients vérifient la chaîne. Un Mirror Hub ne peut que répliquer des données publiques.
-
-### 8.2 Principes de conception MHP
-
-- Sélection explicite des pairs : chaque hub maintient une liste d'autorisation de hubs de confiance
-- Pas de découverte automatique de hubs
-- Données échangées : Mesh Directory (groupes publics), listes de révocation, credentials utilisateurs cross-hub
-- Authentification cross-hub : l'utilisateur du Hub A présente un JWT signé par Hub A ; Hub B vérifie en utilisant la clé publique de Hub A (récupérée une fois à la première interaction, mise en cache)
-
-### 8.3 Accès client cross-hub
-
-Client de Hub A accédant à un groupe sur Hub B :
-1. Le Mesh Directory de Hub A ou un lien direct amène le client vers Hub B
-2. Le client présente son JWT Hub A directement à Hub B
-3. Hub B vérifie la signature JWT avec la clé publique de Hub A
-4. Hub B émet un token local de courte durée pour cette session
-5. Le client rejoint le node normalement
-
----
-
-## 9. Modération
-
-### 9.1 Contenu public
-
-```
-Signalement #1 → suspension automatique de l'accès public au contenu
- → notification à l'opérateur du node
-Une republication autorisée
-Signalement #2 → escalade vers les modérateurs du hub
-Confirmé → groupe révoqué sur le hub local
- → révocation propagée aux hubs fédérés via MHP
-```
-
-Mécanisme : hash blake3 du contenu ajouté à la liste de blocage du hub. Le node reçoit un avis de révocation signé et coupe l'accès public.
-
-### 9.2 CSAM
-
-Hash matching contre la base de données NCMEC/IWF sur tout contenu public lors de l'enregistrement. La participation démontre la bonne foi et réduit significativement l'exposition légale. Pas de scanning de contenu privé/chiffré.
-
-### 9.3 Copyright
-
-Cadre de notification légale DMCA/équivalent (takedown sur notification). Pas de blocage technique automatique — trop complexe, trop de faux positifs (fair use, variations régionales). Le hub peut révoquer sur demande légale confirmée.
-
-### 9.4 Contenu privé
-
-Non modérable directement (chiffré E2E par conception). Seule action disponible : révoquer l'utilisateur ou le groupe au niveau du hub sur demande légale formelle. Le hub émet un token de révocation signé que les nodes de tous les membres peuvent vérifier.
-
----
-
-## 10. Système de modules Python
-
-Le node peut charger des modules d'extension (Python) s'exécutant dans un sous-processus sandbox.
-
-**Manifeste de module** (capacités déclarées) :
-```python
-{
- "name": "group-chat",
- "version": "1.0.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**APIs disponibles (restreintes) :**
-- `read_index()` — lecture de l'index courant du groupe (lecture seule)
-- `send_message(content)` — poster un message dans le fil du groupe
-- `receive_events(handler)` — s'abonner aux événements du groupe (nouveau fichier, nouveau message)
-
-**Non disponible :**
-- Accès réseau arbitraire
-- Accès au système de fichiers hors du contexte du groupe
-- Appels système
-
-**Premier module officiel :** fil de discussion de groupe (façon Signal, avec pièces jointes). Fourni avec le node.
-
----
-
-## 11. Cadre légal
-
-**Opérateur du node :** hébergeur légal principal du contenu. Entièrement responsable de ce qu'il partage. Le logiciel node communique clairement cela lors de l'installation.
-
-**Opérateur du hub :** registrar, pas hébergeur de contenu. Stocke un minimum de données personnelles. Opère le mécanisme de takedown. Participe au hash matching CSAM. Exposition légale analogue à celle d'un bureau d'enregistrement de domaines.
-
-**Auteur du protocole/logiciel :** protégé par les usages non-contrefaisants substantiels. Pas de facilitation active de l'infraction.
-
-**Minimisation des données du hub :**
-- Email stocké haché après vérification [TBD]
-- Pas de journalisation des IP (ou suppression automatique après 24h)
-- Aucune métadonnée de contenu stockée
-- Adresse courante du node gérée uniquement par le service de signaling éphémère
-
----
-
-## 12. Fonctionnalités futures (notées, non conçues)
-
-- **Réplication de contenu entre nodes :** node-à-node, autorisée par l'admin, sans implication du hub
-- **Push vidéo depuis mobile :** mobile filme → pousse vers le node hébergeur → distribué comme flux éphémère avec TTL aux membres du groupe. Type MNP `ephemeral_stream` réservé.
-- **Protocole d'enregistrement des Mesh Relays :** relais TURN communautaires enregistrés auprès des hubs
-- **Appairage node-mobile :** QR code depuis l'interface web locale
-- **Téléchargement multi-sources :** récupération de chunks en parallèle depuis plusieurs nodes pour un même fichier public (swarm)
-- **Client iOS**
-- **Chiffrement at-rest sur le node :** optionnel, pour les nodes déployés sur des serveurs distants
-
----
-
-## 13. Questions ouvertes [TBD]
-
-1. **Stockage des bundles GEK :** sur le hub ou sur le node uniquement ? Hub = discovery plus facile ; node uniquement = plus décentralisé
-2. **Schéma d'adressage des groupes :** format URL final
-3. **Périmètre de l'interface web locale du hub pour la V1 :** configuration uniquement, ou aussi navigation dans les groupes ?
-4. **Création de compte :** email seul pour commencer, téléphone associable — à confirmer
-5. **Implémentation du chat :** module bundlé ou fonctionnalité core ?
-6. **Maturité de la lib QUIC :** évaluation de `aioquic` en production à effectuer
-7. **Bibliothèque Double Ratchet :** identifier la meilleure implémentation Python
-8. **Protocole d'enregistrement des relais :** à concevoir lors de l'introduction des relais communautaires
-9. **Échange de répertoire cross-hub :** fréquence, résolution de conflits
-10. **Port de l'interface web locale du node :** à définir
-11. **Stratégie d'expiration et renouvellement des JWT**
-12. **Format du keystore et mécanisme de déverrouillage au démarrage du node**
diff --git a/docs/meshbay-draft-v1.md b/docs/meshbay-draft-v1.md
deleted file mode 100644
index 1eb1758..0000000
--- a/docs/meshbay-draft-v1.md
+++ /dev/null
@@ -1,442 +0,0 @@
-# MeshBay — Architecture Draft v1
-
-> Status: preliminary draft — many points still open, marked [TBD]
-
----
-
-## 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), aiming 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
-
----
-
-## 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 |
-| **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 group content |
-| **Mesh Directory** | Public registry of groups (hub level) |
-| **Mesh Group Index** | Encrypted file listing for a group (node level) |
-
----
-
-## 3. System Components
-
-### 3.1 Mesh Hub
-
-A lightweight server acting as a registrar. It is intentionally kept minimal to reduce legal exposure and operational burden.
-
-**What the hub stores:**
-- User accounts: username, hashed email, `PK_user` (public key fingerprint), hub ID, status
-- Group registry: name, `PK_group`, hosting node address, visibility, member list with encrypted GEK bundles
-- 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 IP addresses (handled by ephemeral signaling service)
-
-**Hub interactions — when is it called?**
-
-| Event | Hub load | Frequency |
-|---|---|---|
-| Account creation | Hash credential, store PK | Once |
-| Login | Verify credentials, issue signed JWT | Per session (~30-day validity) |
-| Group creation | Register name, PK_group, node | Once per group |
-| Member add/remove | Store/remove encrypted GEK bundle | On admin action |
-| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
-| NAT signaling | Relay a few WebSocket messages (<1 KB) | Per new P2P connection |
-| Public search | Delegate query to nodes at request time | On demand |
-| MHP federation sync | Exchange public directory updates | Background, periodic |
-| Revocation | Issue signed revocation token | Rare |
-
-**The hub is never in the data path after initial connection setup.**
-
-**JWT as offline passport:**
-The hub issues a JWT signed with its Ed25519 private key. Nodes verify this JWT locally using the hub's known public key — no hub roundtrip required per request. JWT validity: ~30 days.
-
-**Tech stack:**
-- Language: Python
-- Framework: FastAPI + Uvicorn
-- Database: PostgreSQL + SQLAlchemy + Alembic
-- Deployment: behind Apache reverse proxy (ProxyPass)
-- Authentication: own system (JWT signed with Ed25519, no OAuth dependency)
-
-**Account creation:** [TBD] — email only at first, phone number associable later. Via Android app, both collected by default. Fusionable accounts.
-
-### 3.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)
-- Serve files and video streams to group members
-- Manage all cryptographic keys locally (keystore, password-protected)
-- Handle P2P connections and NAT traversal
-- Run the MNP protocol
-- Host the Python module sandbox
-- Serve the local web UI (localhost)
-- Receive and redistribute ephemeral video from mobile [future]
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-**Tech stack:**
-- Language: Python (primary), Rust extensions only if strictly necessary for hot paths
-- QUIC: `aioquic`
-- ICE/STUN: `aioice`
-- WebRTC (future): `aiortc`
-- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated)
-- Serialization: `msgpack`
-- Compression: `zstandard` (zstd)
-- File watching: `watchdog`
-- Local DB: SQLite
-- Local web UI: served by the node on localhost (port [TBD])
-
-**Node pairing with mobile:** QR code from local web UI [future].
-
-### 3.3 Mesh Client
-
-Web browser or Android app. Consumes content from the node; manages account via the hub.
-
-**Hub-side operations (via hub):**
-- Account creation and login
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing (Mesh Group Index)
-- Message feed reading (with attachments, Signal-like)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: browse files in a group
-- Feed mode: message thread with attachments
-- Hub/Node UI split to be defined
-
-### 3.4 Mesh Relay
-
-Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail. Traffic is always E2E encrypted — the relay sees only opaque QUIC packets and cannot read content.
-
-Not operated by meshbay.org. A protocol for relay registration with hubs is [TBD].
-
----
-
-## 4. 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: shared files, message thread, member list. It can be open entry, request-based, or invitation-only regardless of its public visibility.
-
-A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members with the GEK can decrypt anything.
-
-**Group addressing** [TBD]:
-```
-meshbay.org/u/username/groupname — public group via hub
-meshbay.org/g/groupname — direct public group
-group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
-```
-
----
-
-## 5. Cryptographic Architecture
-
-### 5.1 Key Hierarchy
-
-```
-User Identity Key Ed25519 Signing, authentication
-User Exchange Key X25519 Key agreement
-Group Identity Key Ed25519 Group metadata signing (held by admin node)
-Group Encryption Key ChaCha20 Content and index encryption (symmetric, 256-bit)
-Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
-```
-
-All private keys are stored exclusively on the node (or client device), in a password-protected local keystore. The hub never sees any private key.
-
-### 5.2 GEK Management
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK is encrypted for each member using X25519 key agreement + HKDF
-3. Encrypted GEK bundles stored on hub (or node — [TBD])
-
-**Member addition:**
-- GEK encrypted with new member's `PK_user` and distributed
-
-**Member revocation:**
-- Admin node generates a new GEK
-- Re-encrypts for all remaining members
-- New content encrypted with new GEK
-- Former member retains ability to decrypt previously received content (acceptable trade-off — full re-encryption not planned)
-
-### 5.3 On-the-Fly Encryption for File Transfer
-
-Files are stored in plaintext on the host's disk. The node encrypts at read time before transmission.
-
-```
-Disk (plaintext) → [Node] → zstd compress → GEK encrypt (per-chunk) → QUIC session → [Client] → QUIC decrypt → GEK decrypt → plaintext
-```
-
-**Chunking strategy:**
-- Chunk size: 1 MB (amortizes AEAD overhead, allows seeking)
-- Per-chunk key derivation:
- `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
-- Each chunk independently decryptable (enables video seeking)
-- Compress before encrypt (zstd compression is useless after encryption)
-
-**Chunk authentication:**
-Each chunk (or batch) is signed with the node's Ed25519 key. The client verifies before decryption. Prevents data injection by a compromised relay.
-
-### 5.4 Transport Security
-
-- Primary protocol: **QUIC** (TLS 1.3 integrated, UDP-based, multiplexed)
-- Per-connection session keys via X25519 ECDH + HKDF
-- The QUIC layer is independent from the GEK application layer — two independent encryption layers
-
-### 5.5 Chat Encryption
-
-Group messaging uses the **Double Ratchet algorithm** (as in Signal):
-- Forward secrecy and break-in recovery per message
-- Each message independently encrypted
-- Implementation: existing Python or Rust library [TBD]
-
----
-
-## 6. Network and Connectivity
-
-### 6.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection, no NAT issue
-2. UPnP / NAT-PMP on router → node opens port automatically
-3. ICE + STUN / UDP hole punching → works for ~80-85% of cases
-4. Mesh Relay (TURN fallback) → community-operated, E2E encrypted traffic
-```
-
-**Signaling** (steps 3/4): coordinated via hub WebSocket, <1 KB per attempt, stateless after connection established.
-
-**Step 4 coverage:** ~15-20% of connections (symmetric NAT on both sides, CGNAT). The relay sees only encrypted QUIC packets.
-
-### 6.2 MNP — Mesh Node Protocol
-
-Application-level protocol over QUIC. Defined blocks:
-
-- **Handshake**: key exchange, group membership verification (JWT presentation)
-- **Index sync**: encrypted delta Mesh Group Index on connection
-- **File transfer**: chunk request/response with hash verification
-- **VOD streaming**: HLS/DASH segments, encrypted per-segment with GEK-derived keys
-- **Messaging**: Double Ratchet messages encapsulated in MNP frames
-- **[Future] Ephemeral stream**: `ephemeral_stream` type with TTL metadata
-
-### 6.3 Public Content Delivery
-
-Public files are identified by their `blake3` hash. Multiple nodes can serve the same file:
-
-1. Node A has public file X (hash H)
-2. Any node that obtains X and chooses to mirror it registers with the hub: "I serve hash H"
-3. Hub maintains: `{ blake3_hash → [node_A, node_B, ...] }`
-4. Client requests X → hub returns source list → client fetches in parallel chunks from multiple nodes
-
-**Public content transport:** TLS only (no GEK). Content signed with the original node's Ed25519 key for authenticity verification by clients, even when served from a mirror. Door left open for GEK on "registered-users-only public" groups in a future revision.
-
----
-
-## 7. Indexes
-
-### 7.1 Mesh Directory (hub level)
-
-Public registry of groups. Exchanged between hubs via MHP.
-
-Format: msgpack, signed by hub's Ed25519 key.
-
-Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy.
-
-### 7.2 Mesh Group Index (node level)
-
-File listing for a group. Generated and maintained by the hosting node.
-
-Format: msgpack → zstd compressed → GEK encrypted (private groups) or plaintext signed (public groups).
-
-Entry structure:
-```python
-{
- "id": "<blake3_hash>",
- "name": "filename.mkv",
- "path": "Movies/2024/", # relative to shared directory
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds, for media
- "thumb_hash":"<blake3>", # thumbnail hash (thumbnail also GEK-encrypted)
- "added_at": 1720000000
-}
-```
-
-**Delta updates:** each update carries `{base_version, additions, deletions}` — no full re-encryption on every change.
-
-**Transit:** nodes push index deltas to connected members on change. Members pull full index on first connection. Hub stores no index content — only the node address for routing.
-
-### 7.3 Search
-
-**Private groups:** search is entirely local on the client device. The client maintains a local encrypted cache of all indexes for groups it belongs to. No network call, no hub involvement, instant results.
-
-**Public groups:** client queries node(s) directly at request time. Hub provides routing (which node hosts which group) but performs no content lookup itself.
-
-**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this interaction. In-memory micro-cache of results: **60-second TTL maximum, RAM only, never written to disk, public content only.** This qualifies as technical caching (EU DSA Article 13) and does not constitute indexing.
-
----
-
-## 8. Hub Federation (MHP)
-
-### 8.1 Hub Hierarchy
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (self-hosted, CA-delegated)
- │ └── issues user credentials, manages its own groups
- │ └── can federate with other Full Hubs via MHP
- └── Mirror Hub
- └── hosts public Mesh Directory only (no user accounts)
-```
-
-A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub) proving its authority. Clients verify the chain. A Mirror Hub can only replicate public directory data.
-
-### 8.2 MHP Design Principles
-
-- Explicit peer selection: each hub maintains an allowlist of trusted peer hubs
-- No automatic hub discovery
-- Exchanged data: Mesh Directory (public groups), revocation lists, cross-hub user credentials
-- Cross-hub authentication: user from Hub A presents JWT signed by Hub A; Hub B verifies using Hub A's public key (fetched once on first interaction, cached)
-
-### 8.3 Cross-Hub Client Access
-
-Client from Hub A accessing a group on Hub B:
-1. Hub A's public directory or direct link leads client to Hub B
-2. Client presents Hub A JWT to Hub B directly
-3. Hub B verifies JWT signature using Hub A's public key
-4. Hub B issues a short-lived local token for this session
-5. Client proceeds to node as normal
-
----
-
-## 9. Moderation
-
-### 9.1 Public Content
-
-```
-Report #1 → automatic suspension of public access to content
- → 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 of content added to hub blocklist. Node receives signed revocation notice and cuts public access.
-
-### 9.2 CSAM
-
-Hash matching against NCMEC/IWF database on all public content at registration time. Participation demonstrates good faith and significantly reduces legal exposure. No scanning of private/encrypted content.
-
-### 9.3 Copyright
-
-DMCA/legal notice framework (takedown on notification). No automated technical blocking — too complex, too many false positives (fair use, regional variations). Hub can revoke on confirmed legal request.
-
-### 9.4 Private Content
-
-Not directly moderatable (E2E encrypted by design). Only action available: revoke user or group at hub level on formal legal request. Hub issues a signed revocation token that all group members' nodes can verify.
-
----
-
-## 10. Python Module System
-
-The node can load extension modules (Python) that run in a sandboxed subprocess.
-
-**Module manifest** (declared capabilities):
-```python
-{
- "name": "group-chat",
- "version": "1.0.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**Available APIs (restricted):**
-- `read_index()` — read current group index (read-only)
-- `send_message(content)` — post a message to the group thread
-- `receive_events(handler)` — subscribe to group events (new file, new message)
-
-**Not available:**
-- Arbitrary network access
-- Filesystem access outside the group context
-- System calls
-
-**First official module:** group chat thread (Signal-like, with attachments). Bundled with node.
-
----
-
-## 11. Legal Framework
-
-**Node operator:** primary legal host of content. Fully responsible for what they share. Node software clearly communicates this at setup.
-
-**Hub operator:** registrar, not content host. Stores minimal PII. Operates takedown mechanism. Participates in CSAM hash matching. Analogous to a domain registrar in legal exposure terms.
-
-**Protocol/software author:** protected by substantial non-infringing uses. No active facilitation of infringement.
-
-**Hub data minimization:**
-- Email stored hashed after verification [TBD]
-- No IP address logging (or auto-deletion after 24h)
-- No content metadata stored
-- Node current address managed by ephemeral signaling service only
-
----
-
-## 12. Future Features (noted, not designed)
-
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Mobile video push:** mobile films → pushes to hosting node → distributed as ephemeral stream with TTL to group members. MNP `ephemeral_stream` type reserved.
-- **Mesh Relay registration protocol:** community TURN relays registered with hubs
-- **Node mobile pairing:** QR code from local web UI
-- **Multi-source download:** parallel chunk fetching from multiple nodes for same public file (swarm)
-- **iOS client**
-- **At-rest encryption on node:** optional, for nodes deployed on remote servers
-
----
-
-## 13. Open Questions [TBD]
-
-1. **GEK bundle storage:** on hub or on node only? Hub = easier discovery; node only = more decentralized
-2. **Group address scheme:** final URL format
-3. **Hub local web UI scope for V1:** config only, or also group browsing?
-4. **Account creation:** email only to start, phone associable — confirm
-5. **Chat implementation:** bundled module or core feature?
-6. **QUIC library maturity:** `aioquic` production readiness assessment needed
-7. **Double Ratchet library:** identify best Python implementation
-8. **Relay registration protocol:** design when community relays are introduced
-9. **Cross-hub directory exchange:** frequency, conflict resolution
-10. **Node port for local web UI:** to assign
-11. **JWT expiry and refresh strategy**
-12. **Keystore format and unlock mechanism on node startup**
diff --git a/docs/meshbay-draft-v2-fr.md b/docs/meshbay-draft-v2-fr.md
deleted file mode 100644
index c61203e..0000000
--- a/docs/meshbay-draft-v2-fr.md
+++ /dev/null
@@ -1,550 +0,0 @@
-# MeshBay — Brouillon d'Architecture v2
-
-> Statut : brouillon préliminaire — points ouverts marqués [TBD]
-> Changements depuis v1 : logs IP (légal), versionnage des protocoles, dimensionnement matériel, stratégie JWT, propositions keystore, chat en core, relay déplacé en futur, miroir hub en futur, GEK clarifié, port 18000, lazy admin keystore.
-
----
-
-## 1. Présentation du projet
-
-MeshBay est une plateforme décentralisée pair-à-pair pour le partage de fichiers, le streaming vidéo et la messagerie de groupe. Elle combine une fédération d'identité (via les Mesh Hubs) avec un échange de données véritablement pair-à-pair (via les Mesh Nodes), conçue pour être résiliente, résistante à la censure et accessible aux utilisateurs.
-
-**Principes fondamentaux :**
-- Les données ne transitent jamais par un serveur central — seuls l'identité et le routage le font
-- Chiffrement de bout en bout pour tout contenu privé (fichiers, index, messages)
-- L'opérateur du node est l'hébergeur légal et porte l'entière responsabilité de son contenu
-- Le hub est un registrar léger, pas un hébergeur ni un indexeur
-- Open source, auto-hébergeable à chaque niveau
-
-**Domaine :** meshbay.org (configurable dans tout le code source)
-
----
-
-## 2. Terminologie
-
-| Terme | Rôle |
-|---|---|
-| **Mesh Hub** | Serveur d'autorité d'identité et registre de groupes |
-| **Mesh Node** | Programme local sur la machine de l'utilisateur hébergeur |
-| **Mesh Client** | Navigateur web ou application Android (utilisateur final) |
-| **Mesh Relay** | Relais TURN communautaire [futur] |
-| **MNP** | Mesh Node Protocol — protocole P2P entre nodes et clients |
-| **MHP** | Mesh Bay Hub Protocol — protocole de fédération inter-hubs |
-| **GEK** | Group Encryption Key — clé symétrique de chiffrement des groupes privés |
-| **Mesh Directory** | Registre public des groupes (niveau hub) |
-| **Mesh Group Index** | Listing des fichiers d'un groupe (niveau node, chiffré pour les groupes privés) |
-
----
-
-## 3. Versionnage des protocoles
-
-Tous les protocoles (MNP, MHP, API REST du hub) portent une information de version explicite.
-
-**Format :** `MAJOR.MINOR`
-- Incrément MAJOR : changement cassant, incompatible
-- Incrément MINOR : ajout rétrocompatible
-
-**Négociation :** lors du handshake, les deux parties déclarent leur plage de versions supportées. Le MINOR le plus élevé mutuellement supporté au sein du même MAJOR est utilisé. En l'absence de version commune, la connexion est refusée avec une erreur explicite.
-
-**Politique de support :** une version supporte le MAJOR courant et au moins les deux MINOR précédents (N-2).
-
-**Implémentation :** champ `version` dans chaque en-tête de message msgpack. L'étape de handshake précède tous les autres échanges.
-
----
-
-## 4. Composants du système
-
-### 4.1 Mesh Hub
-
-Serveur léger agissant comme un registrar. Intentionnellement minimal pour limiter l'exposition légale et le coût opérationnel.
-
-**Ce que le hub stocke :**
-- Comptes utilisateurs : nom d'utilisateur, email (conservé pour la récupération de compte — voir §4.1.1), numéro de téléphone optionnel, `PK_user`, ID du hub, statut, timestamp de création
-- Registre de groupes : nom, `PK_group`, identifiant du node hébergeur, visibilité, politique d'adhésion, liste des membres avec bundles GEK chiffrés (groupes privés uniquement)
-- Logs de connexion obligatoires (voir §4.1.2)
-- Listes de révocation (utilisateurs et groupes)
-- Hubs pairs enregistrés (liste d'autorisation explicite — pas de découverte automatique)
-
-**Ce que le hub ne stocke jamais :**
-- Contenu de fichiers ou métadonnées
-- Index de groupes privés
-- Contenu de messages
-- IP courante des nodes (gérée par le signaling éphémère — voir §4.1.3)
-
-#### 4.1.1 Données de compte
-
-L'email est conservé en clair (non haché) pour permettre :
-- La récupération de compte (réinitialisation de mot de passe)
-- Les notifications légales
-- Le contact en cas d'abus
-
-Numéro de téléphone : optionnel, associable après la création du compte. Sur Android, les deux sont collectés à l'inscription. Les comptes sont fusionnables (email + téléphone pointant vers le même compte).
-
-Email et téléphone sont stockés chiffrés au repos dans la base de données.
-
-#### 4.1.2 Logs IP obligatoires (conformité légale)
-
-Les cadres légaux (LCEN en France, directive e-Commerce UE, DSA) imposent aux prestataires de conserver des logs de connexion. Le hub enregistre les événements suivants avec horodatage et adresse IP :
-
-| Événement | Rétention |
-|---|---|
-| Création de compte | 1 an minimum |
-| Login (succès et échec) | 1 an minimum |
-| Création de groupe | 1 an minimum |
-| Adhésion / départ d'un groupe | 1 an minimum |
-| Suppression de groupe | 1 an minimum |
-| Actions de révocation | 1 an minimum |
-
-Les logs sont stockés dans une table séparée à accès contrôlé. Ils ne sont utilisés qu'à des fins de conformité légale et ne sont pas exposés aux utilisateurs ou opérateurs sauf sur demande légale.
-
-#### 4.1.3 Service de signaling
-
-La coordination de la traversée NAT est gérée par un endpoint WebSocket léger, logiquement séparé de l'API principale du hub. Il est sans état : l'état de connexion est maintenu uniquement en mémoire et effacé après l'établissement de la connexion P2P (typiquement en quelques secondes). Aucun stockage persistant des IP des nodes.
-
-**Résumé des interactions hub :**
-
-| Événement | Charge crypto hub | Fréquence |
-|---|---|---|
-| Création de compte | Hash Argon2, stockage PK | Une fois |
-| Login | Vérification mot de passe, émission JWT (signature Ed25519) | Par session |
-| Création de groupe | Enregistrement métadonnées | Une fois par groupe |
-| Ajout/suppression membre | Stockage/suppression bundle GEK | Sur action admin |
-| Discovery de groupe | Retour adresse node + PK_node + bundle GEK | Par accès initial |
-| Signaling NAT | Relais messages WebSocket (<1 Ko) | Par nouvelle connexion P2P |
-| Recherche publique | Délégation aux nodes, cache 60s en mémoire | Sur demande |
-| Sync fédération MHP | Échange Mesh Directory | Background, périodique |
-| Révocation | Signature Ed25519 token de révocation | Rare |
-
-**Le hub n'est jamais dans le chemin des données après l'établissement de la connexion. La vérification des JWT par les nodes est locale (Ed25519, aucun aller-retour hub).**
-
-#### 4.1.4 Stratégie JWT
-
-Deux tokens émis à la connexion :
-
-**Access token** (JWT, signé Ed25519) :
-- Validité : 1 heure
-- Payload : `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, claim d'appartenance aux groupes signé par le hub
-- Présenté aux nodes pour authentification et vérification d'accès aux groupes
-- Vérifié localement par les nodes avec la clé publique connue du hub — aucun aller-retour hub
-- Fenêtre de compromission : 1 heure maximum
-
-**Refresh token** (opaque, 256 bits aléatoires) :
-- Validité : 30–90 jours [TBD durée exacte]
-- Stocké de manière sécurisée côté client uniquement
-- Utilisé exclusivement avec le hub pour obtenir un nouvel access token
-- Révocable immédiatement par le hub (invalide tous les renouvellements futurs pour ce token)
-- Stocké côté serveur sous forme de valeur hachée
-
-**Flux de révocation :** le hub invalide le refresh token → le prochain renouvellement d'access token échoue → l'accès aux nodes expire au plus dans 1 heure.
-
-**Stack technique :**
-- Langage : Python
-- Framework : FastAPI + Uvicorn
-- Base de données : PostgreSQL + SQLAlchemy + Alembic
-- Déploiement : reverse proxy Apache (ProxyPass + terminaison SSL)
-- Authentification : système propre (JWT Ed25519, Argon2id pour le hachage des mots de passe)
-- Hub accessible par domaine et par IP directe (avertissement certificat auto-signé attendu pour l'accès par IP ; documenté)
-
-### 4.2 Mesh Node
-
-Programme local sur la machine de l'utilisateur hébergeur. Le node est l'hébergeur effectif de tout le contenu.
-
-**Responsabilités :**
-- Surveiller et indexer les répertoires partagés (Mesh Group Index)
-- Servir fichiers et flux vidéo aux membres du groupe
-- Gérer toutes les clés cryptographiques localement (keystore chiffré)
-- Gérer les connexions P2P et la traversée NAT
-- Exécuter le protocole MNP
-- Héberger le sandbox de modules Python d'extension
-- Servir l'interface web locale (localhost:18000)
-- Héberger le chat de groupe (fonctionnalité core)
-
-**Plateforme :** Linux en priorité, cross-platform dès le départ (Windows/macOS). Python assure la portabilité.
-
-#### 4.2.1 Keystore et déverrouillage
-
-Les clés privées (identité utilisateur, identité groupe, copies GEK) sont stockées dans un fichier keystore local chiffré.
-
-**Format :** conteneur msgpack chiffré avec AES-256-GCM, clé dérivée du mot de passe maître par Argon2id (paramétré pour ~1s de dérivation sur le matériel cible).
-
-**Trois modes de déverrouillage :**
-
-| Mode | Fonctionnement | Niveau de sécurité |
-|---|---|---|
-| **Sécurisé (défaut)** | Mot de passe saisi au démarrage via terminal ou interface web locale | Élevé |
-| **Lazy (fichier)** | Mot de passe ou clé dérivée stocké dans `~/.config/meshbay/unlock.key` (chmod 600), lu automatiquement au démarrage | Moyen — acceptable pour une machine home physiquement sécurisée. Risque documenté lors de la configuration. |
-| **Service (headless)** | Variable d'environnement `MESHBAY_UNLOCK_KEY`, définie via `EnvironmentFile=` systemd pointant vers un fichier chmod 600 | Moyen-élevé — pratique standard pour les déploiements serveur |
-
-Futur : intégration keychain OS (libsecret/GNOME Keyring sur Linux, Windows Credential Manager, Keychain macOS).
-
-#### 4.2.2 Dimensionnement matériel
-
-La contrainte principale est la **bande passante montante**, pas le CPU ou la RAM.
-
-| Scénario | Utilisateurs simultanés | Upload requis | CPU | RAM |
-|---|---|---|---|---|
-| Fichiers + chat, peu de streaming | 10 | 20–50 Mbps | 2 cœurs | 512 Mo |
-| Streaming 1080p actif (5–6 flux) | 10 | 50–80 Mbps | 2–4 cœurs | 1 Go |
-| Usage mixte | 50 | 200–300 Mbps | 4 cœurs | 2 Go |
-| Streaming actif | 50 | 400 Mbps | 4–8 cœurs | 2–4 Go |
-| Tous usages | 100 | 800 Mbps–1 Gbps | 8 cœurs | 4–8 Go |
-
-Au-delà de 20–30 utilisateurs en streaming actif, un serveur dédié est nécessaire. Une connexion fibre domestique (100–500 Mbps symétrique) convient pour un petit groupe.
-
-**Stack technique :**
-- Langage : Python (principal). Extension Rust uniquement si un chemin critique s'avère insuffisant.
-- Couche d'abstraction transport : interface `Transport` découplant QUIC du fallback TCP+TLS
-- QUIC : `aioquic` (maintenu par des ingénieurs Cloudflare). Fallback : TCP + TLS 1.3 + HTTP/2 si QUIC s'avère insuffisant en production
-- ICE/STUN : `aioice`
-- WebRTC [futur] : `aiortc`
-- Crypto : `cryptography` (PyCA, backed OpenSSL, accélération matérielle AES-NI/ChaCha)
-- Sérialisation : `msgpack`
-- Compression : `zstandard` (zstd)
-- Surveillance fichiers : `watchdog`
-- BDD locale : SQLite
-- Interface web locale : servie par le node sur `localhost:18000`
-
-### 4.3 Mesh Client
-
-Navigateur web ou application Android. Consomme le contenu depuis les nodes ; gère le compte via le hub.
-
-**Opérations côté hub :**
-- Création de compte et login (Android : email + téléphone à l'inscription)
-- Recherche et découverte de groupes publics
-- Gestion de l'appartenance aux groupes
-
-**Opérations côté node (P2P direct) :**
-- Navigation dans les fichiers via Mesh Group Index
-- Chat de groupe (messages + pièces jointes, façon Signal — fonctionnalité core)
-- Téléchargement de fichiers
-- Streaming vidéo (VOD)
-- [Futur] Flux vidéo éphémère
-
-**Modes client** [à concevoir] :
-- Mode explorateur : navigateur de fichiers pour le contenu du groupe
-- Mode flux : fil de chat avec pièces jointes
-- Articulation UI hub/node à définir ; l'app Android se connectera directement au node rapidement après la création du compte
-
-### 4.4 Mesh Relay
-
-**[Fonctionnalité future]** Relais TURN opéré par la communauté. Utilisé uniquement en dernier recours quand toutes les méthodes de connexion P2P échouent (~15–20% des connexions). Le trafic est toujours chiffré E2E — le relais ne voit que des paquets QUIC opaques.
-
-Non opéré par meshbay.org. Un protocole d'enregistrement des relais (hub-médié) sera conçu lors de l'introduction de cette fonctionnalité. N'impacte pas le design actuel.
-
----
-
-## 5. Modèle de groupe
-
-Les groupes sont l'unité organisationnelle centrale.
-
-| Paramètre | Options |
-|---|---|
-| Visibilité | Public / Privé |
-| Politique d'adhésion | Libre / Sur demande / Sur invitation uniquement |
-| Admin | L'opérateur du node hébergeur (hébergeur légal) |
-
-Un groupe public fonctionne comme un forum thématique : fichiers, fil de chat, liste de membres. La politique d'adhésion est indépendante de la visibilité (un groupe public peut nécessiter une approbation pour rejoindre).
-
-Le contenu d'un groupe privé (fichiers, index, messages) est toujours chiffré E2E avec la GEK. Seuls les membres possédant la GEK peuvent déchiffrer quoi que ce soit.
-
-**Adressage des groupes :**
-```
-meshbay.org/u/username/groupname — groupe public via hub
-meshbay.org/g/groupname — groupe public (raccourci)
-group://<PK_group_fingerprint>@<node_addr> — accès direct sans hub
-```
-`meshbay.org` est entièrement configurable dans le code source (constante/fichier de config). Le hub est accessible par domaine ou par IP (accès par IP nécessite un certificat auto-signé ; avertissement navigateur attendu et documenté).
-
----
-
-## 6. Architecture cryptographique
-
-### 6.1 Hiérarchie de clés
-
-```
-Clé d'identité utilisateur Ed25519 Signature, authentification
-Clé d'échange utilisateur X25519 Accord de clé
-Clé d'identité groupe Ed25519 Signature métadonnées groupe (tenue par le node admin)
-Clé de chiffrement groupe ChaCha20 Chiffrement contenu et index privés (symétrique, 256 bits)
-Clés de session X25519/HKDF Perfect forward secrecy par connexion P2P
-```
-
-Toutes les clés privées stockées exclusivement sur le node (ou l'appareil client) dans le keystore chiffré. Le hub ne voit jamais aucune clé privée.
-
-### 6.2 Gestion de la GEK
-
-**Périmètre :** la GEK s'applique uniquement aux groupes privés. Les groupes publics utilisent TLS uniquement (pas de chiffrement applicatif).
-
-**Création de groupe :**
-1. Le node admin génère la GEK (ChaCha20-Poly1305, 256 bits, CSPRNG)
-2. GEK chiffrée pour chaque membre via accord de clé X25519 + HKDF
-3. Bundles GEK chiffrés stockés sur le hub (blobs opaques — le hub ne peut pas les déchiffrer ; charge négligeable : ~200–400 octets par membre par groupe)
-
-**Justification du stockage sur hub :** les membres peuvent récupérer leur bundle GEK même si le node est hors ligne. L'exposition du hub est minimale — il stocke du texte chiffré qu'il ne peut pas lire.
-
-**Ajout de membre :**
-- GEK chiffrée avec la `PK_user` du nouveau membre et uploadée sur le hub
-
-**Révocation de membre :**
-- Le node admin génère une nouvelle GEK
-- Re-chiffrement pour tous les membres restants, upload des nouveaux bundles
-- Les nouveaux contenus sont chiffrés avec la nouvelle GEK
-- L'ancien membre conserve la capacité de déchiffrer le contenu précédemment reçu (compromis acceptable — re-chiffrement rétroactif complet non prévu)
-
-### 6.3 Chiffrement à la volée pour le transfert de fichiers
-
-Les fichiers sont stockés en clair sur le disque de l'hébergeur. Le node chiffre à la lecture.
-
-```
-Disque (clair) → compression zstd → chiffrement GEK (par chunk) → session QUIC → Client → déchiffrement QUIC → déchiffrement GEK → clair
-```
-
-**Chunking :**
-- Taille de chunk : 1 Mo (amortit l'overhead AEAD ; permet le seek)
-- Dérivation de clé par chunk : `chunk_key = HKDF(GEK, "file:" || blake3(fichier) || "chunk:" || index)`
-- Chaque chunk déchiffrable indépendamment → permet le seek VOD
-- Compresser avant chiffrer (la compression est inefficace sur du texte chiffré)
-
-**Authentification des chunks :** chaque chunk signé avec la clé Ed25519 du node. Le client vérifie avant déchiffrement. Prévient l'injection de données par un relais compromis.
-
-**Optimisations chiffrement :**
-- `cryptography` (PyCA) utilise OpenSSL, contourne le GIL Python pour les ops crypto
-- ChaCha20-Poly1305 : ~500 Mo/s sans AES-NI ; AES-256-GCM : >2 Go/s avec AES-NI
-- Pour un home node (50 Mbps upload = 6 Mo/s), le chiffrement n'est pas le goulot d'étranglement
-- Pipeline asyncio (lecture → compression → chiffrement → envoi) sans charger les fichiers entiers en mémoire
-- Clés de chunk dérivées par batch au début du transfert, pas chunk par chunk
-
-### 6.4 Sécurité du transport
-
-- Principal : **QUIC** (TLS 1.3 intégré, UDP, streams multiplexés)
-- Fallback : **TCP + TLS 1.3 + HTTP/2** (même protocole applicatif, performances moindres)
-- Interface transport abstraite dans le code — swappable sans changer le protocole applicatif
-- Clés de session par connexion via X25519 ECDH + HKDF (indépendantes de la couche GEK)
-
-### 6.5 Chiffrement du chat
-
-Le chat de groupe est une **fonctionnalité core** (pas un module d'extension). Utilise l'algorithme **Double Ratchet** (comme Signal) :
-- Forward secrecy et break-in recovery par message
-- Chaque message chiffré indépendamment
-- Pièces jointes : chiffrées avec la clé de message Double Ratchet courante, hash inclus dans le message
-- Implémentation Python : [TBD — évaluer les bibliothèques existantes]
-
----
-
-## 7. Réseau et connectivité
-
-### 7.1 Traversée NAT — ordre des tentatives
-
-```
-1. IPv6 disponible des deux côtés → connexion directe
-2. UPnP / NAT-PMP sur le routeur → le node ouvre un port automatiquement
-3. ICE + STUN / UDP hole punching → ~80–85% de réussite
-4. Mesh Relay (TURN) → [fonctionnalité future]
-```
-
-Sans l'étape 4, ~15% des connexions entre peers sous NAT symétrique échoueront. Comportement documenté jusqu'à l'implémentation du Mesh Relay.
-
-Signaling (étape 3) : coordonné via l'endpoint WebSocket du hub, <1 Ko par tentative, sans état persistant.
-
-### 7.2 MNP — Mesh Node Protocol
-
-Protocole applicatif sur QUIC (ou fallback TCP+TLS). Tous les messages portent un champ `version`.
-
-**Types de messages définis :**
-
-| Type | Description |
-|---|---|
-| `handshake` | Échange de clés, présentation JWT, négociation de version |
-| `index_sync` | Delta de Mesh Group Index chiffré |
-| `file_request` | Demande de chunk(s) d'un fichier par hash + index de chunk |
-| `file_chunk` | Données de chunk + signature |
-| `stream_segment` | Segment HLS/DASH (VOD), chiffré avec clé dérivée de la GEK |
-| `chat_message` | Frame de message chiffré Double Ratchet |
-| `chat_attachment` | Métadonnées de pièce jointe + clé ; données transférées comme chunks de fichier |
-| `ephemeral_stream` | [réservé, futur] Vidéo éphémère avec métadonnées TTL |
-
-### 7.3 Diffusion de contenu public — Swarm
-
-Fichiers publics identifiés par leur hash `blake3`. Plusieurs nodes peuvent servir le même fichier :
-
-1. Tout node possédant un fichier public et choisissant de le mirrorer s'enregistre : `{ hash → adresse_node }` auprès du hub
-2. Le hub maintient une table de sources : `{ blake3_hash → [node_A, node_B, ...] }`
-3. Un client demande un fichier → le hub retourne la liste des sources → le client récupère des chunks en parallèle depuis plusieurs nodes
-4. Intégrité vérifiée par hash blake3 sur chaque chunk
-
-**Transport :** TLS uniquement pour le contenu public (pas de GEK). Contenu signé avec la clé Ed25519 du node original — les clients vérifient l'authenticité même depuis un miroir.
-
----
-
-## 8. Index
-
-### 8.1 Mesh Directory (niveau hub)
-
-Registre public des groupes, échangé entre hubs via MHP.
-
-Format : `msgpack`, signé avec la clé Ed25519 du hub, porte un champ `version`.
-
-Champs par entrée : nom de groupe, `PK_group`, hub hébergeur, description, tags de type de contenu, politique d'adhésion, date de création.
-
-### 8.2 Mesh Group Index (niveau node)
-
-Listing des fichiers d'un groupe. Généré et maintenu par le node hébergeur.
-
-Format : `msgpack` → `zstd` → chiffré GEK (groupes privés) ou signé en clair Ed25519 (groupes publics).
-
-Structure d'une entrée :
-```python
-{
- "version": 1,
- "id": "<blake3_hash>",
- "name": "fichier.mkv",
- "path": "Films/2024/",
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # secondes, pour les médias
- "thumb_hash": "<blake3>", # miniature aussi chiffrée GEK
- "added_at": 1720000000
-}
-```
-
-Mises à jour delta : `{ base_version, additions, deletions }` — pas de re-chiffrement complet à chaque changement.
-
-Transit : les nodes poussent les deltas d'index aux membres connectés sur modification ; les membres tirent l'index complet à la première connexion. Le hub ne stocke aucun contenu d'index.
-
-### 8.3 Recherche
-
-**Groupes privés :** entièrement locale sur l'appareil du client. Le client maintient un cache local chiffré de tous les index des groupes dont il est membre. Aucun appel réseau, aucune implication du hub, résultats instantanés.
-
-**Groupes publics :** le client interroge les nodes directement à la demande. Le hub fournit le routage uniquement.
-
-**Interface web du hub — recherche :** délègue la requête aux nodes concernés à la demande. Le hub ne stocke rien de cette interaction. Micro-cache en mémoire des résultats : **TTL 60 secondes maximum, RAM uniquement, jamais écrit sur disque, contenu public uniquement.** Relève du caching technique (DSA UE Article 13) — pas de l'indexation.
-
----
-
-## 9. Fédération inter-hubs (MHP)
-
-### 9.1 Hiérarchie des hubs
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (auto-hébergé, CA déléguée)
- │ └── émet des credentials utilisateurs, gère ses propres groupes
- │ └── se fédère avec d'autres Full Hubs via MHP
- └── Mirror Hub
- └── héberge uniquement le Mesh Directory public (pas de comptes, pas d'émission de clés)
-```
-
-Un Full Hub reçoit un certificat signé par le Root Hub (ou un Full Hub parent). Les Mirror Hubs ne peuvent que répliquer les données publiques. Promotion/rétrogradation possible sans casser le protocole.
-
-### 9.2 Conception MHP
-
-- Sélection explicite des pairs : chaque hub maintient une liste d'autorisation de hubs de confiance
-- Pas de découverte automatique de hubs
-- Données échangées : Mesh Directory (groupes publics), listes de révocation, données d'authentification cross-hub
-- Tous les messages MHP portent un champ `version`
-
-### 9.3 Accès client cross-hub
-
-1. Le client (utilisateur Hub A) découvre un groupe sur Hub B via le Mesh Directory ou un lien direct
-2. Le client présente son JWT Hub A directement à Hub B
-3. Hub B vérifie le JWT avec la clé publique de Hub A (récupérée une fois, mise en cache)
-4. Hub B émet un token de session local de courte durée
-5. Le client se connecte au node normalement
-
----
-
-## 10. Modération
-
-### 10.1 Contenu public
-
-```
-Signalement #1 → suspension automatique de l'accès public au contenu
- → notification à l'opérateur du node
-Une republication autorisée
-Signalement #2 → escalade vers les modérateurs du hub
-Confirmé → groupe révoqué sur le hub local
- → révocation propagée aux hubs fédérés via MHP
-```
-
-Mécanisme : hash `blake3` du contenu ajouté à la liste de blocage du hub. Token de révocation signé envoyé au node.
-
-### 10.2 CSAM
-
-Hash matching contre la base de données NCMEC/IWF sur le contenu public lors de l'enregistrement. Pas de scanning du contenu privé/chiffré. La participation est obligatoire pour les opérateurs de hub et réduit significativement l'exposition légale.
-
-### 10.3 Copyright
-
-Cadre de notification légale DMCA/équivalent. Takedown sur notification. Pas de blocage technique automatique (risque de faux positifs, fair use). Le hub peut révoquer sur demande légale confirmée.
-
-### 10.4 Contenu privé
-
-Non modérable directement (chiffré E2E par conception). Action disponible : révoquer l'utilisateur ou le groupe au niveau du hub sur demande légale formelle. Le hub émet un token de révocation signé Ed25519 vérifiable offline par les nodes de tous les membres.
-
----
-
-## 11. Système de modules Python d'extension
-
-Le node charge des modules d'extension (Python) dans un sous-processus sandbox. **Le chat est une fonctionnalité core intégrée, pas un module.**
-
-**Manifeste de module :**
-```python
-{
- "name": "mon-extension",
- "version": "1.0.0",
- "mnp_version": ">=1.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**APIs disponibles :**
-- `read_index()` — lecture de l'index courant du groupe (lecture seule)
-- `send_message(content)` — poster dans le fil du groupe
-- `receive_events(handler)` — s'abonner aux événements du groupe
-
-**Non disponible :** accès réseau arbitraire, accès au système de fichiers hors du contexte du groupe, appels système.
-
----
-
-## 12. Cadre légal
-
-**Opérateur du node :** hébergeur légal principal du contenu. Entièrement responsable de ce qu'il partage. Le logiciel node communique cela explicitement lors de l'installation.
-
-**Opérateur du hub (meshbay.org) :** registrar, pas hébergeur de contenu. Stocke un minimum de données. Opère le mécanisme de takedown. Participe au hash matching CSAM. Exposition légale analogue à celle d'un bureau d'enregistrement de domaines.
-
-**Auteur du protocole/logiciel :** protégé par les usages non-contrefaisants substantiels.
-
-**Données du hub :**
-- Email et téléphone optionnel : conservés pour la récupération de compte et la conformité légale
-- Mot de passe : haché Argon2id, jamais stocké en clair
-- Logs de connexion : conservés selon les obligations légales (1 an minimum)
-- Métadonnées de contenu : jamais stockées
-- IP courante des nodes : non persistée (signaling éphémère)
-
----
-
-## 13. Fonctionnalités futures
-
-- **Mesh Relay :** relais TURN communautaires, protocole d'enregistrement via hub, trafic E2E chiffré
-- **Réplication de contenu entre nodes :** node-à-node, autorisée par l'admin, sans implication du hub
-- **Miroir de hub (répartition de charge) :** réplication complète du hub (BDD users, registre de groupes, bundles GEK) pour distribuer la charge. Nécessite une stratégie de BDD distribuée (streaming replication PostgreSQL ou équivalent). Complexe — à concevoir quand nécessaire.
-- **Push vidéo depuis mobile → node :** mobile filme → pousse vers le node hébergeur → flux éphémère avec TTL distribué aux membres du groupe. Type MNP `ephemeral_stream` réservé.
-- **Appairage node–mobile :** QR code depuis l'interface web locale
-- **Téléchargement multi-sources :** récupération de chunks en parallèle depuis le swarm pour les fichiers publics
-- **Client iOS**
-- **Chiffrement at-rest sur le node :** optionnel pour les nodes déployés sur des serveurs distants
-- **Intégration keychain OS pour le déverrouillage du keystore**
-
----
-
-## 14. Questions ouvertes [TBD]
-
-1. **Durée de validité du refresh token :** 30 ou 90 jours ?
-2. **Schéma d'adressage des groupes :** confirmation du format URL final
-3. **Bibliothèque Double Ratchet :** identifier la meilleure implémentation Python
-4. **Emplacement des bundles GEK pour les groupes à accès mixte** (public restreint aux inscrits) : hub ou node ?
-5. **Fréquence de sync fédération MHP et résolution de conflits**
-6. **Stratégie de réplication pour le miroir de hub** (quand implémenté)
-7. **Stockage des pièces jointes du chat :** stockées sur le node comme des fichiers ordinaires, ou store séparé ?
-8. **Conception du protocole d'enregistrement des relais** (quand implémenté)
-9. **Claims du payload JWT :** champs exacts à inclure pour la vérification d'accès aux groupes par le node
-10. **Paramètres Argon2id :** calibrage pour le matériel cible (home server vs VPS)
diff --git a/docs/meshbay-draft-v2.md b/docs/meshbay-draft-v2.md
deleted file mode 100644
index 493e877..0000000
--- a/docs/meshbay-draft-v2.md
+++ /dev/null
@@ -1,550 +0,0 @@
-# MeshBay — Architecture Draft v2
-
-> Status: preliminary draft — open points marked [TBD]
-> Changes from v1: IP logging (legal), protocol versioning, hardware sizing, JWT strategy, keystore proposals, chat as core, relay moved to future, hub mirror future, GEK clarified, port 18000, lazy admin keystore.
-
----
-
-## 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`, 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.
-
-#### 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).**
-
-#### 4.1.4 JWT Strategy
-
-Two tokens issued at login:
-
-**Access token** (JWT, signed Ed25519):
-- Validity: 1 hour
-- Payload: `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, hub-signed groups membership claim
-- 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
-
-**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.
-
-**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.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)
-- Serve files and video streams to group members
-- Manage all cryptographic keys locally (encrypted keystore)
-- Handle P2P connections and NAT traversal
-- Run the MNP protocol
-- Host the Python extension module sandbox
-- Serve the local web UI (localhost:18000)
-- Host the group chat (core feature)
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-#### 4.2.1 Keystore and Unlock
-
-Private keys (user identity, group identity, 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 (tuned for ~1s derivation on target hardware).
-
-**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.
-
-**Tech stack:**
-- Language: Python (primary). Rust extension only if a specific hot path proves insufficient.
-- Transport abstraction layer: `Transport` interface decouples QUIC from TCP+TLS fallback
-- QUIC: `aioquic` (Cloudflare-maintained). Fallback: TCP + TLS 1.3 + HTTP/2 if QUIC proves insufficient in production
-- ICE/STUN: `aioice`
-- WebRTC [future]: `aiortc`
-- 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.
-
-**Hub-side operations:**
-- Account creation and login (Android: email + phone at registration)
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing via Mesh Group Index
-- Group chat (messages + attachments, Signal-like — core feature)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: file browser for group content
-- Feed mode: chat thread with attachments
-- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
-
-### 4.4 Mesh Relay
-
-**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (~15–20% of connections). Traffic is always E2E encrypted — the relay sees only opaque QUIC packets.
-
-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.
-
----
-
-## 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
-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.
-
-### 6.2 GEK Management
-
-**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption).
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK encrypted for each member via X25519 key agreement + HKDF
-3. Encrypted GEK bundles stored on hub (opaque blobs — hub cannot decrypt them; charge is negligible: ~200–400 bytes per member per group)
-
-**Storing on hub rationale:** members can retrieve their GEK bundle even when the node is offline. Hub exposure is minimal — it stores ciphertext it cannot read.
-
-**Member addition:**
-- GEK encrypted with new member's `PK_user` and uploaded to hub
-
-**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)
-
-### 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) → QUIC session → Client → QUIC decrypt → GEK decrypt → plaintext
-```
-
-**Chunking:**
-- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking)
-- Per-chunk key derivation: `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
-- 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 optimization:**
-- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops
-- ChaCha20-Poly1305: ~500 MB/s on hardware without AES-NI; AES-256-GCM: >2 GB/s with AES-NI
-- For typical home node (50 Mbps upload = 6 MB/s), encryption is not the bottleneck
-- For high-concurrency scenarios: 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
-
-- Primary: **QUIC** (TLS 1.3 integrated, UDP, multiplexed streams)
-- Fallback: **TCP + TLS 1.3 + HTTP/2** (same application protocol, lower performance)
-- Transport interface abstracted in code — swappable without protocol changes
-- Per-connection session keys via X25519 ECDH + HKDF (independent of GEK layer)
-
-### 6.5 Chat Encryption
-
-Group chat is a **core feature** (not an extension module). Uses the **Double Ratchet algorithm** (as in Signal):
-- Forward secrecy and break-in recovery per message
-- Each message independently encrypted
-- Attachment files: encrypted with the current Double Ratchet message key, hash included in message
-- Python implementation: [TBD — evaluate existing libraries]
-
----
-
-## 7. Network and Connectivity
-
-### 7.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection
-2. UPnP / NAT-PMP on router → node opens port automatically
-3. ICE + STUN / UDP hole punching → ~80–85% success rate
-4. Mesh Relay (TURN) → [future feature]
-```
-
-Without step 4, ~15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented.
-
-Signaling (step 3): coordinated via hub WebSocket endpoint, <1 KB per attempt, no persistent state.
-
-### 7.2 MNP — Mesh Node Protocol
-
-Application-level protocol over QUIC (or TCP+TLS fallback). All messages carry a `version` field.
-
-**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 + 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. Hub Federation (MHP)
-
-### 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
-
----
-
-## 10. Moderation
-
-### 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.
-
----
-
-## 11. Python Extension Module System
-
-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.
-
----
-
-## 12. Legal Framework
-
-**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)
-
----
-
-## 13. Future Features
-
-- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
-- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. 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
-- **iOS client**
-- **At-rest encryption on node:** optional for server-deployed nodes
-- **OS keychain integration for keystore unlock**
-
----
-
-## 14. Open Questions [TBD]
-
-1. **Refresh token validity:** 30 or 90 days?
-2. **Group address scheme:** final URL format confirmation
-3. **Double Ratchet library:** identify best Python implementation
-4. **GEK bundle location for groups with mixed access** (public-restricted): hub or node?
-5. **MHP federation sync frequency and conflict resolution**
-6. **Hub mirror replication strategy** (when implemented)
-7. **Chat attachment storage:** stored on node like regular files, or separate store?
-8. **Relay registration protocol design** (when implemented)
-9. **JWT payload claims:** exact fields to include for node group-access verification
-10. **Argon2id parameters:** tuning for target hardware (home server vs. VPS)
diff --git a/docs/meshbay-draft-v3.md b/docs/meshbay-draft-v3.md
deleted file mode 100644
index 97c391d..0000000
--- a/docs/meshbay-draft-v3.md
+++ /dev/null
@@ -1,893 +0,0 @@
-# MeshBay — Architecture Draft v3
-
-> Status: preliminary draft — open points marked [TBD]
-> Changes from v2: jti mandatory in JWT (Spike 3), Argon2id params corrected (Spike 1), NAT traversal order corrected (Spike 4), transport flipped to TCP+TLS 1.3 v1 / QUIC v2, GEK wrapping protocol confirmed with exact parameters (Spike 6), hub API table expanded with 4 new endpoints (Spike 6), package structure decided (3 packages, uv monorepo), key persistence requirement added (Spike 6), new sections: Hub API Reference, TCP+TLS Transport v1, Package Structure.
-
----
-
-## 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`
-- WebRTC [future]: `aiortc`
-- 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.
-
-**Hub-side operations:**
-- Account creation and login (Android: email + phone at registration)
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing via Mesh Group Index
-- Group chat (messages + attachments, Signal-like — core feature)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: file browser for group content
-- Feed mode: chat thread with attachments
-- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
-
-### 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.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. Hub Federation (MHP)
-
-### 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
-
----
-
-## 10. Moderation
-
-### 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.
-
----
-
-## 11. Python Extension Module System
-
-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.
-
----
-
-## 12. Legal Framework
-
-**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
-
----
-
-## 13. Future Features
-
-- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic. Necessary for symmetric NAT (CGNAT mobile, some professional ISPs).
-- **QUIC transport (v2):** replace TCP+TLS 1.3 with QUIC once application protocol is stable. Transport abstraction layer makes this a drop-in replacement.
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
-- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. 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
-- **iOS client**
-- **At-rest encryption on node:** optional for server-deployed nodes
-- **OS keychain integration for keystore unlock**
-- **WebRTC:** `aiortc` for browser-native P2P (no node required for clients)
-
----
-
-## 14. Open Questions [TBD]
-
-**Resolved by POC (no longer open):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R1 | Argon2id parameters: what values target ~500ms? | `iterations=4, memory_cost=262144` (256 MB). Use `meshbay-node --calibrate-argon2` for hardware-specific tuning. | Spike 1 |
-| R2 | JWT payload claims: what fields for offline node verification? | `jti` (UUID4), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, group membership claim. `jti` is mandatory (prevents replay, enables revocation). | Spike 3 |
-| R3 | GEK wrapping protocol: exact algorithm? | ECIES-like: ephemeral X25519 + HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1") + ChaCha20-Poly1305(aad=pk_recipient). Hub stores opaque 48-byte blobs. | 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). UPnP unreliable — demoted to step 3. | Spike 4 |
-| R5 | Transport: QUIC or TCP+TLS 1.3 for v1? | TCP+TLS 1.3 for v1 (lower complexity, works everywhere). QUIC for v2 via `Transport` abstraction. | Spike 5 |
-| R6 | Hub API: which endpoints for GEK distribution? | `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek` | Spike 6 |
-| R7 | Package structure? | 3 packages: `meshbay-common`, `meshbay-hub`, `meshbay-node`. uv workspace monorepo. | POC |
-
-**Resolved by first security review (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R8 | Group chat encryption model? | Sender Keys protocol (Signal Groups approach). Double Ratchet kept for future 1:1 DM only. MLS considered for v2 if groups > 50 members. | Security review C1 |
-| R9 | Token denylist distribution? | Push via existing hub→node WebSocket. Node maintains an in-memory jti set. MNP handshake checks the set before accepting a JWT. No periodic polling needed. | Security review S3 |
-| R10 | Chunk key HKDF: salt or info? | `info` (domain separation), `salt=None`. GEK is CSPRNG output (already uniform), so HKDF extract step doesn't need a random salt. Spec wording corrected to match code (RFC 5869 compliant). | Security review M5 |
-| R11 | AES-GCM keystore IV size? | 96-bit (12 bytes), per NIST SP 800-38D recommendation. Code fixed from 128-bit to 96-bit. | Security review S4 |
-
-**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. **Hub mirror replication strategy** (when implemented)
-6. **Chat attachment storage:** stored on node like regular files, or separate store?
-7. **Relay registration protocol design** (when implemented)
-8. **QUIC migration timeline:** when is the application protocol considered stable enough to begin v2 transport implementation?
-9. **Refresh token rotation:** implement one-time-use refresh tokens (rotate on each use, detect reuse as theft indicator). RFC 6819 §5.2.2.3.
-10. **Email encryption at rest:** spec requires encrypted email/phone in DB, implementation stores plaintext. Needs server-side encryption with key from hub config.
diff --git a/docs/meshbay-draft-v4.md b/docs/meshbay-draft-v4.md
deleted file mode 100644
index 26c7bf6..0000000
--- a/docs/meshbay-draft-v4.md
+++ /dev/null
@@ -1,1368 +0,0 @@
-# MeshBay — Architecture Draft v4
-
-> Status: active development — Phases 1–12 complete (except 10.9 → Phase 13), 191 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, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search), **node sovereignty model** (§4.2.x — node operator is sole content authority, deny-by-default, uploader_id tracking), **cryptographic sovereignty enforcement** (GEK-HMAC handshake challenge, Ed25519 admin challenge-response, gek_req removed), **Phase 12 — P2P crypto material** (GEK+keypair bundles moved off hub to node BundleStore, password split, key persistence in IndexedDB/sessionStorage, DTLS channel binding fix).
-
----
-
-## 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 |
-| 11 | P2P crypto material | **ALL crypto material moved off hub to P2P channel.** GEK bundles and keypair bundles stored on node (`BundleStore` SQLite), exchanged via MNP DataChannel. Hub `GEKBundle` model and `/gek` endpoint removed. Hub never touches, stores, or proxies any crypto material. | Phase 12 — T3 attack surface reduction |
-| 12 | Password split | Hub receives `auth_key` (PBKDF2-SHA512, auth salt), never raw password. Separate `bundle_key` (PBKDF2-SHA512, bundle salt) encrypts keypair bundles on the node. Hub cannot derive `bundle_key` from `auth_key`. | Phase 12 — T1 |
-| 13 | Node auth | Node daemon authenticates to hub via Ed25519 signed timestamp (`POST /v1/nodes/auth`), not password. JWT `scope: "node"` blocks group mutation endpoints. | Phase 12 — NS7 |
-| 14 | Key persistence | Browser stores `_bundleKey` (CryptoKey) in IndexedDB and `_sessionKeys` in sessionStorage. Survives page refresh without re-login. Public key derived from recovered private key via JWK export (`_pkFromSk`), no hub dependency. | Phase 12 — browser hardening |
-| 15 | DTLS channel binding | Browser saves raw answer SDP before `setRemoteDescription` (Chrome may drop sha-256 fingerprint). GEK-HMAC uses `_rawAnswerSdp` for fingerprint extraction. | Phase 12 — handshake fix |
-
----
-
-## 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/admin/revoke` | Access token (admin) | Revoke a user or group | ✓ Phase 8 |
-| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens | [TBD] |
-
-**Admin / moderation:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) | ✓ Phase 10 |
-| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) | ✓ Phase 10 |
-| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable by username) | ✓ Phase 10 |
-| GET | `/v1/admin/users/{user_id}` | Moderator+ | User detail (email, role, status, group count) | ✓ Phase 10 |
-| PATCH | `/v1/admin/users/{user_id}` | Moderator+ | Update user role or status | ✓ Phase 10 |
-| GET | `/v1/admin/groups` | Moderator+ | List all groups with member count | ✓ Phase 10 |
-| PATCH | `/v1/admin/groups/{group_id}` | Moderator+ | Update group status | ✓ Phase 10 |
-| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user_id) | ✓ Phase 10 |
-| GET | `/v1/admin/blocklist` | Admin | List blocked content hashes | ✓ Phase 8 |
-| POST | `/v1/admin/blocklist` | Admin | Manually block a content hash | ✓ Phase 8 |
-| DELETE | `/v1/admin/blocklist/{hash}` | Admin | Unblock a content hash | ✓ Phase 8 |
-| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) | ✓ Phase 10 |
-| POST | `/v1/notifications/{id}/read` | Access token | Mark notification as read | ✓ Phase 10 |
-| POST | `/v1/notifications/read-all` | Access token | Mark all notifications as read | ✓ Phase 10 |
-| GET | `/v1/groups?q=` | None | Search public groups by name (ilike) | ✓ Phase 10 |
-| GET | `/v1/hub/version` | None | Client version check (hub, MNP, MHP) | ✓ Phase 10 |
-| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) | ✓ Phase 10b |
-| POST | `/v1/groups/{id}/join` | Access token | Self-join open group | ✓ Phase 10b |
-
-### 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.
-
-#### 4.2.x Node Sovereignty — Content Authorization Model
-
-The node operator is the **sole authority** over content stored on their machine.
-No external actor — including the hub admin — can modify, delete, or control
-files on a node they do not operate. This is a non-negotiable design invariant,
-enforced by **cryptography**, not just policy.
-
-**Two trust domains, strictly separated:**
-
-| Domain | Authority | Scope |
-|---|---|---|
-| **Hub** | Hub admin / moderator | User accounts, group registry, group membership, GEK distribution, moderation (suspend user/group at hub level) |
-| **Node** | Node operator | Files on disk, file deletion, upload acceptance, chat storage, who can do what with node content |
-
-The hub certifies **identity** (JWT) and **group membership** (`groups` claim).
-The node decides **authorization for content operations** based on that identity.
-These two concerns must never be conflated.
-
-##### Cryptographic enforcement — two defense layers
-
-A malicious hub admin controls the JWT signing key and could forge JWTs to
-impersonate any user, including the node operator. Policy-only checks (comparing
-`user_id` to `node_user_id`) are insufficient because the hub controls the
-identity layer. Two cryptographic mechanisms make this impossible:
-
-**Layer 1 — GEK proof in handshake (membership verification):**
-
-After JWT verification, the node challenges the connecting user to prove they
-possess the Group Encryption Key (GEK). The hub never has the GEK — it only
-stores opaque ECIES-wrapped bundles. Without the GEK, a hub admin who forges
-a JWT still cannot access any group content.
-
-```
-Client → Node: handshake { token, group_id }
-Node: verify JWT, verify group_id in claims
- nonce = random(32)
-Node → Client: handshake_challenge { nonce: base64(nonce) }
-Client: proof = HMAC-SHA256(GEK, nonce)
-Client → Node: handshake_response { proof: base64(proof) }
-Node: verify HMAC — if wrong, reject connection
-Node → Client: handshake_ack { is_node_admin, node_pk, v }
-```
-
-This blocks: content reading, index reading, chat reading, file upload, chat
-injection — ALL operations require passing the GEK proof first.
-
-**Layer 2 — Ed25519 challenge-response for admin operations:**
-
-The node operator's Ed25519 public key is pinned locally in `node.toml`
-(auto-pinned from keystore on first startup). Destructive operations (file
-deletion) require the user to sign a random challenge with their Ed25519
-private key. The hub cannot forge this signature.
-
-```
-Client → Node: file_delete { file_id }
-Node: (if uploader → allow immediately)
- (else) challenge = random(32)
-Node → Client: admin_challenge { challenge: base64(challenge), file_id }
-Client: signature = Ed25519.sign(sk_ed, challenge)
-Client → Node: admin_response { signature: base64(signature), file_id }
-Node: verify(admin_pk_ed25519, signature, challenge)
- if valid → delete file
-```
-
-**Node configuration — admin key pinning:**
-
-```toml
-# node.toml
-admin_pk_ed25519 = "base64-encoded-32-bytes-raw-Ed25519-public-key"
-```
-
-Auto-pinned from the node operator's keystore on first startup. The daemon
-logs: "Admin Ed25519 key pinned for node sovereignty".
-
-**GEK distribution — browser flow (node no longer serves GEK):**
-
-The node NEVER serves the GEK in plaintext. Browser clients obtain the GEK
-from their hub-stored encrypted bundle:
-
-1. `GET /v1/groups/{id}/gek` → encrypted ECIES bundle (AES-256-GCM variant)
-2. Browser unwraps with its X25519 private key (from keypair bundle)
-3. Browser uses raw GEK bytes for the handshake HMAC proof
-4. Browser imports GEK as HKDF key for chunk decryption
-
-This eliminates the `gek_req`/`gek_resp` MNP messages from the protocol.
-
-**Authorization rules for destructive file operations (enforced by the node):**
-
-| Action | Who can do it | Enforcement point |
-|---|---|---|
-| Delete a file | Node operator (Ed25519 challenge-response) OR the user who uploaded it | Node (`_do_file_delete`) |
-| Delete any file | Node operator only (Ed25519 challenge-response) | Node (`_do_file_delete`) |
-
-Default posture: **deny.** If the admin key is not pinned, all admin operations
-are refused. If the GEK proof fails, the connection is refused entirely.
-
-**Protocol enforcement — MNP handshake_ack:**
-
-The handshake_ack message carries `is_node_admin: bool` — the node tells the
-client whether the authenticated user is the node operator. Clients MUST use
-this node-reported flag (not the hub's `group.admin_id`) to decide whether
-to show destructive operations like file deletion.
-
-```
-handshake_ack:
- v: "0.1"
- node_pk: "<base64>"
- is_node_admin: true | false # node-side authorization, NOT hub-side
-```
-
-**Index entry — uploader tracking:**
-
-Each `IndexEntry` carries an `uploader_id` field (user_id of who uploaded the
-file, or null for files that pre-existed on disk). This enables the "uploader
-can delete their own files" rule without granting node-admin privileges.
-
-**What the hub admin CANNOT do on a node they don't operate:**
-- Delete files (requires Ed25519 key pinned on node — hub can't forge)
-- Read files (requires GEK — hub never has it)
-- Read index / chat (requires GEK proof in handshake)
-- Upload files (requires GEK proof in handshake)
-- Impersonate the node operator (JWT forgery blocked by Ed25519 challenge)
-
-**What the hub admin CAN do (hub-level only):**
-- Suspend a user account (blocks JWT issuance → user loses access everywhere)
-- Suspend a group (blocks signaling → no new P2P connections to nodes for that group)
-- These are hub-level actions that don't touch node content
-
-**Remaining trust assumptions:**
-- The hub serves the SPA code to browsers (a malicious hub could inject JS — fundamentally unsolvable in browser; native client or browser extension required for full integrity)
-- The hub relays WebRTC signaling — ✅ MITIGATED: DTLS channel binding in GEK-HMAC proof (`HMAC(GEK, nonce || offer_fp || answer_fp)`) detects fingerprint substitution (MitM)
-- The hub receives raw password at login — ✅ MITIGATED: password split (auth_key ≠ bundle_key, independent PBKDF2 derivations). Hub receives auth_key only, cannot derive bundle_key to decrypt keypair bundle. Legacy accounts migrated on first login.
-- The hub controls public key distribution — can substitute keys during invite to intercept GEK. Fix: out-of-band key verification (safety numbers) — Phase 12
-
-> **Design lesson (2026-08-12):** The initial implementation conflated hub
-> `group.admin_id` (who created the group on the hub) with node operator
-> authority (who runs the machine). The SPA used the hub's `is_admin` flag
-> to show file deletion controls, and the node's delete handler used a
-> fail-open check (`if node_user_id and ...` — allowed everyone when
-> `node_user_id` was not set). Both violated node sovereignty. Fixed by:
-> (1) deny-by-default on the node, (2) `is_node_admin` in handshake_ack,
-> (3) `uploader_id` tracking in the index, (4) SPA uses node-reported
-> permissions only. Then hardened with cryptographic enforcement:
-> (5) GEK-HMAC proof in handshake (blocks forged-JWT access),
-> (6) Ed25519 challenge-response for admin ops (blocks identity impersonation),
-> (7) removal of `gek_req` endpoint (node never serves GEK in plaintext),
-> (8) DTLS channel binding in GEK-HMAC proof to detect WebRTC signaling MitM,
-> (9) chat `sender_id` fixed to authenticated identity (prevents impersonation),
-> (10) Ed25519 challenge for ALL file deletions — uploaders verified by stored pk, not JWT sub,
-> (11) password split — hub receives PBKDF2 auth_key, never raw password (cannot derive bundle_key).
-
-**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 |
-| Node admin | The hosting node operator — sovereign over content, sole delete authority (see §4.2.x) |
-| Hub group creator | The user who registered the group on the hub — manages membership and GEK distribution |
-
-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 |
-| `handshake_challenge` | Node sends GEK proof nonce (base64, 32 bytes random) — see §4.2.x |
-| `handshake_response` | Client proves GEK possession: HMAC-SHA256(GEK, nonce) |
-| `handshake_ack` | Node response: version, node public key, `is_node_admin` (node-level authorization) |
-| `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 |
-| `file_delete` | Client requests file deletion by file_id |
-| `file_delete_ack` | Node confirms deletion |
-| `file_upload` | Client pushes file chunk to node |
-| `file_upload_ack` | Node acknowledges chunk receipt |
-| `admin_challenge` | Node sends Ed25519 sign challenge for admin ops (base64, 32 bytes) |
-| `admin_response` | Client returns Ed25519 signature over the challenge |
-| `stream_request` | Client requests MSE video stream |
-| `stream_init` | Node sends codec info + signals stream start |
-| `stream_data` | Node sends encrypted fMP4 segment |
-| `stream_end` | Node signals end of stream |
-| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key |
-| `chat_message` | Sender Keys encrypted message frame (group chat) |
-| `chat_history` | Client requests chat history |
-| `chat_history_response` | Node responds with stored messages |
-| `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,
- "uploader_id": "<user_id>" # who uploaded this file (null = pre-existing on disk)
-}
-```
-
-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
-- Delete: node operator can delete any file; uploader can delete their own uploads.
- Hub admin has NO delete authority on nodes they don't operate (see §4.2.x).
-
-**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 |
-
-**Resolved by node sovereignty fix (2026-08-12):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R21 | Who authorizes file deletion on a node? | Node operator (sovereign) OR original uploader. Hub admin has no authority over node content. Enforced: deny-by-default in MNP `file_delete`, `is_node_admin` in handshake_ack, `uploader_id` in IndexEntry. | Security fix — §4.2.x |
-| R22 | Can a malicious hub admin access node content? | No. Two cryptographic layers: (1) GEK-HMAC proof in handshake — hub never has the GEK, can't pass the challenge. (2) Ed25519 challenge-response for admin ops — hub can't forge the node operator's signature. `gek_req` endpoint removed. | Crypto enforcement — §4.2.x |
-
-**Resolved by Phase 12 — P2P crypto material (2026-08-13):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R23 | Where are GEK bundles stored? | On node only (`BundleStore` SQLite, `data_dir/bundles.db`). Hub `GEKBundle` model removed. Exchanged via MNP `gek_bundle_store`/`gek_bundle_fetch`/`gek_bundle_resp` over WebRTC DataChannel. | Phase 12 — T3 |
-| R24 | Where are keypair bundles stored? | On node only (`BundleStore`). Encrypted with password-derived AES key (`bundle_key`). Browser pushes after registration, recovers during handshake. Hub `keypair_bundle` column removed. | Phase 12 — T3 |
-| R25 | How does the browser recover keys after localStorage cleared? | Transport fetches `keypair_bundle` from node during handshake, decrypts with `_bundleKey` (PBKDF2 from password). Public key derived from private key via JWK export — no hub fetch needed. `_bundleKey` persisted in IndexedDB, `_sessionKeys` in sessionStorage. | Phase 12 |
-| R26 | How does the browser handle Chrome SDP re-serialization? | `this._rawAnswerSdp = answer.sdp` saved before `setRemoteDescription`. DTLS fingerprint extracted from raw SDP, not `pc.remoteDescription.sdp` (Chrome may drop sha-256 line when re-serializing multi-hash SDP from aiortc). | Phase 12 |
-| R27 | Should the browser auto-regenerate keys on login? | No. Auto-regeneration silently rotates hub keys, breaking GEK unwrap (GEK wrapped for old keys). Keys recovered from node via `_bundleKey`. Regeneration only on explicit user request. | Phase 12 |
-
-**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? → Resolved: always on 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/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
index e4218ee..de91c80 100644
--- a/docs/meshbay-draft-v5.md
+++ b/docs/meshbay-draft-v5.md
@@ -10,7 +10,7 @@
> and pairing design, `docs/invite-pairing-v1.md`. For the desktop client — shell,
> device linking, account creation, node management — `docs/desktop-client-v1.md`
> (2026-08-17) is authoritative and supersedes §8.2 here.
-> Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4.
+> Supersedes draft v4 (archived in `old-draft.md`). Sections not restated here are unchanged from v4.
>
> v5 exists because the second security review (`second-review.md`, 2026-08-13) found
> that v4 described a system the code did not implement, and because several v4 claims
diff --git a/docs/old-draft.md b/docs/old-draft.md
new file mode 100644
index 0000000..f02c54f
--- /dev/null
+++ b/docs/old-draft.md
@@ -0,0 +1,4497 @@
+# MeshBay — Archived Drafts
+
+> **Status: historical archive. Nothing here is authoritative.**
+>
+> This file consolidates the superseded architecture drafts (v1–v4), the original
+> POC plan, and the Phase 1–12 development log. They are kept for provenance and
+> for the section references (`draft-v3 §4.1.3`, `draft-v4 §6.6`, …) still made
+> from live documents and code comments.
+>
+> For what is true now, read instead:
+>
+> | Topic | Document |
+> |---|---|
+> | Current specification | `meshbay-draft-v6.md` (+ `meshbay-draft-v5.md` for what v6 does not restate) |
+> | Roadmap | `devel-phases-next.md` |
+> | Security findings | `first-review.md`, `second-review.md` |
+> | Client architecture decisions | `tmp-decisions.md`, `desktop-client-v1.md` |
+>
+> Contents of this archive, in order:
+> 1. Architecture Draft v1
+> 2. Architecture Draft v2
+> 3. Architecture Draft v3
+> 4. Architecture Draft v4
+> 5. POC v1
+> 6. Development Phases (1–12)
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: Architecture Draft v1 (was docs/meshbay-draft-v1.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — Architecture Draft v1
+
+> Status: preliminary draft — many points still open, marked [TBD]
+
+---
+
+## 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), aiming 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
+
+---
+
+## 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 |
+| **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 group content |
+| **Mesh Directory** | Public registry of groups (hub level) |
+| **Mesh Group Index** | Encrypted file listing for a group (node level) |
+
+---
+
+## 3. System Components
+
+### 3.1 Mesh Hub
+
+A lightweight server acting as a registrar. It is intentionally kept minimal to reduce legal exposure and operational burden.
+
+**What the hub stores:**
+- User accounts: username, hashed email, `PK_user` (public key fingerprint), hub ID, status
+- Group registry: name, `PK_group`, hosting node address, visibility, member list with encrypted GEK bundles
+- 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 IP addresses (handled by ephemeral signaling service)
+
+**Hub interactions — when is it called?**
+
+| Event | Hub load | Frequency |
+|---|---|---|
+| Account creation | Hash credential, store PK | Once |
+| Login | Verify credentials, issue signed JWT | Per session (~30-day validity) |
+| Group creation | Register name, PK_group, node | Once per group |
+| Member add/remove | Store/remove encrypted GEK bundle | On admin action |
+| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
+| NAT signaling | Relay a few WebSocket messages (<1 KB) | Per new P2P connection |
+| Public search | Delegate query to nodes at request time | On demand |
+| MHP federation sync | Exchange public directory updates | Background, periodic |
+| Revocation | Issue signed revocation token | Rare |
+
+**The hub is never in the data path after initial connection setup.**
+
+**JWT as offline passport:**
+The hub issues a JWT signed with its Ed25519 private key. Nodes verify this JWT locally using the hub's known public key — no hub roundtrip required per request. JWT validity: ~30 days.
+
+**Tech stack:**
+- Language: Python
+- Framework: FastAPI + Uvicorn
+- Database: PostgreSQL + SQLAlchemy + Alembic
+- Deployment: behind Apache reverse proxy (ProxyPass)
+- Authentication: own system (JWT signed with Ed25519, no OAuth dependency)
+
+**Account creation:** [TBD] — email only at first, phone number associable later. Via Android app, both collected by default. Fusionable accounts.
+
+### 3.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)
+- Serve files and video streams to group members
+- Manage all cryptographic keys locally (keystore, password-protected)
+- Handle P2P connections and NAT traversal
+- Run the MNP protocol
+- Host the Python module sandbox
+- Serve the local web UI (localhost)
+- Receive and redistribute ephemeral video from mobile [future]
+
+**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
+
+**Tech stack:**
+- Language: Python (primary), Rust extensions only if strictly necessary for hot paths
+- QUIC: `aioquic`
+- ICE/STUN: `aioice`
+- WebRTC (future): `aiortc`
+- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated)
+- Serialization: `msgpack`
+- Compression: `zstandard` (zstd)
+- File watching: `watchdog`
+- Local DB: SQLite
+- Local web UI: served by the node on localhost (port [TBD])
+
+**Node pairing with mobile:** QR code from local web UI [future].
+
+### 3.3 Mesh Client
+
+Web browser or Android app. Consumes content from the node; manages account via the hub.
+
+**Hub-side operations (via hub):**
+- Account creation and login
+- Public group search and discovery
+- Group membership management
+
+**Node-side operations (direct P2P):**
+- File browsing (Mesh Group Index)
+- Message feed reading (with attachments, Signal-like)
+- File download
+- Video streaming (VOD)
+- [Future] Ephemeral video feed
+
+**Client modes** [to be designed]:
+- Explorer mode: browse files in a group
+- Feed mode: message thread with attachments
+- Hub/Node UI split to be defined
+
+### 3.4 Mesh Relay
+
+Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail. Traffic is always E2E encrypted — the relay sees only opaque QUIC packets and cannot read content.
+
+Not operated by meshbay.org. A protocol for relay registration with hubs is [TBD].
+
+---
+
+## 4. 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: shared files, message thread, member list. It can be open entry, request-based, or invitation-only regardless of its public visibility.
+
+A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members with the GEK can decrypt anything.
+
+**Group addressing** [TBD]:
+```
+meshbay.org/u/username/groupname — public group via hub
+meshbay.org/g/groupname — direct public group
+group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
+```
+
+---
+
+## 5. Cryptographic Architecture
+
+### 5.1 Key Hierarchy
+
+```
+User Identity Key Ed25519 Signing, authentication
+User Exchange Key X25519 Key agreement
+Group Identity Key Ed25519 Group metadata signing (held by admin node)
+Group Encryption Key ChaCha20 Content and index encryption (symmetric, 256-bit)
+Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
+```
+
+All private keys are stored exclusively on the node (or client device), in a password-protected local keystore. The hub never sees any private key.
+
+### 5.2 GEK Management
+
+**Group creation:**
+1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
+2. GEK is encrypted for each member using X25519 key agreement + HKDF
+3. Encrypted GEK bundles stored on hub (or node — [TBD])
+
+**Member addition:**
+- GEK encrypted with new member's `PK_user` and distributed
+
+**Member revocation:**
+- Admin node generates a new GEK
+- Re-encrypts for all remaining members
+- New content encrypted with new GEK
+- Former member retains ability to decrypt previously received content (acceptable trade-off — full re-encryption not planned)
+
+### 5.3 On-the-Fly Encryption for File Transfer
+
+Files are stored in plaintext on the host's disk. The node encrypts at read time before transmission.
+
+```
+Disk (plaintext) → [Node] → zstd compress → GEK encrypt (per-chunk) → QUIC session → [Client] → QUIC decrypt → GEK decrypt → plaintext
+```
+
+**Chunking strategy:**
+- Chunk size: 1 MB (amortizes AEAD overhead, allows seeking)
+- Per-chunk key derivation:
+ `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
+- Each chunk independently decryptable (enables video seeking)
+- Compress before encrypt (zstd compression is useless after encryption)
+
+**Chunk authentication:**
+Each chunk (or batch) is signed with the node's Ed25519 key. The client verifies before decryption. Prevents data injection by a compromised relay.
+
+### 5.4 Transport Security
+
+- Primary protocol: **QUIC** (TLS 1.3 integrated, UDP-based, multiplexed)
+- Per-connection session keys via X25519 ECDH + HKDF
+- The QUIC layer is independent from the GEK application layer — two independent encryption layers
+
+### 5.5 Chat Encryption
+
+Group messaging uses the **Double Ratchet algorithm** (as in Signal):
+- Forward secrecy and break-in recovery per message
+- Each message independently encrypted
+- Implementation: existing Python or Rust library [TBD]
+
+---
+
+## 6. Network and Connectivity
+
+### 6.1 NAT Traversal — Attempt Order
+
+```
+1. IPv6 available on both sides → direct connection, no NAT issue
+2. UPnP / NAT-PMP on router → node opens port automatically
+3. ICE + STUN / UDP hole punching → works for ~80-85% of cases
+4. Mesh Relay (TURN fallback) → community-operated, E2E encrypted traffic
+```
+
+**Signaling** (steps 3/4): coordinated via hub WebSocket, <1 KB per attempt, stateless after connection established.
+
+**Step 4 coverage:** ~15-20% of connections (symmetric NAT on both sides, CGNAT). The relay sees only encrypted QUIC packets.
+
+### 6.2 MNP — Mesh Node Protocol
+
+Application-level protocol over QUIC. Defined blocks:
+
+- **Handshake**: key exchange, group membership verification (JWT presentation)
+- **Index sync**: encrypted delta Mesh Group Index on connection
+- **File transfer**: chunk request/response with hash verification
+- **VOD streaming**: HLS/DASH segments, encrypted per-segment with GEK-derived keys
+- **Messaging**: Double Ratchet messages encapsulated in MNP frames
+- **[Future] Ephemeral stream**: `ephemeral_stream` type with TTL metadata
+
+### 6.3 Public Content Delivery
+
+Public files are identified by their `blake3` hash. Multiple nodes can serve the same file:
+
+1. Node A has public file X (hash H)
+2. Any node that obtains X and chooses to mirror it registers with the hub: "I serve hash H"
+3. Hub maintains: `{ blake3_hash → [node_A, node_B, ...] }`
+4. Client requests X → hub returns source list → client fetches in parallel chunks from multiple nodes
+
+**Public content transport:** TLS only (no GEK). Content signed with the original node's Ed25519 key for authenticity verification by clients, even when served from a mirror. Door left open for GEK on "registered-users-only public" groups in a future revision.
+
+---
+
+## 7. Indexes
+
+### 7.1 Mesh Directory (hub level)
+
+Public registry of groups. Exchanged between hubs via MHP.
+
+Format: msgpack, signed by hub's Ed25519 key.
+
+Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy.
+
+### 7.2 Mesh Group Index (node level)
+
+File listing for a group. Generated and maintained by the hosting node.
+
+Format: msgpack → zstd compressed → GEK encrypted (private groups) or plaintext signed (public groups).
+
+Entry structure:
+```python
+{
+ "id": "<blake3_hash>",
+ "name": "filename.mkv",
+ "path": "Movies/2024/", # relative to shared directory
+ "size": 4294967296,
+ "type": "video", # video | audio | image | document | archive | other
+ "duration": 7245, # seconds, for media
+ "thumb_hash":"<blake3>", # thumbnail hash (thumbnail also GEK-encrypted)
+ "added_at": 1720000000
+}
+```
+
+**Delta updates:** each update carries `{base_version, additions, deletions}` — no full re-encryption on every change.
+
+**Transit:** nodes push index deltas to connected members on change. Members pull full index on first connection. Hub stores no index content — only the node address for routing.
+
+### 7.3 Search
+
+**Private groups:** search is entirely local on the client device. The client maintains a local encrypted cache of all indexes for groups it belongs to. No network call, no hub involvement, instant results.
+
+**Public groups:** client queries node(s) directly at request time. Hub provides routing (which node hosts which group) but performs no content lookup itself.
+
+**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this interaction. In-memory micro-cache of results: **60-second TTL maximum, RAM only, never written to disk, public content only.** This qualifies as technical caching (EU DSA Article 13) and does not constitute indexing.
+
+---
+
+## 8. Hub Federation (MHP)
+
+### 8.1 Hub Hierarchy
+
+```
+Root Hub (meshbay.org)
+ ├── Full Hub (self-hosted, CA-delegated)
+ │ └── issues user credentials, manages its own groups
+ │ └── can federate with other Full Hubs via MHP
+ └── Mirror Hub
+ └── hosts public Mesh Directory only (no user accounts)
+```
+
+A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub) proving its authority. Clients verify the chain. A Mirror Hub can only replicate public directory data.
+
+### 8.2 MHP Design Principles
+
+- Explicit peer selection: each hub maintains an allowlist of trusted peer hubs
+- No automatic hub discovery
+- Exchanged data: Mesh Directory (public groups), revocation lists, cross-hub user credentials
+- Cross-hub authentication: user from Hub A presents JWT signed by Hub A; Hub B verifies using Hub A's public key (fetched once on first interaction, cached)
+
+### 8.3 Cross-Hub Client Access
+
+Client from Hub A accessing a group on Hub B:
+1. Hub A's public directory or direct link leads client to Hub B
+2. Client presents Hub A JWT to Hub B directly
+3. Hub B verifies JWT signature using Hub A's public key
+4. Hub B issues a short-lived local token for this session
+5. Client proceeds to node as normal
+
+---
+
+## 9. Moderation
+
+### 9.1 Public Content
+
+```
+Report #1 → automatic suspension of public access to content
+ → 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 of content added to hub blocklist. Node receives signed revocation notice and cuts public access.
+
+### 9.2 CSAM
+
+Hash matching against NCMEC/IWF database on all public content at registration time. Participation demonstrates good faith and significantly reduces legal exposure. No scanning of private/encrypted content.
+
+### 9.3 Copyright
+
+DMCA/legal notice framework (takedown on notification). No automated technical blocking — too complex, too many false positives (fair use, regional variations). Hub can revoke on confirmed legal request.
+
+### 9.4 Private Content
+
+Not directly moderatable (E2E encrypted by design). Only action available: revoke user or group at hub level on formal legal request. Hub issues a signed revocation token that all group members' nodes can verify.
+
+---
+
+## 10. Python Module System
+
+The node can load extension modules (Python) that run in a sandboxed subprocess.
+
+**Module manifest** (declared capabilities):
+```python
+{
+ "name": "group-chat",
+ "version": "1.0.0",
+ "permissions": ["read_index", "send_message", "receive_events"]
+}
+```
+
+**Available APIs (restricted):**
+- `read_index()` — read current group index (read-only)
+- `send_message(content)` — post a message to the group thread
+- `receive_events(handler)` — subscribe to group events (new file, new message)
+
+**Not available:**
+- Arbitrary network access
+- Filesystem access outside the group context
+- System calls
+
+**First official module:** group chat thread (Signal-like, with attachments). Bundled with node.
+
+---
+
+## 11. Legal Framework
+
+**Node operator:** primary legal host of content. Fully responsible for what they share. Node software clearly communicates this at setup.
+
+**Hub operator:** registrar, not content host. Stores minimal PII. Operates takedown mechanism. Participates in CSAM hash matching. Analogous to a domain registrar in legal exposure terms.
+
+**Protocol/software author:** protected by substantial non-infringing uses. No active facilitation of infringement.
+
+**Hub data minimization:**
+- Email stored hashed after verification [TBD]
+- No IP address logging (or auto-deletion after 24h)
+- No content metadata stored
+- Node current address managed by ephemeral signaling service only
+
+---
+
+## 12. Future Features (noted, not designed)
+
+- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
+- **Mobile video push:** mobile films → pushes to hosting node → distributed as ephemeral stream with TTL to group members. MNP `ephemeral_stream` type reserved.
+- **Mesh Relay registration protocol:** community TURN relays registered with hubs
+- **Node mobile pairing:** QR code from local web UI
+- **Multi-source download:** parallel chunk fetching from multiple nodes for same public file (swarm)
+- **iOS client**
+- **At-rest encryption on node:** optional, for nodes deployed on remote servers
+
+---
+
+## 13. Open Questions [TBD]
+
+1. **GEK bundle storage:** on hub or on node only? Hub = easier discovery; node only = more decentralized
+2. **Group address scheme:** final URL format
+3. **Hub local web UI scope for V1:** config only, or also group browsing?
+4. **Account creation:** email only to start, phone associable — confirm
+5. **Chat implementation:** bundled module or core feature?
+6. **QUIC library maturity:** `aioquic` production readiness assessment needed
+7. **Double Ratchet library:** identify best Python implementation
+8. **Relay registration protocol:** design when community relays are introduced
+9. **Cross-hub directory exchange:** frequency, conflict resolution
+10. **Node port for local web UI:** to assign
+11. **JWT expiry and refresh strategy**
+12. **Keystore format and unlock mechanism on node startup**
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: Architecture Draft v2 (was docs/meshbay-draft-v2.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — Architecture Draft v2
+
+> Status: preliminary draft — open points marked [TBD]
+> Changes from v1: IP logging (legal), protocol versioning, hardware sizing, JWT strategy, keystore proposals, chat as core, relay moved to future, hub mirror future, GEK clarified, port 18000, lazy admin keystore.
+
+---
+
+## 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`, 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.
+
+#### 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).**
+
+#### 4.1.4 JWT Strategy
+
+Two tokens issued at login:
+
+**Access token** (JWT, signed Ed25519):
+- Validity: 1 hour
+- Payload: `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, hub-signed groups membership claim
+- 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
+
+**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.
+
+**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.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)
+- Serve files and video streams to group members
+- Manage all cryptographic keys locally (encrypted keystore)
+- Handle P2P connections and NAT traversal
+- Run the MNP protocol
+- Host the Python extension module sandbox
+- Serve the local web UI (localhost:18000)
+- Host the group chat (core feature)
+
+**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
+
+#### 4.2.1 Keystore and Unlock
+
+Private keys (user identity, group identity, 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 (tuned for ~1s derivation on target hardware).
+
+**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.
+
+**Tech stack:**
+- Language: Python (primary). Rust extension only if a specific hot path proves insufficient.
+- Transport abstraction layer: `Transport` interface decouples QUIC from TCP+TLS fallback
+- QUIC: `aioquic` (Cloudflare-maintained). Fallback: TCP + TLS 1.3 + HTTP/2 if QUIC proves insufficient in production
+- ICE/STUN: `aioice`
+- WebRTC [future]: `aiortc`
+- 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.
+
+**Hub-side operations:**
+- Account creation and login (Android: email + phone at registration)
+- Public group search and discovery
+- Group membership management
+
+**Node-side operations (direct P2P):**
+- File browsing via Mesh Group Index
+- Group chat (messages + attachments, Signal-like — core feature)
+- File download
+- Video streaming (VOD)
+- [Future] Ephemeral video feed
+
+**Client modes** [to be designed]:
+- Explorer mode: file browser for group content
+- Feed mode: chat thread with attachments
+- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
+
+### 4.4 Mesh Relay
+
+**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (~15–20% of connections). Traffic is always E2E encrypted — the relay sees only opaque QUIC packets.
+
+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.
+
+---
+
+## 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
+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.
+
+### 6.2 GEK Management
+
+**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption).
+
+**Group creation:**
+1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
+2. GEK encrypted for each member via X25519 key agreement + HKDF
+3. Encrypted GEK bundles stored on hub (opaque blobs — hub cannot decrypt them; charge is negligible: ~200–400 bytes per member per group)
+
+**Storing on hub rationale:** members can retrieve their GEK bundle even when the node is offline. Hub exposure is minimal — it stores ciphertext it cannot read.
+
+**Member addition:**
+- GEK encrypted with new member's `PK_user` and uploaded to hub
+
+**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)
+
+### 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) → QUIC session → Client → QUIC decrypt → GEK decrypt → plaintext
+```
+
+**Chunking:**
+- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking)
+- Per-chunk key derivation: `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
+- 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 optimization:**
+- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops
+- ChaCha20-Poly1305: ~500 MB/s on hardware without AES-NI; AES-256-GCM: >2 GB/s with AES-NI
+- For typical home node (50 Mbps upload = 6 MB/s), encryption is not the bottleneck
+- For high-concurrency scenarios: 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
+
+- Primary: **QUIC** (TLS 1.3 integrated, UDP, multiplexed streams)
+- Fallback: **TCP + TLS 1.3 + HTTP/2** (same application protocol, lower performance)
+- Transport interface abstracted in code — swappable without protocol changes
+- Per-connection session keys via X25519 ECDH + HKDF (independent of GEK layer)
+
+### 6.5 Chat Encryption
+
+Group chat is a **core feature** (not an extension module). Uses the **Double Ratchet algorithm** (as in Signal):
+- Forward secrecy and break-in recovery per message
+- Each message independently encrypted
+- Attachment files: encrypted with the current Double Ratchet message key, hash included in message
+- Python implementation: [TBD — evaluate existing libraries]
+
+---
+
+## 7. Network and Connectivity
+
+### 7.1 NAT Traversal — Attempt Order
+
+```
+1. IPv6 available on both sides → direct connection
+2. UPnP / NAT-PMP on router → node opens port automatically
+3. ICE + STUN / UDP hole punching → ~80–85% success rate
+4. Mesh Relay (TURN) → [future feature]
+```
+
+Without step 4, ~15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented.
+
+Signaling (step 3): coordinated via hub WebSocket endpoint, <1 KB per attempt, no persistent state.
+
+### 7.2 MNP — Mesh Node Protocol
+
+Application-level protocol over QUIC (or TCP+TLS fallback). All messages carry a `version` field.
+
+**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 + 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. Hub Federation (MHP)
+
+### 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
+
+---
+
+## 10. Moderation
+
+### 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.
+
+---
+
+## 11. Python Extension Module System
+
+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.
+
+---
+
+## 12. Legal Framework
+
+**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)
+
+---
+
+## 13. Future Features
+
+- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic
+- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
+- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
+- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. 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
+- **iOS client**
+- **At-rest encryption on node:** optional for server-deployed nodes
+- **OS keychain integration for keystore unlock**
+
+---
+
+## 14. Open Questions [TBD]
+
+1. **Refresh token validity:** 30 or 90 days?
+2. **Group address scheme:** final URL format confirmation
+3. **Double Ratchet library:** identify best Python implementation
+4. **GEK bundle location for groups with mixed access** (public-restricted): hub or node?
+5. **MHP federation sync frequency and conflict resolution**
+6. **Hub mirror replication strategy** (when implemented)
+7. **Chat attachment storage:** stored on node like regular files, or separate store?
+8. **Relay registration protocol design** (when implemented)
+9. **JWT payload claims:** exact fields to include for node group-access verification
+10. **Argon2id parameters:** tuning for target hardware (home server vs. VPS)
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: Architecture Draft v3 (was docs/meshbay-draft-v3.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — Architecture Draft v3
+
+> Status: preliminary draft — open points marked [TBD]
+> Changes from v2: jti mandatory in JWT (Spike 3), Argon2id params corrected (Spike 1), NAT traversal order corrected (Spike 4), transport flipped to TCP+TLS 1.3 v1 / QUIC v2, GEK wrapping protocol confirmed with exact parameters (Spike 6), hub API table expanded with 4 new endpoints (Spike 6), package structure decided (3 packages, uv monorepo), key persistence requirement added (Spike 6), new sections: Hub API Reference, TCP+TLS Transport v1, Package Structure.
+
+---
+
+## 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`
+- WebRTC [future]: `aiortc`
+- 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.
+
+**Hub-side operations:**
+- Account creation and login (Android: email + phone at registration)
+- Public group search and discovery
+- Group membership management
+
+**Node-side operations (direct P2P):**
+- File browsing via Mesh Group Index
+- Group chat (messages + attachments, Signal-like — core feature)
+- File download
+- Video streaming (VOD)
+- [Future] Ephemeral video feed
+
+**Client modes** [to be designed]:
+- Explorer mode: file browser for group content
+- Feed mode: chat thread with attachments
+- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
+
+### 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.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. Hub Federation (MHP)
+
+### 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
+
+---
+
+## 10. Moderation
+
+### 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.
+
+---
+
+## 11. Python Extension Module System
+
+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.
+
+---
+
+## 12. Legal Framework
+
+**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
+
+---
+
+## 13. Future Features
+
+- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic. Necessary for symmetric NAT (CGNAT mobile, some professional ISPs).
+- **QUIC transport (v2):** replace TCP+TLS 1.3 with QUIC once application protocol is stable. Transport abstraction layer makes this a drop-in replacement.
+- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
+- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
+- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. 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
+- **iOS client**
+- **At-rest encryption on node:** optional for server-deployed nodes
+- **OS keychain integration for keystore unlock**
+- **WebRTC:** `aiortc` for browser-native P2P (no node required for clients)
+
+---
+
+## 14. Open Questions [TBD]
+
+**Resolved by POC (no longer open):**
+
+| # | Question | Resolution | Source |
+|---|---|---|---|
+| R1 | Argon2id parameters: what values target ~500ms? | `iterations=4, memory_cost=262144` (256 MB). Use `meshbay-node --calibrate-argon2` for hardware-specific tuning. | Spike 1 |
+| R2 | JWT payload claims: what fields for offline node verification? | `jti` (UUID4), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, group membership claim. `jti` is mandatory (prevents replay, enables revocation). | Spike 3 |
+| R3 | GEK wrapping protocol: exact algorithm? | ECIES-like: ephemeral X25519 + HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1") + ChaCha20-Poly1305(aad=pk_recipient). Hub stores opaque 48-byte blobs. | 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). UPnP unreliable — demoted to step 3. | Spike 4 |
+| R5 | Transport: QUIC or TCP+TLS 1.3 for v1? | TCP+TLS 1.3 for v1 (lower complexity, works everywhere). QUIC for v2 via `Transport` abstraction. | Spike 5 |
+| R6 | Hub API: which endpoints for GEK distribution? | `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek` | Spike 6 |
+| R7 | Package structure? | 3 packages: `meshbay-common`, `meshbay-hub`, `meshbay-node`. uv workspace monorepo. | POC |
+
+**Resolved by first security review (2026-08-10):**
+
+| # | Question | Resolution | Source |
+|---|---|---|---|
+| R8 | Group chat encryption model? | Sender Keys protocol (Signal Groups approach). Double Ratchet kept for future 1:1 DM only. MLS considered for v2 if groups > 50 members. | Security review C1 |
+| R9 | Token denylist distribution? | Push via existing hub→node WebSocket. Node maintains an in-memory jti set. MNP handshake checks the set before accepting a JWT. No periodic polling needed. | Security review S3 |
+| R10 | Chunk key HKDF: salt or info? | `info` (domain separation), `salt=None`. GEK is CSPRNG output (already uniform), so HKDF extract step doesn't need a random salt. Spec wording corrected to match code (RFC 5869 compliant). | Security review M5 |
+| R11 | AES-GCM keystore IV size? | 96-bit (12 bytes), per NIST SP 800-38D recommendation. Code fixed from 128-bit to 96-bit. | Security review S4 |
+
+**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. **Hub mirror replication strategy** (when implemented)
+6. **Chat attachment storage:** stored on node like regular files, or separate store?
+7. **Relay registration protocol design** (when implemented)
+8. **QUIC migration timeline:** when is the application protocol considered stable enough to begin v2 transport implementation?
+9. **Refresh token rotation:** implement one-time-use refresh tokens (rotate on each use, detect reuse as theft indicator). RFC 6819 §5.2.2.3.
+10. **Email encryption at rest:** spec requires encrypted email/phone in DB, implementation stores plaintext. Needs server-side encryption with key from hub config.
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: Architecture Draft v4 (was docs/meshbay-draft-v4.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — Architecture Draft v4
+
+> Status: active development — Phases 1–12 complete (except 10.9 → Phase 13), 191 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, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search), **node sovereignty model** (§4.2.x — node operator is sole content authority, deny-by-default, uploader_id tracking), **cryptographic sovereignty enforcement** (GEK-HMAC handshake challenge, Ed25519 admin challenge-response, gek_req removed), **Phase 12 — P2P crypto material** (GEK+keypair bundles moved off hub to node BundleStore, password split, key persistence in IndexedDB/sessionStorage, DTLS channel binding fix).
+
+---
+
+## 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 |
+| 11 | P2P crypto material | **ALL crypto material moved off hub to P2P channel.** GEK bundles and keypair bundles stored on node (`BundleStore` SQLite), exchanged via MNP DataChannel. Hub `GEKBundle` model and `/gek` endpoint removed. Hub never touches, stores, or proxies any crypto material. | Phase 12 — T3 attack surface reduction |
+| 12 | Password split | Hub receives `auth_key` (PBKDF2-SHA512, auth salt), never raw password. Separate `bundle_key` (PBKDF2-SHA512, bundle salt) encrypts keypair bundles on the node. Hub cannot derive `bundle_key` from `auth_key`. | Phase 12 — T1 |
+| 13 | Node auth | Node daemon authenticates to hub via Ed25519 signed timestamp (`POST /v1/nodes/auth`), not password. JWT `scope: "node"` blocks group mutation endpoints. | Phase 12 — NS7 |
+| 14 | Key persistence | Browser stores `_bundleKey` (CryptoKey) in IndexedDB and `_sessionKeys` in sessionStorage. Survives page refresh without re-login. Public key derived from recovered private key via JWK export (`_pkFromSk`), no hub dependency. | Phase 12 — browser hardening |
+| 15 | DTLS channel binding | Browser saves raw answer SDP before `setRemoteDescription` (Chrome may drop sha-256 fingerprint). GEK-HMAC uses `_rawAnswerSdp` for fingerprint extraction. | Phase 12 — handshake fix |
+
+---
+
+## 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/admin/revoke` | Access token (admin) | Revoke a user or group | ✓ Phase 8 |
+| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens | [TBD] |
+
+**Admin / moderation:**
+
+| Method | Path | Auth | Description | Status |
+|---|---|---|---|---|
+| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) | ✓ Phase 10 |
+| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) | ✓ Phase 10 |
+| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable by username) | ✓ Phase 10 |
+| GET | `/v1/admin/users/{user_id}` | Moderator+ | User detail (email, role, status, group count) | ✓ Phase 10 |
+| PATCH | `/v1/admin/users/{user_id}` | Moderator+ | Update user role or status | ✓ Phase 10 |
+| GET | `/v1/admin/groups` | Moderator+ | List all groups with member count | ✓ Phase 10 |
+| PATCH | `/v1/admin/groups/{group_id}` | Moderator+ | Update group status | ✓ Phase 10 |
+| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user_id) | ✓ Phase 10 |
+| GET | `/v1/admin/blocklist` | Admin | List blocked content hashes | ✓ Phase 8 |
+| POST | `/v1/admin/blocklist` | Admin | Manually block a content hash | ✓ Phase 8 |
+| DELETE | `/v1/admin/blocklist/{hash}` | Admin | Unblock a content hash | ✓ Phase 8 |
+| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) | ✓ Phase 10 |
+| POST | `/v1/notifications/{id}/read` | Access token | Mark notification as read | ✓ Phase 10 |
+| POST | `/v1/notifications/read-all` | Access token | Mark all notifications as read | ✓ Phase 10 |
+| GET | `/v1/groups?q=` | None | Search public groups by name (ilike) | ✓ Phase 10 |
+| GET | `/v1/hub/version` | None | Client version check (hub, MNP, MHP) | ✓ Phase 10 |
+| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) | ✓ Phase 10b |
+| POST | `/v1/groups/{id}/join` | Access token | Self-join open group | ✓ Phase 10b |
+
+### 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.
+
+#### 4.2.x Node Sovereignty — Content Authorization Model
+
+The node operator is the **sole authority** over content stored on their machine.
+No external actor — including the hub admin — can modify, delete, or control
+files on a node they do not operate. This is a non-negotiable design invariant,
+enforced by **cryptography**, not just policy.
+
+**Two trust domains, strictly separated:**
+
+| Domain | Authority | Scope |
+|---|---|---|
+| **Hub** | Hub admin / moderator | User accounts, group registry, group membership, GEK distribution, moderation (suspend user/group at hub level) |
+| **Node** | Node operator | Files on disk, file deletion, upload acceptance, chat storage, who can do what with node content |
+
+The hub certifies **identity** (JWT) and **group membership** (`groups` claim).
+The node decides **authorization for content operations** based on that identity.
+These two concerns must never be conflated.
+
+##### Cryptographic enforcement — two defense layers
+
+A malicious hub admin controls the JWT signing key and could forge JWTs to
+impersonate any user, including the node operator. Policy-only checks (comparing
+`user_id` to `node_user_id`) are insufficient because the hub controls the
+identity layer. Two cryptographic mechanisms make this impossible:
+
+**Layer 1 — GEK proof in handshake (membership verification):**
+
+After JWT verification, the node challenges the connecting user to prove they
+possess the Group Encryption Key (GEK). The hub never has the GEK — it only
+stores opaque ECIES-wrapped bundles. Without the GEK, a hub admin who forges
+a JWT still cannot access any group content.
+
+```
+Client → Node: handshake { token, group_id }
+Node: verify JWT, verify group_id in claims
+ nonce = random(32)
+Node → Client: handshake_challenge { nonce: base64(nonce) }
+Client: proof = HMAC-SHA256(GEK, nonce)
+Client → Node: handshake_response { proof: base64(proof) }
+Node: verify HMAC — if wrong, reject connection
+Node → Client: handshake_ack { is_node_admin, node_pk, v }
+```
+
+This blocks: content reading, index reading, chat reading, file upload, chat
+injection — ALL operations require passing the GEK proof first.
+
+**Layer 2 — Ed25519 challenge-response for admin operations:**
+
+The node operator's Ed25519 public key is pinned locally in `node.toml`
+(auto-pinned from keystore on first startup). Destructive operations (file
+deletion) require the user to sign a random challenge with their Ed25519
+private key. The hub cannot forge this signature.
+
+```
+Client → Node: file_delete { file_id }
+Node: (if uploader → allow immediately)
+ (else) challenge = random(32)
+Node → Client: admin_challenge { challenge: base64(challenge), file_id }
+Client: signature = Ed25519.sign(sk_ed, challenge)
+Client → Node: admin_response { signature: base64(signature), file_id }
+Node: verify(admin_pk_ed25519, signature, challenge)
+ if valid → delete file
+```
+
+**Node configuration — admin key pinning:**
+
+```toml
+# node.toml
+admin_pk_ed25519 = "base64-encoded-32-bytes-raw-Ed25519-public-key"
+```
+
+Auto-pinned from the node operator's keystore on first startup. The daemon
+logs: "Admin Ed25519 key pinned for node sovereignty".
+
+**GEK distribution — browser flow (node no longer serves GEK):**
+
+The node NEVER serves the GEK in plaintext. Browser clients obtain the GEK
+from their hub-stored encrypted bundle:
+
+1. `GET /v1/groups/{id}/gek` → encrypted ECIES bundle (AES-256-GCM variant)
+2. Browser unwraps with its X25519 private key (from keypair bundle)
+3. Browser uses raw GEK bytes for the handshake HMAC proof
+4. Browser imports GEK as HKDF key for chunk decryption
+
+This eliminates the `gek_req`/`gek_resp` MNP messages from the protocol.
+
+**Authorization rules for destructive file operations (enforced by the node):**
+
+| Action | Who can do it | Enforcement point |
+|---|---|---|
+| Delete a file | Node operator (Ed25519 challenge-response) OR the user who uploaded it | Node (`_do_file_delete`) |
+| Delete any file | Node operator only (Ed25519 challenge-response) | Node (`_do_file_delete`) |
+
+Default posture: **deny.** If the admin key is not pinned, all admin operations
+are refused. If the GEK proof fails, the connection is refused entirely.
+
+**Protocol enforcement — MNP handshake_ack:**
+
+The handshake_ack message carries `is_node_admin: bool` — the node tells the
+client whether the authenticated user is the node operator. Clients MUST use
+this node-reported flag (not the hub's `group.admin_id`) to decide whether
+to show destructive operations like file deletion.
+
+```
+handshake_ack:
+ v: "0.1"
+ node_pk: "<base64>"
+ is_node_admin: true | false # node-side authorization, NOT hub-side
+```
+
+**Index entry — uploader tracking:**
+
+Each `IndexEntry` carries an `uploader_id` field (user_id of who uploaded the
+file, or null for files that pre-existed on disk). This enables the "uploader
+can delete their own files" rule without granting node-admin privileges.
+
+**What the hub admin CANNOT do on a node they don't operate:**
+- Delete files (requires Ed25519 key pinned on node — hub can't forge)
+- Read files (requires GEK — hub never has it)
+- Read index / chat (requires GEK proof in handshake)
+- Upload files (requires GEK proof in handshake)
+- Impersonate the node operator (JWT forgery blocked by Ed25519 challenge)
+
+**What the hub admin CAN do (hub-level only):**
+- Suspend a user account (blocks JWT issuance → user loses access everywhere)
+- Suspend a group (blocks signaling → no new P2P connections to nodes for that group)
+- These are hub-level actions that don't touch node content
+
+**Remaining trust assumptions:**
+- The hub serves the SPA code to browsers (a malicious hub could inject JS — fundamentally unsolvable in browser; native client or browser extension required for full integrity)
+- The hub relays WebRTC signaling — ✅ MITIGATED: DTLS channel binding in GEK-HMAC proof (`HMAC(GEK, nonce || offer_fp || answer_fp)`) detects fingerprint substitution (MitM)
+- The hub receives raw password at login — ✅ MITIGATED: password split (auth_key ≠ bundle_key, independent PBKDF2 derivations). Hub receives auth_key only, cannot derive bundle_key to decrypt keypair bundle. Legacy accounts migrated on first login.
+- The hub controls public key distribution — can substitute keys during invite to intercept GEK. Fix: out-of-band key verification (safety numbers) — Phase 12
+
+> **Design lesson (2026-08-12):** The initial implementation conflated hub
+> `group.admin_id` (who created the group on the hub) with node operator
+> authority (who runs the machine). The SPA used the hub's `is_admin` flag
+> to show file deletion controls, and the node's delete handler used a
+> fail-open check (`if node_user_id and ...` — allowed everyone when
+> `node_user_id` was not set). Both violated node sovereignty. Fixed by:
+> (1) deny-by-default on the node, (2) `is_node_admin` in handshake_ack,
+> (3) `uploader_id` tracking in the index, (4) SPA uses node-reported
+> permissions only. Then hardened with cryptographic enforcement:
+> (5) GEK-HMAC proof in handshake (blocks forged-JWT access),
+> (6) Ed25519 challenge-response for admin ops (blocks identity impersonation),
+> (7) removal of `gek_req` endpoint (node never serves GEK in plaintext),
+> (8) DTLS channel binding in GEK-HMAC proof to detect WebRTC signaling MitM,
+> (9) chat `sender_id` fixed to authenticated identity (prevents impersonation),
+> (10) Ed25519 challenge for ALL file deletions — uploaders verified by stored pk, not JWT sub,
+> (11) password split — hub receives PBKDF2 auth_key, never raw password (cannot derive bundle_key).
+
+**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 |
+| Node admin | The hosting node operator — sovereign over content, sole delete authority (see §4.2.x) |
+| Hub group creator | The user who registered the group on the hub — manages membership and GEK distribution |
+
+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 |
+| `handshake_challenge` | Node sends GEK proof nonce (base64, 32 bytes random) — see §4.2.x |
+| `handshake_response` | Client proves GEK possession: HMAC-SHA256(GEK, nonce) |
+| `handshake_ack` | Node response: version, node public key, `is_node_admin` (node-level authorization) |
+| `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 |
+| `file_delete` | Client requests file deletion by file_id |
+| `file_delete_ack` | Node confirms deletion |
+| `file_upload` | Client pushes file chunk to node |
+| `file_upload_ack` | Node acknowledges chunk receipt |
+| `admin_challenge` | Node sends Ed25519 sign challenge for admin ops (base64, 32 bytes) |
+| `admin_response` | Client returns Ed25519 signature over the challenge |
+| `stream_request` | Client requests MSE video stream |
+| `stream_init` | Node sends codec info + signals stream start |
+| `stream_data` | Node sends encrypted fMP4 segment |
+| `stream_end` | Node signals end of stream |
+| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key |
+| `chat_message` | Sender Keys encrypted message frame (group chat) |
+| `chat_history` | Client requests chat history |
+| `chat_history_response` | Node responds with stored messages |
+| `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,
+ "uploader_id": "<user_id>" # who uploaded this file (null = pre-existing on disk)
+}
+```
+
+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
+- Delete: node operator can delete any file; uploader can delete their own uploads.
+ Hub admin has NO delete authority on nodes they don't operate (see §4.2.x).
+
+**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 |
+
+**Resolved by node sovereignty fix (2026-08-12):**
+
+| # | Question | Resolution | Source |
+|---|---|---|---|
+| R21 | Who authorizes file deletion on a node? | Node operator (sovereign) OR original uploader. Hub admin has no authority over node content. Enforced: deny-by-default in MNP `file_delete`, `is_node_admin` in handshake_ack, `uploader_id` in IndexEntry. | Security fix — §4.2.x |
+| R22 | Can a malicious hub admin access node content? | No. Two cryptographic layers: (1) GEK-HMAC proof in handshake — hub never has the GEK, can't pass the challenge. (2) Ed25519 challenge-response for admin ops — hub can't forge the node operator's signature. `gek_req` endpoint removed. | Crypto enforcement — §4.2.x |
+
+**Resolved by Phase 12 — P2P crypto material (2026-08-13):**
+
+| # | Question | Resolution | Source |
+|---|---|---|---|
+| R23 | Where are GEK bundles stored? | On node only (`BundleStore` SQLite, `data_dir/bundles.db`). Hub `GEKBundle` model removed. Exchanged via MNP `gek_bundle_store`/`gek_bundle_fetch`/`gek_bundle_resp` over WebRTC DataChannel. | Phase 12 — T3 |
+| R24 | Where are keypair bundles stored? | On node only (`BundleStore`). Encrypted with password-derived AES key (`bundle_key`). Browser pushes after registration, recovers during handshake. Hub `keypair_bundle` column removed. | Phase 12 — T3 |
+| R25 | How does the browser recover keys after localStorage cleared? | Transport fetches `keypair_bundle` from node during handshake, decrypts with `_bundleKey` (PBKDF2 from password). Public key derived from private key via JWK export — no hub fetch needed. `_bundleKey` persisted in IndexedDB, `_sessionKeys` in sessionStorage. | Phase 12 |
+| R26 | How does the browser handle Chrome SDP re-serialization? | `this._rawAnswerSdp = answer.sdp` saved before `setRemoteDescription`. DTLS fingerprint extracted from raw SDP, not `pc.remoteDescription.sdp` (Chrome may drop sha-256 line when re-serializing multi-hash SDP from aiortc). | Phase 12 |
+| R27 | Should the browser auto-regenerate keys on login? | No. Auto-regeneration silently rotates hub keys, breaking GEK unwrap (GEK wrapped for old keys). Keys recovered from node via `_bundleKey`. Regeneration only on explicit user request. | Phase 12 |
+
+**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? → Resolved: always on 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)
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: POC v1 (was docs/poc-v1.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — POC v1
+
+> Goal: validate key concepts before committing to a full implementation.
+> Scope: Hub/Node exchange in Python, crypto stack, NAT traversal, encrypted file chunk transfer.
+> Everything in-memory (no database), minimal code, TCP only (no QUIC yet).
+
+---
+
+## Environment
+
+### Remote — meshbay.org (Hub)
+- OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4
+- Public fixed IP, ports 80 and 443 open
+- Clean slate: no web server installed
+- SSH access: `ssh cbesson@meshbay.org`
+
+### Local — Fedora 44 (Node)
+- Laptop behind SFR residential NAT (likely Restricted Cone NAT — UPnP supported)
+- Python 3.13+ via system packages
+- User: `cbesson` (sudoer, no password)
+
+---
+
+## Python Dependencies
+
+```bash
+# Shared (hub and node)
+cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id
+PyJWT>=2.9 # JWT with EdDSA (Ed25519) support
+blake3>=1.0 # Fast content hashing
+
+# Hub only (meshbay.org)
+fastapi>=0.115
+uvicorn[standard]>=0.30
+
+# Node only (Fedora laptop)
+httpx>=0.28 # Async HTTP client for hub→node calls
+aioice>=0.9 # STUN queries for NAT discovery
+miniupnpc>=2.2 # UPnP port mapping on SFR box
+```
+
+Install on each machine:
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install <packages above>
+```
+
+---
+
+## Hub Setup on meshbay.org
+
+For the POC, uvicorn runs directly on port 80 via iptables redirect (no Caddy/nginx needed yet — HTTPS added before production).
+
+```bash
+# On meshbay.org
+# Redirect port 80 → 8000 (persistent via iptables-save if needed)
+sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
+
+# Run hub (from poc directory, venv activated)
+uvicorn hub:app --host 127.0.0.1 --port 8000 --reload
+```
+
+> Note: HTTPS (via Caddy + Let's Encrypt) is mandatory before any data beyond this POC. Not in scope here.
+
+---
+
+## Spike Overview
+
+| # | Name | Where | Validates | Duration |
+|---|---|---|---|---|
+| 1 | Crypto primitives | Local | Python crypto stack covers all needs | ~1h |
+| 2 | Hub skeleton | meshbay.org | Hub API, JWT issuance | ~2h |
+| 3 | Node registration | Fedora | Hub-Node handshake, JWT offline verify | ~1h |
+| 4 | NAT traversal | Both | SFR box UPnP + STUN, P2P reachability | ~2h |
+| 5 | Encrypted transfer | Both | On-the-fly GEK encryption, P2P chunk | ~2h |
+
+---
+
+## Spike 1 — Crypto Primitives (local only)
+
+**Goal:** confirm `cryptography` (PyCA) covers all MeshBay cryptographic needs without gaps or performance surprises.
+
+**File:** `spike1_crypto.py`
+
+**What to test:**
+
+```python
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id # PyCA 43+
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3, os, time
+```
+
+**Test 1: Ed25519 — hub keypair, sign JWT payload, verify**
+```python
+sk_hub = Ed25519PrivateKey.generate()
+pk_hub = sk_hub.public_key()
+msg = b"test payload"
+sig = sk_hub.sign(msg)
+pk_hub.verify(sig, msg) # raises if invalid
+print("Ed25519 OK")
+```
+
+**Test 2: X25519 — two-party key agreement for GEK wrapping**
+```python
+sk_a = X25519PrivateKey.generate()
+sk_b = X25519PrivateKey.generate()
+shared_a = sk_a.exchange(sk_b.public_key())
+shared_b = sk_b.exchange(sk_a.public_key())
+assert shared_a == shared_b
+print("X25519 OK")
+```
+
+**Test 3: GEK derivation and ChaCha20-Poly1305 on a 1 MB chunk**
+```python
+gek = ChaCha20Poly1305.generate_key()
+cipher = ChaCha20Poly1305(gek)
+chunk = os.urandom(1024 * 1024) # 1 MB
+
+t0 = time.perf_counter()
+nonce = os.urandom(12)
+ct = cipher.encrypt(nonce, chunk, None)
+pt = cipher.decrypt(nonce, ct, None)
+elapsed = time.perf_counter() - t0
+
+assert pt == chunk
+print(f"ChaCha20-Poly1305 1MB: {elapsed*1000:.1f} ms")
+```
+
+**Test 4: HKDF chunk key derivation**
+```python
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes
+chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0"
+).derive(gek)
+print(f"HKDF derived key: {chunk_key.hex()[:16]}...")
+```
+
+**Test 5: Argon2id keystore key derivation**
+```python
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+salt = os.urandom(16)
+t0 = time.perf_counter()
+kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+key = kdf.derive(b"mypassword")
+print(f"Argon2id: {(time.perf_counter()-t0)*1000:.0f} ms, key: {key.hex()[:16]}...")
+```
+
+**Test 6: PyJWT with Ed25519 (EdDSA)**
+```python
+import jwt
+sk_hub_pem = sk_hub.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()
+)
+pk_hub_pem = pk_hub.public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo
+)
+payload = {"sub": "user_abc", "pk_user": "base64...", "exp": 9999999999}
+token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA")
+decoded = jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"])
+assert decoded["sub"] == "user_abc"
+print("JWT EdDSA OK")
+```
+
+**Success criteria:** all tests pass, ChaCha20 1MB < 20ms, Argon2id ~1s.
+
+---
+
+## Spike 2 — Hub Skeleton (meshbay.org)
+
+**Goal:** minimal FastAPI hub, in-memory storage, 5 endpoints.
+
+**File:** `hub.py` (on meshbay.org)
+
+### Hub keypair generation (run once, save to disk)
+
+```python
+# gen_hub_keys.py — run once on meshbay.org
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+import base64, json
+
+sk = Ed25519PrivateKey.generate()
+pk = sk.public_key()
+
+with open("hub_private.pem", "wb") as f:
+ f.write(sk.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption()
+ ))
+with open("hub_public.pem", "wb") as f:
+ f.write(pk.public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo
+ ))
+print("Hub keypair generated.")
+```
+
+### Hub API (`hub.py`)
+
+```python
+from fastapi import FastAPI, HTTPException, Depends, Header
+from pydantic import BaseModel
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+import jwt, uuid, os, time, base64
+
+app = FastAPI(title="MeshBay Hub POC")
+
+# Load hub keypair
+with open("hub_private.pem", "rb") as f:
+ HUB_SK_PEM = f.read()
+with open("hub_public.pem", "rb") as f:
+ HUB_PK_PEM = f.read()
+
+HUB_ID = "meshbay.org"
+ACCESS_TOKEN_TTL = 3600 # 1 hour
+REFRESH_TOKEN_TTL = 86400 * 30 # 30 days
+
+# In-memory stores (POC only — not persistent)
+users = {} # username → {user_id, pw_hash, pw_salt, pk_ed25519, pk_x25519}
+nodes = {} # node_id → {user_id, pk_node, endpoint_hint, registered_at}
+refresh_tokens = {} # token → user_id
+
+# --- Models ---
+
+class UserRegister(BaseModel):
+ username: str
+ password: str
+ pk_user_ed25519: str # base64
+ pk_user_x25519: str # base64
+
+class UserLogin(BaseModel):
+ username: str
+ password: str
+
+class NodeAnnounce(BaseModel):
+ pk_node: str # base64 Ed25519 public key
+ endpoint_hint: str | None = None # "ip:port" or null
+
+# --- Helpers ---
+
+def hash_password(password: str) -> tuple[bytes, bytes]:
+ salt = os.urandom(16)
+ kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+ return kdf.derive(password.encode()), salt
+
+def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
+ kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
+ try:
+ kdf.verify(password.encode(), pw_hash)
+ return True
+ except Exception:
+ return False
+
+def issue_access_token(user: dict) -> str:
+ payload = {
+ "iss": HUB_ID,
+ "sub": user["user_id"],
+ "pk_user": user["pk_ed25519"],
+ "hub_id": HUB_ID,
+ "iat": int(time.time()),
+ "exp": int(time.time()) + ACCESS_TOKEN_TTL,
+ }
+ return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA")
+
+def get_current_user(authorization: str = Header(...)) -> dict:
+ try:
+ scheme, token = authorization.split()
+ if scheme.lower() != "bearer":
+ raise ValueError
+ payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"])
+ user_id = payload["sub"]
+ user = next((u for u in users.values() if u["user_id"] == user_id), None)
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+ return user
+ except Exception:
+ raise HTTPException(status_code=401, detail="Invalid token")
+
+# --- Endpoints ---
+
+@app.get("/v1/hub/info")
+def hub_info():
+ return {
+ "hub_id": HUB_ID,
+ "pk_hub_ed25519": base64.b64encode(
+ Ed25519PrivateKey.from_private_bytes(
+ # shortcut for POC — load pk directly
+ open("hub_public.pem","rb").read()
+ ).public_bytes(...) # see note below
+ ).decode(),
+ "mnp_version": "0.1",
+ "mhp_version": "0.1",
+ }
+ # Note: return pk_hub_pem directly for POC, nodes store it on first contact
+
+@app.get("/v1/hub/pubkey")
+def hub_pubkey():
+ """Return hub Ed25519 public key PEM — cached by nodes on first contact."""
+ return {"pk_hub_pem": HUB_PK_PEM.decode()}
+
+@app.post("/v1/users/register", status_code=201)
+def register(body: UserRegister):
+ if body.username in users:
+ raise HTTPException(status_code=409, detail="Username taken")
+ pw_hash, pw_salt = hash_password(body.password)
+ user_id = str(uuid.uuid4())
+ users[body.username] = {
+ "user_id": user_id,
+ "username": body.username,
+ "pw_hash": pw_hash,
+ "pw_salt": pw_salt,
+ "pk_ed25519": body.pk_user_ed25519,
+ "pk_x25519": body.pk_user_x25519,
+ }
+ return {"user_id": user_id}
+
+@app.post("/v1/users/login")
+def login(body: UserLogin):
+ user = users.get(body.username)
+ if not user or not verify_password(body.password, user["pw_hash"], user["pw_salt"]):
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ access_token = issue_access_token(user)
+ refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode()
+ refresh_tokens[refresh_token] = user["user_id"]
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "token_type": "bearer",
+ "expires_in": ACCESS_TOKEN_TTL,
+ }
+
+@app.post("/v1/users/token/refresh")
+def refresh(body: dict):
+ rt = body.get("refresh_token", "")
+ user_id = refresh_tokens.get(rt)
+ if not user_id:
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
+ user = next((u for u in users.values() if u["user_id"] == user_id), None)
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+ return {"access_token": issue_access_token(user), "token_type": "bearer"}
+
+@app.post("/v1/nodes/announce", status_code=201)
+def announce_node(body: NodeAnnounce, user: dict = Depends(get_current_user)):
+ node_id = str(uuid.uuid4())
+ nodes[node_id] = {
+ "node_id": node_id,
+ "user_id": user["user_id"],
+ "pk_node": body.pk_node,
+ "endpoint_hint": body.endpoint_hint,
+ "announced_at": int(time.time()),
+ }
+ return {"node_id": node_id}
+
+@app.get("/v1/nodes/{node_id}")
+def get_node(node_id: str, user: dict = Depends(get_current_user)):
+ node = nodes.get(node_id)
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+ return {
+ "node_id": node["node_id"],
+ "pk_node": node["pk_node"],
+ "endpoint_hint": node["endpoint_hint"],
+ }
+```
+
+**Success criteria:**
+- Hub starts, all 6 endpoints respond correctly
+- `GET /v1/hub/pubkey` returns the PEM
+- `POST /v1/users/register` + `POST /v1/users/login` returns a valid JWT
+- JWT verified by `jwt.decode()` with hub public key — passes
+
+---
+
+## Spike 3 — Node Registration (Fedora laptop)
+
+**Goal:** node generates its keypair, registers a user on the hub, gets a JWT, and verifies it locally without contacting the hub again.
+
+**File:** `node.py`
+
+```python
+import httpx, asyncio, jwt, base64, os
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+HUB_URL = "http://meshbay.org" # HTTP for POC, HTTPS later
+
+async def main():
+ async with httpx.AsyncClient() as client:
+
+ # 1. Fetch hub public key (first contact — cache this)
+ r = await client.get(f"{HUB_URL}/v1/hub/pubkey")
+ hub_pk_pem = r.json()["pk_hub_pem"].encode()
+ print(f"[node] Hub PK fetched ({len(hub_pk_pem)} bytes)")
+
+ # 2. Generate node identity keypairs
+ sk_ed = Ed25519PrivateKey.generate()
+ pk_ed = sk_ed.public_key()
+ sk_x = X25519PrivateKey.generate()
+ pk_x = sk_x.public_key()
+
+ pk_ed_b64 = base64.b64encode(
+ pk_ed.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ ).decode()
+ pk_x_b64 = base64.b64encode(
+ pk_x.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ ).decode()
+
+ # 3. Register user (skip if already registered)
+ r = await client.post(f"{HUB_URL}/v1/users/register", json={
+ "username": "testnode",
+ "password": "testpass123",
+ "pk_user_ed25519": pk_ed_b64,
+ "pk_user_x25519": pk_x_b64,
+ })
+ print(f"[node] Register: {r.status_code} {r.text}")
+
+ # 4. Login, get access token
+ r = await client.post(f"{HUB_URL}/v1/users/login", json={
+ "username": "testnode",
+ "password": "testpass123",
+ })
+ data = r.json()
+ access_token = data["access_token"]
+ print(f"[node] Login OK, token: {access_token[:40]}...")
+
+ # 5. Verify JWT locally — NO hub roundtrip
+ decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
+ print(f"[node] JWT verified locally: sub={decoded['sub']}, exp={decoded['exp']}")
+
+ # 6. Announce node to hub
+ r = await client.post(
+ f"{HUB_URL}/v1/nodes/announce",
+ json={"pk_node": pk_ed_b64, "endpoint_hint": None},
+ headers={"Authorization": f"Bearer {access_token}"}
+ )
+ node_id = r.json()["node_id"]
+ print(f"[node] Node announced: {node_id}")
+
+asyncio.run(main())
+```
+
+**Success criteria:**
+- Node registers, logs in, receives JWT
+- JWT decoded offline using only the hub's public key — no hub call
+- Node announced; `GET /v1/nodes/{node_id}` from hub returns correct PK
+
+---
+
+## Spike 4 — NAT Traversal (both machines)
+
+**Goal:** discover the local node's external IP:port via STUN and UPnP; test reachability from meshbay.org.
+
+**File:** `spike4_nat.py` (Fedora laptop)
+
+### Part A — UPnP (try first, most reliable on SFR box)
+
+```python
+import miniupnpc
+import socket
+
+def try_upnp(internal_port=19000):
+ u = miniupnpc.UPnP()
+ u.discoverdelay = 200
+ ndevices = u.discover()
+ if ndevices == 0:
+ print("UPnP: no IGD found")
+ return None
+
+ u.selectigd()
+ external_ip = u.externalipaddress()
+ local_ip = socket.gethostbyname(socket.gethostname())
+
+ result = u.addportmapping(
+ internal_port, 'TCP', local_ip, internal_port,
+ 'MeshBay POC', ''
+ )
+ if result:
+ print(f"UPnP: mapped {external_ip}:{internal_port} → {local_ip}:{internal_port}")
+ return f"{external_ip}:{internal_port}"
+ else:
+ print("UPnP: mapping failed")
+ return None
+```
+
+### Part B — STUN discovery
+
+```python
+import asyncio
+import aioice
+
+async def stun_discover(local_port=19001):
+ # Use Cloudflare STUN server
+ stun_servers = [("stun.cloudflare.com", 3478), ("stun.l.google.com", 19302)]
+
+ connection = aioice.Connection(ice_controlling=True, stun_server=stun_servers[0])
+ await connection.gather_candidates()
+
+ for candidate in connection.local_candidates:
+ if candidate.type == "srflx": # server-reflexive = external address
+ print(f"STUN srflx: {candidate.host}:{candidate.port}")
+ return f"{candidate.host}:{candidate.port}"
+
+ print("STUN: no srflx candidate found (may be symmetric NAT)")
+ return None
+```
+
+### Part C — Reachability test from meshbay.org
+
+Once the node has an external address (from UPnP or STUN), it announces it to the hub (`endpoint_hint`). Then from meshbay.org:
+
+```bash
+# On meshbay.org — manually test TCP reachability
+nc -zv <external_ip> <external_port>
+# or
+python3 -c "import socket; s=socket.create_connection(('<external_ip>', <port>), timeout=5); print('REACHABLE'); s.close()"
+```
+
+And on the Fedora node, a simple listener:
+```python
+# On Fedora, open a listener on the discovered port
+import socket
+s = socket.socket()
+s.bind(('', 19000))
+s.listen(1)
+print("Listening on 19000...")
+conn, addr = s.accept()
+print(f"Connection from {addr}")
+conn.sendall(b"HELLO FROM NODE\n")
+conn.close()
+```
+
+**Expected outcomes on SFR residential:**
+
+| Method | Expected result | Confidence |
+|---|---|---|
+| UPnP | Works — SFR La Box supports UPnP IGD | High |
+| STUN srflx | Discovered — SFR is cone NAT for residential | High |
+| Direct TCP from meshbay.org | Works if UPnP succeeded | High |
+| Hole punching only | Depends on NAT type discovered | Medium |
+
+**Success criteria:** at least one method allows meshbay.org to reach the Fedora node's port directly.
+
+---
+
+## Spike 5 — Encrypted File Transfer (both machines)
+
+**Goal:** node serves an encrypted file chunk via direct P2P TCP connection; client decrypts and verifies.
+
+**Prerequisite:** Spike 4 succeeded — external IP:port is known and reachable.
+
+**File:** `spike5_server.py` (Fedora), `spike5_client.py` (meshbay.org)
+
+### Node side — serve one encrypted chunk
+
+```python
+# spike5_server.py — Fedora laptop
+import asyncio, os, base64
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3, struct, json
+
+# Keypair (reuse from Spike 3 or generate here)
+sk_node = Ed25519PrivateKey.generate()
+pk_node_bytes = sk_node.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
+)
+
+# Generate GEK (in a real system, loaded from keystore)
+gek_raw = ChaCha20Poly1305.generate_key()
+cipher = ChaCha20Poly1305(gek_raw)
+
+CHUNK_SIZE = 1024 * 1024 # 1 MB
+
+def make_chunk(file_path: str, chunk_index: int) -> bytes:
+ """Read, compress (skipped for POC), encrypt, sign a chunk."""
+ with open(file_path, "rb") as f:
+ f.seek(chunk_index * CHUNK_SIZE)
+ data = f.read(CHUNK_SIZE)
+
+ file_hash = blake3.blake3(open(file_path, "rb").read()).digest()
+
+ # Per-chunk key derivation
+ chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big")
+ ).derive(gek_raw)
+ chunk_cipher = ChaCha20Poly1305(chunk_key)
+
+ nonce = os.urandom(12)
+ ct = chunk_cipher.encrypt(nonce, data, None)
+ chunk_hash = blake3.blake3(ct).digest()
+
+ # Sign: chunk_index + nonce + ciphertext_hash
+ sig_payload = chunk_index.to_bytes(4, "big") + nonce + chunk_hash
+ sig = sk_node.sign(sig_payload)
+
+ return json.dumps({
+ "chunk_index": chunk_index,
+ "nonce": base64.b64encode(nonce).decode(),
+ "ciphertext": base64.b64encode(ct).decode(),
+ "chunk_hash": base64.b64encode(chunk_hash).decode(),
+ "signature": base64.b64encode(sig).decode(),
+ "pk_node": base64.b64encode(pk_node_bytes).decode(),
+ "gek_hint": base64.b64encode(gek_raw).decode(), # POC: send GEK in band — never in production!
+ }).encode()
+
+async def handle_client(reader, writer):
+ request = await reader.read(1024)
+ req = json.loads(request)
+ chunk_index = req.get("chunk_index", 0)
+ file_path = req.get("file", "testfile.bin")
+
+ print(f"[node] Client requests chunk {chunk_index} of {file_path}")
+ chunk_data = make_chunk(file_path, chunk_index)
+
+ writer.write(len(chunk_data).to_bytes(4, "big") + chunk_data)
+ await writer.drain()
+ writer.close()
+ print(f"[node] Chunk {chunk_index} sent ({len(chunk_data)} bytes)")
+
+async def main():
+ # Create a 5MB test file
+ if not os.path.exists("testfile.bin"):
+ with open("testfile.bin", "wb") as f:
+ f.write(os.urandom(5 * 1024 * 1024))
+ print("[node] Test file created (5 MB)")
+
+ server = await asyncio.start_server(handle_client, "0.0.0.0", 19000)
+ print("[node] Serving on port 19000 — waiting for client...")
+ async with server:
+ await server.serve_forever()
+
+asyncio.run(main())
+```
+
+### Client side — request, verify, decrypt
+
+```python
+# spike5_client.py — meshbay.org
+import asyncio, base64, json
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives import hashes, serialization
+import blake3
+
+NODE_HOST = "<external_ip>" # from Spike 4
+NODE_PORT = 19000
+
+async def main():
+ reader, writer = await asyncio.open_connection(NODE_HOST, NODE_PORT)
+
+ # Request chunk 0
+ request = json.dumps({"file": "testfile.bin", "chunk_index": 0}).encode()
+ writer.write(request)
+ await writer.drain()
+
+ # Receive
+ length_bytes = await reader.readexactly(4)
+ length = int.from_bytes(length_bytes, "big")
+ data = await reader.readexactly(length)
+ writer.close()
+
+ chunk = json.loads(data)
+ print(f"[client] Received chunk {chunk['chunk_index']}")
+
+ # 1. Verify signature
+ pk_node_bytes = base64.b64decode(chunk["pk_node"])
+ pk_node = Ed25519PublicKey.from_public_bytes(pk_node_bytes)
+ ct = base64.b64decode(chunk["ciphertext"])
+ nonce = base64.b64decode(chunk["nonce"])
+ chunk_hash = base64.b64decode(chunk["chunk_hash"])
+ sig = base64.b64decode(chunk["signature"])
+
+ sig_payload = (0).to_bytes(4, "big") + nonce + chunk_hash
+ pk_node.verify(sig, sig_payload) # raises on failure
+ print("[client] Signature OK")
+
+ # 2. Verify ciphertext hash
+ assert blake3.blake3(ct).digest() == chunk_hash
+ print("[client] Ciphertext hash OK")
+
+ # 3. Derive chunk key and decrypt (GEK from POC hint — never in production)
+ gek_raw = base64.b64decode(chunk["gek_hint"])
+ # (in production, client has GEK from hub's GEK bundle)
+ chunk_key = HKDF(
+ algorithm=hashes.SHA256(), length=32, salt=None,
+ info=b"file:" + bytes(32) + b":chunk:" + (0).to_bytes(4, "big")
+ # Note: in production, file_hash is sent separately or in index
+ ).derive(gek_raw)
+ plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ct, None)
+ print(f"[client] Decrypted {len(plaintext)} bytes")
+ print("[client] Encrypted P2P transfer: SUCCESS")
+
+asyncio.run(main())
+```
+
+**Note on GEK in POC:** the GEK is included in the response as `gek_hint` for POC convenience only. In production, the client gets the GEK from the hub's encrypted GEK bundle (delivered at login, decrypted client-side with the user's X25519 private key).
+
+**Success criteria:**
+- Client receives chunk from node via direct TCP connection
+- Signature verification passes
+- Ciphertext hash matches
+- Decryption produces the original bytes
+- End-to-end: `original_bytes == decrypted_bytes` ✓
+
+---
+
+## What POC Validates (and Doesn't)
+
+### Validated by these spikes
+
+| Concept | Spike | Validation |
+|---|---|---|
+| Python crypto stack is sufficient | 1 | All primitives work, performance acceptable |
+| Hub/Node JWT handshake | 2, 3 | JWT issued by hub, verified offline by node |
+| Hub-Node REST protocol (minimal MNP/HTTP) | 2, 3 | API contract works end-to-end |
+| SFR NAT traversal via UPnP | 4 | P2P reachability confirmed |
+| STUN external address discovery | 4 | Confirmed/fallback documented |
+| On-the-fly per-chunk encryption | 5 | GEK + HKDF chunk derivation + ChaCha20 |
+| Chunk signature and verification | 5 | Ed25519 sign/verify before decryption |
+| Real P2P file transfer | 5 | No hub in data path |
+
+### NOT in scope
+
+- Database (all in-memory)
+- HTTPS / TLS (HTTP for POC)
+- QUIC transport (plain TCP)
+- GEK bundle distribution via hub (GEK sent in-band for POC)
+- Group management
+- Chat / Double Ratchet
+- Mesh Group Index
+- MHP federation
+- Android client
+- Module system
+- Persistence between restarts
+
+---
+
+## Spike Order Dependency Graph
+
+```
+Spike 1 (crypto)
+ └──→ Spike 2 (hub skeleton)
+ └──→ Spike 3 (node registration)
+ └──→ Spike 4 (NAT traversal)
+ └──→ Spike 5 (encrypted transfer)
+```
+
+Spike 1 is a prerequisite for all others. Spikes 2 and 3 can overlap if two people work in parallel. Spike 4 can begin independently once Spike 3 is running.
+
+
+---
+
+<!-- ============================================================ -->
+<!-- ARCHIVED: Development Phases (1-12) (was docs/devel-phases.md) -->
+<!-- ============================================================ -->
+
+# MeshBay — Development Phases
+
+> Reference: architecture spec in draft v3 (section 3 of this archive)
+> POC results: `poc/spike-results.md`
+
+---
+
+## Phase 1 — Foundations ✅ DONE
+
+**Goal:** validate all blocking concepts before writing production code.
+
+### Deliverables
+
+| Item | Status | Notes |
+|---|---|---|
+| POC Spike 1 — Crypto primitives | ✅ | All 22 tests pass. ChaCha20 1MB in 1.1ms. |
+| POC Spike 2 — Hub skeleton | ✅ | 12/12 endpoints. JWT EdDSA offline verify in 884µs. |
+| POC Spike 3 — Node registration | ✅ | Full handshake. jti bug found and fixed. |
+| POC Spike 4 — NAT traversal | ✅ | Cone NAT on SFR. UDP P2P works. UPnP disabled (SFR). |
+| POC Spike 5 — Encrypted transfer | ✅ | 1MB P2P. 3.2ms encrypt, 3.9ms decrypt. 4.3MB/s. |
+| POC Spike 6 — GEK distribution | ✅ | X25519+HKDF wrap/unwrap. 0.48ms/0.59ms. Hub opaque. |
+| Security cleanup meshbay.org | ✅ | UFW: 22/80/443 only. No services exposed. |
+| Git monorepo | ✅ | 3 packages: meshbay-common, meshbay-hub, meshbay-node. |
+| Draft v3 | ✅ | POC findings integrated. All corrections applied. |
+| CLAUDE.md conventions | ✅ | Python 3.12+, uv, ruff, SemVer, commit format. |
+
+### Key findings from POC
+
+- jti mandatory in all JWTs (Ed25519 is deterministic — same payload = same token)
+- Argon2id at 64MB/3iter = 78ms — increase to 256MB for production (~500ms target)
+- NAT order: STUN/hole-punching is priority 2, not UPnP (UPnP disabled on tested SFR box)
+- TCP+TLS for v1 transport; QUIC in v2
+- GEK wrapping: ephemeral X25519 + HKDF(salt=pk_eph) + ChaCha20-Poly1305(aad=pk_recipient)
+
+---
+
+## Phase 2 — Node v1 ✅ DONE
+
+**Goal:** working Mesh Node: indexes a directory, registers with hub,
+serves encrypted chunks over TCP+TLS, local web UI on localhost:18000.
+
+**Transport:** TCP+TLS 1.3 (QUIC in v2). Self-signed cert per node.
+Node identity verified via Ed25519 PK from hub, not TLS cert chain (client uses CERT_NONE).
+
+### Milestones
+
+| # | Component | File(s) | Tests |
+|---|---|---|---|
+| 2.1 | Keystore | `meshbay_node/keystore.py` | 10/10 |
+| 2.2 | Hub client | `meshbay_node/hub_client.py` | 6/6 |
+| 2.3 | Directory indexer | `meshbay_node/indexer/indexer.py` | 5/5 |
+| 2.4 | Mesh Group Index | `meshbay_node/indexer/group_index.py` | 5/5 |
+| 2.5 | TCP+TLS chunk server | `meshbay_node/transport/server.py` | 3/3 |
+| 2.6 | TCP+TLS chunk client | `meshbay_node/transport/client.py` | included above |
+| 2.7 | TLS cert helper | `meshbay_node/transport/tls_cert.py` | — |
+| 2.8 | Config (TOML + env) | `meshbay_node/config.py` | — |
+| 2.9 | Local web UI | `meshbay_node/ui/app.py` | — |
+| 2.10 | Daemon + CLI | `meshbay_node/daemon.py` | — |
+
+**Total: 29/29 tests passing**
+
+### Python dependencies (dev venv — `/home/cbesson/meshbay/.venv`)
+
+Installed packages (freeze) as of Phase 2 completion:
+
+```
+aioice==0.10.2 # ICE/STUN for NAT traversal
+blake3==1.0.9 # fast content hashing
+cryptography==50.0.0 # Ed25519, X25519, ChaCha20, Argon2id, AES-GCM
+fastapi==0.141.1 # local web UI + hub POC
+httpx==0.28.1 # hub client HTTP
+meshbay-common==0.1.0 # editable install
+meshbay-node==0.1.0 # editable install
+msgpack==1.2.1 # wire serialisation
+PyJWT==2.13.0 # JWT EdDSA
+pytest==9.1.1 # test runner
+pytest-asyncio==1.4.0 # async test support
+uvicorn==0.52.1 # ASGI server (local UI)
+watchdog==6.0.0 # filesystem watcher
+zstandard==0.25.0 # zstd compression
+```
+
+Also installed transitively: pydantic 2.13.4, starlette 1.6.0, anyio 4.14.2, uvloop 0.22.1.
+
+### meshbay_common/crypto.py — Argon2id NOTE
+
+Current params: `iterations=3, memory_cost=65536` (64MB) → ~78ms on dev laptop.
+**Must increase to `memory_cost=262144` (256MB) before production keystore use.**
+Run `meshbay-node calibrate-argon2` on target hardware to tune.
+
+### Out of scope for Phase 2
+
+Multiple groups, chat, QUIC, module sandbox, mobile pairing, HLS streaming.
+
+---
+
+## Phase 3 — Hub v1 production ✅ DONE
+
+**Goal:** replace POC in-memory hub with a production-ready service on meshbay.org.
+PostgreSQL persistence, HTTPS via Caddy, all endpoints hardened, legal IP logging, deploy.
+
+### Environment
+
+- **Server:** meshbay.org — Ubuntu 26.04 LTS, Python 3.14.4, OVH VPS
+- **Database:** PostgreSQL 16 (to install)
+- **Proxy:** Caddy (to install — handles Let's Encrypt automatically)
+- **Service:** systemd `meshbay-hub.service`
+
+### Python dependencies to add (Phase 3)
+
+```
+sqlalchemy>=2.0 # async ORM (SQLAlchemy 2.x)
+alembic>=1.13 # DB migrations
+asyncpg>=0.30 # PostgreSQL async driver
+aiosqlite>=0.20 # SQLite async driver (tests only)
+slowapi>=0.1 # rate limiting (FastAPI middleware)
+```
+
+### Milestones
+
+| # | Component | File(s) | Status |
+|---|---|---|---|
+| 3.1 | DB models | `meshbay_hub/db/models.py` | ✅ |
+| 3.2 | DB engine + session | `meshbay_hub/db/engine.py` | ✅ |
+| 3.3 | Alembic migrations | `meshbay_hub/db/migrations/` | ✅ initial_schema |
+| 3.4 | Hub config | `meshbay_hub/config.py` | ✅ |
+| 3.5 | Auth (JWT + Argon2id) | `meshbay_hub/auth.py` | ✅ |
+| 3.6 | API deps | `meshbay_hub/api/deps.py` | ✅ |
+| 3.7 | Hub info router | `meshbay_hub/api/hub.py` | ✅ |
+| 3.8 | Users router | `meshbay_hub/api/users.py` | ✅ |
+| 3.9 | Nodes router | `meshbay_hub/api/nodes.py` | ✅ |
+| 3.10 | Groups router | `meshbay_hub/api/groups.py` | ✅ |
+| 3.11 | Rate limiting | `meshbay_hub/api/middleware.py` | ✅ slowapi |
+| 3.12 | App factory + lifespan | `meshbay_hub/app.py` | ✅ |
+| 3.13 | Hub daemon CLI | `meshbay_hub/daemon.py` | ✅ |
+| 3.14 | PostgreSQL 16 | meshbay.org | ✅ DB: meshbay_hub |
+| 3.15 | Caddy + Let's Encrypt | meshbay.org Caddyfile | ✅ HTTPS auto-cert |
+| 3.16 | Systemd service | `/etc/systemd/system/meshbay-hub.service` | ✅ |
+| 3.17 | Deploy + smoke test | https://meshbay.org | ✅ 11/11 endpoints |
+
+**Total: 40 local tests (SQLite) + 11/11 smoke tests HTTPS production**
+
+### Phase 3 deployment details (meshbay.org)
+
+- PostgreSQL 16, user `meshbay`, DB `meshbay_hub`
+- Caddy auto-handles Let's Encrypt for `meshbay.org` and `www.meshbay.org`
+- `www.meshbay.org` → 301 redirect → `meshbay.org`
+- TLS setup documented in `HTTPS.md`
+- Hub listens on `127.0.0.1:8000`, Caddy proxies 80/443
+- Systemd service: `meshbay-hub.service` (restart-on-failure)
+- Hub config: `~/.config/meshbay/hub.toml`
+- Hub keypair: `~/.config/meshbay/hub_private.pem` (chmod 600)
+- Source deployed at: `~/meshbay-hub/` (common_pkg + hub_pkg)
+
+### Additional Python dependencies added in Phase 3
+
+```
+sqlalchemy==2.0.51 # async ORM
+alembic==1.19.1 # DB migrations
+asyncpg==0.31.0 # PostgreSQL async driver
+aiosqlite # SQLite async (tests only)
+slowapi==0.1.10 # rate limiting
+hatchling==1.31.0 # build backend (needed for pip install)
+```
+
+### Notes
+
+- Initial DB schema created via `init_db()` (`create_all`) — Alembic tracks future changes
+- IP logging records: account_create, login, login_fail, group_create, node_announce
+- Rate limiting active on /v1/users/register and /v1/users/login (slowapi)
+- Revocation, CSAM hash matching, moderation deferred to Phase 5
+
+### Phase 3 scope
+
+**In scope:**
+- All POC endpoints from Spike 2+6, production-ready
+- PostgreSQL via SQLAlchemy async + Alembic migrations
+- IP logging (creation, login, group events) — 1-year retention, legal compliance
+- Access token (JWT, 1h) + refresh token (30d, stored hashed)
+- Rate limiting on auth endpoints
+- Hub config file `/etc/meshbay/hub.toml` or env vars
+- HTTPS via Caddy + auto Let's Encrypt on meshbay.org
+- Systemd service with restart-on-failure
+
+**Deferred to later:**
+- Revocation push (WebSocket signaling to nodes)
+- CSAM hash matching (NCMEC/IWF integration)
+- Moderation flow (blocklist + takedown)
+- MHP federation
+- RPM/DEB packaging
+
+### Testing strategy
+
+- Unit tests with SQLite in-memory (`aiosqlite`) — no PostgreSQL needed locally
+- API tests via `httpx.AsyncClient` + `ASGITransport` — no network
+- All tests run in the existing `.venv` after adding Phase 3 deps
+
+---
+
+## Phase 4 — Integration & Web Client ✅ DONE
+
+**Goal:** end-to-end working product in a browser: login via hub, discover groups,
+browse files on a node, download and stream public content.
+
+### Architecture decision
+
+Browsers cannot make raw TCP connections — the node must speak HTTP.
+- Node adds an **HTTP file API** (port 19001) serving public content via standard `fetch()`
+- Private content (GEK decrypt in browser) deferred to Phase 5 (requires WebCrypto + ChaCha20 WASM)
+- Hub serves the web application at `meshbay.org/app/`
+
+### Milestones
+
+| # | Component | File(s) | Status |
+|---|---|---|---|
+| 4.1 | Node HTTP file API | `meshbay_node/transport/http_server.py` | ✅ 7/7 tests |
+| 4.2 | Hub web app (HTML/JS) | `meshbay_hub/api/webapp.py` + `static/app.js` | ✅ live on meshbay.org |
+| 4.3 | Group listing endpoint | `meshbay_hub/api/groups.py` GET /v1/groups | ✅ |
+| 4.4 | End-to-end integration test | local node ↔ hub ↔ browser | ✅ smoke test |
+| 4.5 | HLS streaming (public) | node `/hls/{id}/playlist.m3u8` + `.ts` via ffmpeg | ✅ included in 4.1 |
+
+**Total: 47/47 tests. https://meshbay.org live with web client.**
+
+### Phase 4 deployment
+
+- `https://meshbay.org/` — web app (HTML/JS SPA)
+- `https://meshbay.org/app.js` — JS client
+- `https://meshbay.org/v1/groups` — public group listing (no auth)
+- Node HTTP API (port 19001): `/index`, `/file/{id}`, `/hls/{id}/*.m3u8`, `/hls/{id}/*.ts`
+- HLS streaming uses ffmpeg for on-the-fly segmentation
+- Public content: no auth for index, auth required for chunks
+- Private content (GEK decrypt in browser): deferred to Phase 5
+
+### Dependencies added in Phase 4
+
+```
+# Node (runtime)
+ffmpeg (system package) — HLS segmentation via subprocess
+```
+
+### Phase 4 scope
+
+**In scope (public content only):**
+- Node HTTP API: serve public Mesh Group Index (JSON) + file chunks (binary)
+- Hub web app: login, group discovery, file browser, download link
+- HLS basic streaming: node segments video on-the-fly, browser plays natively
+- JWT auth passed as query param or header to node HTTP API
+
+**Deferred to Phase 5:**
+- Private group content in browser (requires ChaCha20-Poly1305 via WASM)
+- Chat UI
+- Multi-group node
+- Android client
+
+---
+
+## Phase 5 — QUIC, Federation, Moderation ✅ DONE (Mobile deferred)
+
+**Goal:** full decentralization, mobile support, community infrastructure.
+
+| # | Component | Notes |
+|---|---|---|
+| # | Component | File(s) | Status |
+|---|---|---|---|
+| 5.1 | QUIC transport (MNP v2) | `transport/quic_server.py` + `quic_client.py` | ✅ 3/3 tests |
+| 5.2 | MHP federation | `meshbay_hub/api/federation.py` | ✅ GET/POST /mhp/* |
+| 5.3 | Mesh Relay | `meshbay_hub/api/relay.py` | ✅ register+list+approve |
+| 5.4 | Android client | — | ⏳ deferred (different tech) |
+| 5.5 | Content replication | — | ⏳ deferred |
+| 5.6 | iOS client | — | ⏳ after Android |
+| 5.7 | Revocation push | `api/revocation.py` + `node/revocation.py` | ✅ 3/3 tests |
+| 5.8 | CSAM hash matching | `meshbay_hub/csam.py` | ✅ CSAMChecker + admin API |
+| 5.9 | Moderation | `api/moderation.py` | ✅ 6/6 tests |
+| 5.10 | RPM/DEB packaging | `packaging/` | ✅ spec + control + systemd |
+
+**Total: 59/59 tests.**
+
+### Phase 5 bugs fixed
+
+- **QUIC**: `asyncio.Event` race condition in client recv loop (quic_event_received
+ overwrote `_stream_events[0]` created by `_recv`). Fixed with `asyncio.Queue`
+ (no shared mutable state between coroutines).
+- **QUIC**: `verify_peer` parameter renamed to `verify_mode` in aioquic 1.3.0.
+- **QUIC**: `connect()` returns protocol directly (not `(transport, proto)` tuple).
+
+### Phase 5 dependencies added
+
+```
+aioquic==1.3.0 # QUIC transport (Cloudflare-maintained)
+websockets # hub→node revocation push
+```
+
+### Deferred to future
+
+- Android/iOS client (Kotlin/Flutter — different tech stack, dedicated effort)
+- Content replication (node-to-node) → Phase 6
+- MHP federation persistence (currently in-memory) → Phase 6
+
+---
+
+## Phase 6 — Chat, Multi-group, Federation persistence, Replication ✅ DONE
+
+**Goal:** complete the product with group chat (Double Ratchet), multi-group node support,
+persistent MHP federation, content replication, and browser private group decryption.
+
+### Milestones
+
+| # | Component | File(s) | Status |
+|---|---|---|---|
+| 6.1 | Double Ratchet chat | `meshbay_common/ratchet.py` | ✅ 11/11 tests |
+| 6.2 | Multi-group node | `meshbay_node/config.py` [[groups]] | ✅ |
+| 6.3 | MHP federation persistence | `FederatedGroup` + `SwarmSource` DB tables | ✅ |
+| 6.4 | Content replication | `node/replication.py` + hub `/v1/swarm/*` | ✅ |
+| 6.5 | Browser private group | `webcrypto.py` + `static/crypto.js` | ✅ 4/4 tests |
+| 6.6 | Dérivation clés depuis password | `meshbay_common/keyderive.py` + `static/keyderive.js` | ✅ 7/7 tests |
+| 6.7 | Bundle clés chiffré (web) | Hub: `keypair_bundle` field + migration Alembic | ✅ |
+| 6.8 | Scripts démo opérationnels | `QE/demo-v1/` + `QE/demo-v2/` (non versionné) | ✅ testés |
+| 6.9 | QUICKSTART réécrit | `QUICKSTART.md` | ✅ |
+| 6.6 | Dérivation clés depuis password | `meshbay_common/keyderive.py` + `static/keyderive.js` | ✅ 7/7 tests |
+| 6.7 | Bundle clés chiffré (web) | Hub: `keypair_bundle` field + migration Alembic | ✅ |
+| 6.8 | Scripts démo opérationnels | `QE/demo-v1/` (non versionné) | ✅ testés |
+| 6.9 | QUICKSTART réécrit | `QUICKSTART.md` | ✅ |
+
+**Total: 81/81 tests.**
+
+---
+
+## Phase 7 — Node v2: production, streaming, chat ✅ DONE
+
+**Goal:** multi-group node, Sender Keys chat, QUIC 0-RTT, jti denylist push, HLS streaming.
+
+Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
+
+See `devel-phases-next.md` for details.
+
+---
+
+## Phase 8 — Hub v2: admin, federation, security ✅ DONE
+
+**Goal:** admin roles, email encryption, refresh token rotation, rate limiting, healthcheck.
+
+Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
+Deployed to meshbay.org. All security review items S1/S2/S5 resolved.
+
+See `devel-phases-next.md` for details.
+
+---
+
+## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
+
+**Goal:** browser connects P2P to a node behind residential NAT via WebRTC DataChannel.
+Full SPA: login, groups, file browser, download, video playback, chat, i18n, dark/light.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 9.1–9.5 | WebRTC DataChannel spike + E2E NAT validation | ✅ |
+| 9.6–9.12 | Preact SPA (login, groups, files, video, chat, i18n, settings) | ✅ |
+| 9.13 | Tests: 132 passing | ✅ |
+| 9.14–9.16 | Performance: pipelining, binary wire format, I/O reduction | ✅ |
+| 9.17 | Large file download: File System Access API (stream to disk) | ✅ |
+
+**Total: 132/132 tests. Deployed to meshbay.org + Orange node (2026-08-11).**
+
+### Key technical decisions
+
+- **Transport:** WebRTC DataChannel (aiortc on node) — browsers can't use QUIC for NAT traversal
+- **Wire format:** length-prefixed msgpack, binary chunk fields (no base64)
+- **UI:** Preact + htm ESM (vendored, no build step, no CDN, no npm)
+- **Crypto:** WebCrypto SubtleCrypto AES-256-GCM for E2E chunk decryption in browser
+- **Large files:** File System Access API (`showSaveFilePicker`) — stream to disk, ~8 MB RAM
+- **Indexer:** 2s debounce + path-based dedup for file copy events
+
+### NAT traversal validated
+
+Two ISPs (SFR + Orange residential NAT), Chrome + Firefox, IPv4 STUN + IPv6 direct.
+No TURN relay needed. See `devel-phases-next.md` for detailed test matrix.
+
+### QE deployment state (2026-08-11)
+
+- **Hub (meshbay.org):** running as `meshbay-hub.service`, DB has 3 users
+ (admin, cbesson, grenet), 1 group (`d3bbd90b`), `admin_usernames = ["admin"]`
+- **Node (Orange host via `ssh cbesson@localhost -p 2222`):** running as
+ `nohup .venv/bin/python3 QE/demo-v3/run_node_simple.py`, user grenet,
+ connected via WS to hub, WebRTC + QUIC dual transport
+- **Credentials:** `QE/demo-v3/creds.json` (not versioned)
+- **Shared dir on node:** `~/meshbay/QE/demo-v3/shared/`
+- All 3 users password: see creds.json
+
+### Dependencies added
+
+- `aiortc>=1.9` in meshbay-node (WebRTC DataChannel)
+- `preact` + `htm` vendored as `static/vendor/htm-preact.js` (ESM, ~3 KB gzipped)
+
+---
+
+## Conventions
+
+- Commits: `feat(node):`, `fix(hub):`, `chore(common):`, `docs:`, `test(node):`
+- Branch per feature/fix, merge to `main` (when remote configured)
+- **Always close test UFW ports after any spike on meshbay.org**
+- **Never commit key material** (keystore.enc, hub_private.pem, *.key, node_state.json, unlock.key)
+- meshbay.org is internet-facing: only run known-safe services, close ports after tests
diff --git a/docs/poc-v1-fr.md b/docs/poc-v1-fr.md
deleted file mode 100644
index 262a245..0000000
--- a/docs/poc-v1-fr.md
+++ /dev/null
@@ -1,432 +0,0 @@
-# MeshBay — POC v1 (FR)
-
-> Objectif : valider les concepts clés avant de s'engager dans une implémentation complète.
-> Périmètre : échange Hub/Node en Python, stack crypto, traversée NAT, transfert chiffré de chunk de fichier.
-> Tout en mémoire (pas de base de données), code minimal, TCP uniquement (pas de QUIC pour l'instant).
-
----
-
-## Environnement
-
-### Distant — meshbay.org (Hub)
-- OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4
-- IP fixe publique, ports 80 et 443 ouverts
-- Serveur vierge : aucun serveur web installé
-- Accès SSH : `ssh cbesson@meshbay.org`
-
-### Local — Fedora 44 (Node)
-- Laptop derrière NAT résidentiel SFR (vraisemblablement Restricted Cone NAT — UPnP supporté)
-- Python 3.13+ via paquets système
-- Utilisateur : `cbesson` (sudoer sans mot de passe)
-
----
-
-## Dépendances Python
-
-```bash
-# Partagé (hub et node)
-cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id
-PyJWT>=2.9 # JWT avec support EdDSA (Ed25519)
-blake3>=1.0 # Hachage rapide du contenu
-
-# Hub uniquement (meshbay.org)
-fastapi>=0.115
-uvicorn[standard]>=0.30
-
-# Node uniquement (laptop Fedora)
-httpx>=0.28 # Client HTTP async pour les appels node→hub
-aioice>=0.9 # Requêtes STUN pour la découverte NAT
-miniupnpc>=2.2 # Ouverture de port UPnP sur la box SFR
-```
-
-Installation sur chaque machine :
-```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install <paquets ci-dessus>
-```
-
----
-
-## Configuration du hub sur meshbay.org
-
-Pour le POC, uvicorn tourne directement sur le port 80 via une redirection iptables (pas de Caddy/nginx pour l'instant — HTTPS ajouté avant la production).
-
-```bash
-# Sur meshbay.org
-# Redirection port 80 → 8000
-sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
-
-# Lancer le hub (depuis le répertoire poc, venv activé)
-uvicorn hub:app --host 127.0.0.1 --port 8000 --reload
-```
-
-> Note : HTTPS (via Caddy + Let's Encrypt) est obligatoire avant tout usage réel au-delà de ce POC.
-
----
-
-## Vue d'ensemble des spikes
-
-| # | Nom | Où | Ce que ça valide | Durée |
-|---|---|---|---|---|
-| 1 | Primitives crypto | Local | La stack Python crypto couvre tous les besoins | ~1h |
-| 2 | Squelette hub | meshbay.org | API hub, émission JWT | ~2h |
-| 3 | Enregistrement node | Fedora | Handshake Hub-Node, vérification JWT offline | ~1h |
-| 4 | Traversée NAT | Les deux | UPnP box SFR + STUN, accessibilité P2P | ~2h |
-| 5 | Transfert chiffré | Les deux | Chiffrement GEK à la volée, chunk P2P | ~2h |
-
----
-
-## Spike 1 — Primitives cryptographiques (local uniquement)
-
-**Objectif :** confirmer que `cryptography` (PyCA) couvre tous les besoins cryptographiques de MeshBay sans lacune ni surprise de performance.
-
-**Fichier :** `spike1_crypto.py`
-
-**Test 1 : Ed25519 — keypair hub, signature, vérification**
-```python
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-sk_hub = Ed25519PrivateKey.generate()
-pk_hub = sk_hub.public_key()
-msg = b"test payload"
-sig = sk_hub.sign(msg)
-pk_hub.verify(sig, msg) # lève une exception si invalide
-print("Ed25519 OK")
-```
-
-**Test 2 : X25519 — accord de clé pour l'enveloppement de la GEK**
-```python
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-
-sk_a = X25519PrivateKey.generate()
-sk_b = X25519PrivateKey.generate()
-shared_a = sk_a.exchange(sk_b.public_key())
-shared_b = sk_b.exchange(sk_a.public_key())
-assert shared_a == shared_b
-print("X25519 OK")
-```
-
-**Test 3 : ChaCha20-Poly1305 sur un chunk de 1 Mo**
-```python
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-import os, time
-
-gek = ChaCha20Poly1305.generate_key()
-cipher = ChaCha20Poly1305(gek)
-chunk = os.urandom(1024 * 1024) # 1 Mo
-
-t0 = time.perf_counter()
-nonce = os.urandom(12)
-ct = cipher.encrypt(nonce, chunk, None)
-pt = cipher.decrypt(nonce, ct, None)
-elapsed = time.perf_counter() - t0
-
-assert pt == chunk
-print(f"ChaCha20-Poly1305 1 Mo : {elapsed*1000:.1f} ms")
-```
-
-**Test 4 : Dérivation de clé de chunk par HKDF**
-```python
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes
-import blake3
-
-chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0"
-).derive(gek)
-print(f"Clé HKDF : {chunk_key.hex()[:16]}...")
-```
-
-**Test 5 : Argon2id — dérivation de clé keystore**
-```python
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-
-salt = os.urandom(16)
-t0 = time.perf_counter()
-kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
-key = kdf.derive(b"motdepasse")
-print(f"Argon2id : {(time.perf_counter()-t0)*1000:.0f} ms")
-```
-
-**Test 6 : PyJWT avec Ed25519 (EdDSA)**
-```python
-import jwt
-from cryptography.hazmat.primitives import serialization
-
-sk_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
-)
-pk_pem = pk_hub.public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
-)
-payload = {"sub": "user_abc", "pk_user": "base64...", "exp": 9999999999}
-token = jwt.encode(payload, sk_pem, algorithm="EdDSA")
-decoded = jwt.decode(token, pk_pem, algorithms=["EdDSA"])
-assert decoded["sub"] == "user_abc"
-print("JWT EdDSA OK")
-```
-
-**Critères de succès :** tous les tests passent, ChaCha20 1 Mo < 20 ms, Argon2id ~1s.
-
----
-
-## Spike 2 — Squelette du hub (meshbay.org)
-
-**Objectif :** hub FastAPI minimal avec stockage en mémoire, 6 endpoints.
-
-**Fichier :** `hub.py` (sur meshbay.org)
-
-### Génération du keypair hub (une seule fois)
-
-```python
-# gen_hub_keys.py — exécuter une seule fois sur meshbay.org
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-
-sk = Ed25519PrivateKey.generate()
-with open("hub_private.pem", "wb") as f:
- f.write(sk.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
- ))
-with open("hub_public.pem", "wb") as f:
- f.write(sk.public_key().public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
- ))
-print("Keypair hub généré.")
-```
-
-### Endpoints du hub
-
-```
-GET /v1/hub/pubkey → PEM de la clé publique Ed25519 du hub
-POST /v1/users/register → {username, password, pk_user_ed25519, pk_user_x25519} → {user_id}
-POST /v1/users/login → {username, password} → {access_token, refresh_token}
-POST /v1/users/token/refresh → {refresh_token} → {access_token}
-POST /v1/nodes/announce → (auth) {pk_node, endpoint_hint} → {node_id}
-GET /v1/nodes/{node_id} → (auth) {pk_node, endpoint_hint}
-```
-
-### Structure JWT (access token)
-
-```json
-{
- "iss": "meshbay.org",
- "sub": "<user_id>",
- "pk_user": "<base64 Ed25519 publique>",
- "hub_id": "meshbay.org",
- "iat": 1720000000,
- "exp": 1720003600
-}
-```
-
-Signé avec la clé Ed25519 privée du hub. Vérifiable par n'importe qui possédant la clé publique du hub — aucun appel hub requis.
-
-**Critères de succès :**
-- Hub démarre, tous les endpoints répondent correctement
-- `POST /v1/users/register` + `POST /v1/users/login` retourne un JWT valide
-- `jwt.decode()` avec la clé publique du hub passe sans erreur
-
----
-
-## Spike 3 — Enregistrement du node (laptop Fedora)
-
-**Objectif :** le node génère son keypair, s'enregistre sur le hub, obtient un JWT, et le vérifie localement sans contacter le hub.
-
-**Fichier :** `node.py`
-
-**Séquence :**
-1. Récupérer la clé publique du hub (`GET /v1/hub/pubkey`) — mettre en cache
-2. Générer le keypair Ed25519 + X25519 du node
-3. Enregistrer l'utilisateur sur le hub
-4. Se connecter, recevoir l'access token (JWT)
-5. **Vérifier le JWT localement** avec la clé publique du hub — aucun appel réseau
-6. Annoncer le node au hub
-
-**Vérification JWT offline (point clé) :**
-```python
-# Aucun appel hub — juste la signature Ed25519
-decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
-print(f"[node] JWT vérifié localement : sub={decoded['sub']}")
-```
-
-C'est la validation du concept fondamental : le hub est une autorité d'identité qui émet des credentials vérifiables hors ligne. Après le login, le hub n'est plus dans la boucle.
-
-**Critères de succès :**
-- Node s'enregistre, se connecte, reçoit un JWT
-- JWT décodé offline avec la seule clé publique du hub
-- Node annoncé ; `GET /v1/nodes/{node_id}` depuis le hub retourne le bon PK
-
----
-
-## Spike 4 — Traversée NAT (les deux machines)
-
-**Objectif :** découvrir l'IP:port externe du node local via UPnP et STUN ; tester l'accessibilité depuis meshbay.org.
-
-**Fichier :** `spike4_nat.py` (laptop Fedora)
-
-### Partie A — UPnP (à tenter en premier, plus fiable sur box SFR)
-
-```python
-import miniupnpc, socket
-
-def try_upnp(internal_port=19000):
- u = miniupnpc.UPnP()
- u.discoverdelay = 200
- if u.discover() == 0:
- print("UPnP : aucune IGD trouvée")
- return None
-
- u.selectigd()
- external_ip = u.externalipaddress()
- local_ip = socket.gethostbyname(socket.gethostname())
-
- if u.addportmapping(internal_port, 'TCP', local_ip, internal_port, 'MeshBay POC', ''):
- print(f"UPnP : {external_ip}:{internal_port} → {local_ip}:{internal_port}")
- return f"{external_ip}:{internal_port}"
- print("UPnP : échec du mapping")
- return None
-```
-
-### Partie B — Découverte STUN
-
-```python
-import asyncio, aioice
-
-async def stun_discover():
- connection = aioice.Connection(
- ice_controlling=True,
- stun_server=("stun.cloudflare.com", 3478)
- )
- await connection.gather_candidates()
-
- for candidate in connection.local_candidates:
- if candidate.type == "srflx": # server-reflexive = adresse externe
- print(f"STUN srflx : {candidate.host}:{candidate.port}")
- return f"{candidate.host}:{candidate.port}"
-
- print("STUN : aucun candidat srflx (NAT symétrique possible)")
- return None
-```
-
-### Partie C — Test d'accessibilité depuis meshbay.org
-
-Le node annonce son `endpoint_hint` au hub. Depuis meshbay.org :
-
-```bash
-# Test TCP depuis meshbay.org
-python3 -c "
-import socket
-s = socket.create_connection(('<ip_externe>', <port>), timeout=5)
-print('ACCESSIBLE')
-s.close()
-"
-```
-
-Sur le laptop Fedora, un listener simple sur le port découvert :
-```python
-import socket
-s = socket.socket()
-s.bind(('', 19000))
-s.listen(1)
-print("En écoute sur 19000...")
-conn, addr = s.accept()
-print(f"Connexion depuis {addr}")
-conn.sendall(b"BONJOUR DU NODE\n")
-conn.close()
-```
-
-**Résultats attendus sur SFR résidentiel :**
-
-| Méthode | Résultat attendu | Niveau de confiance |
-|---|---|---|
-| UPnP | Fonctionne — La Box SFR supporte UPnP IGD | Élevé |
-| STUN srflx | Découvert — SFR est un cone NAT pour le résidentiel | Élevé |
-| TCP direct depuis meshbay.org | Fonctionne si UPnP a réussi | Élevé |
-| Hole punching seul | Dépend du type NAT découvert | Moyen |
-
-**Critères de succès :** au moins une méthode permet à meshbay.org d'atteindre directement le port du laptop Fedora.
-
----
-
-## Spike 5 — Transfert chiffré de fichier (les deux machines)
-
-**Objectif :** le node sert un chunk de fichier chiffré via connexion TCP P2P directe ; le client déchiffre et vérifie.
-
-**Prérequis :** Spike 4 réussi — IP:port externe connu et accessible.
-
-### Côté node (laptop Fedora)
-
-Pipeline : lire le chunk → dériver la clé via HKDF(GEK, file_hash, chunk_index) → chiffrer ChaCha20-Poly1305 → signer Ed25519 → envoyer.
-
-**Points clés :**
-- Clé par chunk dérivée de la GEK (pas la GEK directement)
-- Chaque chunk signaturé avant envoi
-- La GEK n'est jamais envoyée en clair en production (envoyée en clair uniquement pour ce POC — voir note ci-dessous)
-
-### Côté client (meshbay.org)
-
-Pipeline : recevoir → vérifier signature Ed25519 → vérifier hash blake3 du ciphertext → déchiffrer ChaCha20-Poly1305 → obtenir les octets en clair.
-
-### Note sur la GEK dans le POC
-
-Pour ce POC, la GEK est transmise dans la réponse comme `gek_hint` pour des raisons de commodité. **En production, le client obtient la GEK depuis le bundle GEK chiffré du hub** (déchiffré côté client avec sa clé X25519 privée). Le mécanisme de distribution de la GEK est délibérément hors périmètre de ce POC.
-
-**Critères de succès :**
-- Le client reçoit le chunk depuis le node via TCP direct (sans hub dans le chemin)
-- La vérification de signature passe ✓
-- Le hash du ciphertext correspond ✓
-- Le déchiffrement produit les octets originaux ✓
-- `octets_originaux == octets_déchiffrés` ✓
-
----
-
-## Ce que le POC valide (et ne valide pas)
-
-### Validé par ces spikes
-
-| Concept | Spike | Validation |
-|---|---|---|
-| Stack Python crypto suffisante | 1 | Toutes les primitives fonctionnent, performance acceptable |
-| Handshake Hub/Node via JWT | 2, 3 | JWT émis par le hub, vérifié offline par le node |
-| Protocole REST Hub-Node minimal | 2, 3 | Contrat API validé bout en bout |
-| Traversée NAT SFR via UPnP | 4 | Accessibilité P2P confirmée |
-| Découverte adresse externe STUN | 4 | Confirmée / fallback documenté |
-| Chiffrement par chunk à la volée | 5 | GEK + HKDF par chunk + ChaCha20 |
-| Signature et vérification de chunk | 5 | Ed25519 sign/verify avant déchiffrement |
-| Transfert de fichier P2P réel | 5 | Aucun hub dans le chemin des données |
-
-### Hors périmètre
-
-- Base de données (tout en mémoire)
-- HTTPS / TLS (HTTP pour le POC)
-- Transport QUIC (TCP simple)
-- Distribution du bundle GEK via hub (GEK transmise en clair pour le POC)
-- Gestion de groupes
-- Chat / Double Ratchet
-- Mesh Group Index
-- Fédération MHP
-- Client Android
-- Système de modules
-- Persistance entre les redémarrages
-
----
-
-## Graphe de dépendance des spikes
-
-```
-Spike 1 (crypto)
- └──→ Spike 2 (squelette hub)
- └──→ Spike 3 (enregistrement node)
- └──→ Spike 4 (traversée NAT)
- └──→ Spike 5 (transfert chiffré)
-```
-
-Le Spike 1 est un prérequis pour tous les autres. Les Spikes 2 et 3 peuvent être menés en parallèle si deux personnes travaillent. Le Spike 4 peut commencer indépendamment dès que le Spike 3 est fonctionnel.
diff --git a/docs/poc-v1.md b/docs/poc-v1.md
deleted file mode 100644
index 8f66159..0000000
--- a/docs/poc-v1.md
+++ /dev/null
@@ -1,767 +0,0 @@
-# MeshBay — POC v1
-
-> Goal: validate key concepts before committing to a full implementation.
-> Scope: Hub/Node exchange in Python, crypto stack, NAT traversal, encrypted file chunk transfer.
-> Everything in-memory (no database), minimal code, TCP only (no QUIC yet).
-
----
-
-## Environment
-
-### Remote — meshbay.org (Hub)
-- OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4
-- Public fixed IP, ports 80 and 443 open
-- Clean slate: no web server installed
-- SSH access: `ssh cbesson@meshbay.org`
-
-### Local — Fedora 44 (Node)
-- Laptop behind SFR residential NAT (likely Restricted Cone NAT — UPnP supported)
-- Python 3.13+ via system packages
-- User: `cbesson` (sudoer, no password)
-
----
-
-## Python Dependencies
-
-```bash
-# Shared (hub and node)
-cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id
-PyJWT>=2.9 # JWT with EdDSA (Ed25519) support
-blake3>=1.0 # Fast content hashing
-
-# Hub only (meshbay.org)
-fastapi>=0.115
-uvicorn[standard]>=0.30
-
-# Node only (Fedora laptop)
-httpx>=0.28 # Async HTTP client for hub→node calls
-aioice>=0.9 # STUN queries for NAT discovery
-miniupnpc>=2.2 # UPnP port mapping on SFR box
-```
-
-Install on each machine:
-```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install <packages above>
-```
-
----
-
-## Hub Setup on meshbay.org
-
-For the POC, uvicorn runs directly on port 80 via iptables redirect (no Caddy/nginx needed yet — HTTPS added before production).
-
-```bash
-# On meshbay.org
-# Redirect port 80 → 8000 (persistent via iptables-save if needed)
-sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
-
-# Run hub (from poc directory, venv activated)
-uvicorn hub:app --host 127.0.0.1 --port 8000 --reload
-```
-
-> Note: HTTPS (via Caddy + Let's Encrypt) is mandatory before any data beyond this POC. Not in scope here.
-
----
-
-## Spike Overview
-
-| # | Name | Where | Validates | Duration |
-|---|---|---|---|---|
-| 1 | Crypto primitives | Local | Python crypto stack covers all needs | ~1h |
-| 2 | Hub skeleton | meshbay.org | Hub API, JWT issuance | ~2h |
-| 3 | Node registration | Fedora | Hub-Node handshake, JWT offline verify | ~1h |
-| 4 | NAT traversal | Both | SFR box UPnP + STUN, P2P reachability | ~2h |
-| 5 | Encrypted transfer | Both | On-the-fly GEK encryption, P2P chunk | ~2h |
-
----
-
-## Spike 1 — Crypto Primitives (local only)
-
-**Goal:** confirm `cryptography` (PyCA) covers all MeshBay cryptographic needs without gaps or performance surprises.
-
-**File:** `spike1_crypto.py`
-
-**What to test:**
-
-```python
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id # PyCA 43+
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3, os, time
-```
-
-**Test 1: Ed25519 — hub keypair, sign JWT payload, verify**
-```python
-sk_hub = Ed25519PrivateKey.generate()
-pk_hub = sk_hub.public_key()
-msg = b"test payload"
-sig = sk_hub.sign(msg)
-pk_hub.verify(sig, msg) # raises if invalid
-print("Ed25519 OK")
-```
-
-**Test 2: X25519 — two-party key agreement for GEK wrapping**
-```python
-sk_a = X25519PrivateKey.generate()
-sk_b = X25519PrivateKey.generate()
-shared_a = sk_a.exchange(sk_b.public_key())
-shared_b = sk_b.exchange(sk_a.public_key())
-assert shared_a == shared_b
-print("X25519 OK")
-```
-
-**Test 3: GEK derivation and ChaCha20-Poly1305 on a 1 MB chunk**
-```python
-gek = ChaCha20Poly1305.generate_key()
-cipher = ChaCha20Poly1305(gek)
-chunk = os.urandom(1024 * 1024) # 1 MB
-
-t0 = time.perf_counter()
-nonce = os.urandom(12)
-ct = cipher.encrypt(nonce, chunk, None)
-pt = cipher.decrypt(nonce, ct, None)
-elapsed = time.perf_counter() - t0
-
-assert pt == chunk
-print(f"ChaCha20-Poly1305 1MB: {elapsed*1000:.1f} ms")
-```
-
-**Test 4: HKDF chunk key derivation**
-```python
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes
-chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0"
-).derive(gek)
-print(f"HKDF derived key: {chunk_key.hex()[:16]}...")
-```
-
-**Test 5: Argon2id keystore key derivation**
-```python
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-salt = os.urandom(16)
-t0 = time.perf_counter()
-kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
-key = kdf.derive(b"mypassword")
-print(f"Argon2id: {(time.perf_counter()-t0)*1000:.0f} ms, key: {key.hex()[:16]}...")
-```
-
-**Test 6: PyJWT with Ed25519 (EdDSA)**
-```python
-import jwt
-sk_hub_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
-)
-pk_hub_pem = pk_hub.public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
-)
-payload = {"sub": "user_abc", "pk_user": "base64...", "exp": 9999999999}
-token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA")
-decoded = jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"])
-assert decoded["sub"] == "user_abc"
-print("JWT EdDSA OK")
-```
-
-**Success criteria:** all tests pass, ChaCha20 1MB < 20ms, Argon2id ~1s.
-
----
-
-## Spike 2 — Hub Skeleton (meshbay.org)
-
-**Goal:** minimal FastAPI hub, in-memory storage, 5 endpoints.
-
-**File:** `hub.py` (on meshbay.org)
-
-### Hub keypair generation (run once, save to disk)
-
-```python
-# gen_hub_keys.py — run once on meshbay.org
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-import base64, json
-
-sk = Ed25519PrivateKey.generate()
-pk = sk.public_key()
-
-with open("hub_private.pem", "wb") as f:
- f.write(sk.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
- ))
-with open("hub_public.pem", "wb") as f:
- f.write(pk.public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
- ))
-print("Hub keypair generated.")
-```
-
-### Hub API (`hub.py`)
-
-```python
-from fastapi import FastAPI, HTTPException, Depends, Header
-from pydantic import BaseModel
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization, hashes
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-import jwt, uuid, os, time, base64
-
-app = FastAPI(title="MeshBay Hub POC")
-
-# Load hub keypair
-with open("hub_private.pem", "rb") as f:
- HUB_SK_PEM = f.read()
-with open("hub_public.pem", "rb") as f:
- HUB_PK_PEM = f.read()
-
-HUB_ID = "meshbay.org"
-ACCESS_TOKEN_TTL = 3600 # 1 hour
-REFRESH_TOKEN_TTL = 86400 * 30 # 30 days
-
-# In-memory stores (POC only — not persistent)
-users = {} # username → {user_id, pw_hash, pw_salt, pk_ed25519, pk_x25519}
-nodes = {} # node_id → {user_id, pk_node, endpoint_hint, registered_at}
-refresh_tokens = {} # token → user_id
-
-# --- Models ---
-
-class UserRegister(BaseModel):
- username: str
- password: str
- pk_user_ed25519: str # base64
- pk_user_x25519: str # base64
-
-class UserLogin(BaseModel):
- username: str
- password: str
-
-class NodeAnnounce(BaseModel):
- pk_node: str # base64 Ed25519 public key
- endpoint_hint: str | None = None # "ip:port" or null
-
-# --- Helpers ---
-
-def hash_password(password: str) -> tuple[bytes, bytes]:
- salt = os.urandom(16)
- kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
- return kdf.derive(password.encode()), salt
-
-def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
- kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
- try:
- kdf.verify(password.encode(), pw_hash)
- return True
- except Exception:
- return False
-
-def issue_access_token(user: dict) -> str:
- payload = {
- "iss": HUB_ID,
- "sub": user["user_id"],
- "pk_user": user["pk_ed25519"],
- "hub_id": HUB_ID,
- "iat": int(time.time()),
- "exp": int(time.time()) + ACCESS_TOKEN_TTL,
- }
- return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA")
-
-def get_current_user(authorization: str = Header(...)) -> dict:
- try:
- scheme, token = authorization.split()
- if scheme.lower() != "bearer":
- raise ValueError
- payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"])
- user_id = payload["sub"]
- user = next((u for u in users.values() if u["user_id"] == user_id), None)
- if not user:
- raise HTTPException(status_code=401, detail="User not found")
- return user
- except Exception:
- raise HTTPException(status_code=401, detail="Invalid token")
-
-# --- Endpoints ---
-
-@app.get("/v1/hub/info")
-def hub_info():
- return {
- "hub_id": HUB_ID,
- "pk_hub_ed25519": base64.b64encode(
- Ed25519PrivateKey.from_private_bytes(
- # shortcut for POC — load pk directly
- open("hub_public.pem","rb").read()
- ).public_bytes(...) # see note below
- ).decode(),
- "mnp_version": "0.1",
- "mhp_version": "0.1",
- }
- # Note: return pk_hub_pem directly for POC, nodes store it on first contact
-
-@app.get("/v1/hub/pubkey")
-def hub_pubkey():
- """Return hub Ed25519 public key PEM — cached by nodes on first contact."""
- return {"pk_hub_pem": HUB_PK_PEM.decode()}
-
-@app.post("/v1/users/register", status_code=201)
-def register(body: UserRegister):
- if body.username in users:
- raise HTTPException(status_code=409, detail="Username taken")
- pw_hash, pw_salt = hash_password(body.password)
- user_id = str(uuid.uuid4())
- users[body.username] = {
- "user_id": user_id,
- "username": body.username,
- "pw_hash": pw_hash,
- "pw_salt": pw_salt,
- "pk_ed25519": body.pk_user_ed25519,
- "pk_x25519": body.pk_user_x25519,
- }
- return {"user_id": user_id}
-
-@app.post("/v1/users/login")
-def login(body: UserLogin):
- user = users.get(body.username)
- if not user or not verify_password(body.password, user["pw_hash"], user["pw_salt"]):
- raise HTTPException(status_code=401, detail="Invalid credentials")
- access_token = issue_access_token(user)
- refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode()
- refresh_tokens[refresh_token] = user["user_id"]
- return {
- "access_token": access_token,
- "refresh_token": refresh_token,
- "token_type": "bearer",
- "expires_in": ACCESS_TOKEN_TTL,
- }
-
-@app.post("/v1/users/token/refresh")
-def refresh(body: dict):
- rt = body.get("refresh_token", "")
- user_id = refresh_tokens.get(rt)
- if not user_id:
- raise HTTPException(status_code=401, detail="Invalid refresh token")
- user = next((u for u in users.values() if u["user_id"] == user_id), None)
- if not user:
- raise HTTPException(status_code=401, detail="User not found")
- return {"access_token": issue_access_token(user), "token_type": "bearer"}
-
-@app.post("/v1/nodes/announce", status_code=201)
-def announce_node(body: NodeAnnounce, user: dict = Depends(get_current_user)):
- node_id = str(uuid.uuid4())
- nodes[node_id] = {
- "node_id": node_id,
- "user_id": user["user_id"],
- "pk_node": body.pk_node,
- "endpoint_hint": body.endpoint_hint,
- "announced_at": int(time.time()),
- }
- return {"node_id": node_id}
-
-@app.get("/v1/nodes/{node_id}")
-def get_node(node_id: str, user: dict = Depends(get_current_user)):
- node = nodes.get(node_id)
- if not node:
- raise HTTPException(status_code=404, detail="Node not found")
- return {
- "node_id": node["node_id"],
- "pk_node": node["pk_node"],
- "endpoint_hint": node["endpoint_hint"],
- }
-```
-
-**Success criteria:**
-- Hub starts, all 6 endpoints respond correctly
-- `GET /v1/hub/pubkey` returns the PEM
-- `POST /v1/users/register` + `POST /v1/users/login` returns a valid JWT
-- JWT verified by `jwt.decode()` with hub public key — passes
-
----
-
-## Spike 3 — Node Registration (Fedora laptop)
-
-**Goal:** node generates its keypair, registers a user on the hub, gets a JWT, and verifies it locally without contacting the hub again.
-
-**File:** `node.py`
-
-```python
-import httpx, asyncio, jwt, base64, os
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-
-HUB_URL = "http://meshbay.org" # HTTP for POC, HTTPS later
-
-async def main():
- async with httpx.AsyncClient() as client:
-
- # 1. Fetch hub public key (first contact — cache this)
- r = await client.get(f"{HUB_URL}/v1/hub/pubkey")
- hub_pk_pem = r.json()["pk_hub_pem"].encode()
- print(f"[node] Hub PK fetched ({len(hub_pk_pem)} bytes)")
-
- # 2. Generate node identity keypairs
- sk_ed = Ed25519PrivateKey.generate()
- pk_ed = sk_ed.public_key()
- sk_x = X25519PrivateKey.generate()
- pk_x = sk_x.public_key()
-
- pk_ed_b64 = base64.b64encode(
- pk_ed.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
- ).decode()
- pk_x_b64 = base64.b64encode(
- pk_x.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
- ).decode()
-
- # 3. Register user (skip if already registered)
- r = await client.post(f"{HUB_URL}/v1/users/register", json={
- "username": "testnode",
- "password": "testpass123",
- "pk_user_ed25519": pk_ed_b64,
- "pk_user_x25519": pk_x_b64,
- })
- print(f"[node] Register: {r.status_code} {r.text}")
-
- # 4. Login, get access token
- r = await client.post(f"{HUB_URL}/v1/users/login", json={
- "username": "testnode",
- "password": "testpass123",
- })
- data = r.json()
- access_token = data["access_token"]
- print(f"[node] Login OK, token: {access_token[:40]}...")
-
- # 5. Verify JWT locally — NO hub roundtrip
- decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
- print(f"[node] JWT verified locally: sub={decoded['sub']}, exp={decoded['exp']}")
-
- # 6. Announce node to hub
- r = await client.post(
- f"{HUB_URL}/v1/nodes/announce",
- json={"pk_node": pk_ed_b64, "endpoint_hint": None},
- headers={"Authorization": f"Bearer {access_token}"}
- )
- node_id = r.json()["node_id"]
- print(f"[node] Node announced: {node_id}")
-
-asyncio.run(main())
-```
-
-**Success criteria:**
-- Node registers, logs in, receives JWT
-- JWT decoded offline using only the hub's public key — no hub call
-- Node announced; `GET /v1/nodes/{node_id}` from hub returns correct PK
-
----
-
-## Spike 4 — NAT Traversal (both machines)
-
-**Goal:** discover the local node's external IP:port via STUN and UPnP; test reachability from meshbay.org.
-
-**File:** `spike4_nat.py` (Fedora laptop)
-
-### Part A — UPnP (try first, most reliable on SFR box)
-
-```python
-import miniupnpc
-import socket
-
-def try_upnp(internal_port=19000):
- u = miniupnpc.UPnP()
- u.discoverdelay = 200
- ndevices = u.discover()
- if ndevices == 0:
- print("UPnP: no IGD found")
- return None
-
- u.selectigd()
- external_ip = u.externalipaddress()
- local_ip = socket.gethostbyname(socket.gethostname())
-
- result = u.addportmapping(
- internal_port, 'TCP', local_ip, internal_port,
- 'MeshBay POC', ''
- )
- if result:
- print(f"UPnP: mapped {external_ip}:{internal_port} → {local_ip}:{internal_port}")
- return f"{external_ip}:{internal_port}"
- else:
- print("UPnP: mapping failed")
- return None
-```
-
-### Part B — STUN discovery
-
-```python
-import asyncio
-import aioice
-
-async def stun_discover(local_port=19001):
- # Use Cloudflare STUN server
- stun_servers = [("stun.cloudflare.com", 3478), ("stun.l.google.com", 19302)]
-
- connection = aioice.Connection(ice_controlling=True, stun_server=stun_servers[0])
- await connection.gather_candidates()
-
- for candidate in connection.local_candidates:
- if candidate.type == "srflx": # server-reflexive = external address
- print(f"STUN srflx: {candidate.host}:{candidate.port}")
- return f"{candidate.host}:{candidate.port}"
-
- print("STUN: no srflx candidate found (may be symmetric NAT)")
- return None
-```
-
-### Part C — Reachability test from meshbay.org
-
-Once the node has an external address (from UPnP or STUN), it announces it to the hub (`endpoint_hint`). Then from meshbay.org:
-
-```bash
-# On meshbay.org — manually test TCP reachability
-nc -zv <external_ip> <external_port>
-# or
-python3 -c "import socket; s=socket.create_connection(('<external_ip>', <port>), timeout=5); print('REACHABLE'); s.close()"
-```
-
-And on the Fedora node, a simple listener:
-```python
-# On Fedora, open a listener on the discovered port
-import socket
-s = socket.socket()
-s.bind(('', 19000))
-s.listen(1)
-print("Listening on 19000...")
-conn, addr = s.accept()
-print(f"Connection from {addr}")
-conn.sendall(b"HELLO FROM NODE\n")
-conn.close()
-```
-
-**Expected outcomes on SFR residential:**
-
-| Method | Expected result | Confidence |
-|---|---|---|
-| UPnP | Works — SFR La Box supports UPnP IGD | High |
-| STUN srflx | Discovered — SFR is cone NAT for residential | High |
-| Direct TCP from meshbay.org | Works if UPnP succeeded | High |
-| Hole punching only | Depends on NAT type discovered | Medium |
-
-**Success criteria:** at least one method allows meshbay.org to reach the Fedora node's port directly.
-
----
-
-## Spike 5 — Encrypted File Transfer (both machines)
-
-**Goal:** node serves an encrypted file chunk via direct P2P TCP connection; client decrypts and verifies.
-
-**Prerequisite:** Spike 4 succeeded — external IP:port is known and reachable.
-
-**File:** `spike5_server.py` (Fedora), `spike5_client.py` (meshbay.org)
-
-### Node side — serve one encrypted chunk
-
-```python
-# spike5_server.py — Fedora laptop
-import asyncio, os, base64
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3, struct, json
-
-# Keypair (reuse from Spike 3 or generate here)
-sk_node = Ed25519PrivateKey.generate()
-pk_node_bytes = sk_node.public_key().public_bytes(
- serialization.Encoding.Raw, serialization.PublicFormat.Raw
-)
-
-# Generate GEK (in a real system, loaded from keystore)
-gek_raw = ChaCha20Poly1305.generate_key()
-cipher = ChaCha20Poly1305(gek_raw)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-
-def make_chunk(file_path: str, chunk_index: int) -> bytes:
- """Read, compress (skipped for POC), encrypt, sign a chunk."""
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- data = f.read(CHUNK_SIZE)
-
- file_hash = blake3.blake3(open(file_path, "rb").read()).digest()
-
- # Per-chunk key derivation
- chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big")
- ).derive(gek_raw)
- chunk_cipher = ChaCha20Poly1305(chunk_key)
-
- nonce = os.urandom(12)
- ct = chunk_cipher.encrypt(nonce, data, None)
- chunk_hash = blake3.blake3(ct).digest()
-
- # Sign: chunk_index + nonce + ciphertext_hash
- sig_payload = chunk_index.to_bytes(4, "big") + nonce + chunk_hash
- sig = sk_node.sign(sig_payload)
-
- return json.dumps({
- "chunk_index": chunk_index,
- "nonce": base64.b64encode(nonce).decode(),
- "ciphertext": base64.b64encode(ct).decode(),
- "chunk_hash": base64.b64encode(chunk_hash).decode(),
- "signature": base64.b64encode(sig).decode(),
- "pk_node": base64.b64encode(pk_node_bytes).decode(),
- "gek_hint": base64.b64encode(gek_raw).decode(), # POC: send GEK in band — never in production!
- }).encode()
-
-async def handle_client(reader, writer):
- request = await reader.read(1024)
- req = json.loads(request)
- chunk_index = req.get("chunk_index", 0)
- file_path = req.get("file", "testfile.bin")
-
- print(f"[node] Client requests chunk {chunk_index} of {file_path}")
- chunk_data = make_chunk(file_path, chunk_index)
-
- writer.write(len(chunk_data).to_bytes(4, "big") + chunk_data)
- await writer.drain()
- writer.close()
- print(f"[node] Chunk {chunk_index} sent ({len(chunk_data)} bytes)")
-
-async def main():
- # Create a 5MB test file
- if not os.path.exists("testfile.bin"):
- with open("testfile.bin", "wb") as f:
- f.write(os.urandom(5 * 1024 * 1024))
- print("[node] Test file created (5 MB)")
-
- server = await asyncio.start_server(handle_client, "0.0.0.0", 19000)
- print("[node] Serving on port 19000 — waiting for client...")
- async with server:
- await server.serve_forever()
-
-asyncio.run(main())
-```
-
-### Client side — request, verify, decrypt
-
-```python
-# spike5_client.py — meshbay.org
-import asyncio, base64, json
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3
-
-NODE_HOST = "<external_ip>" # from Spike 4
-NODE_PORT = 19000
-
-async def main():
- reader, writer = await asyncio.open_connection(NODE_HOST, NODE_PORT)
-
- # Request chunk 0
- request = json.dumps({"file": "testfile.bin", "chunk_index": 0}).encode()
- writer.write(request)
- await writer.drain()
-
- # Receive
- length_bytes = await reader.readexactly(4)
- length = int.from_bytes(length_bytes, "big")
- data = await reader.readexactly(length)
- writer.close()
-
- chunk = json.loads(data)
- print(f"[client] Received chunk {chunk['chunk_index']}")
-
- # 1. Verify signature
- pk_node_bytes = base64.b64decode(chunk["pk_node"])
- pk_node = Ed25519PublicKey.from_public_bytes(pk_node_bytes)
- ct = base64.b64decode(chunk["ciphertext"])
- nonce = base64.b64decode(chunk["nonce"])
- chunk_hash = base64.b64decode(chunk["chunk_hash"])
- sig = base64.b64decode(chunk["signature"])
-
- sig_payload = (0).to_bytes(4, "big") + nonce + chunk_hash
- pk_node.verify(sig, sig_payload) # raises on failure
- print("[client] Signature OK")
-
- # 2. Verify ciphertext hash
- assert blake3.blake3(ct).digest() == chunk_hash
- print("[client] Ciphertext hash OK")
-
- # 3. Derive chunk key and decrypt (GEK from POC hint — never in production)
- gek_raw = base64.b64decode(chunk["gek_hint"])
- # (in production, client has GEK from hub's GEK bundle)
- chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + bytes(32) + b":chunk:" + (0).to_bytes(4, "big")
- # Note: in production, file_hash is sent separately or in index
- ).derive(gek_raw)
- plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ct, None)
- print(f"[client] Decrypted {len(plaintext)} bytes")
- print("[client] Encrypted P2P transfer: SUCCESS")
-
-asyncio.run(main())
-```
-
-**Note on GEK in POC:** the GEK is included in the response as `gek_hint` for POC convenience only. In production, the client gets the GEK from the hub's encrypted GEK bundle (delivered at login, decrypted client-side with the user's X25519 private key).
-
-**Success criteria:**
-- Client receives chunk from node via direct TCP connection
-- Signature verification passes
-- Ciphertext hash matches
-- Decryption produces the original bytes
-- End-to-end: `original_bytes == decrypted_bytes` ✓
-
----
-
-## What POC Validates (and Doesn't)
-
-### Validated by these spikes
-
-| Concept | Spike | Validation |
-|---|---|---|
-| Python crypto stack is sufficient | 1 | All primitives work, performance acceptable |
-| Hub/Node JWT handshake | 2, 3 | JWT issued by hub, verified offline by node |
-| Hub-Node REST protocol (minimal MNP/HTTP) | 2, 3 | API contract works end-to-end |
-| SFR NAT traversal via UPnP | 4 | P2P reachability confirmed |
-| STUN external address discovery | 4 | Confirmed/fallback documented |
-| On-the-fly per-chunk encryption | 5 | GEK + HKDF chunk derivation + ChaCha20 |
-| Chunk signature and verification | 5 | Ed25519 sign/verify before decryption |
-| Real P2P file transfer | 5 | No hub in data path |
-
-### NOT in scope
-
-- Database (all in-memory)
-- HTTPS / TLS (HTTP for POC)
-- QUIC transport (plain TCP)
-- GEK bundle distribution via hub (GEK sent in-band for POC)
-- Group management
-- Chat / Double Ratchet
-- Mesh Group Index
-- MHP federation
-- Android client
-- Module system
-- Persistence between restarts
-
----
-
-## Spike Order Dependency Graph
-
-```
-Spike 1 (crypto)
- └──→ Spike 2 (hub skeleton)
- └──→ Spike 3 (node registration)
- └──→ Spike 4 (NAT traversal)
- └──→ Spike 5 (encrypted transfer)
-```
-
-Spike 1 is a prerequisite for all others. Spikes 2 and 3 can overlap if two people work in parallel. Spike 4 can begin independently once Spike 3 is running.
diff --git a/docs/second-review.md b/docs/second-review.md
new file mode 100644
index 0000000..36caabb
--- /dev/null
+++ b/docs/second-review.md
@@ -0,0 +1,870 @@
+# MeshBay — Second Architecture & Security Review
+
+> Date: 2026-08-13
+> Scope: architecture and security design review of the hub ↔ node ↔ client protocol,
+> as specified in draft v4 and the Phase 1–12 log (both archived in `old-draft.md`), `devel-phases-next.md`,
+> and as **implemented** in `packages/` (Phases 1–12 + 10b/10c).
+>
+> Unlike `first-review.md` (2026-08-10), which was a design-level review, this one reads
+> the code that implements the protocol: `protocol.py`, `webrtc_server.py`, `quic_server.py`,
+> `server.py`, `http_server.py`, `daemon.py`, `bundle_store.py`, `ui/app.py`, the hub API
+> routers, and the browser client (`transport.js`, `crypto.js`, `keyderive.js`, `app.js`).
+>
+> Finding numbering is **independent** of `first-review.md`. All C1/H1/M1 references below
+> are new.
+>
+> Not verified: the test suite could not be run (`pytest` is not installed in `.venv`), so
+> the "191 tests" claim is taken at face value. No live testing against meshbay.org was done.
+> This is a code and design review, not a penetration test.
+
+---
+
+## 1. Executive summary
+
+The cryptographic core remains sound: ECIES GEK wrapping, HKDF domain separation, AEAD
+chunk encryption, Ed25519 JWT with `jti`, refresh-token rotation. The *ideas* added since
+the first review — GEK-HMAC handshake proof, DTLS channel binding, Ed25519 admin
+challenge-response, password split, node sovereignty — are the right ideas, and several of
+them are genuinely clever.
+
+**But the implementation does not enforce the model the documents describe.** The
+protection added in Phases 11–12 lives almost entirely on the WebRTC path, while three
+other paths into the same node (HTTP API, QUIC, TCP) were left as they were. The most
+serious result is that **every private group served by a node daemon is exposed in
+plaintext, without any authentication, over the node's HTTP API on `0.0.0.0`**. That single
+defect nullifies the entire GEK-proof / node-sovereignty layer for anyone who can reach the
+node's HTTP port.
+
+Answering the question directly:
+
+> *"The client and the node want to communicate safely, with everything encrypted and
+> unreadable by other parties, even the hub. Does it do what it claims?"*
+
+**Partially, and not today.**
+
+- Against a **passive/honest-but-curious hub**: yes for file content. The hub never sees
+ the GEK, never sees chunks, and is out of the data path after signaling. This part works.
+- Against an **active malicious hub**: **no.** The hub is the public-key directory. When a
+ member invites someone, the inviter fetches the invitee's `pk_x25519` *from the hub* and
+ wraps the GEK for it (`app.js:1399-1410`). A hub that returns its own key gets the GEK for
+ that group. This is documented as trust assumption **T2** but it is not a residual risk —
+ it is a complete break of the confidentiality claim, requiring no exotic capability.
+- **"Everything encrypted"**: **no.** Chat messages are plaintext on the wire (application
+ layer) and plaintext at rest in SQLite. The Mesh Group Index is sent in cleartext over the
+ WebRTC DataChannel. Uploads are transmitted and stored in plaintext. Files are stored in
+ plaintext on the node by design.
+- **"Unreadable by other parties"**: it is readable by every group member, by the node
+ operator, and — via the findings below — by anyone who can reach the node's HTTP port or
+ who can hijack a node's signaling registration on the hub.
+
+There are **6 critical** and **7 high** findings. Most are not exotic crypto issues; they
+are missing authorization checks and paths that were never brought up to the level of the
+newest path. None of them invalidate the architecture — all are fixable inside the existing
+design — but the current build should not be described as end-to-end secure, and should not
+host real private data until C1–C6 are closed.
+
+---
+
+## 2. What is solid
+
+Worth recording, because the delta since the first review is real:
+
+1. **GEK-HMAC handshake proof with DTLS channel binding** (`webrtc_server.py:294-335`,
+ `crypto.js:235-246`). Binding `HMAC(GEK, nonce ‖ offer_fp ‖ answer_fp)` to the DTLS
+ fingerprints of both sides is a correct, well-chosen defence: a signaling relay that
+ substitutes its own fingerprints cannot produce a proof the node accepts. The Chrome
+ raw-SDP workaround (`transport.js:127`) shows this was actually made to work, not just
+ specified.
+2. **Deny-by-default on destructive operations** (`webrtc_server.py:750-754`). If no key is
+ pinned, deletion is refused. Correct posture.
+3. **Ed25519 node→hub authentication** with a domain-separated message
+ (`meshbay:node_auth:{username}:{timestamp}`, `nodes.py:55`) and a node-scoped JWT that
+ `require_user_scope` refuses for mutations. Clean.
+4. **Refresh-token family rotation with reuse detection** (`users.py:213-267`). Textbook
+ OAuth BCP.
+5. **HKDF domain separation** is consistent and the AES/ChaCha20 variants are properly
+ separated by info string (`:aes` suffix), so the two ciphers can never derive the same
+ key from one GEK.
+6. **AEAD-only chunk wire format.** Dropping per-chunk Ed25519 signatures in favour of
+ AES-GCM tags (Phase 9.15) is defensible: the tag authenticates the ciphertext under a key
+ only members hold. (It does have a consequence — see H3.)
+7. **The trust-domain separation in §4.2.x of draft-v4 is the right model.** "The hub
+ certifies identity; the node authorizes content operations" is exactly the correct
+ framing for this system. The problem is enforcement coverage, not the model.
+
+---
+
+## 3. Critical findings
+
+### C1 — Private group content is served in plaintext with no authentication (node HTTP API)
+
+**Location:** `transport/http_server.py:115-160`, wired in `daemon.py:341-366`
+
+The daemon starts `create_http_app()` for **every configured group**, private ones included,
+bound to `0.0.0.0:http_port` (default 19001).
+
+Two endpoints have **no authentication of any kind** — no JWT, no group check, no GEK proof:
+
+```python
+@app.get("/index") # http_server.py:115 — full file listing, no auth
+@app.get("/file/{file_id}") # http_server.py:141 — FileResponse(path) — raw plaintext file
+```
+
+`download_file` reads the file straight off disk and streams it. The `gek` parameter is only
+consulted by the *chunk* endpoint (`/file/{id}/{chunk}`), and even that one accepts **any**
+JWT signed by the hub — no group-membership check, no GEK proof.
+
+The docstring says "Note: this server handles PUBLIC content only", but nothing in the code
+enforces it: the daemon passes the private group's `shared_root` and index unconditionally.
+
+**Impact.** Complete bypass of the entire Phase 12 sovereignty layer. Anyone who can reach
+the port gets the full private index and every private file in cleartext:
+- anyone on the node operator's LAN/VLAN (guest WiFi, roommate, compromised IoT device);
+- anyone on the Internet if the operator forwarded the port (the docs encourage port
+ forwarding for NAT edge cases) or has a permissive IPv6 firewall;
+- any local process/user on the machine.
+
+No JWT forgery, no hub compromise, no GEK required. This is the single most severe issue in
+the codebase and it silently negates NS1/NS3/R22 in the documentation.
+
+**Fix.** Bind to `127.0.0.1` at minimum. Then: refuse to start the HTTP app at all for
+groups with `visibility = "private"`; require a valid JWT *and* group membership on every
+endpoint including `/index` and `/file/{id}`; never serve plaintext bytes for a group that
+has a GEK. Better: delete this server. It predates the WebRTC/QUIC paths and duplicates them
+without any of their controls.
+
+---
+
+### C2 — Any authenticated user can hijack a node's identity on the hub (WebSocket)
+
+**Location:** `api/revocation.py:131-163`
+
+```python
+decoded = decode_access_token(msg["token"])
+node_id = msg.get("node_id") or decoded.get("sub", "unknown") # ← client-supplied
+_connected_nodes[node_id] = ws
+group_ids = msg.get("group_ids", []) # ← client-supplied
+_node_groups[node_id] = group_ids
+```
+
+The hub accepts whatever `node_id` and `group_ids` the connecting party claims. There is no
+check that the JWT subject owns that node record, and no check that `scope == "node"`.
+
+**Impact — this is a full client-impersonation primitive.** Any registered user can:
+
+1. Connect to `/v1/nodes/ws` with their ordinary user JWT and claim the `node_id` of a
+ victim node, overwriting the legitimate entry in `_connected_nodes`.
+2. All subsequent `POST /v1/nodes/{node_id}/webrtc/offer` requests from browsers are relayed
+ to the **attacker** (`signaling.py:56`), who answers with their own SDP.
+3. The victim's browser now has a DataChannel to the attacker, believing it is the node.
+
+The DTLS channel binding does *not* help here: the attacker is the endpoint, not a relay.
+The browser sends its GEK proof to the attacker, who simply ignores it and replies
+`handshake_ack` — `transport.js:204` only checks `ack.type === 'handshake_ack'`.
+
+The attacker then receives:
+- the victim's **encrypted keypair bundle** (`storeKeypairBundle`, `app.js:850`) → offline
+ password brute-force target (see C4);
+- every chat message the victim sends (plaintext);
+- every file the victim uploads (plaintext);
+- and can serve a forged index and forged chat history.
+
+Also: `group_ids` is attacker-controlled, so the attacker can advertise as an online node for
+any group and appear in `GET /v1/groups/{id}/nodes` — the browser picks `nodes[0]`
+(`app.js:822`) with no further verification.
+
+**Fix.** Require `scope == "node"`; look up the `Node` row and verify `node.user_id ==
+payload["sub"]`; derive `group_ids` from the database (`GroupMember` for that user), never
+from the message; reject a second registration for an already-connected `node_id` instead of
+overwriting it.
+
+---
+
+### C3 — The node never authenticates itself to the client
+
+**Location:** `transport.js:56-215`, `app.js:808-827`, `webrtc_server.py:351-362`
+
+Authentication is one-directional. The client proves its identity (JWT) and its membership
+(GEK-HMAC). The node proves *nothing*:
+
+- `handshake_ack` carries `node_pk` but there is no signature over anything — possession of
+ `sk_node` is never demonstrated.
+- The browser fetches `pk_node` from `GET /v1/groups/{id}/nodes` and then **discards it**;
+ `app.js:822` uses only `nodes[0].node_id`.
+- Per-chunk Ed25519 signatures were removed in Phase 9.15, so no later message proves node
+ identity either.
+
+The only implicit authentication is possession of the GEK, and it only covers *file chunks*
+(they will not decrypt otherwise). Everything else — the index, chat history, `is_node_admin`,
+`handshake_challenge`, and everything the client *pushes* — is unauthenticated.
+
+**Impact.** Enables C2 end-to-end, and independently means a hub that returns an attacker's
+`node_id` for a group achieves the same result. `is_node_admin` is trusted by the SPA
+(`app.js:829`) to decide which controls to display, and it comes from an unauthenticated
+peer.
+
+**Fix.** Mutual proof in the handshake. Simplest correct version: the node returns, alongside
+its challenge, `HMAC(GEK, "meshbay:node_proof:v1" ‖ nonce_c ‖ offer_fp ‖ answer_fp)` over a
+client-supplied nonce, and the client verifies it before sending anything sensitive. Add
+`Ed25519(sk_node)` over the same transcript and have the client pin `pk_node` from the hub
+(TOFU + change alerts), so that node identity does not rest on a group-shared secret.
+
+---
+
+### C4 — Users' encrypted private-key bundles are handed to third parties, and are only PBKDF2-protected
+
+**Location:** `webrtc_server.py:194-197, 451-492`, `bundle_store.py:84-100`,
+`keyderive.js:74-87`, `app.js:848-856`
+
+Phase 12 moved keypair bundles off the hub and onto nodes. Three problems compound:
+
+1. **The bundle is served before the GEK proof.** In `_handle_message`, both
+ `GEK_BUNDLE_FETCH` and `KEYPAIR_BUNDLE_FETCH` are dispatched on the condition
+ `self._gek_challenge is not None` — i.e. after JWT verification but **before**
+ `_do_handshake_response` has validated anything. (`_gek_challenge` is even set on the
+ error path where the group has no GEK, `webrtc_server.py:279-292`.) A hub that forges a
+ JWT for user X — trivial, it holds the signing key — retrieves X's encrypted keypair
+ bundle without ever possessing the GEK. This is a chicken-and-egg the design has to solve,
+ but as written the pre-proof window is a data-disclosure window.
+
+2. **The bundle is pushed to every node the user connects to.** `app.js:848` pushes
+ `_pendingBundlePush` to whichever node the group connection landed on. Join five groups
+ hosted by five different people and five unrelated operators now hold your private-key
+ bundle on their disk.
+
+3. **The bundle is protected only by PBKDF2-SHA512, 600 000 iterations**
+ (`keyderive.js:23,74-87`), salted with `SHA-256("meshbay:bundle:v1:" + username)` — a
+ deterministic, non-random salt.
+
+Consequence: the "password split" (T1) does not deliver what §4.2.x claims. It is true that
+the hub cannot *derive* `bundle_key` from `auth_key`. It is not true that the hub is
+therefore locked out: the hub obtains the bundle by forging a JWT (path 1) and then runs an
+offline dictionary attack that costs **only PBKDF2**, not the Argon2id-256MB the hub's own
+password verifier is protected by. The user's password is the last line of defence, and it
+is defended by the *cheaper* of the two KDFs. Recovering it yields `sk_ed25519` and
+`sk_x25519` → unwrapping every GEK bundle → all groups, all content, plus the ability to
+sign as that user.
+
+Every node operator whose group you join gets the same offline target (path 2).
+
+**Fix.** Ranked:
+- **Do not store keypair bundles on other people's machines.** This is the wrong home for
+ them. A native client keeps keys in a local OS-protected keystore; the browser can keep them
+ in IndexedDB with an explicit, user-initiated encrypted export.
+- If the bundle must be remotely recoverable, protect it with Argon2id (256 MB) via WASM, not
+ PBKDF2, and use a random per-user salt fetched alongside the bundle.
+- Serve it only *after* a successful GEK proof, and only from the user's own node.
+- Separate the bundle key from the login password entirely (recovery phrase), so that
+ cracking one does not yield the other.
+
+---
+
+### C5 — Any group member can overwrite arbitrary files in the shared directory, and can seize the group key
+
+Two independent authorization gaps in the MNP handlers, both reachable by any authenticated
+group member (the GEK proof does not distinguish members from each other).
+
+**C5a — Upload overwrites anything** (`webrtc_server.py:683-726`)
+
+```python
+safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_")
+...
+final_path = shared_root / safe_name
+tmp_path.rename(final_path) # unconditional overwrite
+```
+
+Path traversal is blocked, but nothing prevents overwriting an existing file. There is no
+size limit, no quota, no per-user restriction, no operator approval. So:
+- any member can destroy or replace any file at the root of the shared directory —
+ a direct violation of "the node operator is the sole authority over content";
+- and this **bypasses the deletion controls entirely**: overwrite the victim's file, then
+ `_register_uploader` (`:727-736`) tags the entry with *your* `uploader_pk`, after which you
+ can legitimately delete it via the uploader path (`:798-806`);
+- disk-fill DoS is unconstrained.
+
+**C5b — GEK bundle store has no authorization, and auto-activates**
+(`webrtc_server.py:391-449`)
+
+`_do_gek_bundle_store` writes whatever `(group_id, user_id, bundle)` the caller supplies, with
+no check that the caller is the group admin or the node operator, and `INSERT OR REPLACE`
+overwrites existing bundles. Then:
+
+```python
+if node_user_id and target_user_id == node_user_id and group_id:
+ await self._try_activate_gek(group_id, target_user_id) # unwraps and swaps the live GEK
+```
+
+The node operator's `pk_x25519` is public (it is even handed out in `handshake_ack` as
+`node_pk_x25519`, `:359-361`). So any member can wrap a **GEK of their own choosing** for the
+operator's key, store it, and the node will unwrap it and replace the group's active GEK.
+Result: all existing content becomes undecryptable for the legitimate members, and the
+attacker controls the key used from that point on. A member can also silently overwrite other
+members' bundles to lock them out.
+
+**Fix.** Uploads: quarantine to `.uploads/{user_id}/`, refuse to overwrite an existing index
+entry, enforce quotas and a max file size, and require operator opt-in for writes outside the
+upload directory. GEK bundles: require an Ed25519 challenge-response against the pinned admin
+key for `gek_bundle_store`, and never auto-activate a GEK from a peer message — GEK
+initialization belongs to the local admin UI only, which is already implemented
+(`ui/app.py:175-260`).
+
+---
+
+### C6 — The GEK proof only exists on the WebRTC path; QUIC and TCP accept a JWT alone
+
+**Location:** `quic_server.py:148-186`, `server.py:134-165` vs `webrtc_server.py:242-335`
+
+Draft-v4 §4.2.x states: *"ALL operations require passing the GEK proof first."* That is true
+only for `webrtc_server.py`. The QUIC server (started on `::` port 19000) and the TCP server
+(started on `0.0.0.0` port 18001) still perform the Phase 7 handshake: verify JWT → check
+`groups` claim → `handshake_ack`. No challenge, no proof.
+
+**Impact.** A forged JWT (hub) or a stolen JWT reaches the node over QUIC/TCP and can:
+- fetch index and chunks — these are GEK-encrypted, so confidentiality holds *there*;
+- **inject chat messages** into the group store — `_do_chat_message_sync` in `quic_server.py`
+ stores and broadcasts plaintext payloads without any GEK involvement. Chat injection and
+ impersonation of the group's discussion with nothing but a hub-signed token.
+- consume node resources without ever holding the group key.
+
+It also means the denylist/GEK/sovereignty story has to be reasoned about per-transport,
+which is exactly the kind of divergence that produces the next C1.
+
+**Fix.** Factor the handshake (JWT → denylist → group claim → GEK challenge → proof → ack)
+into one function in `meshbay_common` and call it from all three transports. If native
+clients are not using QUIC/TCP yet, disable those listeners by default until they are brought
+to parity.
+
+---
+
+## 4. High findings
+
+### H1 — Cross-group data leakage on multi-group nodes (chat store and peer set)
+
+**Location:** `daemon.py:249`, `webrtc_server.py:601-681, 617, 343-345`
+
+The daemon builds a proper per-group context (`groups_ctx[gid]["chat_store"]`,
+`daemon.py:219-226`) and then sets a single global one:
+
+```python
+self._webrtc._ctx["chat_store"] = first.get("chat_store") # daemon.py:249 — the FIRST group
+```
+
+Both chat handlers read from the *top-level* context, not the group context:
+
+```python
+chat_store = self._ctx.get("chat_store") # webrtc_server.py:602 and :650
+```
+
+So on a node hosting several groups, **all groups write into the first group's chat database,
+and `chat_hist` serves that database to members of every group.** Members of group B read
+group A's private conversation.
+
+The same bug affects broadcast: `_peers` lives in the shared `_ctx` (`:974`, `:343-345`), so
+`_do_chat_message` (`:617-632`) fans out every message to **all connected peers on the node,
+regardless of group**.
+
+**Fix.** `chat_store` and `_peers` must come from `self._group_ctx()`, with one peer registry
+per group. Add a test with two groups and two users that asserts isolation.
+
+---
+
+### H2 — Stored XSS in the node admin UI via uploaded filename → node takeover
+
+**Location:** `ui/app.py:359-365` (and `:632-639` for the audit page)
+
+```python
+file_rows += f"<tr><td>{e.name}</td><td>{e.type}</td>..."
+```
+
+Filenames are interpolated into HTML with no escaping. The upload sanitizer
+(`webrtc_server.py:701`) strips path separators but not `<`, `>`, `"`. Any group member can
+upload a file named `<img src=x onerror="fetch('/api/groups/GID/gek',{method:'POST'})">`.
+
+The local UI has **no authentication at all** (by design, "localhost only"). So when the
+operator opens `http://localhost:18000`, attacker JavaScript runs with full access to the node
+admin API: re-initialize/rotate the GEK, enumerate all groups and shared paths, read the whole
+audit log (users, IPs, actions), read the config. The audit page builds rows with `innerHTML`
+from `e.detail`, which also carries filenames — same vector, different page.
+
+**Fix.** Escape all interpolated values (`html.escape`), use `textContent` in the audit page,
+sanitize uploaded filenames to a conservative allowlist, and add a CSP header to the UI app.
+Consider a localhost token in the URL to blunt DNS-rebinding against the unauthenticated UI.
+
+---
+
+### H3 — An active hub breaks confidentiality through key substitution (T2 is not a residual risk)
+
+> **CLOSED 2026-08-14.** Not by the fix proposed below. The invite path no longer reads
+> the directory at all: the node holds the GEK and wraps it for a key the recipient
+> proves possession of over the authenticated channel, and identities are bound to
+> accounts by one-time codes the hub never sees. Safety numbers would have made the
+> substitution *detectable by a human who checks*; removing the lookup makes it
+> impossible. See `invite-pairing-v1.md` and draft-v5 §5.5.
+>
+> `gek-init` had the same flaw with the node as the victim — it fetched every member's
+> public key from the hub and wrapped for the answer. That is gone too.
+
+**Location:** `app.js:1389-1415`, `users.py:340-358`, `users.py:310-337`
+
+The invite flow is: fetch `pk_x25519` for the invitee **from the hub**, wrap the GEK for it,
+store the bundle on the node. The hub is the sole key directory, and `PUT /v1/users/me/keys`
+lets keys be replaced at any time.
+
+A malicious hub returns its own X25519 key for the invitee. The inviting member wraps the GEK
+for the hub. The hub now holds the group key and can decrypt every chunk it can obtain —
+including chunks captured via C1, C2, or C6. No JWT forgery needed, no JS injection needed,
+nothing detectable by the client.
+
+The documents list this as **T2** under "remaining trust assumptions", alongside T3 (hub
+serves the SPA). That framing understates it: with T2 open, the sentence "unreadable by other
+parties, even the hub" is not true against an adversarial hub, and the GEK-HMAC/sovereignty
+work in Phase 12 does not change that, because the hub obtains the GEK legitimately.
+
+**Fix.** Out-of-band key verification is the only real answer: safety numbers / fingerprint
+comparison, key-change warnings ("Alice's key changed on 2026-08-13 — verify before sharing"),
+and key transparency (a signed append-only log of key bindings the client audits). Until then,
+the honest claim is *"the hub cannot read your content unless it actively attacks you."*
+
+---
+
+### H4 — Group revocation never reaches nodes; jti denylist is volatile
+
+**Location:** `daemon.py:316-330`, `revocation.py:82-96`
+
+The hub signs revocation tokens with `target ∈ {"user", "group"}` and broadcasts them. The
+node handler only implements two cases:
+
+```python
+if target == "user": denylist.deny_user(tid)
+elif target == "jti": denylist.deny_jti(tid)
+# target == "group" → silently dropped
+```
+
+So `POST /v1/admin/revoke` for a group marks it revoked in the hub DB and does nothing on any
+node. Combined with the fact that `webrtc_offer` (`signaling.py:44-85`) checks neither group
+status nor membership, "suspend a group blocks signaling" (draft-v4 §4.2.x) is not true — a
+client holding a `node_id` and a still-valid JWT keeps connecting. The denylist is also
+in-memory only (`Denylist()`), so it is cleared by any node restart.
+
+**Fix.** Handle `target == "group"` on the node (drop sessions, refuse handshakes for that
+group); check group status in `webrtc_offer`; persist the denylist to `data_dir` with
+expiry-based pruning.
+
+---
+
+### H5 — The Ed25519 admin challenge is an unbound signing oracle
+
+**Location:** `webrtc_server.py:756-763`, `keyderive.js:249-256`
+
+```python
+challenge = os.urandom(32) # node → client
+```
+```js
+const sig = await crypto.subtle.sign('Ed25519', sk, challenge); // client signs 32 raw bytes
+```
+
+The client signs 32 arbitrary bytes chosen by the node, with its long-term identity key,
+with no domain separator, no context, and no length constraint. The signed payload does not
+mention "file_delete", the `file_id`, the group, the node, or a timestamp.
+
+Consequences:
+- A malicious or compromised node can request a "deletion" and obtain a signature over any
+ 32-byte string it likes. `meshbay:node_auth:{username}:{timestamp}` is exactly 32 bytes for
+ a 3-character username — currently not exploitable because node auth verifies against
+ `pk_node_ed25519` rather than the user identity key, but that separation is a coincidence of
+ the current schema, not a designed defence.
+- Signatures are not bound to the operation, so a captured signature is reusable for any
+ future challenge that happens to repeat (it will not, but nothing structurally prevents
+ replay across contexts either).
+
+**Fix.** Sign a structured, domain-separated transcript:
+`Ed25519(sk, "meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ timestamp)`,
+and have the client display *what* it is signing. Apply the same rule to every future
+challenge (this is a protocol-wide invariant, not a one-off fix).
+
+---
+
+### H6 — Unauthenticated resource exhaustion on nodes
+
+Several unbounded paths, all reachable by any hub user (no group membership needed for some):
+
+| Vector | Location | Effect |
+|---|---|---|
+| `POST /v1/nodes/{id}/webrtc/offer` | `signaling.py:44` — any authenticated user, no membership check, no rate limit | Node allocates an `RTCPeerConnection` + ICE gathering per request; `_sessions` grows |
+| DataChannel receive buffer | `webrtc_server.py:121-139` — `MAX_MSG = 64 MB`, buffer grows before handshake | Memory exhaustion by claiming a 64 MB frame and dribbling bytes, pre-auth |
+| `stream_req` | `webrtc_server.py:828-906` — spawns `ffmpeg` per request, no concurrency cap | CPU/process exhaustion by any member |
+| `stream_seg` | `webrtc_server.py:573-590` — **synchronous `subprocess.run(timeout=30)` inside the event loop** | One request blocks the entire node for up to 30 s |
+| `file_upload` | `webrtc_server.py:683` — no size/quota limit | Disk fill |
+| `POST /v1/nodes/{id}/incoming` | `revocation.py:202` — any user picks `peer_ip`/`peer_port` | Node emits UDP probes to arbitrary destinations (small reflection primitive) |
+
+**Fix.** Per-user connection caps and rate limits on signaling; membership check before
+relaying an offer; cap the pre-handshake buffer at a few KB; a semaphore around ffmpeg;
+make `stream_seg` async or delete it (superseded by `stream_req`); upload quotas; validate
+that `peer_ip` matches the requester's source address.
+
+---
+
+### H7 — Swarm registration publishes private-group file hashes to the hub (currently masked by a routing bug)
+
+**Location:** `daemon.py:382-387, 501-506`, `groups.py:120`, `hub_client.py:277-294`
+
+The daemon registers the blake3 hashes of **every group's** files with the hub swarm table,
+private groups included — there is no visibility filter. Draft-v4 §7.3 describes the swarm as
+a *public content* mechanism.
+
+Right now this fails silently: the route is declared as `@router.post("/v1/swarm/register")`
+on a router with `prefix="/v1/groups"`, so it is mounted at `/v1/groups/v1/swarm/register`,
+while the node posts to `/v1/swarm/register` → 404, swallowed by `except Exception: pass`.
+
+**Impact.** The bug is currently protecting privacy. Fixing the path without adding a filter
+would immediately leak, to the hub, a content-identifier fingerprint of every private file
+on every node — enough for the hub (or anyone with `GET /v1/swarm/{hash}`, which requires no
+auth) to confirm "does this known file exist in the network, and which node has it". That is
+precisely the metadata the "hub stores no content metadata" claim rules out.
+
+**Fix.** Register hashes only for groups with `visibility == "public"`, fix the route, and
+require authentication on the lookup endpoint.
+
+---
+
+## 5. Medium findings
+
+**M1 — `group_id` is optional in the handshake, which skips the membership check.**
+`webrtc_server.py:257,261` guard on `if group_id and ...`. With `group_id = ""` both checks
+are skipped and `_group_ctx()` (`:506-509`) falls back to `self._ctx`, which the daemon
+populates with the **first group's** gek/index/shared_root (`daemon.py:238-247`). Access still
+requires that group's GEK, so it is not a full bypass — but a user removed from the group on
+the hub who kept the GEK regains access, and the JWT `groups` claim stops being authoritative.
+Make `group_id` mandatory.
+
+**M2 — Argon2id in the node keystore is still 64 MB.** `crypto.py:173-174`
+(`ARGON2_MEMORY_COST = 65536`) with a comment saying to raise it. Only the hub's password
+verifier got the 256 MB bump (`auth.py:30-35`). The docs record R1/8.10 as done, which is true
+for the hub and false for the keystore. Also, `create_keystore` accepts an 8-character
+minimum password, and the calibration command prints instructions to hand-edit a constant in
+`meshbay_common` rather than writing a per-node parameter — so the keystore parameters cannot
+actually be tuned per hardware as §4.2.1 promises.
+
+**M3 — Node operator cannot delete files in the default configuration.** *(CLOSED
+2026-08-14 — the auto-pin is deleted; authority comes from the node's roster, established
+locally by `meshbay-node operator pair`. Asking the hub for the operator's key, the
+obvious-looking fix, would have let the hub install itself as node administrator.)*
+`_resolve_admin_pk`
+(`daemon.py:451-465`) auto-pins the **node keystore's** Ed25519 key, while the browser signs
+challenges with the **user identity** key from the keypair bundle (`app.js:983`). These are
+different keys, so verification fails unless the operator manually sets `admin_pk_ed25519` to
+their browser key. Fails closed, so it is a correctness problem rather than a hole — but the
+sovereignty feature is effectively inert as shipped, and the mismatch will invite the wrong
+fix (relaxing the check) unless it is documented.
+
+> **C4 — REDUCED 2026-08-14, not closed.** The bundle's KDF moved from PBKDF2-SHA512
+> 600k to Argon2id 128 MB/t=3 in the browser (vendored WebAssembly), so an operator
+> attacking one offline no longer enjoys the GPU economics of a compute-only KDF. The
+> pre-proof window is unchanged and still bounded. What remains: bundles are still stored
+> on every node their owner joins, and a weak passphrase still loses — draft-v5 §7.1 gives
+> the measured numbers. It closes at 13.3.
+
+**M4 — Response-to-request matching by arrival order.** `transport.js:390-422` resolves the
+**oldest** pending promise with whatever message arrives, ignoring type. With the 8-deep
+pipelined download window, a node that reorders responses (or an `error` message arriving
+mid-flight) resolves the wrong promise. Add a request id (`rid`) to MNP and echo it in
+responses — cheap, and it also removes the `index_sync` special case at `:408-415`.
+
+**M5 — Index and chat are not encrypted on the WebRTC path.** `_do_index_sync`
+(`webrtc_server.py:511-528`) sends entries as cleartext msgpack; the QUIC/TCP path uses
+`GroupIndex.serialize()` which *is* GEK-encrypted. So the same object has two different
+protection levels depending on transport, and draft-v4 §8.2 ("GEK-encrypted, hub stores
+opaque") describes only one of them. With DTLS in place this is not remotely readable, but it
+means the security property depends entirely on the transport rather than on the data.
+
+**M6 — IP audit log corruption on registration.** `users.py:118-122`:
+
+```python
+await db.execute(IPLog.__table__.update().where(IPLog.user_id == None).values(user_id=user.id))
+```
+
+This backfills **every** IPLog row that has a NULL `user_id` — including failed-login rows for
+other usernames and other users' registrations — with the newly created user's id. For logs
+kept for a year specifically to answer legal requests, this is a data-integrity defect that
+attributes other people's connections to the wrong account. Set `user_id` on the row you just
+created (flush first, or add the row after `db.refresh(user)`).
+
+**M7 — `X-Forwarded-For` is trusted unconditionally.** `users.py:361-365`, `groups.py:313`,
+`nodes.py:131`. Behind Caddy this is fine today; if the hub is ever reachable directly, or a
+second proxy is added, any client can forge the IP written into the compliance log and evade
+per-IP rate limiting. Use a trusted-proxy list and take the rightmost untrusted hop.
+
+**M8 — `announce_node` accepts any `pk_node`.** `nodes.py:88-109` — a user can announce a node
+record containing someone else's public key, and node records accumulate without limit. Combine
+with C2 for a more convincing impersonation. Verify possession (sign a challenge with
+`sk_node`) and enforce one active node record per user unless multi-node is intended.
+
+**M9 — Node accepts node-scoped tokens as client tokens.** All three transports call
+`jwt.decode` without inspecting `scope` (`webrtc_server.py:246`, `quic_server.py:153`,
+`server.py:141`). A node-scoped token (which is also issued with the full `groups` claim,
+`nodes.py:66-71`) is accepted as a regular client anywhere. Check `scope == "user"` on the
+client path.
+
+---
+
+## 6. Low findings / notes
+
+- **L1 — Dead protocol constants.** `GEK_REQUEST`/`GEK_RESPONSE` remain in `protocol.py:32-33`
+ though the handlers are gone (NS3 says "removed"). Delete them so the wire contract matches
+ the docs.
+- **L2 — No MNP version negotiation.** Every message carries `v: "0.1"` and nobody checks it
+ (`webrtc_server.py`, `transport.js`). §3 of draft-v4 specifies range negotiation and an
+ explicit refusal. Currently a version mismatch would fail in undefined ways. Phase 13.6
+ covers this — keep it.
+- **L3 — Error strings leak internals.** `webrtc_server.py:224-226` and `server.py:127` return
+ `str(e)` to the peer, which includes filesystem paths and exception detail.
+- **L4 — `hmacGEK` concatenates without length prefixes** (`crypto.js:235-246`,
+ `webrtc_server.py:326`). With fixed-size inputs this is unambiguous today; if a fingerprint
+ is ever missing (the extractors return empty on failure) the concatenation becomes ambiguous
+ and the proof silently degrades to nonce-only. Prefix lengths, and **reject** empty
+ fingerprints instead of proceeding.
+- **L5 — No security headers / CSP on the hub** (`app.py:97-126`). For an application whose
+ threat model explicitly includes "the hub could inject JS", a strict CSP plus
+ `Subresource-Integrity` on the static bundle at least makes a *silent* injection harder and
+ gives extensions something to pin against.
+- **L6 — `EmailStr` imported but unused** (`users.py:8`, field typed `str`) — no email
+ validation on registration.
+- **L7 — Sender Keys is implemented but unreferenced.** `senderkeys.py` is exercised only by
+ its own tests; no production code imports it. That matches the Phase 13 plan; noting it so
+ the module is not mistaken for an active protection.
+- **L8 — `_register_uploader` matches by name and root path only** (`webrtc_server.py:727-736`)
+ — the first entry with a matching name at the root gets tagged, which is wrong when a file
+ with the same name exists in a subdirectory.
+
+---
+
+## 7. Does the system do what it claims?
+
+> This table is the verdict **on the code as it stood on 2026-08-13**, and is left as the
+> record of what the review found. It is not the current state: Phase 11.5 closed C1–C6
+> and H1–H7 except H3, and the invite redesign closed H3 and M3 on 2026-08-14. For what
+> holds today, and against which adversary, read draft-v5 §2 — never this table.
+
+| Claim (draft-v4) | Verdict | Why |
+|---|---|---|
+| Data never transits a central server | **Yes** | WebRTC DataChannel is genuinely P2P; hub relays SDP only. Well executed. |
+| Hub stores no content, no index, no chat | **Yes** | Confirmed in the schema and routers. GEK bundles are gone from the hub since Phase 12. |
+| E2E encryption for all private content (files, indexes, messages) | **No** | Files: yes. Index: cleartext on the WebRTC path (M5). Chat: plaintext on the wire and at rest (Phase 13 pending). Uploads: plaintext. |
+| Content unreadable by the hub | **Passive hub: yes. Active hub: no** | H3 (key substitution at invite) and C4 (pre-proof keypair-bundle fetch + PBKDF2 cracking) both yield the GEK. T3 (hub-served SPA) is a third path. |
+| Node operator is sole content authority | **No** | C5a (any member overwrites files), C5b (any member seizes the GEK), C1 (anyone reads everything), M3 (operator cannot actually delete). |
+| Hub admin cannot read node content | **No** | C1, and C6 for chat injection. The GEK-proof defence is real but covers one of four paths. |
+| Hub admin cannot delete files | **Yes** | Deny-by-default plus pinned key. Fails closed. Correct. |
+| Suspending a group blocks new connections | **No** | H4 — group revocations are dropped by the node and signaling never checks group status. |
+| Immediate revocation via jti denylist | **Partial** | Works while the node stays up; volatile, and group targets ignored (H4). |
+| Node IPs not persisted | **Yes** | Signaling state is in-memory. But the node's own audit DB stores peer IPs — appropriate, just worth documenting to users. |
+
+**The one-sentence honest version:** *content is encrypted between the browser and the node
+with keys the hub does not hold, and the hub is out of the data path — but the node currently
+gives that content away over an unauthenticated HTTP port, chat is not encrypted at all, and
+a hub that chooses to attack can obtain the group key through the key directory it controls.*
+
+---
+
+## 8. Are the remaining phases enough?
+
+**No — the roadmap does not contain fixes for the findings above.** Mapping the planned work
+onto what was found:
+
+| Planned phase | Addresses | Verdict |
+|---|---|---|
+| 12 — Node CLI + management | M3 partially (a CLI could pin the right admin key) | Useful, not security work |
+| 13.1–13.4 — Sender Keys chat encryption | Part of "chat is plaintext"; nothing else | Necessary but narrower than it looks — see below |
+| 13.5 — Chat retention | Data-minimization only | Good hygiene |
+| 13.6 — MNP version negotiation | L2 | Correct as planned |
+| 14 — Android client | Nothing directly; adds a fourth client to keep in parity | Neutral / new risk |
+| 15 — Resilience (TURN, 0-RTT) | Nothing | Optional |
+| 16 — Packaging + CI | Would catch regressions; 16.4 release signing matters a lot for a native client | Underrated — promote it |
+| 17 — Extension sandbox | Adds a large new attack surface | Should be last, and needs its own review |
+
+**Nothing in the plan addresses C1–C6, H1, H2, H4, H5, H6, or H7.**
+
+A note on Phase 13 specifically, because it is presented as *the* remaining security item:
+Sender Keys protects chat from *someone who is not a group member but holds the node's disk*
+(a compromised node, a seized machine, a hosting provider). It does **not** protect chat from
+the node operator, because on this platform the node operator is a group member and therefore
+a sender-key recipient. It also does not help if the sender keys are distributed "via
+GEK-wrapped channels" as §6.6 describes — that makes them a function of the GEK, so anyone
+with the GEK (C5b, H3) gets them too. Real forward secrecy requires distributing sender keys
+over per-member pairwise channels (the existing `ratchet.py`) keyed to identity keys, not to
+the GEK. Worth settling before writing 13.1.
+
+**Recommendation: insert a remediation phase before Phase 12.** Suggested content, in order:
+
+```
+Phase 11.5 — Security remediation (blocking)
+ 11.5.1 Disable/remove the node HTTP API for private groups; bind loopback [C1]
+ 11.5.2 Authenticate the node WS registration (scope + ownership + DB groups) [C2]
+ 11.5.3 Unify the handshake across WebRTC/QUIC/TCP into meshbay_common [C6]
+ 11.5.4 Mutual handshake proof + pk_node pinning in the client [C3]
+ 11.5.5 Per-group chat_store and per-group peer registry [H1]
+ 11.5.6 Authorize gek_bundle_store; remove GEK auto-activation [C5b]
+ 11.5.7 Upload: no overwrite, per-user quarantine, quotas, filename allowlist [C5a, H2]
+ 11.5.8 Escape all HTML in the node admin UI [H2]
+ 11.5.9 Domain-separate the admin challenge transcript [H5]
+ 11.5.10 Handle group revocation on the node; persist the denylist [H4]
+ 11.5.11 Rate limits and resource caps on signaling, uploads, ffmpeg [H6]
+ 11.5.12 Swarm: public groups only [H7]
+ 11.5.13 Decide the home of keypair bundles (see §9) [C4]
+```
+
+Add regression tests for each: two-group chat isolation, HTTP API refuses private groups,
+handshake parity across transports, upload cannot overwrite, `gek_bundle_store` rejects
+non-admins.
+
+---
+
+## 9. If you build a native client, what changes?
+
+Short answer: **a native client removes the single most fundamental limitation (T3) and lets
+you delete the machinery that exists only to work around the browser — but it does not remove
+any of the findings above, and it adds obligations of its own.**
+
+### What a native client genuinely fixes
+
+- **T3 becomes detectable — it does not disappear.** *(Corrected 2026-08-13; the original
+ text claimed "T3 disappears. Code integrity stops depending on the hub." That was wrong.)*
+ A native client downloaded from `meshbay.org` and signed with a key the hub operator holds
+ relocates the trust from "the JS they serve" to "the binary they serve". What genuinely
+ changes is the **shape of an attack**: in a browser it is one HTTP response, aimed at one
+ user, leaving no artifact — undetectable in principle. Natively it must ship as a build,
+ which is hashable, archivable and comparable between users, so targeting one person means
+ handing them a different binary. That is a real gain, but it is realised **only** by the
+ verification machinery — reproducible builds, published hashes, independent rebuilds
+ (Phase 18.7) — not by the packaging format. Native also costs the browser sandbox, transfers
+ patch velocity for WebKitGTK and every bundled dependency onto the project, and adds new
+ attack surface (loopback media server, IPC bridge, updater). A **browser extension**
+ distributed through Mozilla/Chrome — a channel the hub operator does not control — achieves
+ most of the same benefit while keeping the sandbox. See `tmp-decisions.md`.
+- **Real key storage.** OS keychain / Argon2id-encrypted local keystore, already implemented
+ in `keystore.py`. Keys never leave the device, so **C4 evaporates** — no keypair bundles,
+ no PBKDF2-only protection, no third-party nodes holding your private keys.
+- **Real crypto.** ChaCha20-Poly1305, Argon2id at 256 MB, constant-time primitives — no
+ WebCrypto ceiling. The whole `webcrypto.py` / `:aes` dual-cipher split becomes unnecessary
+ (keep it only while browser clients exist).
+- **Password never transmitted.** The node already authenticates with Ed25519 challenge-
+ response (`nodes.py:30-80`). Clients can do the same, and then `auth_key`/`bundle_key`, the
+ password split, pw_versions and the legacy migration path all go away — a large reduction in
+ code and in attack surface.
+- **Key verification becomes practical.** Safety numbers, TOFU pinning of `pk_node` and of
+ contacts' identity keys, and persistent warnings on key change — the fix for H3. This is
+ achievable in a browser but far more credible in a client the hub does not serve.
+
+### What becomes unnecessary (delete, don't port)
+
+| Component | Reason |
+|---|---|
+| `keypair_bundle_store/fetch/resp` MNP messages, `keypair_bundles` table | Keys live locally (C4) |
+| `deriveAuthKey` / `deriveEncryptionKey` password split, pw_version 3 | Replaced by Ed25519 auth |
+| `webcrypto.py` AES variant + `:aes` HKDF suffix | Only needed for SubtleCrypto |
+| MSE streaming path (`_probe_video`, ffmpeg fMP4 remux, `stream_init/data/end`) | A native player decrypts and plays directly; the node just serves chunks |
+| Node HTTP file API | Already the source of C1; native clients speak MNP |
+| ~~WebRTC transport, hub signaling relay, DTLS channel binding~~ | **Correction (2026-08-13): keep these.** The original text here said "QUIC + `punch_nat()` is already validated" — that oversold a single-ISP demo. `punch_nat()` (`quic_server.py:446`) is one UDP probe to one address: no STUN client (`aioice` is pulled in by `aiortc` only), no candidate gathering, no dual-stack fallback, and it requires the client to already know its own external IP:port and to connect from a fixed source port. ICE/STUN — validated on 2 ISPs, 2 browsers, IPv4 + IPv6 + 4G CGNAT — is the only NAT traversal actually proven in this project, and it lives in the WebRTC path. A native client keeps it by running `aiortc` in Python (`createDataChannel` + `createOffer`), which preserves every native benefit, since none of them come from the transport. QUIC is retained at parity for LAN, port-forwarded and hub-less `group://` access. |
+| `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage, `_pkFromSk` | Browser-specific persistence hacks |
+
+Note what this means for Phase 12's own accounting: **T3 reduction phases 1–3 were largely
+wasted motion.** Moving GEK and keypair bundles from the hub to nodes did not remove the
+hub's access (it can still forge a JWT and fetch them, C4) and it *spread* the private-key
+bundles across untrusted third-party machines. A native client makes the correct answer
+available: the material should live on the user's own device, not on the hub *or* on other
+people's nodes.
+
+### What is still needed regardless of client type
+
+- **All of C1–C6, H1, H2, H4–H7.** Every one of them is server/node-side. A native client
+ changes none of them.
+- **Phase 13 (Sender Keys)** — still required, and still needs the pairwise-distribution
+ decision above.
+- **Phase 12 (Node CLI)** — arguably *more* important with native clients, since group and
+ GEK management moves out of the browser.
+- **Phase 16 (packaging + CI + release signing)** — becomes **critical**, not optional. Once
+ users install software instead of loading a page, your update channel is the new T3. You
+ need signed releases, a documented key, ideally reproducible builds, and a client that
+ verifies signatures. `16.4` should be promoted alongside the remediation phase.
+- **H3 / safety numbers** — the hub remains the key directory even for native clients. Out-of-
+ band verification is the fix, and it is not currently scheduled anywhere.
+
+### Suggested sequencing
+
+> **Superseded 2026-08-13** — the roadmap was rewritten against these findings.
+> See `devel-phases-next.md` for the authoritative plan. Summary:
+
+```
+Phase 11.5 Security remediation ⛔ blocking, everything else waits
+Phase 12 Hub minimization makes "the hub cannot read" structural
+Phase 13 Native desktop client Electron (2026-08-17); reduces T3, C4 partly
+Phase 14 Node CLI (was Phase 12)
+Phase 15 Sender Keys (was Phase 13) — 15.0 distribution decision first
+Phase 16 Android (was Phase 14) — reuses the Phase 13 design
+Phase 17 Resilience (was Phase 15)
+Phase 18 Packaging + CI (was Phase 16) — signing moved into 13.9
+Phase 19 Extensions (was Phase 17)
+```
+
+Also worth an explicit decision: **do you keep the browser client?** Supporting both means
+maintaining two transports, two crypto stacks, two key-storage models, and two handshake
+implementations — which is exactly how C6 and M5 came about. If the browser client stays, it
+should be positioned honestly as *"convenient access with a weaker trust model — the hub can
+serve you modified code"*, with the native client as the recommended path for anything
+sensitive.
+
+---
+
+## 10. Prioritized action plan
+
+| # | Finding | Severity | Effort | When |
+|---|---|---|---|---|
+| C1 | Node HTTP API serves private content unauthenticated | Critical | S | Immediately — one-line bind change unblocks, proper fix same day |
+| C2 | Node WS identity spoofing → client impersonation | Critical | S | Immediately |
+| C5b | Any member can seize the group GEK | Critical | S | Immediately |
+| C5a | Any member can overwrite shared files | Critical | S | Immediately |
+| C6 | No GEK proof on QUIC/TCP transports | Critical | M | Before any further transport work |
+| C3 | No node authentication to the client | Critical | M | With C2 |
+| C4 | Keypair bundles on third-party nodes, PBKDF2-only | Critical | L | Needs the design decision in §9 |
+| H1 | Cross-group chat leakage | High | S | Immediately |
+| H2 | Stored XSS in node admin UI | High | S | Immediately |
+| H3 | Hub key substitution (T2) | High | L | Safety numbers — schedule explicitly |
+| H4 | Group revocation dropped; volatile denylist | High | S | Phase 11.5 |
+| H5 | Unbound Ed25519 signing oracle | High | S | Phase 11.5 |
+| H6 | Unauthenticated resource exhaustion | High | M | Phase 11.5 |
+| H7 | Private hashes registered in swarm | High | S | Fix before repairing the route |
+| M1–M9 | See §5 | Medium | S–M | Phase 11.5 / 12 |
+| L1–L8 | See §6 | Low | S | Opportunistic |
+
+---
+
+## 11. Conclusion
+
+The architecture is still the right architecture. Hub-as-registrar, node-as-host,
+E2E-to-the-node, GEK-per-group, node sovereignty enforced by cryptography rather than policy —
+these are good decisions, and the Phase 12 work (GEK-HMAC proof, DTLS channel binding,
+Ed25519 admin challenge) shows real security engineering.
+
+The gap is between the documents and the code. Draft-v4 describes a system where every
+operation passes a GEK proof, where the hub admin can read nothing, where the node operator is
+sovereign, and where private content is E2E encrypted. The code implements that on one of four
+paths into the node. The other three — HTTP, QUIC, TCP — are at Phase 4/7 level, and the HTTP
+one hands out private files to unauthenticated callers. Meanwhile the hub retains a decisive
+lever it is documented as not having: it is the key directory, and whoever controls the key
+directory controls the group key.
+
+Two concrete recommendations beyond the fix list:
+
+1. **Make transport parity a structural invariant, not a habit.** One shared handshake
+ function in `meshbay_common`, called by every transport, with a test that fails if a
+ transport skips a step. Every finding in the C6/C1 family exists because a new path was
+ added and the old ones stayed behind.
+2. **Write down the threat model explicitly** — one page: passive hub, active hub, malicious
+ node operator, malicious group member, network attacker, local attacker — and mark for each
+ claim which adversary it holds against. Most of the overstatements in the current docs
+ ("unreadable by other parties, even the hub") come from not distinguishing the passive hub
+ from the active one. Once that page exists, the honest claims are still strong ones, and
+ they will be defensible.
+
+The security posture is recoverable, and most of the critical work is small. But the current
+build should not host real private data, and the project should not advertise end-to-end
+confidentiality until at least C1, C2, C3, C5 and H1–H3 are closed.
diff --git a/docs/tmp-decisions.md b/docs/tmp-decisions.md
new file mode 100644
index 0000000..70f5171
--- /dev/null
+++ b/docs/tmp-decisions.md
@@ -0,0 +1,181 @@
+# Client architecture — decisions
+
+> Created 2026-08-13 after the second security review. D1/D2/D3 decided the same day;
+> D4 (hub minimization) deferred. Fold into `meshbay-draft-v5.md`.
+> The analysis below is kept as the rationale behind the decisions, not as open questions.
+
+---
+
+## Status
+
+| # | Decision | State |
+|---|---|---|
+| D1 | Does the hub keep serving the web UI? | ✅ **DECIDED 2026-08-13 — yes** |
+| D2 | Browser extension, native desktop client, or both? | ✅ **DECIDED 2026-08-13 — native client, offered alongside the hub-served SPA.** Shell revised 2026-08-17: **Electron**, see `desktop-client-v1.md` |
+| D3 | Transport: aiortc primary, QUIC at parity, TCP+HTTP removed | ✅ Decided 2026-08-13. Unchanged for the **node**; the desktop client uses Chromium's WebRTC rather than aiortc, and QUIC via a Python sidecar |
+| D4 | Hub minimization (old Phase 12) | ⏸️ **Deferred, may be dropped** |
+
+> **2026-08-17 — the browser-extension options are no longer live.** 12.2 (signed digest
+> of the served bundle) is what Option A needed, and it remains scheduled; nothing else
+> from A or B is planned. The desktop client is the answer to T3 for users who want one.
+
+**What was decided.** The hub keeps serving the web UI — that is the zero-install path
+and it stays. A native desktop client is offered *in addition*, not as a replacement.
+Hub minimization is off the critical path and may be dropped entirely.
+
+**What that means, stated once and then respected.** Keeping the hub in the trusted path
+is a legitimate product call, and this project is not obliged to defend against its own
+operator. But two consequences should be carried deliberately rather than by accident:
+
+1. **T3 is accepted permanently for browser users.** A hub that serves the code can
+ exfiltrate keys from the page regardless of what the protocol does. The native client
+ gives users who care an alternative; browser users are trusting meshbay.org, and the
+ docs should say so plainly rather than claiming end-to-end integrity.
+2. **H3 was the last open High finding and its only fix lived in the dropped phase.**
+ The hub is the public key directory: substituting a key during an invite hands it the
+ group key, silently, with no forgery and no code injection. So key transparency and
+ safety numbers were kept and are now Phase 12.1 — everything else from hub
+ minimization is dropped. If Phase 12 is later dropped too, H3 stays open by choice,
+ and "unreadable by other parties, even the hub" stops being a claim the project can
+ make about an adversarial hub.
+
+The honest framing that survives all of this: **the hub cannot read your content unless
+it actively attacks you.** That is still a strong property, and it is defensible.
+
+## Rationale — why the native client is not a T3 fix
+
+The second review recommended a native client and claimed *"T3 disappears — code integrity
+stops depending on the hub."* **That claim was wrong and has been corrected** in
+`second-review.md` §9.
+
+If the hub operator is the adversary, a native client downloaded from `meshbay.org` and
+signed with a key that operator holds does not remove the trust — it relocates it from "the
+JS they serve" to "the binary they serve." What actually changes is **detectability**:
+
+- **Browser:** an attack is one HTTP response, targeted at one user, leaving no artifact.
+ Undetectable in principle.
+- **Native:** an attack requires shipping a build. That build is an artifact — hashable,
+ archivable, comparable between users, reversible. Targeting one user means giving them a
+ different binary, which reproducible builds and published hashes make detectable.
+
+That is a real improvement, but **the value lives in the verification machinery
+(reproducible builds, published hashes, independent rebuilds — Phase 18.7), not in the
+packaging format.** Without it, a native client from meshbay.org is only marginally more
+trustworthy than the SPA from meshbay.org.
+
+Native also has real costs that were under-weighted: loss of the browser sandbox (a Python
+process with full user privileges vs a seccomp-confined renderer), ownership of patch
+velocity for WebKitGTK and every bundled dependency, and new attack surface (loopback media
+server, IPC bridge, update client).
+
+**Conclusion recorded:** the native client is justified on *product* grounds — durable keys,
+no browser tab, background connectivity, better video, hub-less `group://` access over QUIC.
+It should not be justified as the fix for T3 unless 18.7 ships with it.
+
+---
+
+## D1 rationale — hub keeps serving the UI ✅
+
+Keeping it is defensible. It is how anyone tries the platform without installing anything,
+and it stays the fallback when a device has no client installed.
+
+What must be true now that it stays (Phase 12.2/12.3):
+
+- strict CSP and Subresource Integrity on the bundle
+- the hub publishes a **signed digest** of the served bundle, so any third party — an
+ extension, a native client, a curious user — can verify it
+- `/app/` carries an explicit, visible "reduced trust: this hub serves this code" notice
+- the docs never claim end-to-end integrity for the hub-served SPA path
+
+The honest framing: hub-served SPA is a **convenience tier**, not the secure tier.
+
+---
+
+## D2 rationale — native chosen; extension not taken up
+
+Three shapes, cheapest first:
+
+**Option A — Extension as a verifier (hub still serves the UI)**
+The extension does not ship the UI. It hashes the bundle the hub served and compares it
+against a digest signed by the project. Mismatch → visible alarm, optionally block.
+Converts a silent targeted injection into a loud one. Small effort, keeps today's
+architecture, compatible with D1 = yes.
+
+**Option B — Extension ships the UI (hub serves the API only)**
+The UI lives in the extension, distributed and signed by Mozilla/Chrome — a channel **the
+hub operator does not control**. Manifest V3 forbids remote code, which works in our favour:
+the structure enforces exactly what we want. Keys live in extension storage, isolated from
+page JS. Moderate effort.
+
+**Option C — Native desktop client** ← **CHOSEN**
+Phase 13. Full control, durable keys in an OS keystore, QUIC, hub-less access, best UX.
+Highest effort, and the security argument depends on 18.7.
+
+> **Shell revised 2026-08-17: Electron, not pywebview** (+ an optional Python sidecar for
+> `group://` over QUIC). See `desktop-client-v1.md` §2. The comparison table below
+> was written against pywebview and **two of its rows are wrong for the chosen shell**:
+>
+> - *Browser sandbox* — Electron with `sandbox` and `contextIsolation` **keeps** the
+> Chromium renderer sandbox. The row reads "❌ None (partial under Flatpak)"; that was
+> WebKitGTK's property, not native's.
+> - *Crypto available* — the client keeps WebCrypto **and** gains local Argon2id/ChaCha20
+> in the main process; the row implied WebCrypto is lost with the browser engine.
+>
+> The row that gets *worse*: **patch velocity**. "You own it" is more true of Chromium
+> than of a distro-maintained WebKitGTK, and the renderer parses attacker-controlled
+> content from nodes. That is the real cost and it is accepted deliberately.
+
+### Comparison
+
+| | Hub-served SPA (today) | A: extension verifies | B: extension ships UI | C: native desktop |
+|---|---|---|---|---|
+| Code distribution channel | Hub (the adversary) | Hub, but **verified** | Store (independent) | Hub download + own signing key |
+| Silent targeted injection | Undetectable | **Detected** | Not possible | Detectable *if* 18.7 |
+| Browser sandbox | ✅ Full | ✅ Full | ✅ Full | ❌ None (partial under Flatpak) |
+| Patch velocity | Browser auto-updates | Browser auto-updates | Browser + store review latency | **You own it** (WebKitGTK, Python deps) |
+| Key storage | IndexedDB, page-reachable | unchanged | Extension storage, page-isolated | **OS keystore** |
+| Crypto available | WebCrypto only (no ChaCha20/Argon2id) | unchanged | unchanged | **Full** (ChaCha20, Argon2id 256 MB) |
+| Transport | WebRTC | WebRTC | WebRTC | WebRTC **+ QUIC** |
+| Large file → disk | Chrome only (FS Access API) | unchanged | unchanged | **Native, unlimited** |
+| Hub-less `group://` | ❌ | ❌ | ❌ | ✅ |
+| New attack surface | — | negligible | negligible | loopback server, IPC, updater |
+| Platforms to maintain | 0 | 2 stores | 2 stores | 3 OSes |
+| Effort | 0 | Low | Moderate | High |
+
+### Observations for the decision
+
+- **A and B are not exclusive with C.** A/B protect browser users; C serves users who want a
+ real application. "Both" is coherent — just sequence them.
+- **B gives most of C's security benefit at a fraction of the cost**, because the win was
+ never the packaging format — it was getting the code off the adversary's distribution
+ channel — and the extension keeps the browser sandbox while doing it.
+- **Store review latency is the one place B is worse than C**: a critical fix waits on
+ Mozilla/Google. Mitigate with a version-pinned kill switch.
+- **For node operators specifically, Phase 14 (CLI) beats all three.** The operator is the
+ highest-value target — holds the GEK, is the content authority — and today must use
+ hub-served JS to initialize GEKs and invite members. The CLI removes that dependency at a
+ fraction of any client's cost. If only one thing gets built for T3, it should be the CLI.
+- If **D1 = yes** (hub keeps serving the UI), Option A is the natural companion and is nearly
+ free once 12.6 publishes the signed digest.
+
+### Not yet investigated
+
+- Whether AMO/Chrome Web Store policy accepts an extension whose purpose is P2P file sharing
+- Whether MV3 service-worker lifetimes can hold a long-lived WebRTC DataChannel (may need an
+ offscreen document — worth a spike before committing to B)
+- Safari/iOS: no extension route comparable to AMO; likely out of scope either way
+
+---
+
+## Impact on the roadmap
+
+| Decision | If yes | If no |
+|---|---|---|
+| D1 hub serves UI | Phase 12.6 as written (CSP, SRI, signed digest, reduced-trust notice) | 12.6 shrinks to removing `/app/`; hub becomes API-only |
+| D2 = A | Small new phase; 12.6 is a prerequisite | — |
+| D2 = B | New phase, ~Phase 13-sized; 13.1 platform split is reused directly | — |
+| D2 = C | Phase 13 as written (13.2–13.11), + 18.7 for the security claim to hold | Phase 13 reduces to 13.1 only |
+
+**13.1 (platform adapter split) is worth doing regardless of D2.** It is pure refactoring
+whose acceptance criterion is "the browser SPA behaves identically," and it is the
+prerequisite for A, B and C alike.