summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md85
-rw-r--r--devel-phases-next.md436
-rw-r--r--second-review.md844
-rw-r--r--tmp-decisions.md145
4 files changed, 1451 insertions, 59 deletions
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 `<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 — 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; `<video src="http://127.0.0.1:…">`. Drops MSE + the fMP4 remux for native (WebKitGTK MSE is unreliable). **Hardening is mandatory and mirrors C1: bind `127.0.0.1` only, random port, per-file capability token scoped to the session, no CORS, reject non-local `Origin`** |
+| 13.7 | Native file dialogs | Replace `showSaveFilePicker`; stream decrypted chunks to disk with constant memory |
+| 13.8 | Safety-number UI | Consumes 12.2: display and compare fingerprints, warn on key change |
+| 13.9 | Signed releases + verified updates | **Gate for GA.** GPG/minisign release key, client verifies before applying, documented key + revocation procedure. Without this the update channel becomes the new T3 |
+| 13.10 | Packaging | AppImage + Flatpak (Linux, primary), MSI (Windows), dmg (macOS) |
+| 13.11 | Decision point | Retire the browser SPA, or keep it explicitly labelled reduced-trust (12.6). Deferring is fine; deciding by accident is not |
+
+### 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 ·
+MSE path (`stream_init/data/end`, `_probe_video` remux) · `_bundleKey` in IndexedDB +
+`_sessionKeys` in sessionStorage + `_pkFromSk`.
+
+**Kept regardless:** WebRTC/aiortc 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. Users can manage groups, members, and node state from the
-command line.
+command line. More important once native clients exist, since group and GEK
+management moves out of the browser.
### Milestones
| # | Component | Description |
|---|---|---|
-| 12.1 | `meshbay-node status` | Show daemon state: groups, peers, connected members, uptime |
-| 12.2 | `meshbay-node group list` | List configured groups with online status |
-| 12.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK |
-| 12.4 | `meshbay-node group join` | Join existing group, fetch GEK, add to config |
-| 12.5 | `meshbay-node member invite` | Wrap GEK for new member, push bundle to hub |
-| 12.6 | `meshbay-node member remove` | Rotate GEK, re-wrap for remaining members, push to hub |
-| 12.7 | `meshbay-node member list` | List group members with online status |
-| 12.8 | Config reload (SIGHUP) | Daemon reloads config and adds/removes groups without restart |
+| 14.1 | `meshbay-node status` | Show daemon state: groups, peers, connected members, uptime |
+| 14.2 | `meshbay-node group list` | List configured groups with online status |
+| 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` | Wrap GEK for new member, store bundle in the local BundleStore (**not** the hub — bundles have been P2P since Phase 12) |
+| 14.6 | `meshbay-node member remove` | Rotate GEK, re-wrap for remaining members, store locally |
+| 14.7 | `meshbay-node member list` | List group members with online status |
+| 14.8 | Config reload (SIGHUP) | Daemon reloads config and adds/removes groups without restart |
+| 14.9 | `meshbay-node admin-key` | Pin the operator's **client** Ed25519 key as `admin_pk_ed25519` — fixes M3, where auto-pinning the node keystore key makes operator deletion impossible |
+| 14.10 | `meshbay-node denylist` | Inspect and clear the persisted revocation denylist (11.5.17) |
### Architecture
CLI commands communicate with the running daemon via a local Unix socket
-(`/run/meshbay-node.sock`). The daemon exposes a small internal API for
-status queries and management operations. If the daemon is not running,
+(`/run/meshbay-node.sock`, mode 0600, owner-only). The daemon exposes a small internal API
+for status queries and management operations. If the daemon is not running,
commands that require it fail with a clear error.
---
-## Phase 13 — Chat encryption (Sender Keys) + retention
+## Phase 15 — Chat encryption (Sender Keys) + retention
+
+> Was Phase 13 before the 2026-08-13 renumbering.
**Objective:** implement spec section 6.6 — group chat messages are encrypted
with the Sender Keys protocol. Currently, chat messages are stored and
@@ -603,35 +855,53 @@ transmitted as plaintext payloads (relying on transport encryption only).
### Background
-`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol,
-but the node chat flow (`_do_chat_message`) stores raw payloads without
-encrypting them. The Sender Keys module provides:
-- Per-sender chain key derivation (ratcheting)
-- Symmetric encryption of group messages
-- Key distribution via pairwise GEK-wrapped channels
+`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol, but nothing
+in production imports it — `grep` finds it only in its own tests. The node chat flow
+(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
+derivation, symmetric message encryption, and a distribution format.
+
+### 15.0 — Decide the distribution channel FIRST (blocking sub-milestone)
+
+`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
+direct)". **GEK-wrapped is the wrong choice** and must not be implemented: it makes every
+sender key a function of the GEK, so anyone who holds the GEK — including an attacker who
+obtained it via H3 key substitution, or a former member who kept it — recovers every sender
+key. The encryption would then be decorative.
+
+Distribution must be **pairwise to identity keys**: wrap each sender key with ECIES to the
+recipient's `pk_x25519` (the existing `wrap_gek_aes` primitive), or run the existing
+`ratchet.py` Double Ratchet per member pair. Decide and record before writing 15.1.
+
+### Honest threat delta (state this in the docs, not just here)
+
+Sender Keys protects chat against **someone who holds the node's disk but is not a group
+member** — a seized machine, a hosting provider, a compromised node. It does **not** protect
+chat from the node operator, because on this platform the operator is a group member and
+therefore a legitimate sender-key recipient. Claiming more than that would repeat the
+overstatement pattern `second-review.md` §7 flags.
### Milestones
| # | Component | Description |
|---|---|---|
-| 13.1 | Node: sender key init | Generate sender key on group join, distribute to members |
-| 13.2 | Node: encrypt chat on send | Encrypt payload with sender's chain key before broadcast |
-| 13.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order |
-| 13.4 | Key rotation on member removal | Admin removes member → all remaining members rotate keys |
-| 13.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore |
-| 13.6 | MNP version negotiation | Handshake declares supported version range, not just single `v` field |
-
-### Security note
-
-Without Sender Keys, any node operator (or anyone with filesystem access to
-the node) can read all chat messages in plaintext. With Sender Keys, messages
-are encrypted with per-sender chain keys that the node operator does NOT
-possess — only group members with the distributed sender keys can decrypt.
-This is a fundamental security upgrade for group privacy.
+| 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking |
+| 15.1 | Node: sender key init | Generate sender key on group join, distribute to members |
+| 15.2 | Node: encrypt chat on send | Encrypt payload with sender's chain key before broadcast |
+| 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order |
+| 15.4 | Key rotation on member removal | Admin removes member → all remaining members rotate keys |
+| 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore |
+| 15.6 | MNP version negotiation | Handshake declares supported version range, not just a single `v` field (L2 — today `v` is sent by everyone and checked by no one) |
+| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly |
---
-## Phase 14 — Android client MVP
+## Phase 16 — Android client MVP
+
+> Was Phase 14 before the 2026-08-13 renumbering.
+> **Shares the Phase 13 design:** local keystore, Ed25519 client auth, no keypair bundles,
+> aiortc-equivalent WebRTC for traversal (Android has a native WebRTC stack — prefer it over
+> `punch_nat`, for the same reason the desktop client does). Do not re-derive a second
+> crypto or auth model here.
**Objective:** Android app for account creation, group browsing, file download,
chat. No node functionality on mobile (client-only).
@@ -664,7 +934,9 @@ with an `upload` message type for client→node push.
---
-## Phase 15 — Network resilience (optional, low priority)
+## 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.
@@ -685,20 +957,29 @@ the user explicitly deprioritized this.
---
-## Phase 16 — RPM/DEB packaging + CI
+## 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 |
|---|---|
-| 16.1 | RPM build pipeline (Fedora, RHEL) |
-| 16.2 | DEB build pipeline (Ubuntu, Debian) |
-| 16.3 | GitHub Actions CI (pytest + ruff on PR) |
-| 16.4 | Release signing (GPG key) |
-| 16.5 | Repo apt/dnf on meshbay.org/packages/ |
-| 16.6 | Android APK distribution on meshbay.org/downloads/ |
+| 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 17 — Extension module sandbox (future)
+## 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
@@ -719,17 +1000,51 @@ community developers. Core functionality must be complete and stable first.
## Recommended order
```
-Phase 12 (Node CLI) ← management UX, now the critical path
-Phase 13 (Sender Keys) ← chat security upgrade
-Phase 14 (Android) ← mobile client, long effort
-Phase 16 (Packaging) ← distribution
-Phase 15 (Resilience) ← optional, edge cases only
-Phase 17 (Extensions) ← future, community-driven
+Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
+Phase 13.1 (Platform adapter split) ← free refactor, unblocks every D2 option
+Phase 12 (Hub minimization) ← makes "the hub cannot read" structural
+Phase 14 (Node CLI) ← best security-per-effort answer to T3
+Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first
+── decision point D2: extension / native / both ──
+Phase 13.2–13.11 (Desktop client) ← product-driven; needs 18.7 for the security claim
+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
```
-Phase 11 (daemon) is complete. Phase 12 (CLI) is now the critical path — without
-it, managing groups and members requires manual API calls. After that, Phase 13
-(Sender Keys) closes the chat encryption gap flagged in the 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 (platform adapter split) 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.
+
+**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 |
---
@@ -750,3 +1065,18 @@ it, managing groups and members requires manual API calls. After that, Phase 13
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**, UI assets shipped inside the package and loaded from
+ disk — never fetched from the hub, or T3 is not fixed. ✅ (2026-08-13)
+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)
+20. **Sender keys are distributed pairwise to identity keys, never derived from or wrapped
+ under the GEK.** ✅ (2026-08-13)
+21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
+ must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)
diff --git a/second-review.md b/second-review.md
new file mode 100644
index 0000000..a2218ad
--- /dev/null
+++ b/second-review.md
@@ -0,0 +1,844 @@
+# MeshBay — Second Architecture & Security Review
+
+> Date: 2026-08-13
+> Scope: architecture and security design review of the hub ↔ node ↔ client protocol,
+> as specified in `docs/meshbay-draft-v4.md`, `devel-phases.md`, `devel-phases-next.md`,
+> and as **implemented** in `packages/` (Phases 1–12 + 10b/10c).
+>
+> Unlike `first-review.md` (2026-08-10), which was a design-level review, this one reads
+> the code that implements the protocol: `protocol.py`, `webrtc_server.py`, `quic_server.py`,
+> `server.py`, `http_server.py`, `daemon.py`, `bundle_store.py`, `ui/app.py`, the hub API
+> routers, and the browser client (`transport.js`, `crypto.js`, `keyderive.js`, `app.js`).
+>
+> Finding numbering is **independent** of `first-review.md`. All C1/H1/M1 references below
+> are new.
+>
+> Not verified: the test suite could not be run (`pytest` is not installed in `.venv`), so
+> the "191 tests" claim is taken at face value. No live testing against meshbay.org was done.
+> This is a code and design review, not a penetration test.
+
+---
+
+## 1. Executive summary
+
+The cryptographic core remains sound: ECIES GEK wrapping, HKDF domain separation, AEAD
+chunk encryption, Ed25519 JWT with `jti`, refresh-token rotation. The *ideas* added since
+the first review — GEK-HMAC handshake proof, DTLS channel binding, Ed25519 admin
+challenge-response, password split, node sovereignty — are the right ideas, and several of
+them are genuinely clever.
+
+**But the implementation does not enforce the model the documents describe.** The
+protection added in Phases 11–12 lives almost entirely on the WebRTC path, while three
+other paths into the same node (HTTP API, QUIC, TCP) were left as they were. The most
+serious result is that **every private group served by a node daemon is exposed in
+plaintext, without any authentication, over the node's HTTP API on `0.0.0.0`**. That single
+defect nullifies the entire GEK-proof / node-sovereignty layer for anyone who can reach the
+node's HTTP port.
+
+Answering the question directly:
+
+> *"The client and the node want to communicate safely, with everything encrypted and
+> unreadable by other parties, even the hub. Does it do what it claims?"*
+
+**Partially, and not today.**
+
+- Against a **passive/honest-but-curious hub**: yes for file content. The hub never sees
+ the GEK, never sees chunks, and is out of the data path after signaling. This part works.
+- Against an **active malicious hub**: **no.** The hub is the public-key directory. When a
+ member invites someone, the inviter fetches the invitee's `pk_x25519` *from the hub* and
+ wraps the GEK for it (`app.js:1399-1410`). A hub that returns its own key gets the GEK for
+ that group. This is documented as trust assumption **T2** but it is not a residual risk —
+ it is a complete break of the confidentiality claim, requiring no exotic capability.
+- **"Everything encrypted"**: **no.** Chat messages are plaintext on the wire (application
+ layer) and plaintext at rest in SQLite. The Mesh Group Index is sent in cleartext over the
+ WebRTC DataChannel. Uploads are transmitted and stored in plaintext. Files are stored in
+ plaintext on the node by design.
+- **"Unreadable by other parties"**: it is readable by every group member, by the node
+ operator, and — via the findings below — by anyone who can reach the node's HTTP port or
+ who can hijack a node's signaling registration on the hub.
+
+There are **6 critical** and **7 high** findings. Most are not exotic crypto issues; they
+are missing authorization checks and paths that were never brought up to the level of the
+newest path. None of them invalidate the architecture — all are fixable inside the existing
+design — but the current build should not be described as end-to-end secure, and should not
+host real private data until C1–C6 are closed.
+
+---
+
+## 2. What is solid
+
+Worth recording, because the delta since the first review is real:
+
+1. **GEK-HMAC handshake proof with DTLS channel binding** (`webrtc_server.py:294-335`,
+ `crypto.js:235-246`). Binding `HMAC(GEK, nonce ‖ offer_fp ‖ answer_fp)` to the DTLS
+ fingerprints of both sides is a correct, well-chosen defence: a signaling relay that
+ substitutes its own fingerprints cannot produce a proof the node accepts. The Chrome
+ raw-SDP workaround (`transport.js:127`) shows this was actually made to work, not just
+ specified.
+2. **Deny-by-default on destructive operations** (`webrtc_server.py:750-754`). If no key is
+ pinned, deletion is refused. Correct posture.
+3. **Ed25519 node→hub authentication** with a domain-separated message
+ (`meshbay:node_auth:{username}:{timestamp}`, `nodes.py:55`) and a node-scoped JWT that
+ `require_user_scope` refuses for mutations. Clean.
+4. **Refresh-token family rotation with reuse detection** (`users.py:213-267`). Textbook
+ OAuth BCP.
+5. **HKDF domain separation** is consistent and the AES/ChaCha20 variants are properly
+ separated by info string (`:aes` suffix), so the two ciphers can never derive the same
+ key from one GEK.
+6. **AEAD-only chunk wire format.** Dropping per-chunk Ed25519 signatures in favour of
+ AES-GCM tags (Phase 9.15) is defensible: the tag authenticates the ciphertext under a key
+ only members hold. (It does have a consequence — see H3.)
+7. **The trust-domain separation in §4.2.x of draft-v4 is the right model.** "The hub
+ certifies identity; the node authorizes content operations" is exactly the correct
+ framing for this system. The problem is enforcement coverage, not the model.
+
+---
+
+## 3. Critical findings
+
+### C1 — Private group content is served in plaintext with no authentication (node HTTP API)
+
+**Location:** `transport/http_server.py:115-160`, wired in `daemon.py:341-366`
+
+The daemon starts `create_http_app()` for **every configured group**, private ones included,
+bound to `0.0.0.0:http_port` (default 19001).
+
+Two endpoints have **no authentication of any kind** — no JWT, no group check, no GEK proof:
+
+```python
+@app.get("/index") # http_server.py:115 — full file listing, no auth
+@app.get("/file/{file_id}") # http_server.py:141 — FileResponse(path) — raw plaintext file
+```
+
+`download_file` reads the file straight off disk and streams it. The `gek` parameter is only
+consulted by the *chunk* endpoint (`/file/{id}/{chunk}`), and even that one accepts **any**
+JWT signed by the hub — no group-membership check, no GEK proof.
+
+The docstring says "Note: this server handles PUBLIC content only", but nothing in the code
+enforces it: the daemon passes the private group's `shared_root` and index unconditionally.
+
+**Impact.** Complete bypass of the entire Phase 12 sovereignty layer. Anyone who can reach
+the port gets the full private index and every private file in cleartext:
+- anyone on the node operator's LAN/VLAN (guest WiFi, roommate, compromised IoT device);
+- anyone on the Internet if the operator forwarded the port (the docs encourage port
+ forwarding for NAT edge cases) or has a permissive IPv6 firewall;
+- any local process/user on the machine.
+
+No JWT forgery, no hub compromise, no GEK required. This is the single most severe issue in
+the codebase and it silently negates NS1/NS3/R22 in the documentation.
+
+**Fix.** Bind to `127.0.0.1` at minimum. Then: refuse to start the HTTP app at all for
+groups with `visibility = "private"`; require a valid JWT *and* group membership on every
+endpoint including `/index` and `/file/{id}`; never serve plaintext bytes for a group that
+has a GEK. Better: delete this server. It predates the WebRTC/QUIC paths and duplicates them
+without any of their controls.
+
+---
+
+### C2 — Any authenticated user can hijack a node's identity on the hub (WebSocket)
+
+**Location:** `api/revocation.py:131-163`
+
+```python
+decoded = decode_access_token(msg["token"])
+node_id = msg.get("node_id") or decoded.get("sub", "unknown") # ← client-supplied
+_connected_nodes[node_id] = ws
+group_ids = msg.get("group_ids", []) # ← client-supplied
+_node_groups[node_id] = group_ids
+```
+
+The hub accepts whatever `node_id` and `group_ids` the connecting party claims. There is no
+check that the JWT subject owns that node record, and no check that `scope == "node"`.
+
+**Impact — this is a full client-impersonation primitive.** Any registered user can:
+
+1. Connect to `/v1/nodes/ws` with their ordinary user JWT and claim the `node_id` of a
+ victim node, overwriting the legitimate entry in `_connected_nodes`.
+2. All subsequent `POST /v1/nodes/{node_id}/webrtc/offer` requests from browsers are relayed
+ to the **attacker** (`signaling.py:56`), who answers with their own SDP.
+3. The victim's browser now has a DataChannel to the attacker, believing it is the node.
+
+The DTLS channel binding does *not* help here: the attacker is the endpoint, not a relay.
+The browser sends its GEK proof to the attacker, who simply ignores it and replies
+`handshake_ack` — `transport.js:204` only checks `ack.type === 'handshake_ack'`.
+
+The attacker then receives:
+- the victim's **encrypted keypair bundle** (`storeKeypairBundle`, `app.js:850`) → offline
+ password brute-force target (see C4);
+- every chat message the victim sends (plaintext);
+- every file the victim uploads (plaintext);
+- and can serve a forged index and forged chat history.
+
+Also: `group_ids` is attacker-controlled, so the attacker can advertise as an online node for
+any group and appear in `GET /v1/groups/{id}/nodes` — the browser picks `nodes[0]`
+(`app.js:822`) with no further verification.
+
+**Fix.** Require `scope == "node"`; look up the `Node` row and verify `node.user_id ==
+payload["sub"]`; derive `group_ids` from the database (`GroupMember` for that user), never
+from the message; reject a second registration for an already-connected `node_id` instead of
+overwriting it.
+
+---
+
+### C3 — The node never authenticates itself to the client
+
+**Location:** `transport.js:56-215`, `app.js:808-827`, `webrtc_server.py:351-362`
+
+Authentication is one-directional. The client proves its identity (JWT) and its membership
+(GEK-HMAC). The node proves *nothing*:
+
+- `handshake_ack` carries `node_pk` but there is no signature over anything — possession of
+ `sk_node` is never demonstrated.
+- The browser fetches `pk_node` from `GET /v1/groups/{id}/nodes` and then **discards it**;
+ `app.js:822` uses only `nodes[0].node_id`.
+- Per-chunk Ed25519 signatures were removed in Phase 9.15, so no later message proves node
+ identity either.
+
+The only implicit authentication is possession of the GEK, and it only covers *file chunks*
+(they will not decrypt otherwise). Everything else — the index, chat history, `is_node_admin`,
+`handshake_challenge`, and everything the client *pushes* — is unauthenticated.
+
+**Impact.** Enables C2 end-to-end, and independently means a hub that returns an attacker's
+`node_id` for a group achieves the same result. `is_node_admin` is trusted by the SPA
+(`app.js:829`) to decide which controls to display, and it comes from an unauthenticated
+peer.
+
+**Fix.** Mutual proof in the handshake. Simplest correct version: the node returns, alongside
+its challenge, `HMAC(GEK, "meshbay:node_proof:v1" ‖ nonce_c ‖ offer_fp ‖ answer_fp)` over a
+client-supplied nonce, and the client verifies it before sending anything sensitive. Add
+`Ed25519(sk_node)` over the same transcript and have the client pin `pk_node` from the hub
+(TOFU + change alerts), so that node identity does not rest on a group-shared secret.
+
+---
+
+### C4 — Users' encrypted private-key bundles are handed to third parties, and are only PBKDF2-protected
+
+**Location:** `webrtc_server.py:194-197, 451-492`, `bundle_store.py:84-100`,
+`keyderive.js:74-87`, `app.js:848-856`
+
+Phase 12 moved keypair bundles off the hub and onto nodes. Three problems compound:
+
+1. **The bundle is served before the GEK proof.** In `_handle_message`, both
+ `GEK_BUNDLE_FETCH` and `KEYPAIR_BUNDLE_FETCH` are dispatched on the condition
+ `self._gek_challenge is not None` — i.e. after JWT verification but **before**
+ `_do_handshake_response` has validated anything. (`_gek_challenge` is even set on the
+ error path where the group has no GEK, `webrtc_server.py:279-292`.) A hub that forges a
+ JWT for user X — trivial, it holds the signing key — retrieves X's encrypted keypair
+ bundle without ever possessing the GEK. This is a chicken-and-egg the design has to solve,
+ but as written the pre-proof window is a data-disclosure window.
+
+2. **The bundle is pushed to every node the user connects to.** `app.js:848` pushes
+ `_pendingBundlePush` to whichever node the group connection landed on. Join five groups
+ hosted by five different people and five unrelated operators now hold your private-key
+ bundle on their disk.
+
+3. **The bundle is protected only by PBKDF2-SHA512, 600 000 iterations**
+ (`keyderive.js:23,74-87`), salted with `SHA-256("meshbay:bundle:v1:" + username)` — a
+ deterministic, non-random salt.
+
+Consequence: the "password split" (T1) does not deliver what §4.2.x claims. It is true that
+the hub cannot *derive* `bundle_key` from `auth_key`. It is not true that the hub is
+therefore locked out: the hub obtains the bundle by forging a JWT (path 1) and then runs an
+offline dictionary attack that costs **only PBKDF2**, not the Argon2id-256MB the hub's own
+password verifier is protected by. The user's password is the last line of defence, and it
+is defended by the *cheaper* of the two KDFs. Recovering it yields `sk_ed25519` and
+`sk_x25519` → unwrapping every GEK bundle → all groups, all content, plus the ability to
+sign as that user.
+
+Every node operator whose group you join gets the same offline target (path 2).
+
+**Fix.** Ranked:
+- **Do not store keypair bundles on other people's machines.** This is the wrong home for
+ them. A native client keeps keys in a local OS-protected keystore; the browser can keep them
+ in IndexedDB with an explicit, user-initiated encrypted export.
+- If the bundle must be remotely recoverable, protect it with Argon2id (256 MB) via WASM, not
+ PBKDF2, and use a random per-user salt fetched alongside the bundle.
+- Serve it only *after* a successful GEK proof, and only from the user's own node.
+- Separate the bundle key from the login password entirely (recovery phrase), so that
+ cracking one does not yield the other.
+
+---
+
+### C5 — Any group member can overwrite arbitrary files in the shared directory, and can seize the group key
+
+Two independent authorization gaps in the MNP handlers, both reachable by any authenticated
+group member (the GEK proof does not distinguish members from each other).
+
+**C5a — Upload overwrites anything** (`webrtc_server.py:683-726`)
+
+```python
+safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_")
+...
+final_path = shared_root / safe_name
+tmp_path.rename(final_path) # unconditional overwrite
+```
+
+Path traversal is blocked, but nothing prevents overwriting an existing file. There is no
+size limit, no quota, no per-user restriction, no operator approval. So:
+- any member can destroy or replace any file at the root of the shared directory —
+ a direct violation of "the node operator is the sole authority over content";
+- and this **bypasses the deletion controls entirely**: overwrite the victim's file, then
+ `_register_uploader` (`:727-736`) tags the entry with *your* `uploader_pk`, after which you
+ can legitimately delete it via the uploader path (`:798-806`);
+- disk-fill DoS is unconstrained.
+
+**C5b — GEK bundle store has no authorization, and auto-activates**
+(`webrtc_server.py:391-449`)
+
+`_do_gek_bundle_store` writes whatever `(group_id, user_id, bundle)` the caller supplies, with
+no check that the caller is the group admin or the node operator, and `INSERT OR REPLACE`
+overwrites existing bundles. Then:
+
+```python
+if node_user_id and target_user_id == node_user_id and group_id:
+ await self._try_activate_gek(group_id, target_user_id) # unwraps and swaps the live GEK
+```
+
+The node operator's `pk_x25519` is public (it is even handed out in `handshake_ack` as
+`node_pk_x25519`, `:359-361`). So any member can wrap a **GEK of their own choosing** for the
+operator's key, store it, and the node will unwrap it and replace the group's active GEK.
+Result: all existing content becomes undecryptable for the legitimate members, and the
+attacker controls the key used from that point on. A member can also silently overwrite other
+members' bundles to lock them out.
+
+**Fix.** Uploads: quarantine to `.uploads/{user_id}/`, refuse to overwrite an existing index
+entry, enforce quotas and a max file size, and require operator opt-in for writes outside the
+upload directory. GEK bundles: require an Ed25519 challenge-response against the pinned admin
+key for `gek_bundle_store`, and never auto-activate a GEK from a peer message — GEK
+initialization belongs to the local admin UI only, which is already implemented
+(`ui/app.py:175-260`).
+
+---
+
+### C6 — The GEK proof only exists on the WebRTC path; QUIC and TCP accept a JWT alone
+
+**Location:** `quic_server.py:148-186`, `server.py:134-165` vs `webrtc_server.py:242-335`
+
+Draft-v4 §4.2.x states: *"ALL operations require passing the GEK proof first."* That is true
+only for `webrtc_server.py`. The QUIC server (started on `::` port 19000) and the TCP server
+(started on `0.0.0.0` port 18001) still perform the Phase 7 handshake: verify JWT → check
+`groups` claim → `handshake_ack`. No challenge, no proof.
+
+**Impact.** A forged JWT (hub) or a stolen JWT reaches the node over QUIC/TCP and can:
+- fetch index and chunks — these are GEK-encrypted, so confidentiality holds *there*;
+- **inject chat messages** into the group store — `_do_chat_message_sync` in `quic_server.py`
+ stores and broadcasts plaintext payloads without any GEK involvement. Chat injection and
+ impersonation of the group's discussion with nothing but a hub-signed token.
+- consume node resources without ever holding the group key.
+
+It also means the denylist/GEK/sovereignty story has to be reasoned about per-transport,
+which is exactly the kind of divergence that produces the next C1.
+
+**Fix.** Factor the handshake (JWT → denylist → group claim → GEK challenge → proof → ack)
+into one function in `meshbay_common` and call it from all three transports. If native
+clients are not using QUIC/TCP yet, disable those listeners by default until they are brought
+to parity.
+
+---
+
+## 4. High findings
+
+### H1 — Cross-group data leakage on multi-group nodes (chat store and peer set)
+
+**Location:** `daemon.py:249`, `webrtc_server.py:601-681, 617, 343-345`
+
+The daemon builds a proper per-group context (`groups_ctx[gid]["chat_store"]`,
+`daemon.py:219-226`) and then sets a single global one:
+
+```python
+self._webrtc._ctx["chat_store"] = first.get("chat_store") # daemon.py:249 — the FIRST group
+```
+
+Both chat handlers read from the *top-level* context, not the group context:
+
+```python
+chat_store = self._ctx.get("chat_store") # webrtc_server.py:602 and :650
+```
+
+So on a node hosting several groups, **all groups write into the first group's chat database,
+and `chat_hist` serves that database to members of every group.** Members of group B read
+group A's private conversation.
+
+The same bug affects broadcast: `_peers` lives in the shared `_ctx` (`:974`, `:343-345`), so
+`_do_chat_message` (`:617-632`) fans out every message to **all connected peers on the node,
+regardless of group**.
+
+**Fix.** `chat_store` and `_peers` must come from `self._group_ctx()`, with one peer registry
+per group. Add a test with two groups and two users that asserts isolation.
+
+---
+
+### H2 — Stored XSS in the node admin UI via uploaded filename → node takeover
+
+**Location:** `ui/app.py:359-365` (and `:632-639` for the audit page)
+
+```python
+file_rows += f"<tr><td>{e.name}</td><td>{e.type}</td>..."
+```
+
+Filenames are interpolated into HTML with no escaping. The upload sanitizer
+(`webrtc_server.py:701`) strips path separators but not `<`, `>`, `"`. Any group member can
+upload a file named `<img src=x onerror="fetch('/api/groups/GID/gek',{method:'POST'})">`.
+
+The local UI has **no authentication at all** (by design, "localhost only"). So when the
+operator opens `http://localhost:18000`, attacker JavaScript runs with full access to the node
+admin API: re-initialize/rotate the GEK, enumerate all groups and shared paths, read the whole
+audit log (users, IPs, actions), read the config. The audit page builds rows with `innerHTML`
+from `e.detail`, which also carries filenames — same vector, different page.
+
+**Fix.** Escape all interpolated values (`html.escape`), use `textContent` in the audit page,
+sanitize uploaded filenames to a conservative allowlist, and add a CSP header to the UI app.
+Consider a localhost token in the URL to blunt DNS-rebinding against the unauthenticated UI.
+
+---
+
+### H3 — An active hub breaks confidentiality through key substitution (T2 is not a residual risk)
+
+**Location:** `app.js:1389-1415`, `users.py:340-358`, `users.py:310-337`
+
+The invite flow is: fetch `pk_x25519` for the invitee **from the hub**, wrap the GEK for it,
+store the bundle on the node. The hub is the sole key directory, and `PUT /v1/users/me/keys`
+lets keys be replaced at any time.
+
+A malicious hub returns its own X25519 key for the invitee. The inviting member wraps the GEK
+for the hub. The hub now holds the group key and can decrypt every chunk it can obtain —
+including chunks captured via C1, C2, or C6. No JWT forgery needed, no JS injection needed,
+nothing detectable by the client.
+
+The documents list this as **T2** under "remaining trust assumptions", alongside T3 (hub
+serves the SPA). That framing understates it: with T2 open, the sentence "unreadable by other
+parties, even the hub" is not true against an adversarial hub, and the GEK-HMAC/sovereignty
+work in Phase 12 does not change that, because the hub obtains the GEK legitimately.
+
+**Fix.** Out-of-band key verification is the only real answer: safety numbers / fingerprint
+comparison, key-change warnings ("Alice's key changed on 2026-08-13 — verify before sharing"),
+and key transparency (a signed append-only log of key bindings the client audits). Until then,
+the honest claim is *"the hub cannot read your content unless it actively attacks you."*
+
+---
+
+### H4 — Group revocation never reaches nodes; jti denylist is volatile
+
+**Location:** `daemon.py:316-330`, `revocation.py:82-96`
+
+The hub signs revocation tokens with `target ∈ {"user", "group"}` and broadcasts them. The
+node handler only implements two cases:
+
+```python
+if target == "user": denylist.deny_user(tid)
+elif target == "jti": denylist.deny_jti(tid)
+# target == "group" → silently dropped
+```
+
+So `POST /v1/admin/revoke` for a group marks it revoked in the hub DB and does nothing on any
+node. Combined with the fact that `webrtc_offer` (`signaling.py:44-85`) checks neither group
+status nor membership, "suspend a group blocks signaling" (draft-v4 §4.2.x) is not true — a
+client holding a `node_id` and a still-valid JWT keeps connecting. The denylist is also
+in-memory only (`Denylist()`), so it is cleared by any node restart.
+
+**Fix.** Handle `target == "group"` on the node (drop sessions, refuse handshakes for that
+group); check group status in `webrtc_offer`; persist the denylist to `data_dir` with
+expiry-based pruning.
+
+---
+
+### H5 — The Ed25519 admin challenge is an unbound signing oracle
+
+**Location:** `webrtc_server.py:756-763`, `keyderive.js:249-256`
+
+```python
+challenge = os.urandom(32) # node → client
+```
+```js
+const sig = await crypto.subtle.sign('Ed25519', sk, challenge); // client signs 32 raw bytes
+```
+
+The client signs 32 arbitrary bytes chosen by the node, with its long-term identity key,
+with no domain separator, no context, and no length constraint. The signed payload does not
+mention "file_delete", the `file_id`, the group, the node, or a timestamp.
+
+Consequences:
+- A malicious or compromised node can request a "deletion" and obtain a signature over any
+ 32-byte string it likes. `meshbay:node_auth:{username}:{timestamp}` is exactly 32 bytes for
+ a 3-character username — currently not exploitable because node auth verifies against
+ `pk_node_ed25519` rather than the user identity key, but that separation is a coincidence of
+ the current schema, not a designed defence.
+- Signatures are not bound to the operation, so a captured signature is reusable for any
+ future challenge that happens to repeat (it will not, but nothing structurally prevents
+ replay across contexts either).
+
+**Fix.** Sign a structured, domain-separated transcript:
+`Ed25519(sk, "meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ timestamp)`,
+and have the client display *what* it is signing. Apply the same rule to every future
+challenge (this is a protocol-wide invariant, not a one-off fix).
+
+---
+
+### H6 — Unauthenticated resource exhaustion on nodes
+
+Several unbounded paths, all reachable by any hub user (no group membership needed for some):
+
+| Vector | Location | Effect |
+|---|---|---|
+| `POST /v1/nodes/{id}/webrtc/offer` | `signaling.py:44` — any authenticated user, no membership check, no rate limit | Node allocates an `RTCPeerConnection` + ICE gathering per request; `_sessions` grows |
+| DataChannel receive buffer | `webrtc_server.py:121-139` — `MAX_MSG = 64 MB`, buffer grows before handshake | Memory exhaustion by claiming a 64 MB frame and dribbling bytes, pre-auth |
+| `stream_req` | `webrtc_server.py:828-906` — spawns `ffmpeg` per request, no concurrency cap | CPU/process exhaustion by any member |
+| `stream_seg` | `webrtc_server.py:573-590` — **synchronous `subprocess.run(timeout=30)` inside the event loop** | One request blocks the entire node for up to 30 s |
+| `file_upload` | `webrtc_server.py:683` — no size/quota limit | Disk fill |
+| `POST /v1/nodes/{id}/incoming` | `revocation.py:202` — any user picks `peer_ip`/`peer_port` | Node emits UDP probes to arbitrary destinations (small reflection primitive) |
+
+**Fix.** Per-user connection caps and rate limits on signaling; membership check before
+relaying an offer; cap the pre-handshake buffer at a few KB; a semaphore around ffmpeg;
+make `stream_seg` async or delete it (superseded by `stream_req`); upload quotas; validate
+that `peer_ip` matches the requester's source address.
+
+---
+
+### H7 — Swarm registration publishes private-group file hashes to the hub (currently masked by a routing bug)
+
+**Location:** `daemon.py:382-387, 501-506`, `groups.py:120`, `hub_client.py:277-294`
+
+The daemon registers the blake3 hashes of **every group's** files with the hub swarm table,
+private groups included — there is no visibility filter. Draft-v4 §7.3 describes the swarm as
+a *public content* mechanism.
+
+Right now this fails silently: the route is declared as `@router.post("/v1/swarm/register")`
+on a router with `prefix="/v1/groups"`, so it is mounted at `/v1/groups/v1/swarm/register`,
+while the node posts to `/v1/swarm/register` → 404, swallowed by `except Exception: pass`.
+
+**Impact.** The bug is currently protecting privacy. Fixing the path without adding a filter
+would immediately leak, to the hub, a content-identifier fingerprint of every private file
+on every node — enough for the hub (or anyone with `GET /v1/swarm/{hash}`, which requires no
+auth) to confirm "does this known file exist in the network, and which node has it". That is
+precisely the metadata the "hub stores no content metadata" claim rules out.
+
+**Fix.** Register hashes only for groups with `visibility == "public"`, fix the route, and
+require authentication on the lookup endpoint.
+
+---
+
+## 5. Medium findings
+
+**M1 — `group_id` is optional in the handshake, which skips the membership check.**
+`webrtc_server.py:257,261` guard on `if group_id and ...`. With `group_id = ""` both checks
+are skipped and `_group_ctx()` (`:506-509`) falls back to `self._ctx`, which the daemon
+populates with the **first group's** gek/index/shared_root (`daemon.py:238-247`). Access still
+requires that group's GEK, so it is not a full bypass — but a user removed from the group on
+the hub who kept the GEK regains access, and the JWT `groups` claim stops being authoritative.
+Make `group_id` mandatory.
+
+**M2 — Argon2id in the node keystore is still 64 MB.** `crypto.py:173-174`
+(`ARGON2_MEMORY_COST = 65536`) with a comment saying to raise it. Only the hub's password
+verifier got the 256 MB bump (`auth.py:30-35`). The docs record R1/8.10 as done, which is true
+for the hub and false for the keystore. Also, `create_keystore` accepts an 8-character
+minimum password, and the calibration command prints instructions to hand-edit a constant in
+`meshbay_common` rather than writing a per-node parameter — so the keystore parameters cannot
+actually be tuned per hardware as §4.2.1 promises.
+
+**M3 — Node operator cannot delete files in the default configuration.** `_resolve_admin_pk`
+(`daemon.py:451-465`) auto-pins the **node keystore's** Ed25519 key, while the browser signs
+challenges with the **user identity** key from the keypair bundle (`app.js:983`). These are
+different keys, so verification fails unless the operator manually sets `admin_pk_ed25519` to
+their browser key. Fails closed, so it is a correctness problem rather than a hole — but the
+sovereignty feature is effectively inert as shipped, and the mismatch will invite the wrong
+fix (relaxing the check) unless it is documented.
+
+**M4 — Response-to-request matching by arrival order.** `transport.js:390-422` resolves the
+**oldest** pending promise with whatever message arrives, ignoring type. With the 8-deep
+pipelined download window, a node that reorders responses (or an `error` message arriving
+mid-flight) resolves the wrong promise. Add a request id (`rid`) to MNP and echo it in
+responses — cheap, and it also removes the `index_sync` special case at `:408-415`.
+
+**M5 — Index and chat are not encrypted on the WebRTC path.** `_do_index_sync`
+(`webrtc_server.py:511-528`) sends entries as cleartext msgpack; the QUIC/TCP path uses
+`GroupIndex.serialize()` which *is* GEK-encrypted. So the same object has two different
+protection levels depending on transport, and draft-v4 §8.2 ("GEK-encrypted, hub stores
+opaque") describes only one of them. With DTLS in place this is not remotely readable, but it
+means the security property depends entirely on the transport rather than on the data.
+
+**M6 — IP audit log corruption on registration.** `users.py:118-122`:
+
+```python
+await db.execute(IPLog.__table__.update().where(IPLog.user_id == None).values(user_id=user.id))
+```
+
+This backfills **every** IPLog row that has a NULL `user_id` — including failed-login rows for
+other usernames and other users' registrations — with the newly created user's id. For logs
+kept for a year specifically to answer legal requests, this is a data-integrity defect that
+attributes other people's connections to the wrong account. Set `user_id` on the row you just
+created (flush first, or add the row after `db.refresh(user)`).
+
+**M7 — `X-Forwarded-For` is trusted unconditionally.** `users.py:361-365`, `groups.py:313`,
+`nodes.py:131`. Behind Caddy this is fine today; if the hub is ever reachable directly, or a
+second proxy is added, any client can forge the IP written into the compliance log and evade
+per-IP rate limiting. Use a trusted-proxy list and take the rightmost untrusted hop.
+
+**M8 — `announce_node` accepts any `pk_node`.** `nodes.py:88-109` — a user can announce a node
+record containing someone else's public key, and node records accumulate without limit. Combine
+with C2 for a more convincing impersonation. Verify possession (sign a challenge with
+`sk_node`) and enforce one active node record per user unless multi-node is intended.
+
+**M9 — Node accepts node-scoped tokens as client tokens.** All three transports call
+`jwt.decode` without inspecting `scope` (`webrtc_server.py:246`, `quic_server.py:153`,
+`server.py:141`). A node-scoped token (which is also issued with the full `groups` claim,
+`nodes.py:66-71`) is accepted as a regular client anywhere. Check `scope == "user"` on the
+client path.
+
+---
+
+## 6. Low findings / notes
+
+- **L1 — Dead protocol constants.** `GEK_REQUEST`/`GEK_RESPONSE` remain in `protocol.py:32-33`
+ though the handlers are gone (NS3 says "removed"). Delete them so the wire contract matches
+ the docs.
+- **L2 — No MNP version negotiation.** Every message carries `v: "0.1"` and nobody checks it
+ (`webrtc_server.py`, `transport.js`). §3 of draft-v4 specifies range negotiation and an
+ explicit refusal. Currently a version mismatch would fail in undefined ways. Phase 13.6
+ covers this — keep it.
+- **L3 — Error strings leak internals.** `webrtc_server.py:224-226` and `server.py:127` return
+ `str(e)` to the peer, which includes filesystem paths and exception detail.
+- **L4 — `hmacGEK` concatenates without length prefixes** (`crypto.js:235-246`,
+ `webrtc_server.py:326`). With fixed-size inputs this is unambiguous today; if a fingerprint
+ is ever missing (the extractors return empty on failure) the concatenation becomes ambiguous
+ and the proof silently degrades to nonce-only. Prefix lengths, and **reject** empty
+ fingerprints instead of proceeding.
+- **L5 — No security headers / CSP on the hub** (`app.py:97-126`). For an application whose
+ threat model explicitly includes "the hub could inject JS", a strict CSP plus
+ `Subresource-Integrity` on the static bundle at least makes a *silent* injection harder and
+ gives extensions something to pin against.
+- **L6 — `EmailStr` imported but unused** (`users.py:8`, field typed `str`) — no email
+ validation on registration.
+- **L7 — Sender Keys is implemented but unreferenced.** `senderkeys.py` is exercised only by
+ its own tests; no production code imports it. That matches the Phase 13 plan; noting it so
+ the module is not mistaken for an active protection.
+- **L8 — `_register_uploader` matches by name and root path only** (`webrtc_server.py:727-736`)
+ — the first entry with a matching name at the root gets tagged, which is wrong when a file
+ with the same name exists in a subdirectory.
+
+---
+
+## 7. Does the system do what it claims?
+
+| Claim (draft-v4) | Verdict | Why |
+|---|---|---|
+| Data never transits a central server | **Yes** | WebRTC DataChannel is genuinely P2P; hub relays SDP only. Well executed. |
+| Hub stores no content, no index, no chat | **Yes** | Confirmed in the schema and routers. GEK bundles are gone from the hub since Phase 12. |
+| E2E encryption for all private content (files, indexes, messages) | **No** | Files: yes. Index: cleartext on the WebRTC path (M5). Chat: plaintext on the wire and at rest (Phase 13 pending). Uploads: plaintext. |
+| Content unreadable by the hub | **Passive hub: yes. Active hub: no** | H3 (key substitution at invite) and C4 (pre-proof keypair-bundle fetch + PBKDF2 cracking) both yield the GEK. T3 (hub-served SPA) is a third path. |
+| Node operator is sole content authority | **No** | C5a (any member overwrites files), C5b (any member seizes the GEK), C1 (anyone reads everything), M3 (operator cannot actually delete). |
+| Hub admin cannot read node content | **No** | C1, and C6 for chat injection. The GEK-proof defence is real but covers one of four paths. |
+| Hub admin cannot delete files | **Yes** | Deny-by-default plus pinned key. Fails closed. Correct. |
+| Suspending a group blocks new connections | **No** | H4 — group revocations are dropped by the node and signaling never checks group status. |
+| Immediate revocation via jti denylist | **Partial** | Works while the node stays up; volatile, and group targets ignored (H4). |
+| Node IPs not persisted | **Yes** | Signaling state is in-memory. But the node's own audit DB stores peer IPs — appropriate, just worth documenting to users. |
+
+**The one-sentence honest version:** *content is encrypted between the browser and the node
+with keys the hub does not hold, and the hub is out of the data path — but the node currently
+gives that content away over an unauthenticated HTTP port, chat is not encrypted at all, and
+a hub that chooses to attack can obtain the group key through the key directory it controls.*
+
+---
+
+## 8. Are the remaining phases enough?
+
+**No — the roadmap does not contain fixes for the findings above.** Mapping the planned work
+onto what was found:
+
+| Planned phase | Addresses | Verdict |
+|---|---|---|
+| 12 — Node CLI + management | M3 partially (a CLI could pin the right admin key) | Useful, not security work |
+| 13.1–13.4 — Sender Keys chat encryption | Part of "chat is plaintext"; nothing else | Necessary but narrower than it looks — see below |
+| 13.5 — Chat retention | Data-minimization only | Good hygiene |
+| 13.6 — MNP version negotiation | L2 | Correct as planned |
+| 14 — Android client | Nothing directly; adds a fourth client to keep in parity | Neutral / new risk |
+| 15 — Resilience (TURN, 0-RTT) | Nothing | Optional |
+| 16 — Packaging + CI | Would catch regressions; 16.4 release signing matters a lot for a native client | Underrated — promote it |
+| 17 — Extension sandbox | Adds a large new attack surface | Should be last, and needs its own review |
+
+**Nothing in the plan addresses C1–C6, H1, H2, H4, H5, H6, or H7.**
+
+A note on Phase 13 specifically, because it is presented as *the* remaining security item:
+Sender Keys protects chat from *someone who is not a group member but holds the node's disk*
+(a compromised node, a seized machine, a hosting provider). It does **not** protect chat from
+the node operator, because on this platform the node operator is a group member and therefore
+a sender-key recipient. It also does not help if the sender keys are distributed "via
+GEK-wrapped channels" as §6.6 describes — that makes them a function of the GEK, so anyone
+with the GEK (C5b, H3) gets them too. Real forward secrecy requires distributing sender keys
+over per-member pairwise channels (the existing `ratchet.py`) keyed to identity keys, not to
+the GEK. Worth settling before writing 13.1.
+
+**Recommendation: insert a remediation phase before Phase 12.** Suggested content, in order:
+
+```
+Phase 11.5 — Security remediation (blocking)
+ 11.5.1 Disable/remove the node HTTP API for private groups; bind loopback [C1]
+ 11.5.2 Authenticate the node WS registration (scope + ownership + DB groups) [C2]
+ 11.5.3 Unify the handshake across WebRTC/QUIC/TCP into meshbay_common [C6]
+ 11.5.4 Mutual handshake proof + pk_node pinning in the client [C3]
+ 11.5.5 Per-group chat_store and per-group peer registry [H1]
+ 11.5.6 Authorize gek_bundle_store; remove GEK auto-activation [C5b]
+ 11.5.7 Upload: no overwrite, per-user quarantine, quotas, filename allowlist [C5a, H2]
+ 11.5.8 Escape all HTML in the node admin UI [H2]
+ 11.5.9 Domain-separate the admin challenge transcript [H5]
+ 11.5.10 Handle group revocation on the node; persist the denylist [H4]
+ 11.5.11 Rate limits and resource caps on signaling, uploads, ffmpeg [H6]
+ 11.5.12 Swarm: public groups only [H7]
+ 11.5.13 Decide the home of keypair bundles (see §9) [C4]
+```
+
+Add regression tests for each: two-group chat isolation, HTTP API refuses private groups,
+handshake parity across transports, upload cannot overwrite, `gek_bundle_store` rejects
+non-admins.
+
+---
+
+## 9. If you build a native client, what changes?
+
+Short answer: **a native client removes the single most fundamental limitation (T3) and lets
+you delete the machinery that exists only to work around the browser — but it does not remove
+any of the findings above, and it adds obligations of its own.**
+
+### What a native client genuinely fixes
+
+- **T3 becomes detectable — it does not disappear.** *(Corrected 2026-08-13; the original
+ text claimed "T3 disappears. Code integrity stops depending on the hub." That was wrong.)*
+ A native client downloaded from `meshbay.org` and signed with a key the hub operator holds
+ relocates the trust from "the JS they serve" to "the binary they serve". What genuinely
+ changes is the **shape of an attack**: in a browser it is one HTTP response, aimed at one
+ user, leaving no artifact — undetectable in principle. Natively it must ship as a build,
+ which is hashable, archivable and comparable between users, so targeting one person means
+ handing them a different binary. That is a real gain, but it is realised **only** by the
+ verification machinery — reproducible builds, published hashes, independent rebuilds
+ (Phase 18.7) — not by the packaging format. Native also costs the browser sandbox, transfers
+ patch velocity for WebKitGTK and every bundled dependency onto the project, and adds new
+ attack surface (loopback media server, IPC bridge, updater). A **browser extension**
+ distributed through Mozilla/Chrome — a channel the hub operator does not control — achieves
+ most of the same benefit while keeping the sandbox. See `tmp-decisions.md`.
+- **Real key storage.** OS keychain / Argon2id-encrypted local keystore, already implemented
+ in `keystore.py`. Keys never leave the device, so **C4 evaporates** — no keypair bundles,
+ no PBKDF2-only protection, no third-party nodes holding your private keys.
+- **Real crypto.** ChaCha20-Poly1305, Argon2id at 256 MB, constant-time primitives — no
+ WebCrypto ceiling. The whole `webcrypto.py` / `:aes` dual-cipher split becomes unnecessary
+ (keep it only while browser clients exist).
+- **Password never transmitted.** The node already authenticates with Ed25519 challenge-
+ response (`nodes.py:30-80`). Clients can do the same, and then `auth_key`/`bundle_key`, the
+ password split, pw_versions and the legacy migration path all go away — a large reduction in
+ code and in attack surface.
+- **Key verification becomes practical.** Safety numbers, TOFU pinning of `pk_node` and of
+ contacts' identity keys, and persistent warnings on key change — the fix for H3. This is
+ achievable in a browser but far more credible in a client the hub does not serve.
+
+### What becomes unnecessary (delete, don't port)
+
+| Component | Reason |
+|---|---|
+| `keypair_bundle_store/fetch/resp` MNP messages, `keypair_bundles` table | Keys live locally (C4) |
+| `deriveAuthKey` / `deriveEncryptionKey` password split, pw_version 3 | Replaced by Ed25519 auth |
+| `webcrypto.py` AES variant + `:aes` HKDF suffix | Only needed for SubtleCrypto |
+| MSE streaming path (`_probe_video`, ffmpeg fMP4 remux, `stream_init/data/end`) | A native player decrypts and plays directly; the node just serves chunks |
+| Node HTTP file API | Already the source of C1; native clients speak MNP |
+| ~~WebRTC transport, hub signaling relay, DTLS channel binding~~ | **Correction (2026-08-13): keep these.** The original text here said "QUIC + `punch_nat()` is already validated" — that oversold a single-ISP demo. `punch_nat()` (`quic_server.py:446`) is one UDP probe to one address: no STUN client (`aioice` is pulled in by `aiortc` only), no candidate gathering, no dual-stack fallback, and it requires the client to already know its own external IP:port and to connect from a fixed source port. ICE/STUN — validated on 2 ISPs, 2 browsers, IPv4 + IPv6 + 4G CGNAT — is the only NAT traversal actually proven in this project, and it lives in the WebRTC path. A native client keeps it by running `aiortc` in Python (`createDataChannel` + `createOffer`), which preserves every native benefit, since none of them come from the transport. QUIC is retained at parity for LAN, port-forwarded and hub-less `group://` access. |
+| `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage, `_pkFromSk` | Browser-specific persistence hacks |
+
+Note what this means for Phase 12's own accounting: **T3 reduction phases 1–3 were largely
+wasted motion.** Moving GEK and keypair bundles from the hub to nodes did not remove the
+hub's access (it can still forge a JWT and fetch them, C4) and it *spread* the private-key
+bundles across untrusted third-party machines. A native client makes the correct answer
+available: the material should live on the user's own device, not on the hub *or* on other
+people's nodes.
+
+### What is still needed regardless of client type
+
+- **All of C1–C6, H1, H2, H4–H7.** Every one of them is server/node-side. A native client
+ changes none of them.
+- **Phase 13 (Sender Keys)** — still required, and still needs the pairwise-distribution
+ decision above.
+- **Phase 12 (Node CLI)** — arguably *more* important with native clients, since group and
+ GEK management moves out of the browser.
+- **Phase 16 (packaging + CI + release signing)** — becomes **critical**, not optional. Once
+ users install software instead of loading a page, your update channel is the new T3. You
+ need signed releases, a documented key, ideally reproducible builds, and a client that
+ verifies signatures. `16.4` should be promoted alongside the remediation phase.
+- **H3 / safety numbers** — the hub remains the key directory even for native clients. Out-of-
+ band verification is the fix, and it is not currently scheduled anywhere.
+
+### Suggested sequencing
+
+> **Superseded 2026-08-13** — the roadmap was rewritten against these findings.
+> See `devel-phases-next.md` for the authoritative plan. Summary:
+
+```
+Phase 11.5 Security remediation ⛔ blocking, everything else waits
+Phase 12 Hub minimization makes "the hub cannot read" structural
+Phase 13 Native desktop client pywebview + aiortc; removes T3 and C4
+Phase 14 Node CLI (was Phase 12)
+Phase 15 Sender Keys (was Phase 13) — 15.0 distribution decision first
+Phase 16 Android (was Phase 14) — reuses the Phase 13 design
+Phase 17 Resilience (was Phase 15)
+Phase 18 Packaging + CI (was Phase 16) — signing moved into 13.9
+Phase 19 Extensions (was Phase 17)
+```
+
+Also worth an explicit decision: **do you keep the browser client?** Supporting both means
+maintaining two transports, two crypto stacks, two key-storage models, and two handshake
+implementations — which is exactly how C6 and M5 came about. If the browser client stays, it
+should be positioned honestly as *"convenient access with a weaker trust model — the hub can
+serve you modified code"*, with the native client as the recommended path for anything
+sensitive.
+
+---
+
+## 10. Prioritized action plan
+
+| # | Finding | Severity | Effort | When |
+|---|---|---|---|---|
+| C1 | Node HTTP API serves private content unauthenticated | Critical | S | Immediately — one-line bind change unblocks, proper fix same day |
+| C2 | Node WS identity spoofing → client impersonation | Critical | S | Immediately |
+| C5b | Any member can seize the group GEK | Critical | S | Immediately |
+| C5a | Any member can overwrite shared files | Critical | S | Immediately |
+| C6 | No GEK proof on QUIC/TCP transports | Critical | M | Before any further transport work |
+| C3 | No node authentication to the client | Critical | M | With C2 |
+| C4 | Keypair bundles on third-party nodes, PBKDF2-only | Critical | L | Needs the design decision in §9 |
+| H1 | Cross-group chat leakage | High | S | Immediately |
+| H2 | Stored XSS in node admin UI | High | S | Immediately |
+| H3 | Hub key substitution (T2) | High | L | Safety numbers — schedule explicitly |
+| H4 | Group revocation dropped; volatile denylist | High | S | Phase 11.5 |
+| H5 | Unbound Ed25519 signing oracle | High | S | Phase 11.5 |
+| H6 | Unauthenticated resource exhaustion | High | M | Phase 11.5 |
+| H7 | Private hashes registered in swarm | High | S | Fix before repairing the route |
+| M1–M9 | See §5 | Medium | S–M | Phase 11.5 / 12 |
+| L1–L8 | See §6 | Low | S | Opportunistic |
+
+---
+
+## 11. Conclusion
+
+The architecture is still the right architecture. Hub-as-registrar, node-as-host,
+E2E-to-the-node, GEK-per-group, node sovereignty enforced by cryptography rather than policy —
+these are good decisions, and the Phase 12 work (GEK-HMAC proof, DTLS channel binding,
+Ed25519 admin challenge) shows real security engineering.
+
+The gap is between the documents and the code. Draft-v4 describes a system where every
+operation passes a GEK proof, where the hub admin can read nothing, where the node operator is
+sovereign, and where private content is E2E encrypted. The code implements that on one of four
+paths into the node. The other three — HTTP, QUIC, TCP — are at Phase 4/7 level, and the HTTP
+one hands out private files to unauthenticated callers. Meanwhile the hub retains a decisive
+lever it is documented as not having: it is the key directory, and whoever controls the key
+directory controls the group key.
+
+Two concrete recommendations beyond the fix list:
+
+1. **Make transport parity a structural invariant, not a habit.** One shared handshake
+ function in `meshbay_common`, called by every transport, with a test that fails if a
+ transport skips a step. Every finding in the C6/C1 family exists because a new path was
+ added and the old ones stayed behind.
+2. **Write down the threat model explicitly** — one page: passive hub, active hub, malicious
+ node operator, malicious group member, network attacker, local attacker — and mark for each
+ claim which adversary it holds against. Most of the overstatements in the current docs
+ ("unreadable by other parties, even the hub") come from not distinguishing the passive hub
+ from the active one. Once that page exists, the honest claims are still strong ones, and
+ they will be defensible.
+
+The security posture is recoverable, and most of the critical work is small. But the current
+build should not host real private data, and the project should not advertise end-to-end
+confidentiality until at least C1, C2, C3, C5 and H1–H3 are closed.
diff --git a/tmp-decisions.md b/tmp-decisions.md
new file mode 100644
index 0000000..97a27ea
--- /dev/null
+++ b/tmp-decisions.md
@@ -0,0 +1,145 @@
+# Open decisions — client architecture
+
+> Working note, not a spec. Created 2026-08-13 after the second security review.
+> Delete or fold into `docs/meshbay-draft-v5.md` once decided.
+
+---
+
+## Status
+
+| # | Decision | State |
+|---|---|---|
+| D1 | Does the hub keep serving the web UI? | **Open** — leaning yes |
+| D2 | Browser extension, native desktop client, or both? | **Open** — needs time |
+| D3 | Transport: aiortc primary, QUIC at parity, TCP+HTTP removed | ✅ Decided 2026-08-13 |
+
+**Neither D1 nor D2 blocks anything right now.** Phase 11.5 (security remediation),
+Phase 12 (hub minimization), Phase 14 (node CLI) and Phase 15 (Sender Keys) are entirely
+client-agnostic — every finding they close is node-side or hub-side. Phase 11.5 is in
+progress on that basis.
+
+---
+
+## Why these are open
+
+The second review recommended a native client and claimed *"T3 disappears — code integrity
+stops depending on the hub."* **That claim was wrong and has been corrected** in
+`second-review.md` §9.
+
+If the hub operator is the adversary, a native client downloaded from `meshbay.org` and
+signed with a key that operator holds does not remove the trust — it relocates it from "the
+JS they serve" to "the binary they serve." What actually changes is **detectability**:
+
+- **Browser:** an attack is one HTTP response, targeted at one user, leaving no artifact.
+ Undetectable in principle.
+- **Native:** an attack requires shipping a build. That build is an artifact — hashable,
+ archivable, comparable between users, reversible. Targeting one user means giving them a
+ different binary, which reproducible builds and published hashes make detectable.
+
+That is a real improvement, but **the value lives in the verification machinery
+(reproducible builds, published hashes, independent rebuilds — Phase 18.7), not in the
+packaging format.** Without it, a native client from meshbay.org is only marginally more
+trustworthy than the SPA from meshbay.org.
+
+Native also has real costs that were under-weighted: loss of the browser sandbox (a Python
+process with full user privileges vs a seccomp-confined renderer), ownership of patch
+velocity for WebKitGTK and every bundled dependency, and new attack surface (loopback media
+server, IPC bridge, update client).
+
+**Conclusion recorded:** the native client is justified on *product* grounds — durable keys,
+no browser tab, background connectivity, better video, hub-less `group://` access over QUIC.
+It should not be justified as the fix for T3 unless 18.7 ships with it.
+
+---
+
+## D1 — Should the hub keep serving the UI?
+
+Keeping it is defensible. It is how anyone tries the platform without installing anything,
+and it stays the fallback when a device has no client installed.
+
+What must be true if it stays (all already scheduled in Phase 12.6):
+
+- strict CSP and Subresource Integrity on the bundle
+- the hub publishes a **signed digest** of the served bundle, so any third party — an
+ extension, a native client, a curious user — can verify it
+- `/app/` carries an explicit, visible "reduced trust: this hub serves this code" notice
+- the docs never claim end-to-end integrity for the hub-served SPA path
+
+The honest framing: hub-served SPA is a **convenience tier**, not the secure tier.
+
+---
+
+## D2 — Extension vs native: what each actually covers
+
+Three shapes, cheapest first:
+
+**Option A — Extension as a verifier (hub still serves the UI)**
+The extension does not ship the UI. It hashes the bundle the hub served and compares it
+against a digest signed by the project. Mismatch → visible alarm, optionally block.
+Converts a silent targeted injection into a loud one. Small effort, keeps today's
+architecture, compatible with D1 = yes.
+
+**Option B — Extension ships the UI (hub serves the API only)**
+The UI lives in the extension, distributed and signed by Mozilla/Chrome — a channel **the
+hub operator does not control**. Manifest V3 forbids remote code, which works in our favour:
+the structure enforces exactly what we want. Keys live in extension storage, isolated from
+page JS. Moderate effort.
+
+**Option C — Native desktop client (pywebview + aiortc)**
+Phase 13. Full control, durable keys in an OS keystore, QUIC, hub-less access, best UX.
+Highest effort, and the security argument depends on 18.7.
+
+### Comparison
+
+| | Hub-served SPA (today) | A: extension verifies | B: extension ships UI | C: native desktop |
+|---|---|---|---|---|
+| Code distribution channel | Hub (the adversary) | Hub, but **verified** | Store (independent) | Hub download + own signing key |
+| Silent targeted injection | Undetectable | **Detected** | Not possible | Detectable *if* 18.7 |
+| Browser sandbox | ✅ Full | ✅ Full | ✅ Full | ❌ None (partial under Flatpak) |
+| Patch velocity | Browser auto-updates | Browser auto-updates | Browser + store review latency | **You own it** (WebKitGTK, Python deps) |
+| Key storage | IndexedDB, page-reachable | unchanged | Extension storage, page-isolated | **OS keystore** |
+| Crypto available | WebCrypto only (no ChaCha20/Argon2id) | unchanged | unchanged | **Full** (ChaCha20, Argon2id 256 MB) |
+| Transport | WebRTC | WebRTC | WebRTC | WebRTC **+ QUIC** |
+| Large file → disk | Chrome only (FS Access API) | unchanged | unchanged | **Native, unlimited** |
+| Hub-less `group://` | ❌ | ❌ | ❌ | ✅ |
+| New attack surface | — | negligible | negligible | loopback server, IPC, updater |
+| Platforms to maintain | 0 | 2 stores | 2 stores | 3 OSes |
+| Effort | 0 | Low | Moderate | High |
+
+### Observations for the decision
+
+- **A and B are not exclusive with C.** A/B protect browser users; C serves users who want a
+ real application. "Both" is coherent — just sequence them.
+- **B gives most of C's security benefit at a fraction of the cost**, because the win was
+ never the packaging format — it was getting the code off the adversary's distribution
+ channel — and the extension keeps the browser sandbox while doing it.
+- **Store review latency is the one place B is worse than C**: a critical fix waits on
+ Mozilla/Google. Mitigate with a version-pinned kill switch.
+- **For node operators specifically, Phase 14 (CLI) beats all three.** The operator is the
+ highest-value target — holds the GEK, is the content authority — and today must use
+ hub-served JS to initialize GEKs and invite members. The CLI removes that dependency at a
+ fraction of any client's cost. If only one thing gets built for T3, it should be the CLI.
+- If **D1 = yes** (hub keeps serving the UI), Option A is the natural companion and is nearly
+ free once 12.6 publishes the signed digest.
+
+### Not yet investigated
+
+- Whether AMO/Chrome Web Store policy accepts an extension whose purpose is P2P file sharing
+- Whether MV3 service-worker lifetimes can hold a long-lived WebRTC DataChannel (may need an
+ offscreen document — worth a spike before committing to B)
+- Safari/iOS: no extension route comparable to AMO; likely out of scope either way
+
+---
+
+## Impact on the roadmap
+
+| Decision | If yes | If no |
+|---|---|---|
+| D1 hub serves UI | Phase 12.6 as written (CSP, SRI, signed digest, reduced-trust notice) | 12.6 shrinks to removing `/app/`; hub becomes API-only |
+| D2 = A | Small new phase; 12.6 is a prerequisite | — |
+| D2 = B | New phase, ~Phase 13-sized; 13.1 platform split is reused directly | — |
+| D2 = C | Phase 13 as written (13.2–13.11), + 18.7 for the security claim to hold | Phase 13 reduces to 13.1 only |
+
+**13.1 (platform adapter split) is worth doing regardless of D2.** It is pure refactoring
+whose acceptance criterion is "the browser SPA behaves identically," and it is the
+prerequisite for A, B and C alike.