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.md225
1 files changed, 0 insertions, 225 deletions
diff --git a/docs/per-node-identity-v1.md b/docs/per-node-identity-v1.md
deleted file mode 100644
index da3c6d4..0000000
--- a/docs/per-node-identity-v1.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# MeshBay — Per-node identity
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the per-node identity design; its design
-> content now lives in §3.2.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> 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.