aboutsummaryrefslogtreecommitdiffstats
path: root/docs/per-node-identity-v1.md
blob: da3c6d4b97c6da19d30bad4c1252f49899da2b1c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# 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.