summaryrefslogtreecommitdiffstats
path: root/docs/per-node-identity-v1.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/per-node-identity-v1.md')
-rw-r--r--docs/per-node-identity-v1.md215
1 files changed, 215 insertions, 0 deletions
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.