ciphermesh 2.12.0 → 2.14.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.
- package/CHANGELOG.md +187 -0
- package/README.md +8 -6
- package/README.pt-BR.md +8 -6
- package/docs/ARCHITECTURE.md +332 -230
- package/docs/PROTOCOL.md +160 -5
- package/docs/SETUP.md +18 -0
- package/docs/commands.json +15 -7
- package/docs/design/multi-device.md +290 -0
- package/docs/design/sender-keys-on-relay.md +34 -3
- package/package.json +3 -3
- package/src/client/ChatController.js +736 -26
- package/src/client/UI.js +617 -124
- package/src/client/keyboard.js +388 -0
- package/src/crypto/DeviceIdentity.js +307 -0
- package/src/crypto/KeyManager.js +176 -4
- package/src/crypto/TrustStore.js +153 -0
- package/src/p2p/P2PChatController.js +94 -4
- package/src/protocol/messages.js +25 -1
- package/src/protocol/validators.js +16 -0
- package/src/server/SessionManager.js +63 -6
- package/src/server/WebSocketServer.js +40 -1
- package/src/shared/constants.js +9 -1
- package/src/shared/desktopNotify.js +204 -0
- package/src/shared/deviceProvisioning.js +112 -0
- package/src/shared/notifyWorker.js +46 -0
- package/src/shared/tips.js +1 -0
package/docs/PROTOCOL.md
CHANGED
|
@@ -66,7 +66,8 @@ Every message carries three fields:
|
|
|
66
66
|
and does not correct it; it exists for the recipient.
|
|
67
67
|
|
|
68
68
|
Unknown fields are ignored. This is load-bearing: it is what lets an optional
|
|
69
|
-
field like `pqPublicKey`, `caps` or `
|
|
69
|
+
field like `pqPublicKey`, `caps`, `room` or `identityKey` be added without a
|
|
70
|
+
version bump, and
|
|
70
71
|
what lets an older relay pass through a message it does not fully understand.
|
|
71
72
|
|
|
72
73
|
---
|
|
@@ -83,7 +84,9 @@ through a public hub needs. Capabilities carry that.
|
|
|
83
84
|
3. The relay advertises **its own** abilities in `join_ack.serverCaps`. No client
|
|
84
85
|
can promise these on the relay's behalf.
|
|
85
86
|
4. A feature turns on only when **every member of the room** advertises it *and*
|
|
86
|
-
the relay does.
|
|
87
|
+
the relay does — for features that need the relay to play along. A capability
|
|
88
|
+
whose feature rides a channel the relay already carries has no relay half and
|
|
89
|
+
is decided per peer; `dl1` is the first of those.
|
|
87
90
|
|
|
88
91
|
Absent or empty means an older participant, which is a fallback, not an error.
|
|
89
92
|
|
|
@@ -97,12 +100,17 @@ a peer look capable of something it never claimed.
|
|
|
97
100
|
|---|---|---|
|
|
98
101
|
| `sk1` | client | I can *receive* a group message (§7) |
|
|
99
102
|
| `sk1` | relay | I can fan a room-addressed message out |
|
|
103
|
+
| `dl1` | client | I can read a signed device list handed to me over the pairwise channel (§7) |
|
|
100
104
|
|
|
101
105
|
Neither means "I send group messages". Receive and fan-out ship a release ahead
|
|
102
106
|
of send, because the switch is *every member agrees*: if reading and writing
|
|
103
107
|
arrived together, the switch would only ever be true in rooms where everybody
|
|
104
108
|
upgraded at the same moment.
|
|
105
109
|
|
|
110
|
+
`dl1` has no relay half. A device list travels on the pairwise sealed channel
|
|
111
|
+
the relay already carries, so there is nothing for the relay to agree to and
|
|
112
|
+
nothing it can withhold beyond the frame itself.
|
|
113
|
+
|
|
106
114
|
**What a hostile relay gains by editing these lists:** stripping a capability
|
|
107
115
|
forces the room onto the older path, which is the status quo and reveals nothing
|
|
108
116
|
new. Adding one a peer never claimed makes senders encrypt in a form that peer
|
|
@@ -116,7 +124,8 @@ plaintext. The all-members rule is what keeps the damage on that side.
|
|
|
116
124
|
```
|
|
117
125
|
client relay other clients
|
|
118
126
|
│ join(nickname, publicKey, │ │
|
|
119
|
-
│ pqPublicKey?, caps
|
|
127
|
+
│ pqPublicKey?, caps?, │ │
|
|
128
|
+
│ identityKey?) │ │
|
|
120
129
|
├──────────────────────────────▶│ │
|
|
121
130
|
│ │ peer_joined(peer) │
|
|
122
131
|
│ join_ack(sessionId, peers, ├─────────────────────────────────▶│
|
|
@@ -133,23 +142,47 @@ and must be recognised by key.
|
|
|
133
142
|
|
|
134
143
|
| Field | Type | Required | Notes |
|
|
135
144
|
|---|---|---|---|
|
|
136
|
-
| `nickname` | string | yes | 1–20 chars, `^[a-zA-Z0-9_-]+$`, control characters stripped, case-insensitively unique among live sessions |
|
|
145
|
+
| `nickname` | string | yes | 1–20 chars, `^[a-zA-Z0-9_-]+$`, control characters stripped, case-insensitively unique among live sessions — **except for another device of the same identity**, see below |
|
|
137
146
|
| `publicKey` | base64(32) | yes | Curve25519 identity key |
|
|
138
147
|
| `pqPublicKey` | base64(1184) | no | ML-KEM-768 encapsulation key; absent = classical-only peer |
|
|
139
148
|
| `caps` | string[] | no | §3; omitted when empty |
|
|
149
|
+
| `identityKey` | base64(32) | no | Ed25519 signing key for multi-device; absent = a client from before it existed |
|
|
150
|
+
| `deviceList` | object | no | The signed list from §7, sent to claim a nickname another of your own devices already holds |
|
|
140
151
|
|
|
141
152
|
### `join_ack` (relay → client)
|
|
142
153
|
|
|
143
154
|
| Field | Type | Notes |
|
|
144
155
|
|---|---|---|
|
|
145
156
|
| `sessionId` | string | UUID for this connection |
|
|
146
|
-
| `peers` | object[] | `{ sessionId, nickname, publicKey, pqPublicKey?, caps? }` |
|
|
157
|
+
| `peers` | object[] | `{ sessionId, nickname, publicKey, pqPublicKey?, caps?, identityKey? }` |
|
|
147
158
|
| `room` | string | always `general` on join |
|
|
148
159
|
| `queuedCount` | number | omitted when 0 |
|
|
149
160
|
| `serverCaps` | string[] | omitted when empty |
|
|
150
161
|
| `roomOwner` | string | nickname, when the room has one |
|
|
151
162
|
| `motd` | string | operator notice, when configured |
|
|
152
163
|
|
|
164
|
+
**`identityKey` is relayed verbatim and the relay cannot verify it.** It forwards
|
|
165
|
+
whatever the JOIN carried, so a hostile relay can substitute one: presence in a
|
|
166
|
+
`join_ack` is not evidence of anything. What makes an identity key trustworthy is
|
|
167
|
+
a device list signed by it and checked by the client — never the relay repeating
|
|
168
|
+
it. It is also not what a ban keys on; that stays the Curve25519 `publicKey`.
|
|
169
|
+
|
|
170
|
+
**One nickname may be held by several devices of one identity.** A second
|
|
171
|
+
session is admitted under a name already in use only if its JOIN carries a
|
|
172
|
+
`deviceList` that
|
|
173
|
+
|
|
174
|
+
1. is signed by the identity the existing sessions under that name are using,
|
|
175
|
+
2. names *this* JOIN's own `publicKey`, and
|
|
176
|
+
3. would not take the name past the eight-device limit.
|
|
177
|
+
|
|
178
|
+
No challenge is issued and none is needed. Replaying a list somebody else
|
|
179
|
+
published buys a seat in a room under a name whose messages you cannot read,
|
|
180
|
+
because you do not hold the box secret the list names — and peers do their own
|
|
181
|
+
checking, so they learn nothing from the relay having allowed it. The relay is
|
|
182
|
+
doing admission control on a nickname here, not attesting to an identity.
|
|
183
|
+
|
|
184
|
+
The name is released when the **last** of its sessions leaves, not the first.
|
|
185
|
+
|
|
153
186
|
### `peer_joined` / `peer_left` (relay → clients)
|
|
154
187
|
|
|
155
188
|
`peer_joined` carries the same peer object as `join_ack.peers`. Both carry an
|
|
@@ -179,6 +212,36 @@ route, never to attest.
|
|
|
179
212
|
as `peer_key_updated` to every session sharing a room. The previous key is kept
|
|
180
213
|
briefly on both sides so messages in flight still open.
|
|
181
214
|
|
|
215
|
+
**What a SAS compares is moving.** Historically the code is BLAKE2b over the two
|
|
216
|
+
Curve25519 keys — the device keys — which means it is invalidated by a key
|
|
217
|
+
rotation and says nothing about a person with more than one device. Once both
|
|
218
|
+
sides advertise `dl1` **and** each holds the other's device list, the code is
|
|
219
|
+
computed over the two Ed25519 identity keys instead, under a separate domain
|
|
220
|
+
tag. The switch is symmetric: both sides reach that state together, so a pair
|
|
221
|
+
never sees two different codes. Against a peer without `dl1` both sides compute
|
|
222
|
+
the device code, exactly as before. `/verify` names which of the two it is
|
|
223
|
+
showing.
|
|
224
|
+
|
|
225
|
+
**Existing verifications are carried across, never re-asked.** A record verified
|
|
226
|
+
against a device key gains its identity when a signed device list arrives that
|
|
227
|
+
**names that same device key**, over the pairwise channel only the holder of
|
|
228
|
+
that key could have written to. The identity is then vouched for by precisely
|
|
229
|
+
what the user compared digits over. A list that does not name the key it arrived
|
|
230
|
+
under binds nothing — it is not an attack, a rotation can race a distribution,
|
|
231
|
+
but it proves nothing. A *second, different* identity for a record that already
|
|
232
|
+
has one is never accepted silently; on a verified record it is reported in the
|
|
233
|
+
same voice as a verified-key mismatch, and nothing is changed.
|
|
234
|
+
|
|
235
|
+
**Another device is not a key that changed.** A peer's second device arrives
|
|
236
|
+
under the same nickname with a box key the record has never seen — which is
|
|
237
|
+
exactly the shape of a man-in-the-middle, and is reported as one. The alarm is
|
|
238
|
+
never suppressed in advance: claiming the right `identityKey` in a JOIN proves
|
|
239
|
+
nothing, since the relay forwards that field unchecked. It is *answered*, once a
|
|
240
|
+
device list signed by the identity bound to that record names the key. From then
|
|
241
|
+
on the key is recognised silently, and the record keeps the verified key as its
|
|
242
|
+
primary — a device is added beside it, never over it. A list may only add
|
|
243
|
+
devices to the record its own identity is bound to.
|
|
244
|
+
|
|
182
245
|
**Hybrid post-quantum.** When both sides advertised `pqPublicKey`, the ratchet
|
|
183
246
|
root is mixed once at initialisation:
|
|
184
247
|
|
|
@@ -398,6 +461,98 @@ Room membership is not a formality: without it one connection could inject into
|
|
|
398
461
|
every room on the hub at once, which the unicast path cannot do because it needs
|
|
399
462
|
a `sessionId` it could only have been told.
|
|
400
463
|
|
|
464
|
+
### Device lists
|
|
465
|
+
|
|
466
|
+
A second thing travels the pairwise channel, gated on `dl1` and read by nothing
|
|
467
|
+
yet: a **device list**, the set of box keys that belong to one identity, signed
|
|
468
|
+
by the Ed25519 `identityKey` from §4. Multi-device is designed in
|
|
469
|
+
`docs/design/multi-device.md`; this is the plumbing landing ahead of it. Today
|
|
470
|
+
every list names exactly one device, because nothing can add a second.
|
|
471
|
+
|
|
472
|
+
```json
|
|
473
|
+
{ "action": "device_list",
|
|
474
|
+
"list": {
|
|
475
|
+
"identityPk": "b64(32)",
|
|
476
|
+
"counter": 1,
|
|
477
|
+
"devices": [ { "deviceId": "hex(32)", "boxPk": "b64(32)",
|
|
478
|
+
"label": "", "createdAt": 1739800000000 } ],
|
|
479
|
+
"signature": "b64(64)"
|
|
480
|
+
},
|
|
481
|
+
"sentAt": 1739800000000 }
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
The signature covers a domain tag, the identity key, the counter, **the number
|
|
485
|
+
of devices**, and every field of each one, each length-prefixed by its byte
|
|
486
|
+
count. There is no ML-KEM key in a descriptor: a list is a set of claims about
|
|
487
|
+
identity, a KEM key is transport material already advertised per session in
|
|
488
|
+
JOIN, and carrying it cost 1584 bytes of base64 per device. The count is in there so a device cannot be dropped off the end
|
|
489
|
+
unnoticed; byte prefixes are there so a multi-byte label cannot shift a field
|
|
490
|
+
boundary.
|
|
491
|
+
|
|
492
|
+
**`counter` only ever goes up, and the reader enforces it.** A list at the same
|
|
493
|
+
counter is not newer and is refused, so an edit cannot slip in sideways; a lower
|
|
494
|
+
one is a replay. Without this a relay that kept an old copy could play it back,
|
|
495
|
+
and once revocation exists that is how a removed device would be put back. The
|
|
496
|
+
sender's counter is persisted and moves whenever its descriptor does — a
|
|
497
|
+
box-key rotation changes `boxPk`, so it changes the list.
|
|
498
|
+
|
|
499
|
+
**What authenticates a list is the channel, not the seal.** `crypto_box_seal` is
|
|
500
|
+
anonymous: anyone can seal a blob to you claiming any sender. The payload
|
|
501
|
+
underneath is `crypto_box` to your key from the peer's, so it only opens if the
|
|
502
|
+
sender holds that peer's box secret. That is what makes the list theirs. The
|
|
503
|
+
`identityKey` the relay repeated in `join_ack` is a *hint* — it decides whether
|
|
504
|
+
handing a list over is worth the round trip, and nothing more. A relay that
|
|
505
|
+
tampers with it can stop the exchange happening; it cannot put words in a peer's
|
|
506
|
+
mouth, because it cannot produce the payload.
|
|
507
|
+
|
|
508
|
+
**Answered, not announced**, for the same reason as *Distribution* above: a
|
|
509
|
+
newcomer learns the room before the room learns of the newcomer, so a client
|
|
510
|
+
that announced itself on arrival would be talking to peers holding no key for
|
|
511
|
+
it. Peers who already know you speak first; you answer, and you mark them as
|
|
512
|
+
holding your list **before** sending, because on a synchronous transport their
|
|
513
|
+
answer can arrive before the send call returns.
|
|
514
|
+
|
|
515
|
+
**Revocation is a shorter list with a higher counter**, and the list is only
|
|
516
|
+
half of it. A removed device still holds every member's sender chain, and a
|
|
517
|
+
chain ratchets forward — dropping it from the list stops the relay delivering to
|
|
518
|
+
it and does not stop it reading. So every reader that sees devices disappear
|
|
519
|
+
from a list **rotates its own chain for the room**, exactly as it does when a
|
|
520
|
+
member leaves.
|
|
521
|
+
|
|
522
|
+
On the receiving side a list is a *replacement*, not an addition: the keys it
|
|
523
|
+
names are the keys that are that person, and one that is no longer named stops
|
|
524
|
+
being honoured — including the key the trust record was originally built on. A
|
|
525
|
+
revoked primary that stayed trusted would make revocation decorative.
|
|
526
|
+
|
|
527
|
+
Two limits, stated rather than discovered. A revoked device that is already
|
|
528
|
+
connected keeps its session until it disconnects; it is refused on its next
|
|
529
|
+
JOIN, because the list no longer names its key. And it keeps whatever it
|
|
530
|
+
received before the rotation, which is what forward secrecy means and not a
|
|
531
|
+
defect.
|
|
532
|
+
|
|
533
|
+
**Provisioning happens off the wire.** A second device is added by the user
|
|
534
|
+
carrying two short strings between the two machines, not by anything the relay
|
|
535
|
+
sees:
|
|
536
|
+
|
|
537
|
+
```
|
|
538
|
+
ciphermesh-device://request/<base64url> { deviceId, boxPk, label }
|
|
539
|
+
ciphermesh-device://grant/<base64url> { identityPk, list }
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
The identity secret never moves. A secondary holds only the public half and the
|
|
543
|
+
list it was granted, so it can prove which identity it belongs to and can
|
|
544
|
+
publish that list, but it cannot sign a new one — adding and revoking stay with
|
|
545
|
+
the device that holds the secret. Neither string is confidential; a grant is
|
|
546
|
+
caught if substituted, because it only applies when the list names the exact
|
|
547
|
+
device that asked, by both id and key.
|
|
548
|
+
|
|
549
|
+
A secondary therefore does not rotate its box key: it could not re-sign the list
|
|
550
|
+
that names it, and the result would be a device nothing vouches for.
|
|
551
|
+
|
|
552
|
+
**The label is empty**, and stays empty until there is a second device to tell
|
|
553
|
+
apart. A hostname is the obvious filler and exactly the kind of thing that does
|
|
554
|
+
not go on this wire.
|
|
555
|
+
|
|
401
556
|
---
|
|
402
557
|
|
|
403
558
|
## 8. Offline delivery
|
package/docs/SETUP.md
CHANGED
|
@@ -293,6 +293,24 @@ Run `/help` in the chat for the **full list**. The main ones:
|
|
|
293
293
|
- Or: `netsh advfirewall firewall add rule name="CipherMesh" dir=in action=allow protocol=TCP localport=3600`
|
|
294
294
|
- Test whether the port responds: `curl ws://IP:3600` or open `http://IP:3600` in the browser (it will error out, but if the port connects it is open)
|
|
295
295
|
|
|
296
|
+
### Shift+Enter does not insert a newline
|
|
297
|
+
|
|
298
|
+
A terminal cannot tell Shift+Enter from Enter unless the application asks it to,
|
|
299
|
+
so on startup CipherMesh requests the two protocols that make the difference
|
|
300
|
+
visible — the kitty keyboard protocol and xterm's `modifyOtherKeys`. Terminals
|
|
301
|
+
that implement neither simply ignore the request.
|
|
302
|
+
|
|
303
|
+
On those, use **Alt+Enter** or **Ctrl+J**, which work everywhere.
|
|
304
|
+
|
|
305
|
+
If a terminal reacts badly to the request, `CIPHERMESH_LEGACY_KEYS=1` turns the
|
|
306
|
+
negotiation off and hands the terminal to blessed untouched:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
CIPHERMESH_LEGACY_KEYS=1 ciphermesh
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Alt+Enter and Ctrl+J keep working in that mode; only Shift+Enter is lost.
|
|
313
|
+
|
|
296
314
|
### "npm install" fails on sodium-native
|
|
297
315
|
|
|
298
316
|
`sodium-native` needs to compile C code. Requirements:
|
package/docs/commands.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Generated by scripts/generate-commands.mjs — do not edit. Which commands exist comes from the case labels in the two controllers; prose comes from README.md and the Ctrl+K palette.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"total":
|
|
5
|
-
"relay":
|
|
4
|
+
"total": 68,
|
|
5
|
+
"relay": 68,
|
|
6
6
|
"p2p": 64
|
|
7
7
|
},
|
|
8
8
|
"groups": [
|
|
@@ -196,7 +196,7 @@
|
|
|
196
196
|
{
|
|
197
197
|
"name": "/room",
|
|
198
198
|
"args": "",
|
|
199
|
-
"summary": "Current room
|
|
199
|
+
"summary": "Current room, how it is sending it, and your buffer list",
|
|
200
200
|
"modes": [
|
|
201
201
|
"relay",
|
|
202
202
|
"p2p"
|
|
@@ -289,6 +289,14 @@
|
|
|
289
289
|
"p2p"
|
|
290
290
|
]
|
|
291
291
|
},
|
|
292
|
+
{
|
|
293
|
+
"name": "/device",
|
|
294
|
+
"args": "[list\\|request\\|add\\|accept\\|remove]",
|
|
295
|
+
"summary": "Your devices under one identity. The identity key never leaves the device that holds it, so a second device is granted a signed place on the list rather than a copy of your identity. Removing one rotates the room, so nothing said afterwards reaches it",
|
|
296
|
+
"modes": [
|
|
297
|
+
"relay"
|
|
298
|
+
]
|
|
299
|
+
},
|
|
292
300
|
{
|
|
293
301
|
"name": "/ephemeral",
|
|
294
302
|
"args": "<30s\\|5m\\|1h\\|off>",
|
|
@@ -301,7 +309,7 @@
|
|
|
301
309
|
{
|
|
302
310
|
"name": "/fingerprint",
|
|
303
311
|
"args": "[nick]",
|
|
304
|
-
"summary": "Key fingerprint
|
|
312
|
+
"summary": "Key fingerprint, the identity fingerprint when there is one, and a deterministic **randomart** picture of the key",
|
|
305
313
|
"modes": [
|
|
306
314
|
"relay",
|
|
307
315
|
"p2p"
|
|
@@ -364,7 +372,7 @@
|
|
|
364
372
|
{
|
|
365
373
|
"name": "/verify",
|
|
366
374
|
"args": "<nick>",
|
|
367
|
-
"summary": "SAS code (~40-bit) + QR + key randomart for out-of-band verification",
|
|
375
|
+
"summary": "SAS code (~40-bit) + QR + key randomart for out-of-band verification; says whether the code is over identity keys or device keys",
|
|
368
376
|
"modes": [
|
|
369
377
|
"relay",
|
|
370
378
|
"p2p"
|
|
@@ -574,7 +582,7 @@
|
|
|
574
582
|
{
|
|
575
583
|
"name": "/notify",
|
|
576
584
|
"args": "",
|
|
577
|
-
"summary": "
|
|
585
|
+
"summary": "Desktop notifications — rate-limited, and muted for the session (with a line saying why) if the OS refuses them",
|
|
578
586
|
"modes": [
|
|
579
587
|
"relay",
|
|
580
588
|
"p2p"
|
|
@@ -610,7 +618,7 @@
|
|
|
610
618
|
{
|
|
611
619
|
"name": "/sound",
|
|
612
620
|
"args": "",
|
|
613
|
-
"summary": "Sound
|
|
621
|
+
"summary": "Sound alerts on incoming messages",
|
|
614
622
|
"modes": [
|
|
615
623
|
"relay",
|
|
616
624
|
"p2p"
|
|
@@ -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: **
|
|
4
|
-
|
|
5
|
-
|
|
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
|