diff options
Diffstat (limited to 'docs/devel-phases-next.md')
| -rw-r--r-- | docs/devel-phases-next.md | 1388 |
1 files changed, 0 insertions, 1388 deletions
diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md deleted file mode 100644 index c00143a..0000000 --- a/docs/devel-phases-next.md +++ /dev/null @@ -1,1388 +0,0 @@ -# MeshBay — Next Implementation Phases - -> **Superseded by `MESHBAY_DESIGN.md`.** This was the implementation roadmap; its design -> content now lives in §14.1 (structural decisions), §15 (state of the build). -> -> It is kept because code comments, tests and other documents cite its -> sections and its labels, and because it records reasoning a synthesis -> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design -> document is right; where either disagrees with the code, the code is.** -> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its -> replacement, and §13 defines every label. - -> 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 live streaming path) - ---- - -## 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 + retention - -> **Superseded 2026-09-07 by `docs/chat-sender-keys.md`, which is the -> specification and the decision record. Built.** Read that document before this -> section: the milestone table below is kept for the history of the decision and -> is wrong in three places, each marked. In particular 15.1–15.3 put encryption -> *in the node* — the node is a relay and an archive, and messages are composed -> and read in the client, so built as written the feature would have protected -> nothing it claimed. -> -> The protocol is no longer Sender Keys. `senderkeys.py` is unused and kept for a -> possible future 1:1 DM, alongside `ratchet.py`. - -> 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 — Distribution channel ✅ DECIDED 2026-09-03 - -`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or -direct)". An earlier version of this document rejected GEK-wrapped distribution on the -grounds that anyone holding the GEK recovers every sender key. - -**Revised 2026-09-03 by operator decision: GEK-wrapped distribution is the right choice -for this platform.** The reasoning that led to pairwise was sound in isolation but wrong -for the actual threat model: - -- The GEK already gives access to **all files** in the group. Wrapping sender keys - under it means "anyone who can read the files can read the chat" — which is exactly - the semantics of a group chat. There is no scenario where a member should read files - but not chat, or vice versa. -- The node operator is always a group member and therefore a legitimate sender-key - recipient. Sender Keys does not protect chat from the operator regardless of the - distribution channel (see threat delta below). -- H3 (key substitution at invite) is **closed** (2026-08-14, `invite-pairing-v1.md`). - The GEK is no longer obtainable through the hub. A former member who kept the old - GEK is handled by GEK rotation on removal, which is already implemented. -- Pairwise distribution would add O(devices × members) ECIES wraps per sender key - change, for a marginal security gain: separating "file access" from "chat access" - on a platform where both are gated by the same group membership. - -**Distribution is GEK-wrapped:** each sender key distribution message is encrypted -with `wrap_gek_aes` under the group's current GEK. Every member who has the GEK can -unwrap it. Simple, no fan-out, no new crypto primitive. - -### 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:** - -- **A new device receives all current sender keys via the GEK it already holds** (revised - 2026-09-03). Since distribution is GEK-wrapped, a device that has completed the - handshake and received the GEK can unwrap every sender key distribution message. No - redistribution by every sender is needed; the node replays the latest distribution - message for each active chain. History encrypted under older chain keys remains - unreadable only if the chain has ratcheted forward since — which is the expected - forward-secrecy property, not a gap. -- **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 not the GEK** — -a hosting provider imaging the machine, a backup that leaks, a law-enforcement seizure -where the keystore password is not surrendered. It does **not** protect chat from anyone -who holds the GEK, which includes every current group member and the node operator. -This is the same boundary as file access, by design (operator decision 2026-09-03): -the GEK is the group secret, and both files and chat are gated by it. - -Claiming more than that would repeat the overstatement pattern `second-review.md` §7 -flags. Additions that 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. -- **It does not protect against a former member who kept the GEK before rotation.** - GEK rotation on member removal is implemented, but messages encrypted under the - old GEK remain readable to anyone who held it. This is the same property as files. -- **C4's blast radius reaches chat history.** A browser recovers its identity key from the - keypair bundle on the node; cracking that bundle yields the GEK, and therefore every - sender key distributed under it. 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** | ✅ DECIDED 2026-09-03 (GEK-wrapped), then **overtaken 2026-09-07**: the epoch key is delivered wrapped under the group key, but nothing is *stored* under it — which is what makes a group-key rotation a re-wrap instead of the destruction of the archive | -| 15.0b | **Per-device chains** | ✅ **Obtained without chains.** One key per device, derived by name from the epoch key, so there is no shared mutable sending state to reuse a nonce and nothing to persist per device | -| 15.1 | ~~Node: sender key init~~ | ❌ **Wrong as written** — the node is a relay and an archive. It generates and delivers the epoch key (`ops.open_chat_epoch`, `chat_keys_req`); the client seals | -| 15.2 | ~~Node: encrypt chat on send~~ | ❌ **Wrong as written.** Encryption is in `static/crypto.js`; the node stores what it cannot read | -| 15.3 | ~~Node: decrypt chat on receive~~ | ❌ **Wrong as written.** Only clients decrypt. Out-of-order does not arise: there is no chain to advance | -| 15.4 | Key rotation on removal | ✅ Member revoked, unpinned, device revoked, or `gek_rotate` → a new epoch, pushed to everyone connected. Old epochs kept, or the removal would take the history with it | -| 15.5 | Chat retention config | ✅ `meshbay-node chat prune <days>` / `ops.prune_chat`. Deletes messages, never epoch keys | -| 15.6 | MNP version negotiation | ✅ **DONE 2026-09-03**, and not here: it shipped with **MNP 1.0** (the sealed index and ack), which forced a coordinated deployment anyway. `handshake` and `handshake_challenge` each carry `v` and `v_min`; `check_version` refuses with `version_too_old` / `version_too_new` / `version_unreadable`, shaped like `not_a_member`. The flag day was already being paid for, so the next breaking change costs a refusal message instead of a second one. See `MESHBAY_NODE_PROTOCOL.md` §13.1 | -| 15.7 | Chat attachments | **Documented, not encrypted.** Attachments are ordinary files on a shared root and stay plaintext on disk; the *reference* to one is inside the sealed payload, but the file and its name are in the index. Encrypting them is a different feature with a different blast radius — `docs/chat-sender-keys.md` §5.8 states the asymmetry rather than hiding it | -| 15.8 | **The switch** | ✅ Per group, operator-signed (`OP_CHAT_ENCRYPTED`), reported inside the sealed handshake ack. Off by default — a node upgraded into a running group must refuse nobody. On, the node refuses plaintext outright | -| 15.9 | **`chat encrypt-history`** | ✅ Explicit CLI command, backs `chat.db` up first, one transaction. Deliberately not done by the switch: it rewrites the only copy of a conversation, and a toggle that does that is one somebody flips twice | - ---- - -## 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.~~ **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**. - The GEK is the group secret; both files and chat are gated by it. Pairwise distribution - would add complexity for a separation (files vs chat) that has no meaning in this - platform's group model. Per-device chains (15.0b) remain required for correctness -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) |