From 8a6294b0412a86f378c6e2e937c28de64a903c91 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 16:05:16 +0200 Subject: docs: move root docs into docs/ and archive superseded drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01J74kj44q6REczub8XR3DRy --- docs/devel-phases-next.md | 1343 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1343 insertions(+) create mode 100644 docs/devel-phases-next.md (limited to 'docs/devel-phases-next.md') 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//`, 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 `` 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 `. **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) -- cgit v1.2.3