aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/meshbay-draft-v5.md71
-rw-r--r--docs/per-node-identity-v1.md215
2 files changed, 259 insertions, 27 deletions
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
index ffc30c2..2a0f3b1 100644
--- a/docs/meshbay-draft-v5.md
+++ b/docs/meshbay-draft-v5.md
@@ -65,7 +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 | ✅ | ✅ |
+| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ |
**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,
@@ -92,11 +92,11 @@ 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.
+One boundary deserves naming: an operator hosts your content by design, but they should not
+be able to become *you*. They can still try — a bundle sits on their disk and a weak
+passphrase gives it up — but since 2026-08-14 what it gives up is **the identity you use
+with them**, which unlocks nothing they did not already have. Reading what they host is by
+design; reading what *other* operators host is not, and no longer follows.
---
@@ -319,9 +319,15 @@ Hub minimization was considered and **deferred** (decision D4). The hub keeps se
the web UI and remains in the trusted path by choice. This section describes what it
*does*, not an aspiration.
-**Stores:** accounts (username, encrypted email, public keys, status, role), group
-registry and membership, IP logs (1 year, legal), node registrations, refresh tokens,
-notifications, moderation blocklist.
+**Stores:** accounts (username, encrypted email, status, role), group registry and
+membership, IP logs (1 year, legal), node registrations, refresh tokens, notifications,
+moderation blocklist.
+
+**No longer stores user identity keys** (2026-08-14). `users.pk_ed25519` and
+`users.pk_x25519` are dropped, `PUT /me/keys` is gone, and `/pubkeys` returns an account id
+and a node's linking key — nothing to wrap for. Tokens carry no `pk_user` claim either: the
+node used to record it as the uploader's identity, which let whoever issued tokens decide
+who could delete a file.
**Does not store:** file content, file names, private-group indexes, message content,
private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
@@ -371,19 +377,25 @@ separation, on-the-fly encryption.
### 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.
+A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, and
+is stored on that node. It is what lets a second browser open the same account there — the
+ordinary expectation, and the only mechanism available to a browser that keeps nothing
+durable of its own.
+
+The adversary is concrete: an operator holding their own node's disk, attacking offline at
+their leisure.
-So the adversary is concrete: an operator holding their own node's disk, attacking offline
-at their leisure.
+**What cracking one yields.** The identity that person uses **on that node** — where the
+operator already holds the content, the index and every byte they serve. It is not a key
+anywhere else: each node gets its own, generated the first time its owner joins it, and a
+key one node pinned is a stranger to the next (which asks for a code, like any first
+contact). Until 2026-08-14 a single identity was copied to every node joined, so one crack
+yielded content on *other* operators' nodes and the ability to sign as that user anywhere —
+that was the part worth attacking, and it is gone.
-**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.
+Two smaller consequences fall out. Two operators can no longer tell they host the same
+person by comparing keys. And the hub publishes no user keys at all now (§6.1), so there is
+no directory left to substitute from.
**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,
@@ -417,8 +429,10 @@ anything about one.
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.
+C4 is **reduced, not closed**: bundles still sit on disks their owner does not control, and
+a weak passphrase still gives up the key for that node. It closes when the native client
+stops storing them remotely (Phase 13.3) — the material belongs on the user's own device,
+not on the hub *or* on other people's nodes.
---
@@ -435,11 +449,14 @@ the material belongs on the user's own device, not on the hub *or* on other peop
| 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
+**Several browsers, one identity per node.** A browser keeps nothing durable the user
+controls, so the identity it creates for a node is left with that node, encrypted under the
+passphrase. Any other browser recovers it there with the passphrase alone: same identity on
+that node, same pin, no second code. Joining a *different* node creates a different key and
+needs that operator's code, which is the first contact it has always needed.
+
+This is what makes the product behave the way people expect, and it is also finding C4
+(§7.1) — with a blast radius of one node. 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
diff --git a/docs/per-node-identity-v1.md b/docs/per-node-identity-v1.md
new file mode 100644
index 0000000..673fec1
--- /dev/null
+++ b/docs/per-node-identity-v1.md
@@ -0,0 +1,215 @@
+# MeshBay — Per-node identity
+
+> Status: **implemented 2026-08-14**, deployed and exercised end to end against a wiped
+> hub and a wiped node. Written first as a proposal; §9 records what shipped.
+> Reduces **C4** from "one crack yields the network" to "one crack yields one node".
+> Removes the hub-published user keys, which stopped being load-bearing when H3 closed.
+> Follows the v5 convention: every claim names the adversary it holds against.
+
+---
+
+## 1. What this changes, in one paragraph
+
+Today one identity keypair is copied to every node its owner joins. Cracking the copy
+stored on *any* node yields the identity used on *all* of them. This proposal gives each
+node its own keypair, generated the first time its owner joins it. An operator who cracks
+what sits on their own disk then holds a key that is worthless anywhere else — and on
+their own node they already hold everything it could unlock.
+
+No new screen, no extra code to type, no change to how anyone signs in.
+
+---
+
+## 2. Where we are
+
+Two things are **already per node**, which is what makes this cheap:
+
+| | Today |
+|---|---|
+| Roster pin | per node — each node pins the key it was shown (`roster.identities`) |
+| Bundle storage | per node — each node has its own `bundles.db` |
+| **Key material** | **global — the same keypair is copied into every node's database** |
+
+So the plumbing is in place and only the contents are shared. The change is: generate a
+fresh keypair per node instead of reusing one.
+
+The global key exists because registration creates it (`keyderive.js registerUser`) and
+publishes it to the hub, from where everything else used to fetch it. Since H3 closed,
+almost nothing does.
+
+---
+
+## 3. The design
+
+### 3.1 One keypair per node
+
+The first time a browser joins node N, it generates a keypair for N, encrypts it under the
+passphrase-derived key, and stores it on N — which is the message it already sends
+(`keypair_bundle_store`). N pins the public half through the ordinary join, using the
+pairing code its operator already issues for a first contact.
+
+Nothing else in the join changes: same transcript, same code, same pin, same refusals.
+
+### 3.2 Flows
+
+**First join to a node** — unchanged from the user's side:
+
+```
+browser no key for node N yet → generate one
+browser → N join_request {pk_ed25519_N, pk_x25519_N, code, sig}
+N code valid → pins the pair, wraps the GEK for pk_x25519_N
+browser → N keypair_bundle_store (that node's key, encrypted under the passphrase)
+```
+
+**Second browser, same node** — unchanged:
+
+```
+browser → N keypair_bundle_fetch → decrypt with the passphrase → same key as browser 1
+browser → N join_request (no code) → recognised
+```
+
+**Joining a second node** — already requires a code from that node's operator, so the new
+key is generated in the same step. The user does nothing extra.
+
+**Operator pairing** — identical; the operator's key is per node like everyone else's.
+
+### 3.3 Where the browser keeps them
+
+A map `node_id → bundle` in IndexedDB, instead of one identity. A browser that has never
+seen node N simply fetches N's bundle from N; it only ever needs the key of the node it is
+talking to.
+
+---
+
+## 4. What this fixes, and what it does not
+
+**Fixes: the blast radius.** An operator who cracks the bundle on their own disk gets the
+key used with their own node. There they already control the content, the index and every
+byte they serve — the key adds almost nothing. What disappears is the part that mattered:
+reading that person's content on **other** operators' nodes, and signing as them anywhere
+else. That is the whole of what made C4 more than a redundancy.
+
+**Fixes, incidentally: linkability.** Two operators can no longer tell they host the same
+person by comparing keys. Today they can.
+
+**Does not fix:** a weak passphrase still gives up that node's key, and the bundles still
+sit on disks their owner does not control. Only Phase 13.3 (native client, keys on the
+device) removes that. C4 stays open, with a smaller consequence.
+
+**Does not change** the operator's ability to read what they host. That is by design and
+stated in draft-v5 §2.
+
+---
+
+## 5. Removing the hub-published user keys
+
+They were the directory H3 exploited. Since the node wraps the GEK itself, nothing wraps
+anything for a key fetched from the hub. What remains is inventory.
+
+### 5.1 What still uses them
+
+| Use | Verdict |
+|---|---|
+| `_sessionKeys.pkXB64` set from `/pubkeys` (`app.js:974, 2768`) | **replaceable** — the browser can derive its own public half from its own secret (`_pkXFromSk`, already written) |
+| Invite: username → `user_id` (`app.js:1602`, `ui/app.py`) | **keep the endpoint** — an account id is not a key, and it is how a name is resolved |
+| Settings: `pk_node_ed25519` (`app.js:2210`) | **keep** — that is the node linking key, a different field |
+| JWT claim `pk_user` (`auth.py:151`, filled from `user.pk_ed25519`) | **remove** — see 5.2, it is a live defect |
+
+### 5.2 A defect this uncovered
+
+`pk_user` travels in the JWT and the node records it as `uploader_pk` at upload
+(`webrtc_server.py:1178`), then uses it to authorize deletion by the uploader
+(`:1329-1340`). That key is chosen by the **hub**. A hub that issued a token naming its own
+key could then delete that user's files on any node — deny-by-default was supposed to make
+deletion node-authorized, and this is a hole in it.
+
+With per-node identity there is a better answer available for free: authorize deletion
+against the key the **roster pinned**, which the node established locally and the hub never
+touched. This should be fixed whether or not the rest of the proposal proceeds.
+
+### 5.3 What goes
+
+- `User.pk_ed25519`, `User.pk_x25519` columns (Alembic migration)
+- `pk_user_ed25519` / `pk_user_x25519` in the registration body
+- `PUT /v1/users/me/keys`, and `regenerateKeys()` in `keyderive.js` — rotation becomes
+ per node: `member unpin` plus a fresh code, which already exists
+- the `pk_user` JWT claim, and `AuthorizedPeer.pk_user`
+- the two key fields in the `/pubkeys` response; the endpoint stays for `user_id` and
+ `pk_node_ed25519`
+
+Old tokens keep working while they live (1 h): the node already reads the claim with
+`.get()`, so its absence is not an error.
+
+---
+
+## 6. Work plan
+
+| # | Slice | Where | Effort |
+|---|---|---|---|
+| 1 | `uploader_pk` from the roster pin, not the JWT | `webrtc_server.py` | small — and it is a fix on its own |
+| 2 | Per-node keypair: generate at first join, store per node, keep a `node_id → bundle` map | `app.js`, `transport.js`, `keyderive.js` | **the bulk of it**, all client-side |
+| 3 | Derive our own `pkX` locally instead of reading it back from the hub | `app.js` | small |
+| 4 | Remove the published keys: columns, endpoint fields, registration body, JWT claim, `regenerateKeys` | `db/models.py`, `api/users.py`, `auth.py`, migration, `keyderive.js`, `handshake.py` | medium, touches the schema |
+| 5 | Harness + docs: `e2e.py` per-node keys, draft-v5 §2/§7.1/§8.1, `second-review.md` | QE, docs | small |
+
+Slices 1 and 3 stand alone and could land first. Slice 4 is the only one with a migration.
+
+---
+
+## 7. Risks and open questions
+
+**Existing users.** No big-bang migration: a bundle already on a node simply becomes that
+node's key, and only *new* joins generate fresh ones. Someone already on three nodes keeps
+one shared key across those three until they re-pair — the improvement applies going
+forward. Forcing it would mean unpinning everyone, which is not worth it.
+
+**A browser that loses its map.** It refetches from the node it is connecting to; nothing
+is lost, since a node's key is only needed with that node.
+
+**Is per node the right granularity, rather than per group?** Per node matches the roster,
+which pins per account and not per group, and matches the trust boundary — the operator is
+the adversary, and one operator may host several of your groups. Per group would multiply
+keys with no adversary to justify them.
+
+**Does anything need one identity across nodes?** Nothing found. Chat identity and upload
+attribution are per node; account identity on the hub is the username plus `auth_key`, which
+is untouched. If a future feature needs a global identity — cross-node contacts, say — it
+would need its own key, published deliberately, not this one reused by accident.
+
+---
+
+## 8. How it gets tested
+
+- `e2e.py`: a member joins two groups on the node with one key (unchanged), and the
+ second-browser recovery still works. Add a check that the key stored on the node is the
+ one pinned there, not a global one
+- a negative test: a key pinned by node A, presented to node B, is refused without a code —
+ which is the property the whole proposal buys
+- `test_spa_ordering.py`: generation of the per-node key must happen before `joinGroup()`,
+ same class of ordering guard as the others
+- the hub tests must fail if `/pubkeys` starts returning user keys again
+
+
+---
+
+## 9. What shipped
+
+All five slices, against a deployment wiped for the next demo — so no compatibility path
+was kept and none is owed.
+
+| Slice | Outcome |
+|---|---|
+| 1 | `uploader_pk` comes from the roster pin (`_pinned_pk`), never from the token |
+| 2 | Identity is created at first contact with a node and left there; `transport.js` fetches it or generates it, `app.js` no longer holds a global one |
+| 3 | The browser derives its own public half; nothing is read back from the hub |
+| 4 | `users.pk_ed25519` / `pk_x25519` dropped (migration `a7c31f9e40b2`), `PUT /me/keys` and `regenerateKeys()` gone, `pk_user` claim gone, `/pubkeys` reduced to an account id and the node linking key |
+| 5 | Harness mirrors the client (recover, else generate, then leave the key with the node); tests for the property; docs |
+
+**A simplification worth noting.** Registration no longer generates anything, so a scripted
+signup is now a real account — `demo.py bootstrap` brings a wiped deployment to a working
+demo without a browser, which was impossible before. The old rule "only the admin can be
+registered by script" is gone with the keys it existed for.
+
+**Tests added:** a key pinned by one node, presented to another, is refused as a first
+contact; and someone else's code does not admit it either. That pair is the property this
+whole change buys.