diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
| commit | c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch) | |
| tree | dea71c8e115742beaac5952c8c65481bbc130b07 /docs/per-node-identity-v1.md | |
| parent | ee6573c57f721db8550e34e1c1c79c5922c62a4b (diff) | |
| parent | d324792d68503109ab99616af6c85ee37045e169 (diff) | |
| download | meshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz | |
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they
changed about what this project may claim.
Phase 11.5 closed the gap between the documents and the code: the unauthenticated
node HTTP API and the TCP transport deleted, one handshake shared by the
remaining two transports, mutual authentication, structured admin transcripts,
upload confinement, group isolation, revocation that reaches nodes. Six critical
and seven high findings closed, bounded, or deferred by decision.
The invite redesign closed H3 and M3 — the last open High. The hub was the key
directory: an inviter fetched the invitee's key from it and wrapped the group key
for whatever came back, so a hub answering with its own key was handed the group
key by an honest member following the protocol exactly. That lookup is gone. The
node holds the group key and wraps it itself, for a key its recipient proves
possession of, bound to an account by a one-time code the hub never sees. M3 fell
out of the same work: node authority comes from a local roster, never from the
hub.
Per-node identity cut what remains of C4 down to one operator. A single keypair
used to be copied to every node its owner joined; each node now gets its own, so
cracking the bundle on one machine yields a key that is a stranger everywhere
else — and on that machine, one that unlocks nothing its holder did not already
serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or
publishing user keys at all.
What this project may now say: the hub cannot read your content unless it ships
you malicious client code. T3 remains, accepted (D1), and is what the native
client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at
rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds
against, which is the convention this branch exists to keep.
Four defects were found by deploying it and using a browser, none by the test
suite: a node going deaf on its hub socket, a token that predated group
membership, a client reading values before they were assigned, and identity keys
a browser held but never re-read. The lessons are recorded in CLAUDE.md.
Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair,
invite, join, download, stream, second browser, revoke — run against the live
deployment on a wiped hub and node.
Diffstat (limited to 'docs/per-node-identity-v1.md')
| -rw-r--r-- | docs/per-node-identity-v1.md | 215 |
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. |