diff options
Diffstat (limited to 'devel-phases-next.md')
| -rw-r--r-- | devel-phases-next.md | 167 |
1 files changed, 119 insertions, 48 deletions
diff --git a/devel-phases-next.md b/devel-phases-next.md index 04d7b9b..db9267d 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -1,7 +1,7 @@ # MeshBay — Next Implementation Phases > 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 +> Architecture reference: **docs/meshbay-draft-v5.md** (v4 superseded 2026-08-13) > First security review: first-review.md (2026-08-10) > **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.** > @@ -11,7 +11,8 @@ > 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). +> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13, +> hub minimization deferred by operator decision), 13 (native desktop client). --- @@ -613,7 +614,45 @@ phase reduces the node to two paths and brings both to the same standard. |---|---|---|---| | 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 | +| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections | + +#### 11.5.6 spike results (aioquic 1.3.0) + +**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the +preferred anchor is unavailable. + +**Certificate access is asymmetric and partly private:** + +| Side | Path to the server certificate | API status | +|---|---|---| +| Server | `tls.certificate` | public attribute | +| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` | + +`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is +`protocol._quic.tls._peer_certificate` — two levels of private API. + +**The risk this creates.** Binding a security check to a private attribute means an +aioquic upgrade can remove it silently. A channel binding that silently becomes +unavailable is the worst failure mode: 11.5.21 already established that the handshake +must *refuse* rather than degrade, so a rename would turn every QUIC connection into a +hard failure — noisy, but only if the refusal path is right. If it were ever made +tolerant, it would turn into a silent loss of MitM detection. + +**Options for the implementer, in order of preference:** + +1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in + `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and + non-None on a live connection — so an upgrade fails CI rather than production. Keep + `make_proof()` refusing an empty binding. +2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from + WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the + hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient + connection authentication on its own — but note the QUIC client currently does **not** + verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer), + so this option must be paired with pinning, or the TLS layer authenticates nobody. +3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5. + +Recommendation: option 1 with the guard test, and open option 3 upstream. ### C — Mutual authentication @@ -693,44 +732,56 @@ cannot delete or overwrite another member's file, and cannot change the group ke --- -## Phase 12 — Hub minimization: registrar and nothing more +## Phase 12 — Client key verification + served-SPA integrity -**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". +> **Reworked 2026-08-13 by operator decision.** This phase was "Hub minimization: +> registrar and nothing more". That work is **deferred and may be dropped** — see +> decisions D1/D2 in `tmp-decisions.md`. The hub will keep serving the web UI, and a +> native client will be offered *in addition to* it, not as a replacement. +> +> Two items are kept here because the decision makes them *more* relevant, not less: +> the hub stays in the trusted path, so what it can substitute and what code it serves +> both still matter. Everything else from the old Phase 12 (route blindness test, +> opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped +> from the plan; the swarm item already shipped in 11.5.18. -### What the hub is allowed to know +**Objective:** make the hub's remaining power over confidentiality *detectable*, given +that it stays in the trusted path by choice. -| 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 | +### 12.1 is DONE — and not as it was written + +**H3 is closed (2026-08-14), by removing the lookup rather than by verifying it.** The +plan here was key transparency and safety numbers: keep fetching the invitee's key from +the hub, and give humans a way to notice a substitution. What shipped instead is the +invite redesign in `docs/invite-pairing-v1.md` — the node holds the GEK and wraps it +itself, for a key the recipient proves possession of, and identities are bound to +accounts by one-time codes the hub never sees. + +Why that is better than what was planned: safety numbers make a substitution *detectable +by a human who bothers to check*, at the single worst moment (first contact, when there +is no previous key to compare against). Removing the directory read from the invite path +makes the substitution impossible instead, and costs the user one code to pass along +rather than a fingerprint comparison ritual. + +It also closed **M3** as a side effect, and absorbed **14.3/14.4** (CLI invite, member +management), which had to exist for a headless operator to admit anyone. + +Safety numbers may still return later as defence in depth for *identity* verification — +"is this really Bob's account" — which is a different question from "which key gets the +group key". They are no longer load-bearing. ### Milestones | # | Component | Description | |---|---|---| -| 12.1 | 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 | +| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `docs/invite-pairing-v1.md` | +| 12.2 | Served-SPA integrity | Strict CSP, Subresource Integrity on the bundle, and a signed digest of the served bundle published by the hub so a native client or extension can verify what the browser was given. **Now the highest-value item here**: T3 is the only remaining way an active hub reads content, and it can also lift a pairing code out of the page it served. **The CSP must keep `wasm-unsafe-eval` in `script-src`** — the bundle KDF is Argon2id in WebAssembly since 2026-08-14, and a policy that forbids it locks every user out of their keys | +| 12.3 | Honest labelling | `/app/` states plainly that the hub serves this code and what that implies. Docs stop claiming end-to-end integrity for the hub-served path — the claim that holds is "the hub cannot read your content unless it ships you malicious client code" | +| 12.4 | Written threat model | One page: passive hub, active hub, malicious node operator, malicious member, network attacker, local attacker — and for each claim, which adversary it holds against. This is what stops the overclaiming pattern the second review kept finding | -**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. +**Dropped from the old Phase 12** (recorded so the intent is not lost if it returns): +route-inventory blindness test, opaque private-group name/description, chat_notify +metadata minimization, residual schema cleanup. --- @@ -790,7 +841,7 @@ used for the API only, and the bundle is covered by 13.9 signing. |---|---|---| | 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.3 | Local keystore + Ed25519 client auth — **the real close for C4**, now partially mitigated by Argon2id (draft-v5 §7.1) | 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`** | @@ -817,31 +868,52 @@ These carry NAT traversal and are not browser workarounds. > 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. More important once native clients exist, since group and GEK -management moves out of the browser. +daemon launcher. + +**Partially delivered early (2026-08-13), forced by the first real deployment.** +Every operator action lived behind a web UI on the node's own loopback interface, +so a node on a server reached over SSH could not be operated at all without +port-forwarding a browser session — and 11.5.3 added a token that had to be +copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock +that. + +**Member management landed 2026-08-14** with the invite redesign, for the same +reason: a node admits people from its own roster, and a headless operator had no +way to put anyone on it. `operator pair`, `member list|invite|revoke|unpin` all +work over SSH. **Deleting a file is now the only operator action that still needs +a browser.** ### Milestones | # | Component | Description | |---|---|---| -| 14.1 | `meshbay-node status` | Show daemon state: groups, peers, connected members, uptime | +| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped | +| 14.1b | `meshbay-node ui` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser | +| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine | | 14.2 | `meshbay-node group list` | 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.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced | +| 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one | +| 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset | +| 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add | | 14.8 | Config reload (SIGHUP) | 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.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted | | 14.10 | `meshbay-node denylist` | Inspect and clear the persisted revocation denylist (11.5.17) | +| 14.11 | `meshbay-node file rm` | The one operator action still requiring a browser | ### Architecture -CLI commands communicate with the running daemon via a local Unix socket -(`/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. +CLI commands talk to the running daemon over its **loopback admin API**, authenticated +with the per-run session token (11.5.3) — `_daemon_api()` in `daemon.py`. The Unix-socket +design below was the original plan; the loopback API already existed for the admin UI, +carries the same authorization, and avoided a second control plane. A socket would still +be an improvement (no port, file permissions instead of a token file) if the admin UI +ever goes away. + +`status` deliberately does *not* use it: it reads the keystore, the config and the roster +directly, so it works while the daemon is stopped — which is when an operator most needs +to know why. --- @@ -1002,11 +1074,10 @@ community developers. Core functionality must be complete and stable first. ``` 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 12 (Key verification) ← H3 safety numbers + served-SPA integrity Phase 14 (Node CLI) ← best security-per-effort answer to T3 Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first -── decision point D2: extension / native / both ── -Phase 13.2–13.11 (Desktop client) ← product-driven; needs 18.7 for the security claim +Phase 13.2–13.11 (Desktop client) ← DECIDED: offered alongside the browser SPA Phase 16 (Android) ← reuses the Phase 13 design Phase 17 (Resilience) ← optional, edge cases only Phase 18 (Packaging + CI) ← distro repos; 18.7 gates 13's security argument |