diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 17:51:48 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 17:51:48 +0200 |
| commit | f0984e86d9cb596a282ce6feb7cfc2f075b2794b (patch) | |
| tree | 192198a5d596a06d15095bb1d3540dd4a8d397c8 /docs | |
| parent | 9fa2117de1caf4d713cc0b7a310b9549467738c3 (diff) | |
| download | meshbay-f0984e86d9cb596a282ce6feb7cfc2f075b2794b.tar.gz | |
feat!: identity keys per node — C4's blast radius drops to one operator
One keypair was copied to every node its owner joined, so cracking the bundle on
any single node yielded the identity used on all of them: their content on other
operators' machines, and the ability to sign as them anywhere. That lateral reach
was the part of C4 worth attacking.
Each node now gets its own keypair, generated the first time its owner joins it
and left with that node alone. An operator who cracks what sits on their own disk
holds a key that is a stranger to every other node — and on their own node, one
that unlocks nothing they did not already hold: they serve the content, the index
and every byte of it by design.
Nothing changes for the user. A first contact with a node already needed that
operator's code, and the key is created in the same step; a second browser still
recovers it from the node with the passphrase alone. Two operators can also no
longer tell they host the same person by comparing keys.
BREAKING, and deliberately without a compatibility path — the deployment is wiped
for the next demo:
- users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2)
- registration no longer sends or stores a key
- PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now
`member unpin` plus a fresh code, decided on the machine that pinned it
- /pubkeys returns an account id and a node's linking key. It was the directory
H3 read, and nothing wraps for it any more
- the pk_user JWT claim is gone
That last one closed a live defect the inventory turned up: the node recorded
pk_user as the uploader's identity and authorized deletion against it, so a hub
issuing a token naming its own key could delete anyone's uploads on any node.
Attribution now uses the key the node itself pinned.
A simplification falls out. Registration generates nothing, so a scripted signup
is a real account: `demo.py bootstrap` takes a wiped hub and node to a working
demo with no browser, which was impossible while keys were born in one.
Also fixes, found by running it on a wiped deployment: the key handed back on a
join now belongs to the group the connection is for, not the group named in the
invitation — an operator pairs node-wide but redeems the code while opening a
group, and expects to read it.
Tests: 343, including the two that state the property — a key pinned by one node
is refused at another, and someone else's code does not admit it. Verified end to
end against a wiped hub and node: bootstrap, pair, invite, join, download,
stream, second browser, revoke.
Design: docs/per-node-identity-v1.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/meshbay-draft-v5.md | 71 | ||||
| -rw-r--r-- | docs/per-node-identity-v1.md | 215 |
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. |