ciphermesh 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ # Multi-device
2
+
3
+ Status: **shipped**, steps 1 to 7; step 8 answered and declined. Written
4
+ 2026-08-23 from the code as it stood, and implemented across #493, #494, #496,
5
+ #497, #498, #499, #500, #501 and #502 the same day. Item 4 of #481.
6
+
7
+ The document is kept as written rather than rewritten in the past tense, the way
8
+ `sender-keys-on-relay.md` is: the decisions are recorded in place, under the
9
+ hard parts they answer.
10
+
11
+ This follows the shape of [sender-keys-on-relay.md](sender-keys-on-relay.md),
12
+ which was written the same way and turned out to be worth it. Where that
13
+ document got something wrong — step 5 — the mistake is left visible.
14
+
15
+ ## The problem, measured
16
+
17
+ Multi-device is not a missing feature. It is a **reachable configuration that
18
+ produces the wrong thing**, and the client has a command that walks people into
19
+ it.
20
+
21
+ `/backup` writes `{ identity: keyManager.serialize(), trust: ... }` encrypted
22
+ with the session passphrase — and `serialize()` includes the secret key. On
23
+ another machine, the startup prompt _"Restore identity from a backup? (path or
24
+ Enter)"_ reads it straight back in. Both machines now hold the same X25519
25
+ secret key. What follows is what the code actually does, not what it ought to:
26
+
27
+ - **They cannot both use the name.** `WebSocketServer` rejects a JOIN whose
28
+ nickname is taken (`isNicknameTaken`, line 296). The second device gets
29
+ `NICKNAME_TAKEN`. One person is in the room as two people under two names.
30
+ - **Nothing notices they are the same identity.** `addSession` has no
31
+ uniqueness check on `publicKey`, so the relay is perfectly happy to hold two
32
+ sessions with one key, and treats them as unrelated peers. So does every
33
+ other client.
34
+ - **Each message reaches exactly one of them.** `MessageRouter.route` delivers
35
+ to `msg.to`, a session id. Both devices _could_ open the envelope — same
36
+ secret key — but only one is sent it. Which one depends on which session the
37
+ sender's peer map happened to address. Read a conversation on your laptop,
38
+ and half of it is on your phone.
39
+ - **Verification teaches the wrong lesson.** `TrustStore` is
40
+ `Map<lowerNickname, record>`, so a peer verifies each device separately, as
41
+ two different people. And `computeSAS` hashes the two X25519 keys, so both
42
+ comparisons produce **the same digits** — the peer is asked to verify one
43
+ fingerprint under two names and told that is normal.
44
+ - **Losing one device loses the identity.** One secret key, copied. There is no
45
+ way to revoke a device: `KeyManager.rotate()` replaces the identity
46
+ everywhere at once, for everybody, and the peers see a key change they cannot
47
+ distinguish from an impersonation attempt.
48
+ - **One thing is accidentally right.** Bans key on `session.publicKey`
49
+ (`WebSocketServer` lines 480, 573, 754, 1039), so banning one device does
50
+ correctly ban them all.
51
+
52
+ So the honest summary is not "we do not have multi-device". It is: **the
53
+ project ships a way to get two devices, and the result is a doubled presence, a
54
+ halved conversation, a fingerprint that means less than the user is told, and a
55
+ key that cannot be revoked.** Everything below is about replacing that with
56
+ something defensible.
57
+
58
+ ## What multi-device has to mean here
59
+
60
+ The shape is not novel and there is no reason to invent one. **Per-device keys
61
+ under one identity:**
62
+
63
+ - A long-term **identity key** (Ed25519) that signs and is never used to
64
+ encrypt a message.
65
+ - A **device key** (X25519, plus the ML-KEM half) per device, exactly what
66
+ `KeyManager` already produces — but no longer the identity.
67
+ - A **device list**: the set of device keys currently valid for an identity,
68
+ signed by the identity key, with a monotonic counter so an old list cannot be
69
+ replayed over a newer one.
70
+ - The thing users verify becomes the **identity key**, not a device key.
71
+
72
+ That last point is the one that makes the rest usable. If verification stays on
73
+ the device key, then adding a device invalidates every verification you have,
74
+ and a person with three devices is three SAS comparisons — nine, pairwise, in a
75
+ room of two such people. Moving verification up one level means you verify a
76
+ person once and their devices inherit it, which is the only version anyone will
77
+ actually do.
78
+
79
+ ## What changes
80
+
81
+ 1. `KeyManager` grows an **identity keypair** alongside the device keypair, and
82
+ `fingerprint` is computed from the identity key.
83
+ 2. A device publishes a **descriptor** — its X25519 key, its ML-KEM key, a
84
+ label, a creation time — signed by the identity key.
85
+ 3. A **device list** (identity key + descriptors + counter, signed) is
86
+ distributed to peers over the pairwise channel, the same way a sender key is
87
+ and for the same reason: it is authenticated by opening the envelope it
88
+ arrived in, never asserted by the relay.
89
+ 4. A pairwise send goes to **every live device** of the recipient. A group send
90
+ still goes out once; only distribution multiplies.
91
+ 5. Your own other devices are recipients too, which is what makes a message you
92
+ sent from your phone appear on your laptop.
93
+ 6. **Revocation** is a new signed list with the device removed and the counter
94
+ raised. Peers drop the key on receipt.
95
+
96
+ ## The hard parts
97
+
98
+ ### The fingerprint changes meaning
99
+
100
+ Every existing verified record was verified against a device key. Moving the
101
+ fingerprint to the identity key makes all of them stale at once, and a stale
102
+ verification is exactly what `TrustResult.VERIFIED_MISMATCH` is built to scream
103
+ about — so a careless rollout tells every user, simultaneously, that everyone
104
+ they trust has been replaced.
105
+
106
+ This is the single most dangerous part of the change. It cannot ride a
107
+ capability check the way sender keys did, because it is not about what the
108
+ _other_ side can do; it is about what a local file means.
109
+
110
+ ### The nickname is the identity, as far as the relay is concerned
111
+
112
+ Nicknames are unique and rooms are keyed by session. Two options, both with a
113
+ cost:
114
+
115
+ - **The relay learns about identities.** N sessions may share a nickname if
116
+ they prove the same identity key. This is the honest model and it is a
117
+ protocol change on the relay, plus a proof-of-possession on JOIN so the name
118
+ cannot be taken by asserting somebody else's identity.
119
+ - **Devices get distinct names.** No relay change, and it leaks how many
120
+ devices you have to everyone in the room — metadata this project otherwise
121
+ works hard to withhold.
122
+
123
+ The first is more work and is the right one. It should not be decided by which
124
+ is easier to build.
125
+
126
+ ### Fan-out multiplies, and #481 just decided to keep the loop
127
+
128
+ Item 3 of #481 concluded the per-peer loop stays — deniability and sender-key
129
+ distribution both need it permanently. With D devices per peer that loop
130
+ becomes **N × D**, and sender-key _distribution_ becomes N × D too, even though
131
+ the group send itself stays at one.
132
+
133
+ That is the same scaling wall sender keys were built to remove, re-entered
134
+ through a different door. It has to be sized before anything ships:
135
+ `/room` already reports the send path and the cost, so the measurement has
136
+ somewhere to live.
137
+
138
+ ### Sealed sender leaks the device count
139
+
140
+ An envelope is sealed to one recipient key. Per-device keys mean one envelope
141
+ per device, and the relay can count them. Today the relay learns the recipient
142
+ and not the sender; after this it learns _how many devices the recipient has_,
143
+ which is a stable fingerprint of a person across sessions. Padding the fan-out
144
+ to a fixed bucket is the obvious answer and it is not free.
145
+
146
+ ### Rotation on membership change now fires on device churn
147
+
148
+ `#rotateGroupFor` runs when a member leaves, because a departed member holds a
149
+ chain that ratchets forward. With devices, closing a laptop is a departure.
150
+ Rotating the room every time somebody's second device sleeps is expensive and
151
+ makes a real guarantee look like noise.
152
+
153
+ The rule probably becomes: rotate when the **last** device of a member leaves,
154
+ or when a device is **revoked**. That is a different predicate from the one in
155
+ the code today and it needs its own test per route in, exactly as the departure
156
+ routes did in #482.
157
+
158
+ ### Where the identity key lives
159
+
160
+ If it is on every device, losing any device loses the identity. If it is on one
161
+ primary, that device is a single point of failure for ever adding another. The
162
+ Signal answer is a primary that provisions, and it is probably right here too —
163
+ but this project has no second channel to provision over except the pairwise
164
+ one it is trying to bootstrap, so the provisioning step needs a real design of
165
+ its own.
166
+
167
+ **Decided (2026-08-23): the identity secret never moves.** A secondary device
168
+ generates its own box keypair and receives only the identity's *public* half
169
+ plus a device list signed by it. A stolen phone is then a stolen phone rather
170
+ than a stolen identity, and a secondary cannot add or revoke devices — only the
171
+ device holding the secret can.
172
+
173
+ The provisioning channel is the user, and it is two hops because neither side
174
+ can sign for the other: the new device has to say what its key is before the
175
+ identity can sign for it, and has to be told what identity it belongs to
176
+ afterwards.
177
+
178
+ ```
179
+ B: /device request → ciphermesh-device://request/… (~150 characters)
180
+ A: /device add <request> → ciphermesh-device://grant/… (~740 characters)
181
+ B: /device accept <grant>
182
+ ```
183
+
184
+ Neither string is secret — a request is a public key, a grant is a signed
185
+ statement that was going to be broadcast to every peer anyway — so interception
186
+ achieves nothing. Substitution is caught: a grant only applies if the list names
187
+ the exact device that asked, by both id and key.
188
+
189
+ Two costs, both accepted rather than hidden:
190
+
191
+ - **Losing the primary means no more adding or revoking.** The standard
192
+ trade-off, and the safer side of it.
193
+ - **A secondary cannot rotate its box key**, because it cannot re-sign the list
194
+ that names it. `KeyManager.rotate()` is a no-op there. Fixing that needs a
195
+ channel for a secondary to ask the primary to re-sign, which does not exist
196
+ yet.
197
+
198
+ **Also decided: no ML-KEM key in a device descriptor.** A list is a set of
199
+ claims about identity; a KEM key is transport material, already advertised per
200
+ session in JOIN, and a device could change it without changing who it is.
201
+ Carrying it cost 1 584 bytes of base64 per device — the difference between a
202
+ grant that fits in a QR code and one that does not. Changed while the format
203
+ was still unreleased, which is the only time it is free.
204
+
205
+ ### Message history for a device that was not there
206
+
207
+ A device added today cannot read what the room said yesterday: a sender key
208
+ handed over serialises the chain at its _current_ counter. That is already
209
+ decided for the offline queue in
210
+ [sender-keys-on-relay.md](sender-keys-on-relay.md) and the arithmetic is the
211
+ same here. "A new device starts from now" is a defensible answer. It has to be
212
+ said out loud, in the UI, at the moment somebody adds one — not discovered.
213
+
214
+ ## What to be careful about
215
+
216
+ - **The mlock ceiling.** `sodium_malloc` pages are locked and Linux caps how
217
+ much a process may lock. #481 already recorded a SIGABRT from exactly this,
218
+ surfacing in an unrelated ratchet call because it was the next allocation to
219
+ fail. Per-device keys multiply guarded allocations by D, per peer, per room.
220
+ This is the failure this change is most likely to hit, and it is invisible on
221
+ macOS, where the limit is unlimited. Instrument `sodium_malloc` by call site
222
+ before, not after.
223
+ - **`DoubleRatchet.js` has the same zero-without-free pattern at 19 sites.**
224
+ Noted and deliberately not fixed in #481 because 1,164 allocations is nowhere
225
+ near a limit. Multiply by D and re-check that judgement.
226
+ - **P2P is a different problem.** `P2PChatController` keys peers by _nickname_
227
+ and has no session ids and no relay. Multi-device in the mesh is not the same
228
+ design, and pretending one document covers both is how the relay path ended
229
+ up with assumptions from the mesh baked into it. Out of scope for v1, and say
230
+ so in the UI.
231
+ - **The offline queue keys on nickname + publicKey.** Both halves change
232
+ meaning. Re-read `OfflineQueue.dequeue` against the new model rather than
233
+ assuming it still lines up.
234
+ - **A device list is a replay target.** The counter is not optional, and
235
+ "highest counter wins" has to be enforced on receipt, not on send.
236
+
237
+ ## Suggested order
238
+
239
+ The sender-keys rollout worked because each step was landable on its own and
240
+ the risky one arrived after its safety net. Same shape:
241
+
242
+ *Steps 1 to 5 are done; the notes below are as written, with what shipped
243
+ recorded against each.*
244
+
245
+ 1. **`DeviceIdentity`: an identity keypair, a signed device descriptor, and
246
+ frozen vectors** — a crypto module with no callers. Nothing on the wire,
247
+ nothing in the UI, no behaviour change. **Shipped (#493).**
248
+ 2. **Carry the identity key in `KeyManager`, persisted and backed up,
249
+ advertised in JOIN and used by nobody.** This is the trick that worked for
250
+ the Ed25519 sender signature: land the field while the wire is still free.
251
+ 3. **A signed device list, distributed pairwise, behind a capability** — still
252
+ one device per identity. The plumbing is exercised before it carries weight.
253
+ 4. **Move verification to the identity key**, with the device fingerprint still
254
+ shown, a migration for existing records, and a loud, deliberate story for
255
+ `VERIFIED_MISMATCH`.
256
+ 5. **A second device, receive-only.** Provisioning, and the relay change that
257
+ lets two sessions share a nickname.
258
+ 6. **Sending from a second device, and own-device fan-out.** The point at which
259
+ a conversation stops being split.
260
+ 7. **Revocation**, with a test per route out — the way #482 did for departures.
261
+ 8. **Only then**, the mesh, if at all.
262
+
263
+ **Decided (2026-08-23): not at all, for now.** Multi-device in the mesh is
264
+ not merely unimplemented — it is a different design. `P2PChatController`
265
+ keys its peers by **nickname** (`#peers` is `Map<peerNickname, …>`) and has
266
+ no session ids, so two devices of one person collide on the very thing the
267
+ peer map is indexed by. Supporting them means re-keying the mesh's whole
268
+ model of who a peer is, and doing that inside this arc would have meant
269
+ designing a second system while shipping the first.
270
+
271
+ What shipped instead is honesty. `/device`, `/create`, `/invite` and `/nick`
272
+ are relay-only, and the mesh now says why rather than guessing at a typo —
273
+ `/device` previously suggested `/voice`, which sends somebody looking in
274
+ entirely the wrong place. `test/commands-json.test.js` pins the relay-only
275
+ set, so a fifth cannot join it unnoticed.
276
+
277
+ The prerequisite for revisiting this is a mesh peer identified by something
278
+ other than a name.
279
+
280
+ Steps 1 and 2 are safe enough to do before the rest is agreed. Step 4 is the
281
+ one to slow down on.
282
+
283
+ ## Why it is worth it
284
+
285
+ It is the largest functional gap in the project, and unlike the others it is
286
+ one the product actively invites people into: `/backup` is in the README's
287
+ command table and the restore prompt is the second thing the client asks at
288
+ startup. Together they produce the configuration described at the top. The
289
+ choice is not between multi-device and no multi-device. It is between a
290
+ designed one and the accidental one that exists now.
@@ -1,8 +1,13 @@
1
1
  # Sender keys on the relay
2
2
 
3
- Status: **design, not implemented.** Written 2026-08-07, straight after
4
- measuring the problem, so the next session starts from the constraints rather
5
- than rediscovering them.
3
+ Status: **shipped.** Written 2026-08-07, straight after measuring the problem,
4
+ so the next session started from the constraints rather than rediscovering
5
+ them. The receive half shipped in 2.11.0 and the send half, with rotation on
6
+ every membership change, in 2.12.0. Steps 1-4 below are done; step 5 is
7
+ answered in place and the answer is no.
8
+
9
+ The document is kept as written rather than rewritten in the past tense: what
10
+ it got wrong is as useful as what it got right, and step 5 got it wrong.
6
11
 
7
12
  ## The problem, measured
8
13
 
@@ -110,6 +115,32 @@ order they should be considered:
110
115
  4. Rotation wired to every membership change, with a test per route in.
111
116
  5. Only then, consider retiring the per-peer loop — a release later, at least.
112
117
 
118
+ **Decided (2026-08-23): it is not retired, and the framing was wrong.** Step
119
+ 5 was written as though the per-peer loop were a compatibility shim that
120
+ ages out once everyone upgrades. It is not. It is the pairwise send path,
121
+ and four separate things still require it — two of them permanently:
122
+
123
+ - **`/deniable` is a user-facing mode.** Deniability is a property of the
124
+ pairwise construction: a symmetric key both sides could have derived, so
125
+ neither can prove the other wrote it. A group packet is signed by exactly
126
+ one sender — that is what closed member forgery above. Sending a deniable
127
+ message on the group path would publish the opposite of what was asked
128
+ for, so `#canSendToGroup` refuses it. No amount of upgrading changes this.
129
+ - **Sender-key distribution rides the pairwise channel**, and has to: a
130
+ distribution is authenticated by opening the envelope it arrived in, never
131
+ asserted by the relay. The group path cannot bootstrap itself.
132
+ - **One older peer holds the room** — transitional in principle, permanent
133
+ in practice on a public hub, which is exactly the case this project was
134
+ built for.
135
+ - **An older hub cannot fan out a room-addressed message.** Same shape.
136
+
137
+ So there is no release in which deleting the loop is correct. What was
138
+ actually missing is that nobody could *see* which path a room was on: a room
139
+ pays fifty times over for one line and the only symptom is that it feels
140
+ slow. `/room` now reports the path and, when it is the expensive one, the
141
+ reason — the older hub, or the peers by name. That makes the cost
142
+ attributable, which is what step 5 was really reaching for.
143
+
113
144
  ## Why it is worth it
114
145
 
115
146
  It is the one change that is simultaneously a feature, a fix and an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -53,7 +53,7 @@
53
53
  "commands:build": "node scripts/generate-commands.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@noble/post-quantum": "0.6.1",
56
+ "@noble/post-quantum": "0.7.0",
57
57
  "blessed": "0.1.81",
58
58
  "bonjour-service": "1.4.4",
59
59
  "boxen": "8.0.1",
@@ -63,11 +63,11 @@
63
63
  "node-notifier": "10.0.1",
64
64
  "qrcode-terminal": "0.12.0",
65
65
  "sodium-native": "5.1.0",
66
- "ws": "8.21.1"
66
+ "ws": "8.21.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@eslint/js": "10.0.1",
70
- "eslint": "10.8.0",
70
+ "eslint": "10.8.1",
71
71
  "figlet": "^1.11.4",
72
72
  "globals": "^17.7.0",
73
73
  "prettier": "^3.9.5"