diff options
Diffstat (limited to 'CLAUDE.md')
| -rw-r--r-- | CLAUDE.md | 88 |
1 files changed, 84 insertions, 4 deletions
@@ -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 |