diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
| commit | c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch) | |
| tree | dea71c8e115742beaac5952c8c65481bbc130b07 /devel-phases-next.md | |
| parent | ee6573c57f721db8550e34e1c1c79c5922c62a4b (diff) | |
| parent | d324792d68503109ab99616af6c85ee37045e169 (diff) | |
| download | meshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz | |
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they
changed about what this project may claim.
Phase 11.5 closed the gap between the documents and the code: the unauthenticated
node HTTP API and the TCP transport deleted, one handshake shared by the
remaining two transports, mutual authentication, structured admin transcripts,
upload confinement, group isolation, revocation that reaches nodes. Six critical
and seven high findings closed, bounded, or deferred by decision.
The invite redesign closed H3 and M3 — the last open High. The hub was the key
directory: an inviter fetched the invitee's key from it and wrapped the group key
for whatever came back, so a hub answering with its own key was handed the group
key by an honest member following the protocol exactly. That lookup is gone. The
node holds the group key and wraps it itself, for a key its recipient proves
possession of, bound to an account by a one-time code the hub never sees. M3 fell
out of the same work: node authority comes from a local roster, never from the
hub.
Per-node identity cut what remains of C4 down to one operator. A single keypair
used to be copied to every node its owner joined; each node now gets its own, so
cracking the bundle on one machine yields a key that is a stranger everywhere
else — and on that machine, one that unlocks nothing its holder did not already
serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or
publishing user keys at all.
What this project may now say: the hub cannot read your content unless it ships
you malicious client code. T3 remains, accepted (D1), and is what the native
client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at
rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds
against, which is the convention this branch exists to keep.
Four defects were found by deploying it and using a browser, none by the test
suite: a node going deaf on its hub socket, a token that predated group
membership, a client reading values before they were assigned, and identity keys
a browser held but never re-read. The lessons are recorded in CLAUDE.md.
Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair,
invite, join, download, stream, second browser, revoke — run against the live
deployment on a wiped hub and node.
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 |