diff options
Diffstat (limited to 'docs/per-node-identity-v1.md')
| -rw-r--r-- | docs/per-node-identity-v1.md | 225 |
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. |