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.
- package/CHANGELOG.md +175 -0
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/docs/ARCHITECTURE.md +12 -6
- package/docs/PROTOCOL.md +230 -5
- package/docs/commands.json +13 -5
- package/docs/design/multi-device.md +290 -0
- package/docs/design/sender-keys-on-relay.md +34 -3
- package/package.json +4 -4
- package/src/client/ChatController.js +963 -43
- package/src/crypto/DeviceIdentity.js +307 -0
- package/src/crypto/KeyManager.js +176 -4
- package/src/crypto/SenderKey.js +44 -9
- package/src/crypto/TrustStore.js +153 -0
- package/src/p2p/P2PChatController.js +73 -0
- package/src/protocol/messages.js +36 -3
- package/src/protocol/validators.js +16 -0
- package/src/server/SessionManager.js +63 -6
- package/src/server/WebSocketServer.js +68 -4
- package/src/shared/constants.js +9 -1
- package/src/shared/deviceProvisioning.js +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,181 @@
|
|
|
3
3
|
Notable changes per release. Older versions are reconstructed from the git
|
|
4
4
|
history — the commit bodies and pull requests remain the fuller record.
|
|
5
5
|
|
|
6
|
+
## 2.13.0
|
|
7
|
+
|
|
8
|
+
**Multi-device.** One identity, several devices, and none of them holding a copy
|
|
9
|
+
of the others' secrets.
|
|
10
|
+
|
|
11
|
+
Until now two machines could only share an identity by sharing its private key —
|
|
12
|
+
`/backup` copies the whole thing — after which the relay refused the second
|
|
13
|
+
nickname, each message reached exactly one of them, and a peer verifying both
|
|
14
|
+
was shown the same fingerprint twice and told that was normal. That is replaced.
|
|
15
|
+
|
|
16
|
+
An identity is now an Ed25519 key that only ever signs. Each device has its own
|
|
17
|
+
message key, listed and signed by that identity. Adding a device grants it a
|
|
18
|
+
signed place on the list; it never receives the identity secret, so a stolen
|
|
19
|
+
phone is a stolen phone rather than a stolen identity, and only the device
|
|
20
|
+
holding the secret can add or remove.
|
|
21
|
+
|
|
22
|
+
`/room` also learned to say how the room is sending, and why.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`/device`: one identity, several devices.** A second device asks with
|
|
27
|
+
`/device request`, the device holding the identity key answers with
|
|
28
|
+
`/device add`, and the new one takes it with `/device accept`. Two short
|
|
29
|
+
strings, small enough for a QR code, and neither of them secret — a request is
|
|
30
|
+
a public key and a grant is a signed statement that goes to every peer anyway.
|
|
31
|
+
|
|
32
|
+
The identity secret never moves. A secondary can prove which identity it
|
|
33
|
+
belongs to and can publish that proof, but it cannot sign a new list, so
|
|
34
|
+
adding and removing stay with one device. Two costs come with that and are
|
|
35
|
+
worth knowing: losing that device means no more adding or removing, and a
|
|
36
|
+
secondary does not rotate its message key, because it could not re-sign the
|
|
37
|
+
list that names it.
|
|
38
|
+
|
|
39
|
+
`/device remove` signs a shorter list **and rotates the room**. A removed
|
|
40
|
+
device still holds every member's sender chain, and a chain ratchets forward —
|
|
41
|
+
dropping it from a list stops the relay delivering to it and does not stop it
|
|
42
|
+
reading. It keeps what it already received; that is what forward secrecy
|
|
43
|
+
means.
|
|
44
|
+
|
|
45
|
+
- **Verification follows the identity, not the device.** Once both sides can,
|
|
46
|
+
`/verify` compares identity keys, so adding or rotating a device no longer
|
|
47
|
+
invalidates a verification. The switch is symmetric — both sides make it
|
|
48
|
+
together — so a pair is never shown two different codes, and `/verify` says
|
|
49
|
+
which of the two it is showing. Verifications you already have are carried
|
|
50
|
+
across silently, and only when a signed list names the very key you compared
|
|
51
|
+
digits over.
|
|
52
|
+
|
|
53
|
+
- **Another of someone's devices is not "their key changed".** It still warns
|
|
54
|
+
the first time, because claiming an identity proves nothing on its own. When
|
|
55
|
+
the proof arrives the warning is answered out loud, and that key is quiet from
|
|
56
|
+
then on. Your own other device is recognised as yours: `/users` counts people
|
|
57
|
+
rather than connections, a line you sent from your phone is shown as yours,
|
|
58
|
+
and your own nickname in your own line does not notify you.
|
|
59
|
+
|
|
60
|
+
- **`/room` reports how the room is sending, and why.** One ciphertext for the
|
|
61
|
+
room, or one envelope per member — and when it is the expensive one, the
|
|
62
|
+
reason: an older hub, deniable mode, or the peers by name.
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Sending: one ciphertext to the room (sender keys), read by 4
|
|
66
|
+
Sending: 4 envelopes per message — carol is on a build without sender keys
|
|
67
|
+
Sending: 2 envelopes per message — this relay cannot fan out a room-addressed message
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
An older hub is reported ahead of older peers, because both can be true at
|
|
71
|
+
once and naming peers who are perfectly current sends you after the wrong
|
|
72
|
+
problem. P2P gets the same line for its own reasons: a mesh sends one frame
|
|
73
|
+
per peer either way, so there the saving is *encryptions*, and constant cover
|
|
74
|
+
is a reason to stay pairwise that the relay path does not have.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **A second device no longer reads as an attack.** It arrives under the same
|
|
79
|
+
nickname with a key the trust record has never seen, which is the shape of a
|
|
80
|
+
man-in-the-middle and was reported as one.
|
|
81
|
+
|
|
82
|
+
- **Guarded key memory is released, not merely zeroed.** `KeyManager` zeroed its
|
|
83
|
+
secret keys on `destroy()` and left the pages `mlock`'d until the garbage
|
|
84
|
+
collector happened to run the finaliser. An operating system caps how much a
|
|
85
|
+
process may lock, and the cap is small on Linux and unlimited on macOS — so
|
|
86
|
+
the failure was invisible in development and landed as an abort in whatever
|
|
87
|
+
allocated next. The same pattern was fixed elsewhere in 2.12.0; this is the
|
|
88
|
+
rest of it in that file.
|
|
89
|
+
|
|
90
|
+
### Internal
|
|
91
|
+
|
|
92
|
+
- **The per-peer send loop is not being retired, and the plan that said it might
|
|
93
|
+
be was wrong.** It was written up as a compatibility shim that ages out once
|
|
94
|
+
everybody upgrades. It is not: it is the pairwise send path, and two of the
|
|
95
|
+
four things that need it need it permanently — `/deniable`, because
|
|
96
|
+
deniability is a property of the pairwise construction, and sender-key
|
|
97
|
+
distribution, because a distribution is authenticated by the envelope it
|
|
98
|
+
arrives in and the group path cannot bootstrap itself. Recorded in
|
|
99
|
+
`docs/design/sender-keys-on-relay.md`.
|
|
100
|
+
|
|
101
|
+
- **A design document for multi-device**, written from the code before any of
|
|
102
|
+
it moved, and kept as written with the decisions recorded in place. It is the
|
|
103
|
+
reason the arc could be built in eight landable steps.
|
|
104
|
+
|
|
105
|
+
- **One nickname may be held by several devices of one identity.** The relay
|
|
106
|
+
admits the second only if its JOIN carries a list signed by the identity the
|
|
107
|
+
name is already using and naming that JOIN's own key. No challenge is issued
|
|
108
|
+
and none is needed: replaying somebody else's list buys a seat in a room whose
|
|
109
|
+
messages you cannot read. The name is released when the last device leaves.
|
|
110
|
+
|
|
111
|
+
- **New capability `dl1`**, the first with no relay half — a device list travels
|
|
112
|
+
on the pairwise channel the relay already carries, so there is nothing for it
|
|
113
|
+
to agree to.
|
|
114
|
+
|
|
115
|
+
- **Multi-device is not coming to the mesh**, and the mesh now says so. A P2P
|
|
116
|
+
peer is keyed by nickname, which is exactly what two of your devices share, so
|
|
117
|
+
it is a different design. `/device`, `/create`, `/invite` and `/nick` explain
|
|
118
|
+
why they need a relay instead of guessing at a typo — `/device` used to
|
|
119
|
+
suggest `/voice`.
|
|
120
|
+
|
|
121
|
+
- Dependency bumps: `@noble/post-quantum` 0.7.0, `eslint` 10.8.1,
|
|
122
|
+
`globals` 17.11.0.
|
|
123
|
+
|
|
124
|
+
## 2.12.0
|
|
125
|
+
|
|
126
|
+
Sender keys now send. 2.11.0 shipped the half that reads a group message; this
|
|
127
|
+
is the half that writes one, and a line in a room of fifty costs one encryption
|
|
128
|
+
instead of fifty.
|
|
129
|
+
|
|
130
|
+
The switch is unchanged and still strict — every member of the room and the hub
|
|
131
|
+
itself must say they can handle it. On a public hub the per-peer path remains
|
|
132
|
+
the common case, and it is untouched.
|
|
133
|
+
|
|
134
|
+
### Added
|
|
135
|
+
|
|
136
|
+
- **Group sending on the relay.** 2.11.0 shipped the half that reads one; this
|
|
137
|
+
is the half that writes one. A line in a room of fifty cost fifty encryptions
|
|
138
|
+
and fifty envelopes — against the 1 MiB/s byte budget, a padded message to
|
|
139
|
+
fifty people throttled the sender for saying one thing. It now costs one of
|
|
140
|
+
each.
|
|
141
|
+
|
|
142
|
+
The switch is still the strict one: every member of the room advertises
|
|
143
|
+
`sk1`, **and** the hub does, **and** the message is not deniable. Any one
|
|
144
|
+
false and the per-peer path runs exactly as before, which on a public hub is
|
|
145
|
+
the common case rather than the exception. Nothing was removed.
|
|
146
|
+
|
|
147
|
+
Deniable messages stay pairwise permanently. Deniability comes from a key both
|
|
148
|
+
sides could have derived; a group packet is signed by one sender, which is the
|
|
149
|
+
opposite claim.
|
|
150
|
+
|
|
151
|
+
- **Sender keys rotate on every membership change.** Leaving, switching rooms,
|
|
152
|
+
disconnecting, being kicked, being banned. A chain ratchets forward, so the
|
|
153
|
+
copy a member holds opens every message after it: removing someone stopped the
|
|
154
|
+
relay delivering to them and did not stop them reading. Rotation is what
|
|
155
|
+
closes that, and `test/guarantees.test.js` now asserts it from outside the
|
|
156
|
+
code that implements it.
|
|
157
|
+
|
|
158
|
+
### Fixed
|
|
159
|
+
|
|
160
|
+
- **A kick or a ban is announced as a departure, not only as a notification.**
|
|
161
|
+
Both moved the target out of the room and told the room `peer_kicked`, which
|
|
162
|
+
carries a nickname — and a nickname is not something a client can unwind a
|
|
163
|
+
member by, since `/nick` reassigns them. Every remaining client kept the
|
|
164
|
+
removed peer in its roster indefinitely. They now emit `peer_left` like every
|
|
165
|
+
other way out of a room, which is also what makes rotation reachable for the
|
|
166
|
+
two cases where it matters most.
|
|
167
|
+
|
|
168
|
+
- **Peer capabilities survive a room switch.** `room_changed` and `room_joined`
|
|
169
|
+
carried them and the client dropped them, so after switching rooms every peer
|
|
170
|
+
looked incapable and the room silently never turned the group path on. No
|
|
171
|
+
error, no failure — just an optimisation that quietly never applied.
|
|
172
|
+
|
|
173
|
+
- **Sender-key memory is released, not merely zeroed.** `sodium_malloc` pages are
|
|
174
|
+
`mlock`'d, and an operating system caps how much a process may lock at once.
|
|
175
|
+
Zeroing a spent key left its pages locked until the garbage collector happened
|
|
176
|
+
to run the buffer's finaliser, so a long session derived keys faster than it
|
|
177
|
+
released them. The ceiling is generous on macOS and small on Linux, where a
|
|
178
|
+
busy client would eventually have hit an allocation failure that aborts the
|
|
179
|
+
process rather than returning an error.
|
|
180
|
+
|
|
6
181
|
## 2.11.0
|
|
7
182
|
|
|
8
183
|
Groundwork for sender keys on the relay. **Nothing sends a group message yet** —
|
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ software was built for.
|
|
|
225
225
|
| `/leave [room]` | Leave a room; its buffer closes (the last room is protected) |
|
|
226
226
|
| `/create <room> <password>` | Create a **private room** 🔒 — see below |
|
|
227
227
|
| `/rooms` | List rooms (🔒 marks private ones) |
|
|
228
|
-
| `/room` | Current room
|
|
228
|
+
| `/room` | Current room, how it is sending it, and your buffer list |
|
|
229
229
|
| `/topic [text\|clear]` | Show or set the room topic — shown in the status bar and synced to whoever joins later |
|
|
230
230
|
| `/owner` | Room owner |
|
|
231
231
|
| `/kick` `/mute` `/ban` | Owner moderation — bound to the public key, so a rename does not undo a ban |
|
|
@@ -251,12 +251,13 @@ without verifying couldn't read a word. Share the password out-of-band.
|
|
|
251
251
|
|
|
252
252
|
| Command | Description |
|
|
253
253
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
254
|
-
| `/fingerprint [nick]` | Key fingerprint
|
|
255
|
-
| `/verify <nick>` | SAS code (~40-bit) + QR + key randomart for out-of-band verification |
|
|
254
|
+
| `/fingerprint [nick]` | Key fingerprint, the identity fingerprint when there is one, and a deterministic **randomart** picture of the key |
|
|
255
|
+
| `/verify <nick>` | SAS code (~40-bit) + QR + key randomart for out-of-band verification; says whether the code is over identity keys or device keys |
|
|
256
256
|
| `/verify-confirm <nick>` | Mark peer as verified |
|
|
257
257
|
| `/trust <nick>` / `/trustlist` | Accept new key / trust status |
|
|
258
258
|
| `/contacts [add\|remove\|all]` | Contact book — persistent aliases on trust records ("this fingerprint is João"); shows in `/users`, rides along in identity backups |
|
|
259
259
|
| `/backup [path]` | Encrypted backup of identity + verified peers (restore at startup) |
|
|
260
|
+
| `/device [list\|request\|add\|accept\|remove]` | 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 |
|
|
260
261
|
| `/deniable [on\|off]` | Plausible-deniability mode |
|
|
261
262
|
| `/lock` / `/autolock <min\|off>` | Lock the screen behind the session passphrase — manually or after idle time (`autoLock` in config). Privacy for the "stepped away" moment; `/panic` is for the worst one |
|
|
262
263
|
| `/panic [yes]` | Duress wipe — securely erase all on-disk secrets (session, history, trust, keys) and exit |
|
package/README.pt-BR.md
CHANGED
|
@@ -226,7 +226,7 @@ aquela para a qual este software foi feito.
|
|
|
226
226
|
| `/leave [sala]` | Sai de uma sala; o buffer fecha (a última sala é protegida) |
|
|
227
227
|
| `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
|
|
228
228
|
| `/rooms` | Lista salas (🔒 marca as privadas) |
|
|
229
|
-
| `/room` | Sala atual
|
|
229
|
+
| `/room` | Sala atual, como está enviando, e sua lista de buffers |
|
|
230
230
|
| `/topic [texto\|clear]` | Mostra ou define o assunto da sala — aparece na barra de status e é sincronizado para quem entra depois |
|
|
231
231
|
| `/owner` | Dono da sala |
|
|
232
232
|
| `/kick` `/mute` `/ban` | Moderação (dono da sala) — presa à chave pública, então trocar de apelido não desfaz um ban |
|
|
@@ -252,10 +252,11 @@ sem verificar não leria uma palavra. Combine a senha por outro canal.
|
|
|
252
252
|
|
|
253
253
|
| Comando | Descrição |
|
|
254
254
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
255
|
-
| `/fingerprint [nick]` | Fingerprint
|
|
256
|
-
| `/verify <nick>` | Código SAS (~40 bits) + QR + randomart da chave para verificar |
|
|
255
|
+
| `/fingerprint [nick]` | Fingerprint, o fingerprint de identidade quando existe, e um **randomart** determinístico da chave |
|
|
256
|
+
| `/verify <nick>` | Código SAS (~40 bits) + QR + randomart da chave para verificar; diz se o código é sobre chaves de identidade ou de dispositivo |
|
|
257
257
|
| `/verify-confirm <nick>` | Marca o peer como verificado |
|
|
258
258
|
| `/backup [caminho]` | Backup cifrado da identidade + peers verificados (restaura no startup) |
|
|
259
|
+
| `/device [list\|request\|add\|accept\|remove]` | Seus dispositivos sob uma identidade. A chave de identidade nunca sai do dispositivo que a guarda: um segundo dispositivo recebe um lugar assinado na lista, não uma cópia da identidade. Remover um rotaciona a sala, então nada dito depois chega até ele |
|
|
259
260
|
| `/trust <nick>` / `/trustlist` | Aceita chave nova / status de confiança |
|
|
260
261
|
| `/contacts [add\|remove\|all]` | Agenda — apelidos persistentes nos registros de confiança ("esse fingerprint é o João"); aparece no `/users` e viaja no backup de identidade |
|
|
261
262
|
| `/deniable [on\|off]` | Modo de negação plausível |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -930,12 +930,18 @@ The all-members rule is what keeps the damage on that side of the line.
|
|
|
930
930
|
|---|---|---|
|
|
931
931
|
| `sk1` | client | I can **receive** a group message: I accept a sender key over the pairwise channel and can decrypt what that chain produces. |
|
|
932
932
|
| `sk1` | relay | I can fan a room-addressed `group_message` out to a room's members. |
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
933
|
+
| `dl1` | client | I can read a signed device list handed to me over the pairwise channel. Client-only: a device list rides the sealed channel the relay already carries, so there is no relay half to negotiate. |
|
|
934
|
+
|
|
935
|
+
Neither means "I send group messages". Receive and fan-out shipped a release
|
|
936
|
+
ahead of send — 2.11.0 and 2.12.0 — because the switch is *every member agrees*:
|
|
937
|
+
if the ability to read had arrived with the ability to write, the switch would
|
|
938
|
+
only ever be true in rooms where everybody upgraded at the same moment, which on
|
|
939
|
+
a public hub is close to never.
|
|
940
|
+
|
|
941
|
+
The per-peer loop the switch falls back to is **not** going away; `/room` reports
|
|
942
|
+
which path a room is on and why. See `docs/design/sender-keys-on-relay.md` for
|
|
943
|
+
that decision, and `docs/design/multi-device.md` for the change that would
|
|
944
|
+
multiply the fallback's cost by the number of devices per peer.
|
|
939
945
|
|
|
940
946
|
## 7. Handshake Protocol
|
|
941
947
|
|
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,28 +142,59 @@ 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
|
|
156
189
|
optional `room`; absent means the session's only room. Old clients ignore it.
|
|
157
190
|
|
|
191
|
+
`peer_left` is emitted for **every** way a session stops being in a room:
|
|
192
|
+
leaving, switching, disconnecting, being kicked, being banned. That completeness
|
|
193
|
+
is load-bearing rather than tidy. A sender chain (§7) is shared with exactly the
|
|
194
|
+
members of a room, so the set of departures a client hears about is the set of
|
|
195
|
+
moments it can rotate that chain at — and a departure it never hears about is a
|
|
196
|
+
chain that outlives the membership it was drawn for.
|
|
197
|
+
|
|
158
198
|
### `ping` / `pong`
|
|
159
199
|
|
|
160
200
|
Either direction, no payload beyond the framing.
|
|
@@ -172,6 +212,36 @@ route, never to attest.
|
|
|
172
212
|
as `peer_key_updated` to every session sharing a room. The previous key is kept
|
|
173
213
|
briefly on both sides so messages in flight still open.
|
|
174
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
|
+
|
|
175
245
|
**Hybrid post-quantum.** When both sides advertised `pqPublicKey`, the ratchet
|
|
176
246
|
root is mixed once at initialisation:
|
|
177
247
|
|
|
@@ -245,6 +315,29 @@ message keys may be cached.
|
|
|
245
315
|
The unicast path costs one encryption and one envelope **per recipient**. Sender
|
|
246
316
|
keys make it one of each for the whole room.
|
|
247
317
|
|
|
318
|
+
### When this path is used
|
|
319
|
+
|
|
320
|
+
Three conditions, all required, each failing for a different reason:
|
|
321
|
+
|
|
322
|
+
| Condition | Fails when |
|
|
323
|
+
| --- | --- |
|
|
324
|
+
| every member of the room advertises `sk1` | one peer is on an older build |
|
|
325
|
+
| `join_ack.serverCaps` contains `sk1` | the hub is older |
|
|
326
|
+
| the message is not deniable | see below |
|
|
327
|
+
|
|
328
|
+
Any one false and the per-peer path runs unchanged. A sender **must** re-check
|
|
329
|
+
per message rather than caching the answer: a single arrival can take a room off
|
|
330
|
+
this path, and encrypting for a member who cannot decrypt is silent.
|
|
331
|
+
|
|
332
|
+
**Deniable messages never take this path.** Deniability comes from a symmetric
|
|
333
|
+
key both sides could have derived, so neither can prove the other wrote it. A
|
|
334
|
+
group packet is signed by exactly one sender — sending a deniable message on it
|
|
335
|
+
would publish precisely what deniability is for hiding.
|
|
336
|
+
|
|
337
|
+
**Cover traffic takes whichever path real messages take.** A decoy that travelled
|
|
338
|
+
the per-peer path while the room was sending group messages would be
|
|
339
|
+
distinguishable from the thing it exists to imitate.
|
|
340
|
+
|
|
248
341
|
### The chain
|
|
249
342
|
|
|
250
343
|
Each member owns a symmetric ratchet chain per room:
|
|
@@ -280,6 +373,23 @@ receives one mid-conversation therefore cannot read anything sent before it —
|
|
|
280
373
|
that is forward secrecy, not a defect, and it is why the backlog question in §8
|
|
281
374
|
answers itself.
|
|
282
375
|
|
|
376
|
+
**Who speaks first is not a free choice.** A client drops a ciphertext from a
|
|
377
|
+
session it holds no public key for, and a joiner learns the room from its
|
|
378
|
+
`join_ack` *before* the room learns of the joiner from `peer_joined`. A newcomer
|
|
379
|
+
that distributed on arrival would be talking to peers who cannot yet hear it, and
|
|
380
|
+
would have no way to discover that.
|
|
381
|
+
|
|
382
|
+
So distribution is **answered, never announced**:
|
|
383
|
+
|
|
384
|
+
1. the peers who already know the newcomer distribute to it (on `peer_joined`);
|
|
385
|
+
2. the newcomer replies with its own to anyone whose distribution it receives and
|
|
386
|
+
who does not already hold its current chain.
|
|
387
|
+
|
|
388
|
+
Receiving a distribution proves the sender holds your public key, so the reply
|
|
389
|
+
cannot race. A sender must also distribute to any room member it has not yet
|
|
390
|
+
given its current chain to before sending — the exchange above covers every
|
|
391
|
+
ordinary path, and that check covers the rest.
|
|
392
|
+
|
|
283
393
|
### The message
|
|
284
394
|
|
|
285
395
|
```json
|
|
@@ -325,6 +435,21 @@ follow **every** membership change, and the caller must redistribute afterwards
|
|
|
325
435
|
nothing signals a failure to do so, and the room simply stops being able to read
|
|
326
436
|
the rotator.
|
|
327
437
|
|
|
438
|
+
Concretely, a departure rotates: leaving, switching rooms, disconnecting, being
|
|
439
|
+
kicked, being banned. All five reach the client as `peer_left` (§4), which is why
|
|
440
|
+
that message has to be emitted for all five and not only the voluntary ones — a
|
|
441
|
+
departure the client never hears about is a chain that is never rotated, and a
|
|
442
|
+
removed member whose copy still opens everything that follows.
|
|
443
|
+
|
|
444
|
+
**An arrival does not rotate.** A distribution carries the chain's current
|
|
445
|
+
counter, so a newcomer is handed what opens the next message and nothing before
|
|
446
|
+
it. Rotating on arrival would cost a redistribution to the whole room and buy
|
|
447
|
+
nothing.
|
|
448
|
+
|
|
449
|
+
A full room switch drops every chain rather than rotating one: the client is no
|
|
450
|
+
longer in the rooms those chains were drawn for, and carrying one across would
|
|
451
|
+
use a chain drawn for one membership against another.
|
|
452
|
+
|
|
328
453
|
### What the relay does
|
|
329
454
|
|
|
330
455
|
Validates the shape, checks the sender **is a member of `room`**, spends the same
|
|
@@ -336,6 +461,98 @@ Room membership is not a formality: without it one connection could inject into
|
|
|
336
461
|
every room on the hub at once, which the unicast path cannot do because it needs
|
|
337
462
|
a `sessionId` it could only have been told.
|
|
338
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
|
+
|
|
339
556
|
---
|
|
340
557
|
|
|
341
558
|
## 8. Offline delivery
|
|
@@ -405,6 +622,14 @@ optional `room`. Reasons are truncated to 200 characters. The relay broadcasts
|
|
|
405
622
|
`peer_kicked` / `peer_muted` to the room. A muted session is refused for
|
|
406
623
|
`encrypted_message` and `group_message` alike.
|
|
407
624
|
|
|
625
|
+
A kick or a ban emits **`peer_kicked` and then `peer_left`**, in that order, for
|
|
626
|
+
the same session. `peer_kicked` carries an optional `sessionId` alongside the
|
|
627
|
+
nickname; a client that has it can match the two and report one event once,
|
|
628
|
+
while one that does not — every client before this — simply sees an ordinary
|
|
629
|
+
departure and a kick notice. Nicknames could not do this job: `/nick` reassigns
|
|
630
|
+
them, so unwinding a peer by name drops the wrong session as soon as two people
|
|
631
|
+
have ever shared one.
|
|
632
|
+
|
|
408
633
|
These are relay-enforced conveniences and nothing more. A relay that ignores them
|
|
409
634
|
breaks no cryptographic guarantee, which is why blocking is *also* implemented
|
|
410
635
|
client-side, where it cannot be overruled.
|
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"
|