From ee6573c57f721db8550e34e1c1c79c5922c62a4b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 03:56:30 +0200 Subject: docs: second security review + roadmap rewrite Second architecture and security review (second-review.md): 6 critical and 7 high findings against the Phase 12 implementation, plus an assessment of whether the system meets its end-to-end confidentiality claim. Roadmap rewritten against those findings (devel-phases-next.md): new blocking Phase 11.5 (security remediation), Phase 12 (hub minimization), Phase 13 (native desktop client). Old phases 12-17 renumbered to 14-19. tmp-decisions.md records two open decisions: whether the hub keeps serving the web UI, and browser extension vs native desktop client vs both. CLAUDE.md and devel-phases-next.md also carry pre-existing Phase 12 edits from the working tree that could not be cleanly separated from the review changes. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 85 +++++- devel-phases-next.md | 436 ++++++++++++++++++++++---- second-review.md | 844 +++++++++++++++++++++++++++++++++++++++++++++++++++ tmp-decisions.md | 145 +++++++++ 4 files changed, 1451 insertions(+), 59 deletions(-) create mode 100644 second-review.md create mode 100644 tmp-decisions.md diff --git a/CLAUDE.md b/CLAUDE.md index 846a594..47c2a10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,8 +112,66 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting - **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE - **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection) +**Node sovereignty (2026-08-12):** +- **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE +- **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE +- **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE +- **NS4** `admin_pk_ed25519` pinned in node.toml — auto-pinned from keystore ✅ DONE +- **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE +- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE +- **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE +- **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE + +**Known remaining trust assumptions (Phase 12 — all actionable items done):** +- **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login. +- **T2** Hub controls public key distribution → can substitute keys during invite. Fix: out-of-band key verification (safety numbers) +- **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension + +**T3 attack surface reduction (2026-08-12, all phases complete):** +- **Phase 1** ✅ DONE: GEK bundles moved from hub to node P2P (WebRTC DataChannel). No hub fallback. +- **Phase 2** ✅ DONE: Keypair bundles moved from hub to node P2P. Registration stores locally, pushed to node on first connect. Hub never stores keypair bundles. +- **Phase 3** ✅ DONE: Hub GEK cleanup — `GET /gek` endpoint removed, `GEKBundle` model removed, `gek_bundles` table dropped, `keypair_bundle` column removed, member-add URL cleaned (`/gek` suffix removed), Alembic migrations updated. + +**Browser crypto hardening (2026-08-13):** +- `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh) +- `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close) +- `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch) +- Removed auto-`regenerateKeys()` on login (was silently rotating hub keys, breaking GEK unwrap) +- Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP) +- Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead) + **Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model, -key hierarchy, on-the-fly encryption, transport abstraction. +key hierarchy, on-the-fly encryption, transport abstraction, DTLS channel binding. + +## Second security review (2026-08-13) — see `second-review.md` + +**6 critical, 7 high findings. Phase 11.5 is BLOCKING — see `devel-phases-next.md`.** +The current build must not host real private data. + +The claims above about node sovereignty and P2P crypto material were **overstated**. The +GEK-HMAC proof, Ed25519 admin challenge and channel binding are real, but they are enforced +on the WebRTC path only, and three other paths into the node were left behind. + +- **C1** Node HTTP API (`http_server.py`) serves private group **index and plaintext files + with no authentication**, on `0.0.0.0`, for every group — bypasses the entire sovereignty layer +- **C2** `/v1/nodes/ws` trusts a client-supplied `node_id` → any user hijacks a node's + signaling identity and impersonates it to browsers +- **C3** The node never authenticates itself to the client (`node_pk` is never verified, no proof of possession) +- **C4** Keypair bundles are served pre-proof and pushed to every node joined; PBKDF2-only → offline password attack +- **C5** Any member can overwrite arbitrary shared files (upload) and seize the group GEK (`gek_bundle_store` + auto-activation) +- **C6** GEK proof exists on WebRTC only — QUIC and TCP accept a bare JWT (chat injection) +- **H1** Multi-group nodes share one `chat_store` and one peer registry → cross-group chat leak +- **H2** Stored XSS in the node admin UI via uploaded filename → node takeover +- **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable + even by the hub" is true against a *passive* hub only + +**Corrections to remember:** +- `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate + gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native + clients too (via `aiortc` in Python) +- Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB +- Sender keys must be distributed **pairwise to identity keys**, never GEK-derived +- Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path ## Known calibration TODOs @@ -175,23 +233,38 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | i18n (browser) | `static/i18n.js` | `t()` lookup, ESM, localStorage lang selection | | Admin API (hub) | `meshbay_hub.api.admin` | Phase 10.2 — user/group mgmt, audit logs, stats | | Admin UI (browser) | `static/app.js` | Phase 10.3–10.4 — AdminPage component, 5 tabs | -| Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user` | +| Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user`, `require_user_scope` | +| Node auth (hub) | `meshbay_hub.api.nodes` | `POST /v1/nodes/auth` — Ed25519 challenge-response, node-scoped JWT | | Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) | | Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions | | Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` | -| Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members, GEK bundle store | +| Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members (GEK exchange is P2P) | | File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler | | GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto | +| GEK HMAC proof (browser) | `static/crypto.js` | `hmacGEK()` — HMAC-SHA256 with DTLS channel binding | +| DTLS fp extraction (browser) | `static/transport.js` | `_extractDtlsFingerprint()` — SDP fingerprint for channel binding | +| DTLS fp extraction (node) | `meshbay_node.transport.webrtc_server` | `_extract_dtls_fingerprint()` — SDP fingerprint for channel binding | +| Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response | +| Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password | | GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` | | IndexedDB cache (browser) | `static/app.js` | Phase 10b.5 — group index caching | | Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side | | MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments | | MSE video streaming (browser) | `static/app.js` | Phase 10c — MediaSource + SourceBuffer progressive playback | | Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings | -| Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP all wired | -| Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat DBs | -| Hub WS client | `meshbay_node.hub_client` | `maintain_ws()` + `send_ws()` for signaling | +| Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired | +| Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs | +| Hub WS client | `meshbay_node.hub_client` | `login()` (Ed25519) + `maintain_ws()` + `send_ws()` — no auth_key on node | | Chat store | `meshbay_node.chat.store` | SQLite per-group, `data_dir/{group_id}/chat.db` | +| Audit store | `meshbay_node.audit` | SQLite IP/action log, `data_dir/audit.db` (legal compliance) | +| Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto | +| P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types | +| Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect | +| Key persistence (browser) | `static/app.js` | `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage | +| pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key | +| Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only | +| JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | +| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000) | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | ## meshbay.org server (état cible) diff --git a/devel-phases-next.md b/devel-phases-next.md index 9a8b7ef..04d7b9b 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,8 +1,17 @@ # MeshBay — Next Implementation Phases -> Base: Phases 1–11 complete (except 10.9 → Phase 16). 171 tests. 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). +> 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: docs/meshbay-draft-v4.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 (hub minimization), 13 (native desktop client). --- @@ -567,35 +576,278 @@ allows other nodes/clients to discover which nodes host which content. --- -## Phase 12 — Node CLI + management +## 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:** channel binding for QUIC. No DTLS fingerprint exists — bind to the QUIC server certificate hash as the analogue (`sha256(server_cert) ‖ sha256(client_cert)`); prefer an RFC 5705 TLS exporter if `aioquic` can expose one | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections | + +### 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 — Hub minimization: registrar and nothing more + +**Objective:** reduce the hub to its legitimate role and make that reduction *structural* +rather than a matter of good behaviour. The hub must not be able to see private keys, +unencrypted content, or file listings — not "does not currently", but "cannot". + +### What the hub is allowed to know + +| Category | Allowed | Notes | +|---|---|---| +| Account: username, encrypted email, public keys, status, role | ✅ | Required to be a registrar | +| Group registry: id, admin, visibility, join policy, membership | ✅ | Required to issue the `groups` claim | +| Public group name + description | ✅ | Required for discovery | +| IP logs | ✅ | Legal retention, 1 year | +| Signaling relay (SDP/ICE, in-memory, seconds) | ✅ | Never persisted | +| **Private keys, keypair bundles, GEK bundles** | ❌ | Removed in Phase 12 (old); 13.3 removes the last copies | +| **File content, file names, file hashes, index** | ❌ | H7 was leaking hashes; 11.5.18 closes it | +| **Message content or per-message metadata** | ❌ | `chat_notify` currently leaks it — 12.3 | +| **Private group name / description** | ❌ (target) | 12.5 | + +### Milestones + +| # | Component | Description | +|---|---|---| +| 12.1 | Route inventory + blindness test | Enumerate every hub route; assert no response body can contain key material, content, a file name or a content hash. Runs in CI, fails the build on regression | +| 12.2 | **Key transparency + safety numbers** [H3] | Append-only, hub-signed key log; clients pin the key they first saw and audit the log; key change raises a blocking warning; safety-number comparison UI between two members. This is the fix for the last structural way a hub can read content | +| 12.3 | Chat metadata minimization | `chat_notify` (`webrtc_server.py:634-644` → `revocation.py:101-128`) currently tells the hub *who* posted in *which* group and *when*. Drop `sender_name`, make notification opt-in per group, coalesce and delay to blunt timing correlation | +| 12.4 | Swarm hardening | Enforce 11.5.18 at the API layer too: reject registration for a group the hub knows is private; authenticate `GET /v1/swarm/{hash}` | +| 12.5 | Opaque private-group metadata | For `visibility == "private"`, store name/description as a member-encrypted blob; the hub holds an opaque value and an id. Public groups unchanged (discovery needs plaintext) | +| 12.6 | SPA integrity + honest labelling | Strict CSP, SRI on the bundle, hub publishes a signed digest of the served bundle that native clients and extensions can verify; `/app/` carries an explicit "reduced trust — this hub serves this code" notice | +| 12.7 | Remove dead crypto plumbing | Drop residual columns/migrations/constants from the pre-Phase-12 GEK era so the schema cannot be quietly repopulated | +| 12.8 | 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. Referenced from draft-v5 | + +**Acceptance criteria:** a hub operator holding root on the server, the full PostgreSQL +database, the Ed25519 signing key, and the ability to forge any JWT can obtain: no private +key, no GEK, no file content, no file name, no content hash, no message content, and no +private group name. Every remaining capability is on the list above and is documented in +12.8. Any attempt to substitute a public key is detectable by clients via 12.2. + +--- + +## Phase 13 — Native desktop client (pywebview + aiortc) + +> **Status (2026-08-13): 13.1 active, 13.2–13.11 DEFERRED to after Phase 15**, pending +> decision D2 in `tmp-decisions.md` (browser extension vs native client vs both). +> +> **13.1 (platform adapter split) proceeds regardless** — it is pure refactoring whose +> acceptance criterion is "the browser SPA behaves identically", and it is the prerequisite +> for every option under D2. + +**Objective:** ship a desktop application with durable key storage, hub-independent +`group://` access, and a better media path than the browser allows. + +> ⚠️ **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. Native also *costs* the browser sandbox, hands you patch +> velocity for WebKitGTK and every bundled dependency, and adds the loopback media server, +> the IPC bridge and the updater as new attack surface. +> +> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified +> on product grounds. It permanently closes **C4** as a side effect, but C4 can also be closed +> in a browser by not storing keypair bundles remotely at all. + +### Why this is cheap + +The SPA never touches a browser crypto or network primitive directly: `app.js` contains +**0** occurrences of `crypto.subtle` and **0** of `RTCPeerConnection`. All crypto and +transport go through three injected globals (`window.MeshBayCrypto`, `MeshBayKeys`, +`MeshBayTransport` — 16 call sites) and all hub I/O through one function (`hubFetch`, 30 +call sites). That is the seam. + +| Asset | Lines | Native | +|---|---|---| +| `style.css`, `i18n.js`, `vendor/htm-preact.js` | 1708 | **reuse as-is** | +| `app.js` — components, routing, theme, admin | ~2050 | **reuse as-is** | +| `app.js` — storage glue, `hubFetch`, download/upload callbacks, MSE `VideoPlayer` | ~550 | rewrite | +| `transport.js`, `crypto.js`, `keyderive.js` | 1145 | **delete** | + +≈ **69 % reused unchanged**, and the 31 % that is not is largely code `second-review.md` +says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bundles). + +### 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. + +### Milestones + +| # | Component | Description | +|---|---|---| +| 13.1 | Platform adapter split | Extract `platform-web.js` (WebRTC/WebCrypto/fetch — today's behaviour) and `platform-native.js` (pywebview bridge). `app.js` imports neither directly. **Acceptance: the browser SPA is byte-for-byte functional after the split** — this lands first, on its own, with no native code | +| 13.2 | pywebview shell + Python bridge | `meshbay-client` package; `window.pywebview.api.*` implements the same surface as the three globals; single-instance, tray, window state | +| 13.3 | Local keystore + Ed25519 client auth | Reuse `keystore.py` (Argon2id 256 MB, OS keychain later). Client authenticates like the daemon does: signed timestamp, `POST /v1/users/auth`. **No password on the wire, no `auth_key`/`bundle_key`, no keypair bundle anywhere** → closes **C4** permanently | +| 13.4 | aiortc client transport | `RTCPeerConnection` + `createDataChannel` + `createOffer` in Python; ICE/STUN via `aioice` — the traversal path validated on 2 ISPs. Calls the unified handshake from 11.5.4. QUIC (`quic_client.py`) retained as opt-in for LAN / port-forwarded / hub-less `group://` | +| 13.5 | Local index cache | SQLite in the client profile dir, replacing IndexedDB (also restricted under `file://` in some WebViews) | +| 13.6 | Loopback media server | Python decrypts and serves with HTTP Range; `