summaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
diff options
context:
space:
mode:
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