summaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v5.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/meshbay-draft-v5.md')
-rw-r--r--docs/meshbay-draft-v5.md108
1 files changed, 76 insertions, 32 deletions
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.