diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 14:43:48 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 14:43:48 +0200 |
| commit | 9fa2117de1caf4d713cc0b7a310b9549467738c3 (patch) | |
| tree | d1ac0317a714dda175f8441f1954b81a8fe56cb3 /docs | |
| parent | 1c96aceb54d66cae1b48aa0eb8887f68e53f9e24 (diff) | |
| download | meshbay-9fa2117de1caf4d713cc0b7a310b9549467738c3.tar.gz | |
docs: Argon2id, the multi-browser property, and what a browser found
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 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/invite-pairing-v1.md | 26 | ||||
| -rw-r--r-- | docs/meshbay-draft-v5.md | 108 |
2 files changed, 99 insertions, 35 deletions
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. |