diff options
| -rw-r--r-- | CLAUDE.md | 31 | ||||
| -rw-r--r-- | devel-phases-next.md | 4 | ||||
| -rw-r--r-- | docs/invite-pairing-v1.md | 26 | ||||
| -rw-r--r-- | docs/meshbay-draft-v5.md | 108 | ||||
| -rw-r--r-- | second-review.md | 7 |
5 files changed, 139 insertions, 37 deletions
@@ -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; `<video src="http://127.0.0.1:…">`. Drops MSE + the fMP4 remux for native (WebKitGTK MSE is unreliable). **Hardening is mandatory and mirrors C1: bind `127.0.0.1` only, random port, per-file capability token scoped to the session, no CORS, reject non-local `Origin`** | diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md index 56fccbe..290c756 100644 --- a/docs/invite-pairing-v1.md +++ b/docs/invite-pairing-v1.md @@ -1,6 +1,8 @@ # MeshBay — Invitation and Pairing (design) -> Status: **proposal, not implemented.** Written 2026-08-13. +> Status: **implemented and deployed** (2026-08-14). Written 2026-08-13 as a proposal; +> §9bis records what shipped, what departed from the plan, and what a browser found +> that the automated run could not. > Supersedes the invite flow described in `meshbay-draft-v5.md` §5.1 and the > Phase 12.1 milestone in `devel-phases-next.md`, if adopted. > @@ -86,7 +88,7 @@ signature ceremony, no operator required to be online when someone joins. ``` operator (SSH) meshbay-node operator pair node prints PAIR-CODE: K7M2-QX4P (also written to data_dir/pair-code) -operator (SPA) Settings → "Pair this browser with my node" → types the code +operator (SPA) group → Members tab → "Pair this browser" → types the code SPA → node join_request {role_hint: operator, code, pk_ed25519, pk_x25519, sig} node code valid, unused, unexpired → pins the keys, role = operator node writes the pin to its roster DB, prints it in `status` @@ -120,7 +122,7 @@ platform. ``` bob (SPA) opens the group; client has no GEK for it bob → node handshake {token, group_id, nonce_c} (pre-proof window) -node → bob handshake_challenge {nonce_s} +node → bob handshake_challenge {nonce_s, node_pk} ← bob signs over both bob → node join_request {group_id, pk_ed25519, pk_x25519, code, sig} node 1. rate-limit + attempt count on this connection 2. code matches a pending invite for this user_id and group @@ -414,6 +416,24 @@ DataChannel, the operator gets a code, and a member who has never held the group key redeems it in the pre-proof window and receives the key wrapped for a key only they can open. +### What a browser found that the harness could not + +The design was exercised end to end by `QE/deploy/e2e.py` and passed. It was then tried in +a browser and failed four times, each for a different reason, none of them the pairing code: + +| Symptom | Cause | +|---|---| +| No code ever requested, "no GEK available" | the browser held no identity keys — session restore never re-read the encrypted bundle sitting in its own localStorage | +| "No nodes available" | the node had gone deaf: its hub socket sat in CLOSE-WAIT because the WebRTC offer handler was awaited inside the read loop (a Phase 11 bug) | +| "Not a member of this group" | the token predated being added to the group; `groups` is baked in at sign-in, and nothing refreshed it | +| "Handshake incomplete — reconnect and retry" | `joinGroup()` read `node_pk` and `nonce_node` before they were assigned | + +The common thread is worth keeping: **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`. Three of those four were ordering or plumbing mistakes it could never see. Hence +`packages/meshbay-hub/tests/test_spa_ordering.py`, which asserts on source order — inelegant, +and the only thing that catches them. + ### Deliberate departures from this document | Written | Built | Why | diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index e8cc3a4..ffc30c2 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -65,6 +65,7 @@ This replaces the informal assurances scattered through v4 §4.2.x and §13. | 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 | — | — | ✅ | +| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on your keypair bundle** — see §7.1. Succeeds against a weak passphrase, and yields your content on *other* nodes plus the ability to sign as you | ✅ | ✅ | **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, @@ -91,6 +92,12 @@ even the hub."* Three reasons, all deliberate: - **Members and the operator read everything.** Inherent: "end-to-end" here describes *client ↔ node*, never *client ↔ client*. +One boundary deserves naming, because the table above hid it until 2026-08-14: an operator +hosts your content by design, but they should not be able to become *you*. They can try — +your keypair bundle sits on their disk, and a weak passphrase gives it up (§7.1). That is +the difference between a node operator reading what they host and a node operator reading +what other operators host. + --- ## 3. Transport (replaces v4 §6.4, §6.5, §7.1) @@ -135,13 +142,26 @@ 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} +node → client handshake_challenge {nonce_s, node_pk} + ── pre-proof window: keypair/GEK bundle fetch, join ── 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) + and that ack.node_pk is the key announced above ``` +`node_pk` is announced in the challenge because joining needs it before the ack: a +first-time member signs a transcript naming this node (§5.5) and has no GEK to complete a +handshake with. It is unverified at that point and is never a substitute for the ack — the +ack proves possession and signs the transcript, and the client refuses a peer whose key +changed between the two. A wrong value there only makes the node's own verification fail. + +**Refusals carry a code**, not only a sentence. `not_a_member` in particular is usually a +token issued before the person was added to the group — `groups` is baked in at sign-in and +the hub pushes no updates — so the client refreshes once and retries rather than telling +someone who was invited a minute ago that they are not a member. + ### 4.1 Transcript ``` @@ -346,42 +366,59 @@ separation, on-the-fly encryption. |---|---| | 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 | +| Browser bundle key | **Argon2id 128 MB, t=3, p=1** (was PBKDF2-SHA512 600k until 2026-08-14) | +| Browser `auth_key` | PBKDF2-SHA512, 600 000 iterations — the hub's Argon2id verifier is what protects it at rest | + +### 7.1 The keypair bundle, and what it is worth to an attacker (C4) + +The bundle carries a user's identity keys, encrypted under their passphrase. It is stored +on **every node whose group they join**, because that is what lets them open their account +from a second browser — the ordinary expectation, and the only mechanism available to a +browser that keeps nothing durable of its own. -**Known weakness (C4), reduced 2026-08-14.** Keypair bundles are pushed to every node -whose group the user joins, and 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. +So the adversary is concrete: an operator holding their own node's disk, attacking offline +at their leisure. -The bundle's own protection moved from PBKDF2-SHA512 to **Argon2id, 128 MB, t=3, p=1** -(`static/vendor/argon2.min.js`, WebAssembly, no external host). PBKDF2 is compute-only, -so 600k iterations cost an attacker with a GPU far less than the wall clock suggested: -measured on the dev machine, PBKDF2 costs 241 ms and Argon2id 88 ms natively, but only -one of them makes a graphics card find 128 MB per guess. The honest size of that gain: -on a single card the ceiling moves from roughly 8k guesses/s to roughly 2k — a factor of -four, not a thousand. What it really buys is the cost of scale, since 128 MB per lane caps -a 24 GB card at about 187 concurrent guesses and makes custom hardware unattractive. +**What cracking one yields.** The user's identity keys — and with them, content on +**other** nodes and the ability to sign as that user. *Not* the content on the attacking +operator's own node, which they host in the clear by design. This is the one place where a +node operator can reach past the boundary the rest of the design gives them, and v5 did not +say so before 2026-08-14. -**The passphrase, not the KDF, is what decides this.** At ~2k guesses/s a dictionary-plus- -rules run of 10⁹ candidates — which covers a large share of human-chosen passwords — -takes about six days on one card. Four random words (~52 bits) takes longer than the age -of the universe. No parameter choice saves a weak passphrase; it only moves it from hours -to days. The two implementations — the browser's WASM and -`argon2-cffi` in QE — are held byte-identical by a parity test, because a disagreement -would present as an account nobody can open. +**Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured +on the development machine: PBKDF2-SHA512 600k costs 241 ms per guess on one core, +Argon2id 128 MB/t=3 costs 88 ms — the defender pays *less* — but only one of them forces +an attacker to find 128 MB per guess. -What that changes, and what it does not. The bundle is what a node operator can take and -attack offline; cracking it yields the user's *identity keys*, and with them content on -**other** nodes and the ability to sign as that user — not the content on the operator's -own node, which they host in the clear by design. Argon2id raises the price of that -attack; it does not remove it, and a weak passphrase still loses. The client refuses -passphrases below 12 characters and ~60 bits, which can only be enforced there: with the -password split (T1) the hub never sees a passphrase. +The honest size of the gain: on a single high-end card the ceiling moves from roughly 8k +guesses/s to roughly 2k, a factor of four, not a thousand. What it really buys is the cost +of scale — 128 MB per lane caps a 24 GB card near 187 concurrent guesses, and makes custom +hardware unattractive, where SHA-512 silicon is cheap. -Bundles written before the change are still readable, and are re-encrypted the first time -their owner's browser backs them up. 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. +**The passphrase decides this, not the KDF.** At ~2k guesses/s, a dictionary-and-rules run +of 10⁹ candidates — which covers a large share of human-chosen passwords — takes about six +days on one card. Four random words (~52 bits) outlasts the sun. No parameter choice saves +a weak passphrase; it only moves it from hours to days. The client therefore refuses +passphrases below 12 characters and ~60 estimated bits, and that floor can only live in the +client: with the password split (T1) the hub never sees a passphrase and cannot enforce +anything about one. + +**Operational notes.** + +- Argon2id runs in WebAssembly, vendored under `static/vendor/` with its provenance — + the CSP forbids external hosts, and 12.2 must keep `wasm-unsafe-eval` in `script-src`. +- Two implementations exist (the browser's WASM, `argon2-cffi` in QE). A parity test holds + them byte-identical, because a disagreement would not look like an error — it would look + like an account nobody can open. +- Bundles written under the old KDF carry no marker, are still readable, and are + re-encrypted the first time their owner's browser backs them up. +- Cost is paid **once per sign-in**: 650 ms for the bundle key, plus 239 ms for `auth_key`. + Opening a group, downloading, streaming and reloading the page all reuse it — the key + lives in IndexedDB for the session. +- The pre-proof window that serves bundles is still bounded (4 fetches) and audited. + +C4 closes properly when the native client stops storing bundles remotely (Phase 13.3): +the material belongs on the user's own device, not on the hub *or* on other people's nodes. --- @@ -398,6 +435,13 @@ device, not on the hub *or* on other people's nodes. | Transport | WebRTC | WebRTC **+ QUIC** | | Positioning | **Convenience tier** — zero install | Recommended for sensitive use | +**Several browsers, one identity.** A browser keeps nothing durable the user controls, so +the account's keys are backed up — encrypted under the passphrase — to the nodes whose +groups it joins. Any other browser then recovers them with the passphrase alone: same +identity, same pin, no second pairing code. This is what makes the product behave the way +people expect, and it is also finding C4 (§7.1). The native client removes the need for it +entirely, which is a large part of why it exists. + 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. diff --git a/second-review.md b/second-review.md index 071e6ef..01ee2af 100644 --- a/second-review.md +++ b/second-review.md @@ -557,6 +557,13 @@ their browser key. Fails closed, so it is a correctness problem rather than a ho sovereignty feature is effectively inert as shipped, and the mismatch will invite the wrong fix (relaxing the check) unless it is documented. +> **C4 — REDUCED 2026-08-14, not closed.** The bundle's KDF moved from PBKDF2-SHA512 +> 600k to Argon2id 128 MB/t=3 in the browser (vendored WebAssembly), so an operator +> attacking one offline no longer enjoys the GPU economics of a compute-only KDF. The +> pre-proof window is unchanged and still bounded. What remains: bundles are still stored +> on every node their owner joins, and a weak passphrase still loses — draft-v5 §7.1 gives +> the measured numbers. It closes at 13.3. + **M4 — Response-to-request matching by arrival order.** `transport.js:390-422` resolves the **oldest** pending promise with whatever message arrives, ignoring type. With the 8-deep pipelined download window, a node that reorders responses (or an `error` message arriving |