aboutsummaryrefslogtreecommitdiffstats
path: root/devel-phases-next.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
commitee6573c57f721db8550e34e1c1c79c5922c62a4b (patch)
tree997f251456730487c75641802edfad167b099b67 /devel-phases-next.md
parentf0248975908ad670fa8a820f865bf22ea8d0172d (diff)
downloadmeshbay-ee6573c57f721db8550e34e1c1c79c5922c62a4b.tar.gz
docs: second security review + roadmap rewrite0.1
Second architecture and security review (second-review.md): 6 critical and 7 high findings against the Phase 12 implementation, plus an assessment of whether the system meets its end-to-end confidentiality claim. Roadmap rewritten against those findings (devel-phases-next.md): new blocking Phase 11.5 (security remediation), Phase 12 (hub minimization), Phase 13 (native desktop client). Old phases 12-17 renumbered to 14-19. tmp-decisions.md records two open decisions: whether the hub keeps serving the web UI, and browser extension vs native desktop client vs both. CLAUDE.md and devel-phases-next.md also carry pre-existing Phase 12 edits from the working tree that could not be cleanly separated from the review changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'devel-phases-next.md')
-rw-r--r--devel-phases-next.md436
1 files changed, 383 insertions, 53 deletions
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)