aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/invite-pairing-v1.md26
-rw-r--r--docs/meshbay-draft-v5.md108
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.