aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /CLAUDE.md
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they changed about what this project may claim. Phase 11.5 closed the gap between the documents and the code: the unauthenticated node HTTP API and the TCP transport deleted, one handshake shared by the remaining two transports, mutual authentication, structured admin transcripts, upload confinement, group isolation, revocation that reaches nodes. Six critical and seven high findings closed, bounded, or deferred by decision. The invite redesign closed H3 and M3 — the last open High. The hub was the key directory: an inviter fetched the invitee's key from it and wrapped the group key for whatever came back, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. That lookup is gone. The node holds the group key and wraps it itself, for a key its recipient proves possession of, bound to an account by a one-time code the hub never sees. M3 fell out of the same work: node authority comes from a local roster, never from the hub. Per-node identity cut what remains of C4 down to one operator. A single keypair used to be copied to every node its owner joined; each node now gets its own, so cracking the bundle on one machine yields a key that is a stranger everywhere else — and on that machine, one that unlocks nothing its holder did not already serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or publishing user keys at all. What this project may now say: the hub cannot read your content unless it ships you malicious client code. T3 remains, accepted (D1), and is what the native client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds against, which is the convention this branch exists to keep. Four defects were found by deploying it and using a browser, none by the test suite: a node going deaf on its hub socket, a token that predated group membership, a client reading values before they were assigned, and identity keys a browser held but never re-read. The lessons are recorded in CLAUDE.md. Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair, invite, join, download, stream, second browser, revoke — run against the live deployment on a wiped hub and node.
Diffstat (limited to 'CLAUDE.md')
-rw-r--r--CLAUDE.md88
1 files changed, 84 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 47c2a10..b2ff32a 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
@@ -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):**
@@ -136,7 +143,8 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting
- `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh)
- `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close)
- `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch)
-- Removed auto-`regenerateKeys()` on login (was silently rotating hub keys, breaking GEK unwrap)
+- `regenerateKeys()` is gone entirely (2026-08-14): identity keys are per node, so
+ rotation is `meshbay-node member unpin <user>` plus a fresh code
- Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP)
- Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead)
@@ -165,6 +173,78 @@ 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
+
+## 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
+
+## Identity keys are PER NODE (2026-08-14)
+
+See `docs/per-node-identity-v1.md`. Read it before touching registration, the
+keypair bundle, or anything that looks like a user's public key.
+
+- A keypair is created at **first contact with a node**, encrypted under the
+ passphrase, and left on that node. Never reused elsewhere. Cracking one yields
+ the identity used with that operator and nothing anywhere else
+- **The hub stores and publishes no user keys.** `users.pk_ed25519`/`pk_x25519`
+ are dropped, `PUT /me/keys` is gone, `/pubkeys` returns an account id and the
+ node linking key. Do not reintroduce a key directory — that was H3
+- **Tokens carry no `pk_user`.** The node recorded it as the uploader's identity
+ and authorized deletion against it, so whoever issued tokens decided who could
+ delete a file. Attribution uses the roster pin (`_pinned_pk`)
+- Registration generates nothing, so a scripted signup is a real account —
+ `QE/deploy/demo.py bootstrap` takes a wiped hub and node to a working demo
+- The key handed back on a join belongs to the **group of the connection**, not
+ the group named in the invitation (an operator pairs node-wide while opening a
+ group)
+
+## 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