From a8c1df9fdbcf223c83ef5e92316096d1a206f5dd Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 12:01:47 +0200 Subject: docs: rework Phase 12 — hub minimization deferred by operator decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decisions (tmp-decisions.md D1/D2/D4): - the hub keeps serving the web UI (zero-install path stays) - a native desktop client is offered ALONGSIDE it, not as a replacement - hub minimization is off the critical path and may be dropped Phase 12 was "Hub minimization: registrar and nothing more". Most of it is dropped: route-inventory blindness test, opaque private-group metadata, chat_notify metadata minimization, residual schema cleanup. The swarm item already shipped in 11.5.18. Two items are kept, because the decision makes them more relevant rather than less — the hub stays in the trusted path by choice, so what it can substitute and what code it serves both still matter: 12.1 key transparency + safety numbers [H3]. This is the last open High finding and nothing else fixes it: the hub is the public key directory, so substituting a key during an invite hands it the group key silently, with no JWT forgery and no code injection. Dropping Phase 12 wholesale would have left it open indefinitely. 12.2 served-SPA integrity: CSP, SRI, and a hub-published signed digest of the bundle so a native client can verify what the browser was given. 12.3 honest labelling of /app/ as the hub-served path. 12.4 written threat model — the thing that stops the overclaiming pattern. Recorded consequence: T3 is now accepted permanently for browser users. A hub that serves the code can exfiltrate keys from the page whatever the protocol does. The claim that still holds, and that the docs should make, is "the hub cannot read your content unless it actively attacks you" — not "unreadable by other parties, even the hub". Co-Authored-By: Claude Opus 5 --- devel-phases-next.md | 71 +++++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 34 deletions(-) (limited to 'devel-phases-next.md') diff --git a/devel-phases-next.md b/devel-phases-next.md index 04d7b9b..6dbf1dd 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -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). --- @@ -693,44 +694,47 @@ 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 two remaining powers 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 | +### Why these two survive + +**H3 is the last open High finding, and nothing else fixes it.** The hub is the public +key directory: when a member invites someone, the inviter fetches the invitee's +`pk_x25519` from the hub and wraps the GEK for it. A hub that returns its own key gets +the group key, decrypts everything, and nothing in the protocol notices. This needs no +JWT forgery and no code injection. Deferring Phase 12 wholesale would leave it open +indefinitely, so it moves here rather than disappearing. + +**Serving the SPA is now a deliberate choice, not a residual risk.** A hub that ships +the code can exfiltrate keys from the page whatever the protocol does (T3). That is +accepted — but it should be labelled honestly and made verifiable where possible. ### 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. +| 12.1 | **Key transparency + safety numbers** [H3] | Hub-signed append-only key log; clients pin the key they first saw for a contact and audit the log; a key change raises a blocking warning before any GEK is wrapped for it; safety-number comparison UI between two members. Applies to the SPA and the native client alike | +| 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 | +| 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 actively attacks you" | +| 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 | + +**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. --- @@ -1002,11 +1006,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 -- cgit v1.2.3 From f4bbc090d7e840c5dc7a838289441955cbdb7fd5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 12:04:39 +0200 Subject: docs: draft-v5 architecture spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes draft-v4, which described a system the code did not implement and made several claims that were simply wrong — "ALL operations require the GEK proof" (true on one of four transports), "Argon2id 256 MB" (hub only), "hub stores no content metadata" (private file hashes were registered with it). Written as a delta over v4: sections not restated are unchanged. Carries an explicit rule — a claim must name the adversary it holds against — and a per-adversary table replacing v4's informal assurances. Records the decisions: transport (aiortc primary, QUIC retained, TCP and the node HTTP API removed), unified handshake with mutual authentication, admin operation transcripts, node authority over GEK storage and activation, upload confinement, hub node-registration and signaling authorization, and the client architecture — hub keeps serving the web SPA, native client offered alongside, hub minimization deferred. States plainly what is NOT true. The defensible claim is "the hub cannot read your content unless it actively attacks you", not "unreadable by other parties, even the hub": H3 (hub is the key directory and can substitute a key at invite time) is open until Phase 12.1, and T3 (hub serves the SPA) is accepted permanently by decision. Content is also readable by every group member and by the node operator, so "end-to-end" here means client-to-node, never client-to-client. Corrects the v4 NAT traversal account: punch_nat() is a single UDP probe with no STUN, no candidate gathering and no fallback, validated on one ISP. ICE is the traversal path, including for native clients. Open items listed with status, including C6 on the QUIC path. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- devel-phases-next.md | 2 +- docs/meshbay-draft-v5.md | 343 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 docs/meshbay-draft-v5.md (limited to 'devel-phases-next.md') diff --git a/CLAUDE.md b/CLAUDE.md index 47c2a10..549e83b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## What this project is MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging. -See `docs/meshbay-draft-v3.md` for the architecture specification. +See `docs/meshbay-draft-v5.md` for the architecture specification (v3/v4 superseded). ## Repository structure diff --git a/devel-phases-next.md b/devel-phases-next.md index 6dbf1dd..687e4f1 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.** > diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md new file mode 100644 index 0000000..0d64b0c --- /dev/null +++ b/docs/meshbay-draft-v5.md @@ -0,0 +1,343 @@ +# MeshBay — Architecture Draft v5 + +> Status: Phase 11.5 (security remediation) largely complete — see `devel-phases-next.md`. +> Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4. +> +> v5 exists because the second security review (`second-review.md`, 2026-08-13) found +> that v4 described a system the code did not implement, and because several v4 claims +> were simply wrong. This document states what is true, what is chosen, and what is +> knowingly accepted. **Where a property does not hold, it says so.** + +--- + +## 0. How to read this document + +v4 described intent. Several of its statements were contradicted by the implementation: + +| v4 said | Reality found in the code | +|---|---| +| "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake | +| "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all | +| "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB | +| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub is the key directory, so it can substitute a key at invite time and be handed the GEK legitimately | +| "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub | + +So v5 carries an explicit rule: **a claim in this document must name the adversary it +holds against.** A property that holds against a passive hub and not an active one is +written that way. + +--- + +## 1. Changes from v4 + +| # | Category | Change | Source | +|---|---|---|---| +| 1 | Transport | TCP+TLS transport and the node HTTP file API **removed**. Two listeners remain: WebRTC DataChannel (primary) and QUIC (LAN / port-forwarded / hub-less) | 11.5.1–2, findings C1/C6 | +| 2 | NAT traversal | **ICE/STUN is the traversal mechanism**, for native clients too. `punch_nat()` is a direct-connection helper, not a traversal stack | Decision D3 | +| 3 | Handshake | One implementation in `meshbay_common.handshake`, shared by every transport | 11.5.4, finding C6 | +| 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 | +| 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 | +| 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 | +| 7 | Node authority | GEK bundle storage requires the node operator's signature; **nothing arriving over MNP can activate a GEK** | Finding C5b | +| 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a | +| 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 | +| 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 | +| 11 | Privacy | Only **public** groups register content hashes with the hub | Finding H7 | +| 12 | Client architecture | The hub **keeps serving the web UI**. A native desktop client is offered **alongside** it | Decision D1/D2 | +| 13 | Hub role | "Hub minimization" is **deferred and may be dropped**. The hub stays in the trusted path by choice | Decision D4 | + +--- + +## 2. Security claims — what holds, against whom + +This replaces the informal assurances scattered through v4 §4.2.x and §13. + +| Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker | +|---|---|---|---|---|---| +| File content is unreadable | ✅ | ❌ **H3** | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ | +| Chat content is unreadable | ✅ | ❌ H3 | ❌ plaintext at rest until Phase 15 | ❌ | ✅ | +| File index is unreadable | ✅ | ❌ H3 | ❌ | ❌ | ✅ | +| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | +| Node cannot be impersonated | ✅ | ⚠️ pending 11.5.8 pinning | — | ✅ | ✅ | +| Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ | +| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | + +**The claim this project can make:** *the hub cannot read your content unless it actively +attacks you.* That is true, defensible, and stronger than most platforms offer. + +**The claim it must not make:** *"everything is encrypted and unreadable by other parties, +even the hub."* Two reasons, both deliberate: + +- **H3 — key substitution.** The hub is the public-key directory. When a member invites + someone, the inviter fetches the invitee's `pk_x25519` **from the hub** and wraps the + GEK for it. A hub returning its own key is handed the group key. No forgery, no code + injection, undetectable by the client today. Fixed only by key transparency and safety + numbers (Phase 12.1); open until then. +- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). + A hub that ships the code can lift keys from the page regardless of protocol design. + +Content is also readable by **every group member** and by **the node operator**, who +stores it in plaintext on disk. That is inherent to the model, not a defect — but it +means "end-to-end" here describes *client ↔ node*, never *client ↔ client*. + +--- + +## 3. Transport (replaces v4 §6.4, §6.5, §7.1) + +### 3.1 Listeners + +| Listener | Role | Status | +|---|---|---| +| **WebRTC DataChannel** (aiortc) | Primary, browser **and** native clients | Unified handshake enforced | +| **QUIC** (aioquic) | LAN, port-forwarded, hub-less `group://` | Authorization shared; **GEK proof pending** | +| ~~TCP + TLS 1.3~~ | — | **Removed** (C6) | +| ~~HTTP file API~~ | — | **Removed** (C1) | + +The HTTP API bound `0.0.0.0` for every group, private ones included, and served the +Mesh Group Index and raw plaintext files with no authentication. It was deleted rather +than repaired: it duplicated MNP without any of its controls. + +### 3.2 NAT traversal — correction + +v4 presented `punch_nat()` as the native-client traversal mechanism. It is not one. It +is a single UDP probe to one address (`quic_server.py`), with: + +- no STUN client (the `aioice` dependency is pulled in by `aiortc` only) +- no candidate gathering, no candidate pairs, no dual-stack fallback +- a requirement that the client already know its own external IP:port and connect from a + fixed source port + +It was validated on one ISP and one NAT type. **ICE/STUN is the traversal path** — +validated across two ISPs, two browsers, IPv4 STUN + IPv6 direct, and 4G CGNAT. + +Native clients therefore run `aiortc` in Python rather than reimplementing traversal. +Every native benefit (local keystore, ChaCha20, Argon2id, Ed25519 auth) comes from being +native, not from the transport. QUIC is retained for LAN, port-forwarded and hub-less +access, where a signaling channel is unavailable or unwanted. + +--- + +## 4. MNP handshake (replaces v4 §4.2.x handshake description) + +One implementation: `meshbay_common/handshake.py`. + +``` +client → node handshake {token, group_id, nonce_c} +node authorize_token() JWT · scope · denylist · group_id · membership · hosting +node → client handshake_challenge {nonce_s} +client → node handshake_response {proof} +node verify HMAC(GEK, client transcript) +node → client handshake_ack {proof, sig, node_pk, is_node_admin} +client verify HMAC(GEK, node transcript) + Ed25519(node_pk) +``` + +### 4.1 Transcript + +``` +"meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding +``` + +Every field length-prefixed; the role bound in so a client proof cannot be replayed as a +node proof. v4's transcript was `nonce ‖ offer_fp ‖ answer_fp` — bare concatenation, and +a missing fingerprint silently degraded it to nonce-only, dropping MitM detection. +**An absent channel binding is now refused.** + +### 4.2 Channel binding + +| Transport | Anchor | +|---|---| +| WebRTC | both DTLS certificate fingerprints | +| QUIC | SHA-256 of the server certificate — **unproven**, see 11.5.6. An RFC 5705 exporter would be stronger; aioquic does not currently expose one | + +### 4.3 Authorization rules + +- `group_id` is **mandatory**. Omitting it previously skipped the membership check and + fell back to the node's first group. +- Node-scoped daemon tokens are **refused** on the client path. +- The denylist is consulted for user, `jti` **and** group. + +### 4.4 Mutual authentication + +The node proves GEK possession over a client-chosen nonce **and** signs the transcript +with its long-term key. Previously the client received an unverifiable `node_pk` and +trusted `is_node_admin` from whoever answered — so a peer that had hijacked signaling +could serve a forged index, forged chat history and forged permissions. + +**Still open (11.5.8):** the client verifies the signature but does not yet *pin* the +key, so a substituted node is caught only by its lack of the GEK. + +--- + +## 5. Node authority (replaces v4 §4.2.x) + +### 5.1 Privileged operations + +Destructive and privileged operations require an Ed25519 signature over a structured +transcript, never a JWT — the hub controls JWT issuance and can therefore never +establish node-level authority. + +``` +"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts +``` + +TTL 120 s. The client reconstructs the transcript from announced fields and **refuses to +sign** if the operation or subject does not match what the user requested. v4's challenge +was 32 opaque random bytes signed blind — an unbound signing oracle. + +| Operation | Authority | +|---|---| +| `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) | +| `gek_bundle_store` | **Node operator only** | +| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK | + +`gek_bundle_store` previously accepted any member's bundle and auto-activated one +addressed to the operator. Since the operator's X25519 public key is published in +`handshake_ack`, any member could wrap a key of their choosing for it and take over the +group. **Consequence of the fix:** a group admin who does not run the node can no longer +invite members — the operator decides what is stored on their machine. + +### 5.2 Uploads + +Confined to `shared_root/.uploads/{user_id}/`, filename allowlist, no overwrite, chunk +ordering enforced, size capped. Previously uploads landed in the shared root under a +client-chosen name and overwrote anything there — which also defeated deletion +authorization, since overwriting a file made the attacker its recorded uploader. + +### 5.3 Local admin UI + +Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at +startup. "Localhost only" is not authentication: any local process can reach it, as can +a page in the operator's browser via DNS rebinding — and this API re-initialises group +keys and reads the audit log. All interpolated values are HTML-escaped; CSP contains +exfiltration but cannot prevent injected inline script, so escaping is the actual fix. + +--- + +## 6. Hub (replaces v4 §4.1) + +### 6.1 Role — chosen, not minimal + +Hub minimization was considered and **deferred** (decision D4). The hub keeps serving +the web UI and remains in the trusted path by choice. This section describes what it +*does*, not an aspiration. + +**Stores:** accounts (username, encrypted email, public keys, status, role), group +registry and membership, IP logs (1 year, legal), node registrations, refresh tokens, +notifications, moderation blocklist. + +**Does not store:** file content, file names, private-group indexes, message content, +private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling. + +**Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message +was posted and by whom (`chat_notify` — minimization was in the dropped phase), and +content hashes for **public** groups only. + +### 6.2 Node registration + +Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id` +against the token subject, and derives group claims from the database — a node may +narrow the set to what it hosts but cannot widen it. It previously took `node_id` and +`group_ids` verbatim from the client's first message, letting any user hijack a victim +node's signaling and impersonate it to browsers. + +### 6.3 Signaling + +Rate-limited, SDP size bounded, capped per user, and the caller must share an **active** +group with the target node. Previously reachable by any authenticated user for any node, +making a third party's machine allocate peer connections on demand. `peer_ip` in the NAT +punch request must match the caller's source address. + +### 6.4 Client addresses + +`X-Forwarded-For` is honoured only from a trusted proxy, rightmost hop. It was trusted +unconditionally, so anyone could forge the IP written to the compliance log. + +--- + +## 7. Cryptography (amends v4 §6) + +Unchanged: GEK wrapping (ECIES, X25519 + HKDF + AEAD), per-chunk key derivation, domain +separation, on-the-fly encryption. + +| Parameter | Value | +|---|---| +| Node keystore KDF | Argon2id **256 MB**, iterations 3, lanes 4 — parameters recorded per envelope so they can be raised without orphaning existing keystores | +| Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` | +| Browser bundle key | PBKDF2-SHA512, 600 000 iterations | + +**Known weakness (C4).** Keypair bundles are protected only by PBKDF2 and are pushed to +every node whose group the user joins, while GEK and keypair bundle fetches are served in +the pre-proof window because the client needs its bundle to compute the proof. The window +is bounded (4 fetches) and audited. It closes properly when the native client stops +storing keypair bundles remotely (Phase 13.3) — the material belongs on the user's own +device, not on the hub *or* on other people's nodes. + +--- + +## 8. Clients (replaces v4 §4.3) + +### 8.1 Two clients, deliberately + +| | Hub-served web SPA | Native desktop client | +|---|---|---| +| Distribution | Served by the hub | Installed, signed release | +| Code integrity | **T3 accepted** — the hub can inject | Detectable *if* reproducible builds ship | +| Key storage | IndexedDB / sessionStorage | OS-protected local keystore | +| Crypto | WebCrypto (AES-GCM) | Full (ChaCha20, Argon2id) | +| Transport | WebRTC | WebRTC **+ QUIC** | +| Positioning | **Convenience tier** — zero install | Recommended for sensitive use | + +The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled +honestly: `/app/` states that the hub serves this code, and the docs never claim +end-to-end *integrity* for that path. + +### 8.2 Native client + +pywebview + aiortc, sharing ~69% of the existing UI unchanged (the SPA contains no direct +`crypto.subtle` or `RTCPeerConnection` usage; everything goes through three injected +globals and one fetch helper). Assets ship **inside the package** and load from disk — a +shell pointing its WebView at the hub's `/app/` would be a browser with a different icon +and would fix nothing. + +Native does **not** remove trust in the hub operator; it converts an undetectable, +per-request, per-user attack into a persistent artifact that can be hashed and compared. +That value is realised by reproducible builds and published hashes, not by the packaging +format. + +--- + +## 9. Open items + +| # | Item | Status | +|---|---|---| +| **C6** | GEK proof on the QUIC path | **Open** — authorization shared, proof pending. A forged token still injects chat over QUIC | +| 11.5.6 | QUIC channel-binding anchor | Unproven — certificate hash vs RFC 5705 exporter | +| 11.5.8 | `pk_node` TOFU pinning | Open | +| 11.5.10 | Node announce proof-of-possession | Open | +| **H3** | Hub key substitution | **Open** — fixed by Phase 12.1 safety numbers | +| C4 | Remote keypair bundles | Bounded; closes in Phase 13.3 | +| — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived | +| — | Chat attachments | Plaintext on the node; decide in Phase 15.7 | +| **T3** | Hub serves the SPA | **Accepted permanently** (D1) | + +### Sender Keys — scope correction + +Sender Keys protects chat from someone holding the node's disk who is **not** a group +member — a seized machine, a hosting provider, a compromised node. It does **not** +protect chat from the node operator, who is a group member and therefore a legitimate +recipient. Distributing sender keys under the GEK would make the encryption decorative, +since anyone with the GEK would recover every sender key. + +--- + +## 10. Testing posture + +The pre-remediation suite passed 209 tests while every finding in the review was live. It +tested that features work, and in doing so **pinned four vulnerabilities in place** as +expected behaviour: GEK auto-activation, the transport-wide chat store, the blind admin +challenge, and private-group hashes registered with the hub. A refactor that accidentally +fixed one would have been reported as a regression. + +Security tests are therefore written as **negative assertions** — "this attack does not +work" — and verified to fail against the pre-fix source before being trusted. +`test_security_regressions.py`, `test_node_ws_auth.py` and `test_handshake.py` hold them. -- cgit v1.2.3 From 197f5e25893b845995853379125f607be18fc4e9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 12:08:48 +0200 Subject: docs: record 11.5.6 spike — QUIC channel binding constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated aioquic 1.3.0 before implementing the QUIC challenge/response, since the binding anchor gates the whole design. No RFC 5705 exporter exists (aioquic.tls.Context has no export_keying_material), so the preferred anchor is unavailable. Certificate access is asymmetric: the server reaches its own cert via the public tls.certificate, but the client can only reach the server's via tls._peer_certificate — a private attribute, behind a QuicConnection that exposes no tls accessor at all. That matters because binding a security check to a private API means an upgrade can remove it silently. Since make_proof() refuses an empty binding (11.5.21), a rename would fail loudly rather than degrade — but only while the refusal path stays strict. Three options recorded with a recommendation: certificate hash via the private attribute with a guard test that fails CI on upgrade, plus pinned aioquic; or bind to pk_node instead, which for QUIC may suffice since signaling is not hub-relayed — but that requires certificate pinning, as the QUIC client currently does not verify the TLS certificate at all; or upstream an exporter. No implementation started: the challenge/response needs both protocol sides, test updates in two files and multiple verification cycles. Co-Authored-By: Claude Opus 5 --- devel-phases-next.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) (limited to 'devel-phases-next.md') diff --git a/devel-phases-next.md b/devel-phases-next.md index 687e4f1..902f01d 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -614,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 -- cgit v1.2.3 From 3e9c94a420b6c427eff4ee13aac80cb1430d7a7c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 19:31:29 +0200 Subject: feat(node): CLI for headless operators — status, ui, gek-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every operator action lived behind a web UI on the node's own loopback interface. For the normal deployment — a node on a server reached over SSH — that is unusable: no browser on the host, and 11.5.3 added a per-run token that had to be copied out of a log to get in. status hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is STOPPED, which is exactly when it is needed: the daemon cannot stay up before its key is linked or before a group exists. ui prints the URL and the ssh -L line. It does not open a browser — that was an assumption about the environment, and a wrong one. gek-init initialises a group key through the daemon's loopback API. Same operation as the admin UI button, no browser involved. Also fixes a latent bug in QE/deploy/deploy-node.sh: the pkill pattern was unanchored, so it matched any shell whose command line merely mentioned the daemon — including the one running the script. It killed a session three times before being pinned down. Anchored to the end of the command line. Verified against the live deployment. grenet and cbesson both connect over WebRTC through real NAT and can browse, download, stream, upload and chat. The node audit log confirms the security properties in production: uploads land in .uploads/{user_id}/ (C5a), the invite required the operator's signature over an admin transcript (C5b, H5), the pre-proof bundle window is bounded and audited (C4), and a non-member handshake was refused. Docs updated: Phase 14 marked partially delivered with the reason, draft-v5 §5.3 records the two operator personas, QE/deploy/README.md documents the commands and the remaining browser-only gaps (invite, delete). Tests: 121 node. Co-Authored-By: Claude Opus 5 --- devel-phases-next.md | 16 ++- docs/meshbay-draft-v5.md | 18 +++- packages/meshbay-node/src/meshbay_node/daemon.py | 129 ++++++++++++++++++++++- 3 files changed, 153 insertions(+), 10 deletions(-) (limited to 'devel-phases-next.md') diff --git a/devel-phases-next.md b/devel-phases-next.md index 902f01d..682cb1b 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -859,15 +859,23 @@ 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. The remaining commands matter for the same reason: **a headless operator +still cannot invite a member or delete a file without 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 | diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 9e1ddb3..8035824 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -203,7 +203,23 @@ ordering enforced, size capped. Previously uploads landed in the shared root und client-chosen name and overwrote anything there — which also defeated deletion authorization, since overwriting a file made the attacker its recorded uploader. -### 5.3 Local admin UI +### 5.3 Operator interface + +Two personas, and they need different tools: + +| Operator | Reaches the node via | +|---|---| +| Desktop | the local admin UI in their own browser | +| **Headless / SSH** | the CLI — `meshbay-node status`, `ui`, `gek-init` | + +The CLI is the primary interface for servers, which is the normal deployment. +`status` deliberately reads the keystore and config directly so it works while +the daemon is stopped — the state an operator is most often in, since the daemon +will not stay up before its key is linked or before a group exists. Anything the +UI can do should eventually have a CLI equivalent (Phase 14); until then a +headless operator still needs a browser to invite members or delete files. + +### 5.4 Local admin UI Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at startup. "Localhost only" is not authentication: any local process can reach it, as can diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index bedd2e9..46a1716 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -18,6 +18,9 @@ Startup sequence: Usage: meshbay-node # interactive password prompt meshbay-node --config /path # custom config + meshbay-node status # node state + public key (works while stopped) + meshbay-node ui # print the local admin UI URL + meshbay-node gek-init # initialise the group key (no browser needed) meshbay-node init # write example config + create keystore meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters """ @@ -136,6 +139,12 @@ class NodeDaemon: # re-initialise group keys. ui_token = base64.urlsafe_b64encode(os.urandom(18)).decode().rstrip("=") self._state["ui_token"] = ui_token + # Persisted so `meshbay-node ui` can open the browser. Nobody should ever + # have to copy a token out of a log or a terminal — that is not a workflow. + self._config.data_dir.mkdir(parents=True, exist_ok=True) + self._ui_token_file = self._config.data_dir / "ui-token" + self._ui_token_file.write_text(ui_token) + self._ui_token_file.chmod(0o600) from meshbay_node.ui import create_ui_app ui_app = create_ui_app(self._state) ui_cfg = uvicorn.Config( @@ -146,8 +155,7 @@ class NodeDaemon: ) ui_server = uvicorn.Server(ui_cfg) self._tasks.append(asyncio.create_task(ui_server.serve())) - log.info("Admin UI at http://127.0.0.1:%d/?t=%s", - self._config.node.ui_port, ui_token) + log.info("Admin UI ready — open it with: meshbay-node ui") # 3. Hub connection (Ed25519 auth — retries until node key is linked) hub_cfg = HubConfig( @@ -555,6 +563,10 @@ class NodeDaemon: if self._quic_server: await self._quic_server.stop() + token_file = getattr(self, "_ui_token_file", None) + if token_file is not None: + token_file.unlink(missing_ok=True) + log.info("Node stopped") @@ -565,16 +577,21 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "calibrate-argon2"], - help="init: write example config | calibrate-argon2: benchmark") + choices=["init", "status", "ui", "gek-init", "calibrate-argon2"], + help="init: write example config | status: node state and keys " + "| ui: print the admin UI URL | calibrate-argon2: benchmark") parser.add_argument("--config", type=Path, default=None, help="Config file path") + parser.add_argument("--group", default=None, + help="group id for gek-init (optional if only one is configured)") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) args = parser.parse_args() + # Query commands print a report; library logging would interleave with it. + quiet = args.command in ("status", "ui", "gek-init") logging.basicConfig( - level=getattr(logging, args.log_level), + level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", ) @@ -587,6 +604,108 @@ def main() -> None: calibrate_argon2() return + if args.command == "status": + import json as _json + import urllib.request + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + print(f"hub {cfg.hub.url} (user {cfg.hub.username or '—'})") + + # Read straight from the keystore: the operator needs this key to link the + # node, and that happens before the daemon can ever stay running. + try: + keys = load_or_create_keystore( + path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) + print(f"node key {keys.pk_ed25519_b64}") + except Exception as e: + print(f"node key ") + + token_file = cfg.data_dir / "ui-token" + live = None + if token_file.exists(): + try: + url = (f"http://127.0.0.1:{cfg.node.ui_port}" + f"/api/status?t={token_file.read_text().strip()}") + with urllib.request.urlopen(url, timeout=3) as r: + live = _json.loads(r.read()) + except Exception: + live = None + + if live: + print(f"daemon running — {live.get('status')}") + print(f"node_id {live.get('endpoint_hint') or '—'}") + print(f"groups {live.get('group_count', 0)}" + f" files {live.get('total_files', 0)}" + f" peers {live.get('webrtc_peers', 0)}") + print(f"admin UI meshbay-node ui") + else: + print("daemon not running") + + print(f"config {DEFAULT_CONFIG_PATH}") + if not cfg.groups: + print("groups none configured — create a group on the hub, then add") + print(" a [[groups]] entry with its id and shared_dir") + else: + for g in cfg.groups: + print(f" group {g.name} [{g.visibility}] {g.id or ''}") + print(f" {g.shared_dir or ''}") + if not cfg.admin_pk_ed25519: + print("admin key NOT pinned — file deletion and member invites will be") + print(" refused (node.toml: admin_pk_ed25519)") + return + + if args.command == "gek-init": + import json as _json + import urllib.error + import urllib.request + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + group_id = args.group + if not group_id: + if len(cfg.groups) == 1: + group_id = cfg.groups[0].id + else: + print("--group is required (several groups configured)") + sys.exit(1) + + token_file = cfg.data_dir / "ui-token" + if not token_file.exists(): + print("Node is not running — start it with: meshbay-node") + sys.exit(1) + + # The daemon holds the hub session and the live group contexts, so the CLI + # asks it to do the work rather than duplicating it. Same operation as the + # admin UI button — an operator on a headless host should never need a + # browser on that host to initialise a group key. + url = (f"http://127.0.0.1:{cfg.node.ui_port}/api/groups/{group_id}/gek" + f"?t={token_file.read_text().strip()}") + try: + req = urllib.request.Request(url, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + out = _json.loads(r.read()) + except urllib.error.HTTPError as e: + print(f"failed: {e.code} {e.read().decode()[:300]}") + sys.exit(1) + + print(f"GEK ready for {group_id}") + print(f" wrapped for {out.get('wrapped_count')}/{out.get('total_members')} members") + for err in out.get("errors") or []: + print(f" ! {err}") + return + + if args.command == "ui": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + token_file = cfg.data_dir / "ui-token" + if not token_file.exists(): + print("Node does not appear to be running — start it with: meshbay-node") + sys.exit(1) + print(f"http://127.0.0.1:{cfg.node.ui_port}" + f"/?t={token_file.read_text().strip()}") + print() + print("The UI listens on loopback only. From another machine:") + print(f" ssh -L {cfg.node.ui_port}:127.0.0.1:{cfg.node.ui_port} ") + return + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if not cfg.hub.username: print("Error: hub.username not set in config. Run: meshbay-node init") -- cgit v1.2.3 From d7120761fe8cf406f374ef769db6e1f9bf1fe287 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 01:28:12 +0200 Subject: docs: record the invite redesign — H3 and M3 closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draft-v5 §2: against an active hub, reading content moves from "❌ H3" to "❌ T3 (browser) · ✅ native". The defensible sentence becomes "the hub cannot read your content unless it ships you malicious client code" — T3 is now the only path, it is an artifact rather than a silent directory lie, and it does not exist for a native client. New §5.5 describes admission and key delivery, with the four properties that carry it and the one exception (open-join groups, where the hub can walk in the front door — a property of open joining, and the setting is read from node.toml). Corrected while writing it: §5.1 said the C5b fix stopped a group admin who does not run the node from inviting, and that the redesign reverses this. It does not, because delegation was deferred. What changed is the timing — the operator issues a code and is then out of the loop. devel-phases-next: 12.1 is done and NOT as written. The plan was key transparency plus safety numbers; what shipped removes the directory read instead. Safety numbers make substitution detectable by a human who checks, at first contact, when there is nothing to check against. 12.2 (served-SPA integrity) is now the highest-value item in that phase. Phase 14 marked for what landed. second-review: H3 and M3 annotated closed at the finding, with what actually closed them. The §7 verdict table is left intact — it is the record of an audit on a date, and falsifying it would be worse than leaving it — with a note pointing at draft-v5 §2 for current state. CLAUDE.md matters most here, being loaded every session: NS4 read "admin_pk_ed25519 auto-pinned from keystore ✅ DONE", which is M3 described as a feature. Rewritten, with the two fixes that must never be attempted (auto-pin, hub lookup). QE/deploy/README.md: set-admin-pk retired from the walkthrough; the regression checklist now exercises pairing, joining by code, recognition without a code, and revocation. USERGUIDE.md is beyond the invite work but was actively wrong: it told users to POST GEK bundles to a hub endpoint deleted in Phase 12, and to re-wrap for every remaining member on revocation. Both replaced with what the code does. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 32 +++++++++++- devel-phases-next.md | 72 +++++++++++++++++--------- docs/USERGUIDE.md | 80 +++++++++++++---------------- docs/meshbay-draft-v5.md | 131 ++++++++++++++++++++++++++++++++++++----------- second-review.md | 21 +++++++- 5 files changed, 232 insertions(+), 104 deletions(-) (limited to 'devel-phases-next.md') diff --git a/CLAUDE.md b/CLAUDE.md index 549e83b..b2fb9b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,11 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting - **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE - **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE - **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE -- **NS4** `admin_pk_ed25519` pinned in node.toml — auto-pinned from keystore ✅ DONE +- **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The + keystore key is not the key the browser signs with, so every admin operation failed + closed. Authority now comes from the node's roster — `meshbay-node operator pair` + (2026-08-14). `admin_pk_ed25519` is still read as a legacy form; never auto-pin again, + and never resolve the operator's key through the hub - **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE - **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE - **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE @@ -124,7 +128,10 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting **Known remaining trust assumptions (Phase 12 — all actionable items done):** - **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login. -- **T2** Hub controls public key distribution → can substitute keys during invite. Fix: out-of-band key verification (safety numbers) +- **T2** ✅ **CLOSED 2026-08-14** (the finding is H3). Not by safety numbers: the invite + path stopped reading the directory. The node holds the GEK and wraps it for a key the + recipient proves possession of; identities are bound to accounts by one-time codes the + hub never sees. See `docs/invite-pairing-v1.md` - **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension **T3 attack surface reduction (2026-08-12, all phases complete):** @@ -165,6 +172,27 @@ on the WebRTC path only, and three other paths into the node were left behind. - **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable even by the hub" is true against a *passive* hub only +## Invite redesign (2026-08-14) — closes H3 and M3 + +See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or +`gek_bundle_store`. + +- **The node wraps the group key**, on every connection, for the X25519 key the joiner + signed with their pinned Ed25519 identity. **Nothing fetches a public key from the hub + to wrap for** — not the SPA, not `gek-init`. That lookup *was* H3 +- **`gek_bundle_store` is deleted**, not gated. No member hands the node key material +- **The node's roster decides who gets the key**, not hub membership: a hub that invents + an account and mints it a token gets `not_authorized_for_group` +- **One-time codes** bind a key to an account without the directory. 40 bits, single use, + one account, node-wide lockout. 7 days for invitations, 24 h for operator pairing, both + in `[node]` of node.toml +- **`join_policy`** (`invite`|`open`) is read from **node.toml, never the hub** — a hub + able to declare a group open would be handed its key. Unknown group ⇒ `invite` +- Operator surface over SSH: `operator pair`, `member list|invite|revoke|unpin`. Deleting + a file is the last browser-only operation +- Revocation now works for key delivery (nothing stored survives it) — but **still rotate + the GEK**, the ex-member holds the current one + **Corrections to remember:** - `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native diff --git a/devel-phases-next.md b/devel-phases-next.md index 682cb1b..3c9c639 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -745,29 +745,38 @@ cannot delete or overwrite another member's file, and cannot change the group ke > opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped > from the plan; the swarm item already shipped in 11.5.18. -**Objective:** make the hub's two remaining powers over confidentiality *detectable*, -given that it stays in the trusted path by choice. +**Objective:** make the hub's remaining power over confidentiality *detectable*, given +that it stays in the trusted path by choice. -### Why these two survive +### 12.1 is DONE — and not as it was written -**H3 is the last open High finding, and nothing else fixes it.** The hub is the public -key directory: when a member invites someone, the inviter fetches the invitee's -`pk_x25519` from the hub and wraps the GEK for it. A hub that returns its own key gets -the group key, decrypts everything, and nothing in the protocol notices. This needs no -JWT forgery and no code injection. Deferring Phase 12 wholesale would leave it open -indefinitely, so it moves here rather than disappearing. +**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. -**Serving the SPA is now a deliberate choice, not a residual risk.** A hub that ships -the code can exfiltrate keys from the page whatever the protocol does (T3). That is -accepted — but it should be labelled honestly and made verifiable where possible. +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 | **Key transparency + safety numbers** [H3] | Hub-signed append-only key log; clients pin the key they first saw for a contact and audit the log; a key change raises a blocking warning before any GEK is wrapped for it; safety-number comparison UI between two members. Applies to the SPA and the native client alike | -| 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 | -| 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 actively attacks you" | +| 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 | +| 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 | **Dropped from the old Phase 12** (recorded so the intent is not lost if it returns): @@ -866,8 +875,13 @@ 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. The remaining commands matter for the same reason: **a headless operator -still cannot invite a member or delete a file without a browser.** +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 @@ -879,19 +893,27 @@ still cannot invite a member or delete a file without a browser.** | 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. --- diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index ccbf112..f5c10b8 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -189,59 +189,49 @@ Save `group_id` — you will need it in your `node.toml` and when adding members | Join | Open / approval-gated | By invitation only | | GEK | Not applicable | Required | -For private groups, the group admin generates a Group Encryption Key (GEK) — a random 32-byte ChaCha20-Poly1305 key. The GEK is never sent over the wire in cleartext. Instead, the admin wraps a copy of it for each member using that member's X25519 public key and stores the opaque bundle on the hub. +For private groups the node holds a Group Encryption Key (GEK) — a random 32-byte key that is never sent over the wire in cleartext. Each member receives a copy wrapped for their own X25519 public key (ECIES: X25519 + HKDF + AEAD). + +**The node does the wrapping, and it never asks the hub for anybody's key.** That matters: the hub is the account directory, so a hub that answered a key lookup with its own key would be handed the group key by an honest member following the protocol exactly (finding H3). Instead the recipient presents their own public keys over the authenticated P2P channel, signed by their identity key, and the node wraps for what it just verified. ### Add a member to a private group -The group admin fetches the new member's X25519 public key from the hub, wraps the GEK for them, and uploads the bundle: +The node operator issues a one-time code, from the server or from their browser: -```python -import base64, httpx -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes, serialization -import os - -HUB = "https://meshbay.org" -GROUP_ID = "10484cb7-e45a-4cdc-8b06-f8ccdacc04d2" -TOKEN = "alice_test-access-token" -GEK_RAW = bytes.fromhex("your-32-byte-gek-in-hex") # load from keystore - -headers = {"Authorization": f"Bearer {TOKEN}"} - -# 1. Fetch new member's X25519 public key -r = httpx.get(f"{HUB}/v1/users/bob_test/pubkeys", headers=headers) -r.raise_for_status() -pk_member_raw = base64.b64decode(r.json()["pk_x25519"]) - -# 2. Wrap GEK for them (ECIES-like) -sk_eph = X25519PrivateKey.generate() -pk_eph_raw = sk_eph.public_key().public_bytes( - serialization.Encoding.Raw, serialization.PublicFormat.Raw) -shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_member_raw)) -wrap_key = HKDF(algorithm=hashes.SHA256(), length=32, - salt=pk_eph_raw, info=b"meshbay:gek_wrap:v1").derive(shared) -nonce = os.urandom(12) -wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, GEK_RAW, pk_member_raw) - -# 3. Upload the opaque bundle to the hub -bundle = { - "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(), - "nonce_b64": base64.b64encode(nonce).decode(), - "wrapped_b64": base64.b64encode(wrapped).decode(), -} -r = httpx.post(f"{HUB}/v1/groups/{GROUP_ID}/members/bob_test/gek", - json=bundle, headers=headers) -r.raise_for_status() -print("Bundle stored. bob_test can now access the group.") +```bash +# On the node, over SSH — no browser needed +meshbay-node member invite bob + +INVITATION CODE R3H8-TB6V +valid until 2026-08-21T12:00:00+00:00 ``` -The hub stores the bundle as an opaque blob. It cannot decrypt it — it stores `pk_eph`, `nonce`, and `wrapped` as separate columns but has no key to derive `wrap_key`. +Send the code to Bob however you already talk to him — it never passes through the hub, which is what stops the hub from claiming to be Bob. He enters it the first time he opens the group, and the node then wraps the group key for the key he proved he holds. + +After that first time the pin is his credential: he is recognised on every later connection, and asked for nothing. You do not need to be online when he joins. + +| | | +|---|---| +| Code lifetime | 7 days (`[node] invite_ttl_hours`) | +| Reuse | Single use; re-inviting supersedes the previous code | +| If it expires | Issue another one — nothing else is affected | +| Wrong code, repeatedly | Bounded per connection and node-wide, and logged in the node's audit log | + +The same operation is available in the web app: the group's **Members** tab, if your browser is paired with the node (`meshbay-node operator pair`). + +### Removing a member + +```bash +meshbay-node member revoke bob +meshbay-node gek-init # rotate: Bob still holds the old key +``` + +Revoking stops the node serving Bob the key from his next connection onward — there is no stored bundle left behind that could outlive the decision. It does **not** take back the key he already has, which is why the second command exists. + +### What revocation does and does not do -### Member revocation +Rotating the GEK (`meshbay-node gek-init`) makes the node encrypt new content with a new key, which every remaining member picks up automatically on their next connection — nothing has to be re-uploaded or re-wrapped by hand. -To revoke a member: generate a new GEK, re-encrypt it for all remaining members, and upload the new bundles. The node begins encrypting new content with the new GEK from that point. Former members can still decrypt previously received content (no retroactive re-encryption). +A former member can still decrypt content they already received: there is no retroactive re-encryption, and there is no way to reach into someone's disk. Revocation controls what happens next, not what already happened. --- diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 8035824..b4a9e45 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -1,6 +1,8 @@ # MeshBay — Architecture Draft v5 -> Status: **Phase 11.5 (security remediation) complete.** See `devel-phases-next.md`. +> Status: **Phase 11.5 (security remediation) complete. Invite redesign complete +> (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite +> and pairing design, `docs/invite-pairing-v1.md`. > Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4. > > v5 exists because the second security review (`second-review.md`, 2026-08-13) found @@ -19,7 +21,7 @@ v4 described intent. Several of its statements were contradicted by the implemen | "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake | | "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all | | "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB | -| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub is the key directory, so it can substitute a key at invite time and be handed the GEK legitimately | +| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub was the key directory, so it could substitute a key at invite time and be handed the GEK legitimately *(fixed 2026-08-14; the invite path no longer reads the directory — §5.5)* | | "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub | So v5 carries an explicit rule: **a claim in this document must name the adversary it @@ -38,7 +40,8 @@ written that way. | 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 | | 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 | | 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 | -| 7 | Node authority | GEK bundle storage requires the node operator's signature; **nothing arriving over MNP can activate a GEK** | Finding C5b | +| 7 | Node authority | **Nothing arriving over MNP can activate a GEK.** Member-supplied bundles are not gated but *gone*: `gek_bundle_store` was deleted when the node took over wrapping | Finding C5b | +| 7b | Admission | **The node wraps the group key itself**, for a key the recipient proved possession of, and admits people from its own roster using one-time codes the hub never sees (§5.5) | Findings **H3**, **M3** | | 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a | | 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 | | 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 | @@ -54,31 +57,39 @@ This replaces the informal assurances scattered through v4 §4.2.x and §13. | Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker | |---|---|---|---|---|---| -| File content is unreadable | ✅ | ❌ **H3** | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ | -| Chat content is unreadable | ✅ | ❌ H3 | ❌ plaintext at rest until Phase 15 | ❌ | ✅ | -| File index is unreadable | ✅ | ❌ H3 | ❌ | ❌ | ✅ | +| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ | +| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ plaintext at rest until Phase 15 | ❌ | ✅ | +| File index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | | Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | | Node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | | Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ | | Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | +| Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ | -**The claim this project can make:** *the hub cannot read your content unless it actively -attacks you.* That is true, defensible, and stronger than most platforms offer. +**The claim this project can make:** *the hub cannot read your content unless it ships you +malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path, +it is not silent, and it does not exist for a native client. -**The claim it must not make:** *"everything is encrypted and unreadable by other parties, -even the hub."* Two reasons, both deliberate: - -- **H3 — key substitution.** The hub is the public-key directory. When a member invites - someone, the inviter fetches the invitee's `pk_x25519` **from the hub** and wraps the - GEK for it. A hub returning its own key is handed the group key. No forgery, no code - injection, undetectable by the client today. Fixed only by key transparency and safety - numbers (Phase 12.1); open until then. -- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). - A hub that ships the code can lift keys from the page regardless of protocol design. +**What changed.** The hub used to be the key directory on the invite path: the inviter +fetched the invitee's `pk_x25519` from the hub and wrapped the GEK 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 path is gone. The node holds the GEK and wraps it itself, for a +key the recipient proved possession of over an authenticated channel, bound to an identity +the operator admitted with a one-time code the hub never sees (§5.5). -Content is also readable by **every group member** and by **the node operator**, who -stores it in plaintext on disk. That is inherent to the model, not a defect — but it -means "end-to-end" here describes *client ↔ node*, never *client ↔ client*. +**The claim it must not make:** *"everything is encrypted and unreadable by other parties, +even the hub."* Three reasons, all deliberate: + +- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). A + hub that ships the code can lift keys from the page regardless of protocol design — and + can read a pairing code out of it. This is an artifact-level attack, not a silent + directory lie: the native client (Phase 13) plus reproducible builds (18.7) remove it. +- **Open-join groups.** Where `join_policy = "open"`, anyone the hub calls a member is + served the key, so the hub can join through the front door. That is a property of open + joining, not a defect, and the setting is read from `node.toml` — the hub cannot declare + a group open. +- **Members and the operator read everything.** Inherent: "end-to-end" here describes + *client ↔ node*, never *client ↔ client*. --- @@ -187,14 +198,22 @@ was 32 opaque random bytes signed blind — an unbound signing oracle. | Operation | Authority | |---|---| | `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) | -| `gek_bundle_store` | **Node operator only** | +| `invite_create` | **Node operator only** (delegation designed, deferred) | | GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK | +| ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material | `gek_bundle_store` previously accepted any member's bundle and auto-activated one addressed to the operator. Since the operator's X25519 public key is published in `handshake_ack`, any member could wrap a key of their choosing for it and take over the -group. **Consequence of the fix:** a group admin who does not run the node can no longer -invite members — the operator decides what is stored on their machine. +group. It was first gated behind an operator signature, then removed outright when the +node took over wrapping (§5.5) — the message has no remaining purpose, and a path that +does not exist cannot be mis-authorized. + +**Who can invite.** Still the node operator only: `invite_create` is verified against the +operator keys in the node's roster. A group admin who does not run the node cannot admit +anyone until delegation ships (§6.2 of `invite-pairing-v1.md`, deferred by decision +2026-08-13). What *did* change is the timing: the operator issues a code and is then out +of the loop, so nobody has to be online when the invitee actually joins. ### 5.2 Uploads @@ -210,22 +229,65 @@ Two personas, and they need different tools: | Operator | Reaches the node via | |---|---| | Desktop | the local admin UI in their own browser | -| **Headless / SSH** | the CLI — `meshbay-node status`, `ui`, `gek-init` | +| **Headless / SSH** | the CLI — `status`, `ui`, `gek-init`, `operator pair`, `member list\|invite\|revoke\|unpin` | The CLI is the primary interface for servers, which is the normal deployment. `status` deliberately reads the keystore and config directly so it works while the daemon is stopped — the state an operator is most often in, since the daemon will not stay up before its key is linked or before a group exists. Anything the -UI can do should eventually have a CLI equivalent (Phase 14); until then a -headless operator still needs a browser to invite members or delete files. +UI can do should eventually have a CLI equivalent (Phase 14). Inviting, revoking and +pairing now have one; deleting a file is the remaining browser-only operation. ### 5.4 Local admin UI Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at startup. "Localhost only" is not authentication: any local process can reach it, as can a page in the operator's browser via DNS rebinding — and this API re-initialises group -keys and reads the audit log. All interpolated values are HTML-escaped; CSP contains -exfiltration but cannot prevent injected inline script, so escaping is the actual fix. +keys, issues invitations and reads the audit log. All interpolated values are +HTML-escaped — filenames chosen by members, and usernames that originate at the hub; +CSP contains exfiltration but cannot prevent injected inline script, so escaping is the +actual fix. + +### 5.5 Admission and key delivery (closes H3) + +The node decides who receives the group key, and produces every copy of it itself. + +``` +grenet (SSH) meshbay-node member invite bob → CODE R3H8-TB6V + (or the same from the SPA's Members tab, signed by the paired browser) +grenet sends the code to bob out of band +bob opens the group; the client holds no GEK +bob → node join_request {pk_ed25519, pk_x25519, code, sig} ← pre-proof window +node code valid for this account → pin the identity, admit to the group +node → bob the GEK, wrapped for the X25519 key bob just proved he holds +``` + +Four properties, each load-bearing: + +1. **No public key is ever fetched from the directory.** The invitee's key arrives from + the invitee, signed by their identity key in the same transcript (`meshbay:join:v1`), + so the identity key vouches for the encryption key. This is the H3 fix. +2. **The code binds the key to the account**, and the hub never sees it. 40 bits, + single-use, one account, node-wide lockout on repeated failures. Codes live 7 days + for invitations and 24 h for operator pairing, both configurable. +3. **The node's roster is the authority**, not hub membership. A hub that invents an + account, adds it to a group and mints it a token gets `not_authorized_for_group`. + Exception by configuration: `join_policy = "open"` pins the first comer (TOFU) and + serves them — read from `node.toml`, never from the hub. +4. **Wrapping happens on every connection**, so key rotation propagates by itself and + revocation actually takes effect. No member bundle is stored; nothing outlives the + decision to stop serving someone. (Rotating the GEK after a revocation is still + required — the ex-member holds the old one.) + +`gek-init` no longer pre-wraps the key for anyone but the node's own keystore. It used to +fetch every member's public key from the hub and wrap for what came back, which was H3 +with the node as the victim rather than the inviter. + +Node authority is established the same way, once per node: `meshbay-node operator pair` +prints a code, the operator types it into their browser, and the node pins that identity +key. It is never learned from the hub — a hub able to name the operator's key could +install itself as node administrator (finding M3, which the old keystore auto-pin left +inert and failing closed). --- @@ -248,6 +310,11 @@ private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling. was posted and by whom (`chat_notify` — minimization was in the dropped phase), and content hashes for **public** groups only. +**No longer decides:** who holds the group key. The hub still publishes public keys, and +they are still used to look up an account by name — but nothing wraps a key for a key +that came from there. Hub membership lets someone *reach* a node; the node's roster +decides whether it wraps anything for them (§5.5). A pairing code never touches the hub. + ### 6.2 Node registration Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id` @@ -335,16 +402,18 @@ format. | 11.5.6 | QUIC channel-binding anchor | Certificate hash. Server reads its own certificate from disk; the client's access to the peer certificate is guarded and fails loudly if aioquic moves it. **A resumed TLS session carries no certificate**, so the anchor travels with the session ticket — sound, because the ticket is derived from the handshake where the certificate was presented | | 11.5.8 | `pk_node` TOFU pinning | Pinned per `node_id` on first handshake; a change is **refused**, with a deliberate reset in Settings | | **M8** | Node announce proof-of-possession | Announcer signs `meshbay:node_announce:{user_id}:{pk_node}:{timestamp}`; re-announcing updates in place | +| **H3** | Hub key substitution | **Closed 2026-08-14** by the invite redesign (§5.5), not by safety numbers: no public key is fetched from the directory at all, so there is nothing to substitute. See `docs/invite-pairing-v1.md` | +| **M3** | Node authority inert as shipped | Closed with it — authority comes from the roster, established locally by pairing | ### Still open | # | Item | Status | |---|---|---| -| **H3** | Hub key substitution | **Open** — the last unfixed finding. The hub is the public-key directory, so it can be handed the GEK at invite time. Fixed by Phase 12.1 safety numbers | | C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited); closes in Phase 13.3 when the native client stops storing them remotely | | — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived | | — | Chat attachments | Plaintext on the node; decide in Phase 15.7 | -| **T3** | Hub serves the SPA | **Accepted permanently** (D1) | +| — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite | +| **T3** | Hub serves the SPA | **Accepted permanently** (D1). Now the *only* way an active hub reads content — and it can also read a pairing code out of the page it served. Removed for native clients by Phase 13 + reproducible builds (18.7) | ### Scope note on pinning diff --git a/second-review.md b/second-review.md index a2218ad..071e6ef 100644 --- a/second-review.md +++ b/second-review.md @@ -394,6 +394,16 @@ Consider a localhost token in the URL to blunt DNS-rebinding against the unauthe ### H3 — An active hub breaks confidentiality through key substitution (T2 is not a residual risk) +> **CLOSED 2026-08-14.** Not by the fix proposed below. The invite path no longer reads +> the directory at all: the node holds the GEK and wraps it for a key the recipient +> proves possession of over the authenticated channel, and identities are bound to +> accounts by one-time codes the hub never sees. Safety numbers would have made the +> substitution *detectable by a human who checks*; removing the lookup makes it +> impossible. See `docs/invite-pairing-v1.md` and draft-v5 §5.5. +> +> `gek-init` had the same flaw with the node as the victim — it fetched every member's +> public key from the hub and wrapped for the answer. That is gone too. + **Location:** `app.js:1389-1415`, `users.py:340-358`, `users.py:310-337` The invite flow is: fetch `pk_x25519` for the invitee **from the hub**, wrap the GEK for it, @@ -535,7 +545,11 @@ minimum password, and the calibration command prints instructions to hand-edit a `meshbay_common` rather than writing a per-node parameter — so the keystore parameters cannot actually be tuned per hardware as §4.2.1 promises. -**M3 — Node operator cannot delete files in the default configuration.** `_resolve_admin_pk` +**M3 — Node operator cannot delete files in the default configuration.** *(CLOSED +2026-08-14 — the auto-pin is deleted; authority comes from the node's roster, established +locally by `meshbay-node operator pair`. Asking the hub for the operator's key, the +obvious-looking fix, would have let the hub install itself as node administrator.)* +`_resolve_admin_pk` (`daemon.py:451-465`) auto-pins the **node keystore's** Ed25519 key, while the browser signs challenges with the **user identity** key from the keypair bundle (`app.js:983`). These are different keys, so verification fails unless the operator manually sets `admin_pk_ed25519` to @@ -619,6 +633,11 @@ client path. ## 7. Does the system do what it claims? +> This table is the verdict **on the code as it stood on 2026-08-13**, and is left as the +> record of what the review found. It is not the current state: Phase 11.5 closed C1–C6 +> and H1–H7 except H3, and the invite redesign closed H3 and M3 on 2026-08-14. For what +> holds today, and against which adversary, read draft-v5 §2 — never this table. + | Claim (draft-v4) | Verdict | Why | |---|---|---| | Data never transits a central server | **Yes** | WebRTC DataChannel is genuinely P2P; hub relays SDP only. Well executed. | -- cgit v1.2.3 From 9fa2117de1caf4d713cc0b7a310b9549467738c3 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 14:43:48 +0200 Subject: docs: Argon2id, the multi-browser property, and what a browser found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draft-v5 §7 rewritten around the keypair bundle, because that is where the last open finding actually lives. New §7.1 states the adversary (an operator holding their own node's disk), what cracking a bundle yields (identity keys, hence content on *other* nodes and the ability to sign as that user — not the content they host in the clear by design), and the measured numbers rather than adjectives: PBKDF2 241 ms vs Argon2id 88 ms natively, a GPU ceiling moving from ~8k to ~2k guesses/s, six days for a 10⁹ dictionary run, four random words outlasting the sun. The honest summary is in there too — a factor of four on one card, not a thousand; what it buys is the cost of scale. §2 gains the row the table never had: **your identity keys stay yours**, ⚠️ against a malicious node operator. An operator hosts your content by design, and that was documented; that they can also try to become *you* was not. That is the difference between reading what they host and reading what other operators host. §4 records that the challenge now carries `node_pk`, why (a first-time member signs a transcript naming the node and has no GEK to complete a handshake with), and that it is checked against the ack rather than trusted. Also that refusals carry a code, and what `not_a_member` usually means. §8.1 states the multi-browser property plainly — one identity across browsers, recovered with the passphrase, no second code — together with its cost, since it is the same mechanism as C4. invite-pairing-v1 is no longer "a proposal": it shipped. §9bis gains the four browser-found failures and their common thread — e2e.py is a second implementation of the client, written in the right order by construction, so it proves the protocol and nothing about app.js. CLAUDE.md gets the two things a future session must not rediscover the hard way: the KDF parameters live in three places held identical by a parity test, and an unbounded await on the hub socket makes a node silently unreachable (three found). second-review: C4 marked reduced, not closed. devel-phases-next: 12.2's CSP must keep `wasm-unsafe-eval`, or the strict policy locks every user out of their keys. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 31 ++++++++++++ devel-phases-next.md | 4 +- docs/invite-pairing-v1.md | 26 ++++++++-- docs/meshbay-draft-v5.md | 118 +++++++++++++++++++++++++++++++--------------- second-review.md | 7 +++ 5 files changed, 144 insertions(+), 42 deletions(-) (limited to 'devel-phases-next.md') diff --git a/CLAUDE.md b/CLAUDE.md index b2fb9b0..b8c2646 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,37 @@ See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authorit - Revocation now works for key delivery (nothing stored survives it) — but **still rotate the GEK**, the ex-member holds the current one +## Keypair bundles and the browser KDF (2026-08-14) + +- The bundle key is **Argon2id 128 MB / t=3 / p=1**, WebAssembly vendored under + `static/vendor/` (CSP forbids external hosts; 12.2 must keep `wasm-unsafe-eval`). + **Do not change the parameters in one place**: `keyderive.js`, the QE harness and + `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch + presents as an account nobody can open +- Bundles carry an `MBK2` marker; the PBKDF2 form is still readable and is + re-encrypted on the next backup. Both keys are derived at sign-in because the + passphrase is deliberately not retained +- Cost is paid **once per sign-in** (650 ms bundle + 239 ms auth_key); reloading a + page derives nothing — the key lives in IndexedDB +- The bundle is stored on **every node its owner joins**. That is what makes a + second browser work, and it is C4: cracking one yields identity keys, hence + content on *other* nodes and the ability to sign as that user. Draft-v5 §7.1 has + the measured numbers. **The passphrase is the wall; the KDF is a speed bump** +- Floor: 12 characters and ~60 estimated bits, enforced client-side only — with the + password split (T1) the hub never sees a passphrase + +## Two lessons that cost four rounds of live testing + +- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the + client, written in the right order by construction: it proves the protocol and + nothing about the SPA. Three ordering bugs passed it and failed in a browser. + `test_spa_ordering.py` exists for that class and is worth extending +- **An unbounded `await` on the hub socket makes a node silently unreachable.** + Three instances found in `maintain_ws`: the offer handler awaited inside the read + loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending + the task for good. Symptom is always the same — daemon running, logging nothing, + `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first + **Corrections to remember:** - `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native diff --git a/devel-phases-next.md b/devel-phases-next.md index 3c9c639..db9267d 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -775,7 +775,7 @@ group key". They are no longer load-bearing. | # | Component | Description | |---|---|---| | 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 | +| 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 | @@ -841,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; `