diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
| commit | ee6573c57f721db8550e34e1c1c79c5922c62a4b (patch) | |
| tree | 997f251456730487c75641802edfad167b099b67 /devel-phases-next.md | |
| parent | f0248975908ad670fa8a820f865bf22ea8d0172d (diff) | |
| download | meshbay-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.md | 436 |
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) |