ciphermesh 2.11.0 → 2.12.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 +57 -0
- package/docs/PROTOCOL.md +70 -0
- package/package.json +2 -2
- package/src/client/ChatController.js +249 -21
- package/src/crypto/SenderKey.js +44 -9
- package/src/protocol/messages.js +11 -2
- package/src/server/WebSocketServer.js +28 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
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.12.0
|
|
7
|
+
|
|
8
|
+
Sender keys now send. 2.11.0 shipped the half that reads a group message; this
|
|
9
|
+
is the half that writes one, and a line in a room of fifty costs one encryption
|
|
10
|
+
instead of fifty.
|
|
11
|
+
|
|
12
|
+
The switch is unchanged and still strict — every member of the room and the hub
|
|
13
|
+
itself must say they can handle it. On a public hub the per-peer path remains
|
|
14
|
+
the common case, and it is untouched.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Group sending on the relay.** 2.11.0 shipped the half that reads one; this
|
|
19
|
+
is the half that writes one. A line in a room of fifty cost fifty encryptions
|
|
20
|
+
and fifty envelopes — against the 1 MiB/s byte budget, a padded message to
|
|
21
|
+
fifty people throttled the sender for saying one thing. It now costs one of
|
|
22
|
+
each.
|
|
23
|
+
|
|
24
|
+
The switch is still the strict one: every member of the room advertises
|
|
25
|
+
`sk1`, **and** the hub does, **and** the message is not deniable. Any one
|
|
26
|
+
false and the per-peer path runs exactly as before, which on a public hub is
|
|
27
|
+
the common case rather than the exception. Nothing was removed.
|
|
28
|
+
|
|
29
|
+
Deniable messages stay pairwise permanently. Deniability comes from a key both
|
|
30
|
+
sides could have derived; a group packet is signed by one sender, which is the
|
|
31
|
+
opposite claim.
|
|
32
|
+
|
|
33
|
+
- **Sender keys rotate on every membership change.** Leaving, switching rooms,
|
|
34
|
+
disconnecting, being kicked, being banned. A chain ratchets forward, so the
|
|
35
|
+
copy a member holds opens every message after it: removing someone stopped the
|
|
36
|
+
relay delivering to them and did not stop them reading. Rotation is what
|
|
37
|
+
closes that, and `test/guarantees.test.js` now asserts it from outside the
|
|
38
|
+
code that implements it.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **A kick or a ban is announced as a departure, not only as a notification.**
|
|
43
|
+
Both moved the target out of the room and told the room `peer_kicked`, which
|
|
44
|
+
carries a nickname — and a nickname is not something a client can unwind a
|
|
45
|
+
member by, since `/nick` reassigns them. Every remaining client kept the
|
|
46
|
+
removed peer in its roster indefinitely. They now emit `peer_left` like every
|
|
47
|
+
other way out of a room, which is also what makes rotation reachable for the
|
|
48
|
+
two cases where it matters most.
|
|
49
|
+
|
|
50
|
+
- **Peer capabilities survive a room switch.** `room_changed` and `room_joined`
|
|
51
|
+
carried them and the client dropped them, so after switching rooms every peer
|
|
52
|
+
looked incapable and the room silently never turned the group path on. No
|
|
53
|
+
error, no failure — just an optimisation that quietly never applied.
|
|
54
|
+
|
|
55
|
+
- **Sender-key memory is released, not merely zeroed.** `sodium_malloc` pages are
|
|
56
|
+
`mlock`'d, and an operating system caps how much a process may lock at once.
|
|
57
|
+
Zeroing a spent key left its pages locked until the garbage collector happened
|
|
58
|
+
to run the buffer's finaliser, so a long session derived keys faster than it
|
|
59
|
+
released them. The ceiling is generous on macOS and small on Linux, where a
|
|
60
|
+
busy client would eventually have hit an allocation failure that aborts the
|
|
61
|
+
process rather than returning an error.
|
|
62
|
+
|
|
6
63
|
## 2.11.0
|
|
7
64
|
|
|
8
65
|
Groundwork for sender keys on the relay. **Nothing sends a group message yet** —
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -155,6 +155,13 @@ and must be recognised by key.
|
|
|
155
155
|
`peer_joined` carries the same peer object as `join_ack.peers`. Both carry an
|
|
156
156
|
optional `room`; absent means the session's only room. Old clients ignore it.
|
|
157
157
|
|
|
158
|
+
`peer_left` is emitted for **every** way a session stops being in a room:
|
|
159
|
+
leaving, switching, disconnecting, being kicked, being banned. That completeness
|
|
160
|
+
is load-bearing rather than tidy. A sender chain (§7) is shared with exactly the
|
|
161
|
+
members of a room, so the set of departures a client hears about is the set of
|
|
162
|
+
moments it can rotate that chain at — and a departure it never hears about is a
|
|
163
|
+
chain that outlives the membership it was drawn for.
|
|
164
|
+
|
|
158
165
|
### `ping` / `pong`
|
|
159
166
|
|
|
160
167
|
Either direction, no payload beyond the framing.
|
|
@@ -245,6 +252,29 @@ message keys may be cached.
|
|
|
245
252
|
The unicast path costs one encryption and one envelope **per recipient**. Sender
|
|
246
253
|
keys make it one of each for the whole room.
|
|
247
254
|
|
|
255
|
+
### When this path is used
|
|
256
|
+
|
|
257
|
+
Three conditions, all required, each failing for a different reason:
|
|
258
|
+
|
|
259
|
+
| Condition | Fails when |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| every member of the room advertises `sk1` | one peer is on an older build |
|
|
262
|
+
| `join_ack.serverCaps` contains `sk1` | the hub is older |
|
|
263
|
+
| the message is not deniable | see below |
|
|
264
|
+
|
|
265
|
+
Any one false and the per-peer path runs unchanged. A sender **must** re-check
|
|
266
|
+
per message rather than caching the answer: a single arrival can take a room off
|
|
267
|
+
this path, and encrypting for a member who cannot decrypt is silent.
|
|
268
|
+
|
|
269
|
+
**Deniable messages never take this path.** Deniability comes from a symmetric
|
|
270
|
+
key both sides could have derived, so neither can prove the other wrote it. A
|
|
271
|
+
group packet is signed by exactly one sender — sending a deniable message on it
|
|
272
|
+
would publish precisely what deniability is for hiding.
|
|
273
|
+
|
|
274
|
+
**Cover traffic takes whichever path real messages take.** A decoy that travelled
|
|
275
|
+
the per-peer path while the room was sending group messages would be
|
|
276
|
+
distinguishable from the thing it exists to imitate.
|
|
277
|
+
|
|
248
278
|
### The chain
|
|
249
279
|
|
|
250
280
|
Each member owns a symmetric ratchet chain per room:
|
|
@@ -280,6 +310,23 @@ receives one mid-conversation therefore cannot read anything sent before it —
|
|
|
280
310
|
that is forward secrecy, not a defect, and it is why the backlog question in §8
|
|
281
311
|
answers itself.
|
|
282
312
|
|
|
313
|
+
**Who speaks first is not a free choice.** A client drops a ciphertext from a
|
|
314
|
+
session it holds no public key for, and a joiner learns the room from its
|
|
315
|
+
`join_ack` *before* the room learns of the joiner from `peer_joined`. A newcomer
|
|
316
|
+
that distributed on arrival would be talking to peers who cannot yet hear it, and
|
|
317
|
+
would have no way to discover that.
|
|
318
|
+
|
|
319
|
+
So distribution is **answered, never announced**:
|
|
320
|
+
|
|
321
|
+
1. the peers who already know the newcomer distribute to it (on `peer_joined`);
|
|
322
|
+
2. the newcomer replies with its own to anyone whose distribution it receives and
|
|
323
|
+
who does not already hold its current chain.
|
|
324
|
+
|
|
325
|
+
Receiving a distribution proves the sender holds your public key, so the reply
|
|
326
|
+
cannot race. A sender must also distribute to any room member it has not yet
|
|
327
|
+
given its current chain to before sending — the exchange above covers every
|
|
328
|
+
ordinary path, and that check covers the rest.
|
|
329
|
+
|
|
283
330
|
### The message
|
|
284
331
|
|
|
285
332
|
```json
|
|
@@ -325,6 +372,21 @@ follow **every** membership change, and the caller must redistribute afterwards
|
|
|
325
372
|
nothing signals a failure to do so, and the room simply stops being able to read
|
|
326
373
|
the rotator.
|
|
327
374
|
|
|
375
|
+
Concretely, a departure rotates: leaving, switching rooms, disconnecting, being
|
|
376
|
+
kicked, being banned. All five reach the client as `peer_left` (§4), which is why
|
|
377
|
+
that message has to be emitted for all five and not only the voluntary ones — a
|
|
378
|
+
departure the client never hears about is a chain that is never rotated, and a
|
|
379
|
+
removed member whose copy still opens everything that follows.
|
|
380
|
+
|
|
381
|
+
**An arrival does not rotate.** A distribution carries the chain's current
|
|
382
|
+
counter, so a newcomer is handed what opens the next message and nothing before
|
|
383
|
+
it. Rotating on arrival would cost a redistribution to the whole room and buy
|
|
384
|
+
nothing.
|
|
385
|
+
|
|
386
|
+
A full room switch drops every chain rather than rotating one: the client is no
|
|
387
|
+
longer in the rooms those chains were drawn for, and carrying one across would
|
|
388
|
+
use a chain drawn for one membership against another.
|
|
389
|
+
|
|
328
390
|
### What the relay does
|
|
329
391
|
|
|
330
392
|
Validates the shape, checks the sender **is a member of `room`**, spends the same
|
|
@@ -405,6 +467,14 @@ optional `room`. Reasons are truncated to 200 characters. The relay broadcasts
|
|
|
405
467
|
`peer_kicked` / `peer_muted` to the room. A muted session is refused for
|
|
406
468
|
`encrypted_message` and `group_message` alike.
|
|
407
469
|
|
|
470
|
+
A kick or a ban emits **`peer_kicked` and then `peer_left`**, in that order, for
|
|
471
|
+
the same session. `peer_kicked` carries an optional `sessionId` alongside the
|
|
472
|
+
nickname; a client that has it can match the two and report one event once,
|
|
473
|
+
while one that does not — every client before this — simply sees an ordinary
|
|
474
|
+
departure and a kick notice. Nicknames could not do this job: `/nick` reassigns
|
|
475
|
+
them, so unwinding a peer by name drops the wrong session as soon as two people
|
|
476
|
+
have ever shared one.
|
|
477
|
+
|
|
408
478
|
These are relay-enforced conveniences and nothing more. A relay that ignores them
|
|
409
479
|
breaks no cryptographic guarantee, which is why blocking is *also* implemented
|
|
410
480
|
client-side, where it cannot be overruled.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ciphermesh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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",
|
|
@@ -63,7 +63,7 @@
|
|
|
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.
|
|
66
|
+
"ws": "8.21.3"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@eslint/js": "10.0.1",
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createKickPeer,
|
|
19
19
|
createMutePeer,
|
|
20
20
|
createBanPeer,
|
|
21
|
+
createGroupMessage,
|
|
21
22
|
ERR,
|
|
22
23
|
} from '../protocol/messages.js';
|
|
23
24
|
import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
|
|
@@ -34,8 +35,9 @@ import {
|
|
|
34
35
|
EMOJI_MAP,
|
|
35
36
|
COVER_CONSTANT_MS,
|
|
36
37
|
OWN_CAPABILITIES,
|
|
38
|
+
CAP,
|
|
37
39
|
} from '../shared/constants.js';
|
|
38
|
-
import { normalizeCaps, roomSupports } from '../protocol/capabilities.js';
|
|
40
|
+
import { normalizeCaps, peerSupports, roomSupports } from '../protocol/capabilities.js';
|
|
39
41
|
import { GroupSession } from '../crypto/SenderKey.js';
|
|
40
42
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
41
43
|
import { Handshake } from '../crypto/Handshake.js';
|
|
@@ -86,7 +88,9 @@ export class ChatController {
|
|
|
86
88
|
#peers; // Map<sessionId, { nickname, publicKey, caps }>
|
|
87
89
|
#groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
|
|
88
90
|
#groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
|
|
91
|
+
#distributed = new Map(); // room -> Set<sessionId> holding our current chain
|
|
89
92
|
#serverCaps = []; // what the relay advertised in join_ack
|
|
93
|
+
#kickedSessions = new Set(); // sessionIds announced kicked, awaiting their peer_left
|
|
90
94
|
#lastTypingSent;
|
|
91
95
|
#peerTypingTimers; // Map<sessionId, timeoutId>
|
|
92
96
|
#fileTransfer;
|
|
@@ -850,6 +854,17 @@ export class ChatController {
|
|
|
850
854
|
});
|
|
851
855
|
}
|
|
852
856
|
|
|
857
|
+
// A newcomer holds no chain of ours, so give them one before anything is
|
|
858
|
+
// sent on it. Ordered ahead of the presence and topic sends below because
|
|
859
|
+
// those may themselves go out on the group path.
|
|
860
|
+
//
|
|
861
|
+
// No rotation here. Someone arriving is not someone gaining access to the
|
|
862
|
+
// past: a serialised chain carries its *current* counter, so what they are
|
|
863
|
+
// handed opens what comes next and nothing before it.
|
|
864
|
+
if (room === this.#currentRoom) {
|
|
865
|
+
this.#distributeSenderKey(room, peer.sessionId);
|
|
866
|
+
}
|
|
867
|
+
|
|
853
868
|
// A newcomer doesn't know my presence — send only to them
|
|
854
869
|
if (this.#away || this.#statusText) {
|
|
855
870
|
this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
|
|
@@ -896,11 +911,19 @@ export class ChatController {
|
|
|
896
911
|
}
|
|
897
912
|
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
898
913
|
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
// be sent
|
|
902
|
-
|
|
903
|
-
|
|
914
|
+
// Two separate things, and only the second is forward secrecy.
|
|
915
|
+
//
|
|
916
|
+
// Dropping *their* chain stops us holding a key we can no longer be sent
|
|
917
|
+
// anything on. Rotating *ours* is what stops them reading what the room says
|
|
918
|
+
// next — a chain ratchets forward, so the copy they were handed opens every
|
|
919
|
+
// message after it until we draw a new one.
|
|
920
|
+
//
|
|
921
|
+
// Which rooms rotate is decided by which ones actually lost a member, not by
|
|
922
|
+
// which ones we happen to hold a session for: every rotation costs a
|
|
923
|
+
// redistribution to everyone remaining.
|
|
924
|
+
const lostFrom = new Set();
|
|
925
|
+
if (room && this.#groups.get(room)?.removeMember(msg.sessionId)) {
|
|
926
|
+
lostFrom.add(room);
|
|
904
927
|
}
|
|
905
928
|
|
|
906
929
|
if (goneEntirely) {
|
|
@@ -908,15 +931,27 @@ export class ChatController {
|
|
|
908
931
|
this.#handshake.removePeer(msg.sessionId);
|
|
909
932
|
this.#nonceManager.removePeer(msg.sessionId);
|
|
910
933
|
this.#allPeers.delete(msg.sessionId);
|
|
911
|
-
for (const group of this.#groups
|
|
912
|
-
group.removeMember(msg.sessionId)
|
|
934
|
+
for (const [groupRoom, group] of this.#groups) {
|
|
935
|
+
if (group.removeMember(msg.sessionId)) {
|
|
936
|
+
lostFrom.add(groupRoom);
|
|
937
|
+
}
|
|
913
938
|
}
|
|
914
939
|
}
|
|
915
940
|
|
|
941
|
+
for (const lost of lostFrom) {
|
|
942
|
+
this.#rotateGroupFor(lost);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// A kick already announced itself. Do every bit of the state work, and say
|
|
946
|
+
// nothing — "was kicked" followed by "left" describes one event twice.
|
|
947
|
+
const wasKicked = this.#kickedSessions.delete(msg.sessionId);
|
|
948
|
+
|
|
916
949
|
if (!room || room === this.#currentRoom) {
|
|
917
950
|
this.#rebuildActivePeers();
|
|
918
|
-
|
|
919
|
-
|
|
951
|
+
if (!wasKicked) {
|
|
952
|
+
this.#ui.handshakeDisconnect(nickname);
|
|
953
|
+
}
|
|
954
|
+
} else if (!wasKicked) {
|
|
920
955
|
this.#ui.toBuffer(room, () => {
|
|
921
956
|
this.#ui.addSystemMessage(`${nickname} left #${room}`);
|
|
922
957
|
});
|
|
@@ -924,13 +959,21 @@ export class ChatController {
|
|
|
924
959
|
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
925
960
|
}
|
|
926
961
|
|
|
927
|
-
// ── Sender keys on the relay
|
|
962
|
+
// ── Sender keys on the relay ──────────────────────────────────
|
|
928
963
|
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
964
|
+
// The receive half shipped a release ahead of this one, on purpose: a room
|
|
965
|
+
// switches to group sending only once every member advertises that it can
|
|
966
|
+
// read one, so the readers had to be in the field before the writers or the
|
|
967
|
+
// switch would never have become true for anybody.
|
|
968
|
+
//
|
|
969
|
+
// Three things have to hold before a line goes out once instead of N times,
|
|
970
|
+
// and they fail for different reasons:
|
|
971
|
+
//
|
|
972
|
+
// 1. every member of the room advertises `sk1` — an old peer
|
|
973
|
+
// 2. the relay advertises `sk1` — an old hub
|
|
974
|
+
// 3. the message is not deniable — see #canSendToGroup
|
|
975
|
+
//
|
|
976
|
+
// Any one of them false and the per-peer loop runs, unchanged.
|
|
934
977
|
|
|
935
978
|
#getGroup(room) {
|
|
936
979
|
let group = this.#groups.get(room);
|
|
@@ -951,6 +994,162 @@ export class ChatController {
|
|
|
951
994
|
}
|
|
952
995
|
this.#getGroup(data.room).addMember(fromSessionId, data.dist);
|
|
953
996
|
this.#flushGroupBuffer(data.dist.keyId);
|
|
997
|
+
|
|
998
|
+
// Answer with ours if they do not have it. This is what makes distribution
|
|
999
|
+
// reliable without anything having to know who joined in which order:
|
|
1000
|
+
// whoever knows the other first speaks, and the reply cannot race, because
|
|
1001
|
+
// receiving this proves they already hold our public key.
|
|
1002
|
+
if (!this.#hasDistributedTo(data.room, fromSessionId)) {
|
|
1003
|
+
this.#distributeSenderKey(data.room, fromSessionId);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// A full room switch: every buffer is dropped and we exist only in `room`.
|
|
1008
|
+
// The chains follow. Carrying one across would mean a chain drawn for one
|
|
1009
|
+
// room's membership being used against another's, and a keyId that outlives
|
|
1010
|
+
// the set of people it was ever meant to label.
|
|
1011
|
+
#dropAllGroups() {
|
|
1012
|
+
for (const group of this.#groups.values()) {
|
|
1013
|
+
group.destroy();
|
|
1014
|
+
}
|
|
1015
|
+
this.#groups.clear();
|
|
1016
|
+
this.#groupBuffer.clear();
|
|
1017
|
+
this.#distributed.clear();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Hand our sender key for `room` to one peer, or to everyone in it.
|
|
1021
|
+
//
|
|
1022
|
+
// Always pairwise. The envelope is what proves who the key belongs to; a
|
|
1023
|
+
// distribution arriving on the group path would be a chain vouching for
|
|
1024
|
+
// itself, and the relay would be the only thing asserting whose it was.
|
|
1025
|
+
//
|
|
1026
|
+
// Never sent to a peer that may not know us yet. A client drops a ciphertext
|
|
1027
|
+
// from a session it has no public key for, and it learns ours from the
|
|
1028
|
+
// `peer_joined` the relay sends *after* our `join_ack` — so a newcomer
|
|
1029
|
+
// announcing itself into the room on arrival is talking to people who cannot
|
|
1030
|
+
// hear it. That is why nothing distributes on join: the peers who already
|
|
1031
|
+
// know us distribute to us (#onPeerJoined), and we answer (#onSenderKeyDistribution).
|
|
1032
|
+
#distributeSenderKey(room, toPeer = null) {
|
|
1033
|
+
const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
|
|
1034
|
+
this.#worthDistributingTo(id),
|
|
1035
|
+
);
|
|
1036
|
+
if (recipients.length === 0) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Only now: distribution() is what draws the chain, and a chain is guarded
|
|
1041
|
+
// memory. See #worthDistributingTo.
|
|
1042
|
+
const payload = JSON.stringify({
|
|
1043
|
+
action: 'sk_dist',
|
|
1044
|
+
room,
|
|
1045
|
+
dist: this.#getGroup(room).distribution(),
|
|
1046
|
+
sentAt: Date.now(),
|
|
1047
|
+
});
|
|
1048
|
+
let sent = this.#distributed.get(room);
|
|
1049
|
+
if (!sent) {
|
|
1050
|
+
sent = new Set();
|
|
1051
|
+
this.#distributed.set(room, sent);
|
|
1052
|
+
}
|
|
1053
|
+
// Record before sending, never after.
|
|
1054
|
+
//
|
|
1055
|
+
// Sending re-enters this object. The peer receives the distribution, finds
|
|
1056
|
+
// it holds none of ours, and answers — and its answer can arrive before
|
|
1057
|
+
// #sendPayloadToPeer has returned. Marking afterwards means both sides
|
|
1058
|
+
// consult a record neither has written yet, each answers the other's
|
|
1059
|
+
// answer, and the exchange never converges.
|
|
1060
|
+
//
|
|
1061
|
+
// On a real socket that is a burst of duplicate distributions rather than a
|
|
1062
|
+
// hang, which is why it is worth stating: the bug is re-entrancy, and the
|
|
1063
|
+
// synchronous case is only the one that makes it obvious.
|
|
1064
|
+
for (const peerId of recipients) {
|
|
1065
|
+
sent.add(peerId);
|
|
1066
|
+
}
|
|
1067
|
+
for (const peerId of recipients) {
|
|
1068
|
+
this.#sendPayloadToPeer(peerId, payload);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// A sender key is only ever useful to a peer that can read a group message,
|
|
1073
|
+
// on a hub that can fan one out. Handing one to anybody else is a wasted
|
|
1074
|
+
// round trip — and, less obviously, a wasted allocation.
|
|
1075
|
+
//
|
|
1076
|
+
// Chains live in sodium_malloc'd memory, which is mlock'd. Linux caps how much
|
|
1077
|
+
// a process may lock (RLIMIT_MEMLOCK), and the cap is small; drawing a chain
|
|
1078
|
+
// per room per peer regardless of whether it could ever be used exhausted it,
|
|
1079
|
+
// and sodium_malloc then returns NULL. That surfaced as a SIGABRT in an
|
|
1080
|
+
// unrelated ratchet call — the first allocation to fail, not the one at fault.
|
|
1081
|
+
#worthDistributingTo(peerId) {
|
|
1082
|
+
if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
const peer = this.#peers.get(peerId);
|
|
1086
|
+
return peer ? peerSupports(peer, CAP.SENDER_KEYS) : false;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Has this peer been given our *current* chain for this room? Reset by
|
|
1090
|
+
// rotate(), because after one the answer is no for everybody.
|
|
1091
|
+
#hasDistributedTo(room, peerId) {
|
|
1092
|
+
return this.#distributed.get(room)?.has(peerId) ?? false;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Someone is no longer in `room`: draw a new chain and hand it to whoever is
|
|
1096
|
+
// left.
|
|
1097
|
+
//
|
|
1098
|
+
// This is the forward secrecy the design promises, and the reason the relay
|
|
1099
|
+
// now reports a kick as a departure (#482). Without it a removed member keeps
|
|
1100
|
+
// the chain they were given, and a chain ratchets *forward* — holding it at
|
|
1101
|
+
// counter N opens every counter after N. Being removed from a room would stop
|
|
1102
|
+
// the relay delivering to them and would not stop them reading.
|
|
1103
|
+
//
|
|
1104
|
+
// rotate() has no failure to check and no value to return. The distribution
|
|
1105
|
+
// that follows is the whole point, so the two must not drift apart: a rotation
|
|
1106
|
+
// whose redistribution never happens is a room that quietly stopped being able
|
|
1107
|
+
// to read this client, with nothing raised anywhere.
|
|
1108
|
+
#rotateGroupFor(room) {
|
|
1109
|
+
const group = this.#groups.get(room);
|
|
1110
|
+
if (!group) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
group.rotate();
|
|
1114
|
+
this.#distributed.delete(room); // a new chain: nobody has it
|
|
1115
|
+
if (room === this.#currentRoom) {
|
|
1116
|
+
this.#distributeSenderKey(room);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Can this payload go out once, addressed to the room, instead of N times?
|
|
1121
|
+
#canSendToGroup(deniable) {
|
|
1122
|
+
// Deniability is a property of the pairwise construction — a symmetric key
|
|
1123
|
+
// both sides could have derived, so neither can prove the other wrote it. A
|
|
1124
|
+
// group packet is signed by exactly one sender for exactly that reason, so
|
|
1125
|
+
// sending a deniable message on it would publish the opposite of what was
|
|
1126
|
+
// asked for.
|
|
1127
|
+
if (deniable) {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
if (this.#peers.size === 0) {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
return (
|
|
1134
|
+
this.roomSupportsCapability(CAP.SENDER_KEYS) && this.relaySupportsCapability(CAP.SENDER_KEYS)
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// One encryption, one frame, the whole room. The payload arrives already
|
|
1139
|
+
// room-tagged and already through the private-room layer when there is one —
|
|
1140
|
+
// both happen before the path splits, so a group message and a pairwise one
|
|
1141
|
+
// carry exactly the same bytes inside.
|
|
1142
|
+
#sendRoomGroup(room, payload) {
|
|
1143
|
+
// Nobody can read a packet on a chain they were never given. The exchange
|
|
1144
|
+
// above covers every ordinary path; this covers the rest, and costs one
|
|
1145
|
+
// Set lookup per member when there is nothing to do.
|
|
1146
|
+
for (const [peerId] of this.#peers) {
|
|
1147
|
+
if (!this.#hasDistributedTo(room, peerId)) {
|
|
1148
|
+
this.#distributeSenderKey(room, peerId);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const packet = this.#getGroup(room).encrypt(payload);
|
|
1152
|
+
this.#connection.send(createGroupMessage(room, packet));
|
|
954
1153
|
}
|
|
955
1154
|
|
|
956
1155
|
#onGroupMessage(msg) {
|
|
@@ -3170,6 +3369,11 @@ export class ChatController {
|
|
|
3170
3369
|
this.#allPeers.set(peer.sessionId, {
|
|
3171
3370
|
nickname: peer.nickname,
|
|
3172
3371
|
publicKey: peer.publicKey,
|
|
3372
|
+
// The relay sends these here exactly as it does in join_ack. Dropping
|
|
3373
|
+
// them made every peer look incapable after a room switch, which is not
|
|
3374
|
+
// an error anywhere — it is a room that silently never turns the group
|
|
3375
|
+
// path on, because one absent capability is enough to hold all of it.
|
|
3376
|
+
caps: normalizeCaps(peer.caps),
|
|
3173
3377
|
rooms: new Set([msg.room]),
|
|
3174
3378
|
});
|
|
3175
3379
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
@@ -3179,6 +3383,12 @@ export class ChatController {
|
|
|
3179
3383
|
}
|
|
3180
3384
|
|
|
3181
3385
|
this.#rebuildActivePeers();
|
|
3386
|
+
// A switch drops every buffer, so it drops every chain with them. Carrying
|
|
3387
|
+
// one across would mean a chain drawn for one room's membership being used
|
|
3388
|
+
// against another's, and a keyId outliving the set of people it labelled.
|
|
3389
|
+
// The new room's chain is drawn on first use and distributed by the same
|
|
3390
|
+
// exchange as any other.
|
|
3391
|
+
this.#dropAllGroups();
|
|
3182
3392
|
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
|
|
3183
3393
|
this.#announceJoinedRoom(
|
|
3184
3394
|
msg.room,
|
|
@@ -3205,6 +3415,7 @@ export class ChatController {
|
|
|
3205
3415
|
this.#allPeers.set(peer.sessionId, {
|
|
3206
3416
|
nickname: peer.nickname,
|
|
3207
3417
|
publicKey: peer.publicKey,
|
|
3418
|
+
caps: normalizeCaps(peer.caps),
|
|
3208
3419
|
rooms: new Set([msg.room]),
|
|
3209
3420
|
});
|
|
3210
3421
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
@@ -3263,6 +3474,18 @@ export class ChatController {
|
|
|
3263
3474
|
|
|
3264
3475
|
// ── Handle PEER_KICKED ────────────────────────────────────
|
|
3265
3476
|
#onPeerKicked(msg) {
|
|
3477
|
+
// The relay sends this immediately before the peer_left for the same
|
|
3478
|
+
// session. Remember it so the departure is reported as a kick and not
|
|
3479
|
+
// announced twice; the state work still happens in #onPeerLeft, which is
|
|
3480
|
+
// the one place that knows how to unwind a member.
|
|
3481
|
+
if (typeof msg.sessionId === 'string') {
|
|
3482
|
+
// A kick whose peer_left never arrives (an older relay) must not park an
|
|
3483
|
+
// entry here forever.
|
|
3484
|
+
if (this.#kickedSessions.size >= 64) {
|
|
3485
|
+
this.#kickedSessions.clear();
|
|
3486
|
+
}
|
|
3487
|
+
this.#kickedSessions.add(msg.sessionId);
|
|
3488
|
+
}
|
|
3266
3489
|
if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
|
|
3267
3490
|
const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
|
|
3268
3491
|
this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
|
|
@@ -3558,6 +3781,15 @@ export class ChatController {
|
|
|
3558
3781
|
payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
|
|
3559
3782
|
}
|
|
3560
3783
|
|
|
3784
|
+
// One ciphertext for the room, when the room and the relay can both take
|
|
3785
|
+
// one. Everything above this line has already run, so the bytes inside are
|
|
3786
|
+
// identical either way — including cover traffic, which has to travel the
|
|
3787
|
+
// path real messages travel or it stops resembling them.
|
|
3788
|
+
if (this.#canSendToGroup(deniable)) {
|
|
3789
|
+
this.#sendRoomGroup(this.#currentRoom, payload);
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3561
3793
|
for (const [peerId] of this.#peers) {
|
|
3562
3794
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
3563
3795
|
if (!peerPublicKey) {
|
|
@@ -3810,11 +4042,7 @@ export class ChatController {
|
|
|
3810
4042
|
this.#historyStore.destroy();
|
|
3811
4043
|
}
|
|
3812
4044
|
this.#fileTransfer.destroy();
|
|
3813
|
-
|
|
3814
|
-
group.destroy();
|
|
3815
|
-
}
|
|
3816
|
-
this.#groups.clear();
|
|
3817
|
-
this.#groupBuffer.clear();
|
|
4045
|
+
this.#dropAllGroups();
|
|
3818
4046
|
this.#handshake.destroy();
|
|
3819
4047
|
this.#keyManager.destroy();
|
|
3820
4048
|
this.#connection.close();
|
package/src/crypto/SenderKey.js
CHANGED
|
@@ -16,6 +16,26 @@ const CHAIN_KEY_TAG = Buffer.from([0x02]);
|
|
|
16
16
|
const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
|
|
17
17
|
const KEY_ID_SIZE = 16;
|
|
18
18
|
|
|
19
|
+
// Release a key allocated with sodium_malloc.
|
|
20
|
+
//
|
|
21
|
+
// `sodium_memzero` alone is not enough, and the difference is not academic.
|
|
22
|
+
// sodium_malloc'd pages are **mlock'd**, and an operating system caps how much
|
|
23
|
+
// a process may lock at once — RLIMIT_MEMLOCK, which on Linux is small and on
|
|
24
|
+
// macOS is typically unlimited. Zeroing a key leaves its pages locked until the
|
|
25
|
+
// garbage collector happens to run the buffer's finaliser, so a process that
|
|
26
|
+
// derives keys faster than it collects them walks into an allocation failure it
|
|
27
|
+
// never sees coming: sodium_malloc returns NULL, and the crash lands on whatever
|
|
28
|
+
// unrelated call allocated next.
|
|
29
|
+
//
|
|
30
|
+
// sodium_free zeroes before releasing, so this is strictly stronger than the
|
|
31
|
+
// memzero it replaces. Null-safe and idempotent by convention: every caller
|
|
32
|
+
// drops its reference immediately after, so nothing can reach freed memory.
|
|
33
|
+
function freeKey(buf) {
|
|
34
|
+
if (buf) {
|
|
35
|
+
sodium.sodium_free(buf);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
// An opaque label for a sender chain, handed out with the distribution.
|
|
20
40
|
//
|
|
21
41
|
// On the relay path a group message arrives by fan-out with no sender on it —
|
|
@@ -97,8 +117,9 @@ export class SenderChain {
|
|
|
97
117
|
sodium.crypto_generichash(messageKey, MSG_KEY_TAG, this.#chainKey);
|
|
98
118
|
const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
|
|
99
119
|
sodium.crypto_generichash(nextChainKey, CHAIN_KEY_TAG, this.#chainKey);
|
|
100
|
-
|
|
120
|
+
const spent = this.#chainKey;
|
|
101
121
|
this.#chainKey = nextChainKey;
|
|
122
|
+
freeKey(spent); // released, not merely zeroed — the hot path, once per message
|
|
102
123
|
return messageKey;
|
|
103
124
|
}
|
|
104
125
|
|
|
@@ -112,7 +133,8 @@ export class SenderChain {
|
|
|
112
133
|
|
|
113
134
|
// Receiving: the message key at `targetCounter`, caching skipped keys for
|
|
114
135
|
// out-of-order delivery. Returns null on replay (already consumed) or if the
|
|
115
|
-
// gap exceeds maxSkip. The caller
|
|
136
|
+
// gap exceeds maxSkip. The caller owns the returned key and must release it —
|
|
137
|
+
// groupDecrypt does, on every path including the failures.
|
|
116
138
|
messageKeyFor(targetCounter) {
|
|
117
139
|
if (this.#skipped.has(targetCounter)) {
|
|
118
140
|
const key = this.#skipped.get(targetCounter);
|
|
@@ -145,9 +167,10 @@ export class SenderChain {
|
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
destroy() {
|
|
148
|
-
|
|
170
|
+
freeKey(this.#chainKey);
|
|
171
|
+
this.#chainKey = null;
|
|
149
172
|
for (const key of this.#skipped.values()) {
|
|
150
|
-
|
|
173
|
+
freeKey(key);
|
|
151
174
|
}
|
|
152
175
|
this.#skipped.clear();
|
|
153
176
|
}
|
|
@@ -162,18 +185,18 @@ export function groupEncrypt(messageKey, plaintext) {
|
|
|
162
185
|
const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
|
|
163
186
|
sodium.crypto_secretbox_easy(ciphertext, padded, nonce, messageKey);
|
|
164
187
|
sodium.sodium_memzero(padded);
|
|
165
|
-
|
|
188
|
+
freeKey(messageKey);
|
|
166
189
|
return { ciphertext, nonce };
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
export function groupDecrypt(messageKey, ciphertext, nonce) {
|
|
170
193
|
if (ciphertext.length < sodium.crypto_secretbox_MACBYTES) {
|
|
171
|
-
|
|
194
|
+
freeKey(messageKey);
|
|
172
195
|
return null;
|
|
173
196
|
}
|
|
174
197
|
const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
|
|
175
198
|
const ok = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, messageKey);
|
|
176
|
-
|
|
199
|
+
freeKey(messageKey);
|
|
177
200
|
if (!ok) {
|
|
178
201
|
sodium.sodium_memzero(padded);
|
|
179
202
|
return null;
|
|
@@ -312,14 +335,21 @@ export class GroupSession {
|
|
|
312
335
|
}
|
|
313
336
|
}
|
|
314
337
|
|
|
338
|
+
// Returns whether this actually removed a member. Callers rotate on a real
|
|
339
|
+
// membership change and must not rotate on a departure they have already
|
|
340
|
+
// handled: every rotation costs a redistribution to everyone remaining, and a
|
|
341
|
+
// redistribution that crosses an incoming message is a message the recipient
|
|
342
|
+
// has to buffer.
|
|
315
343
|
removeMember(memberId) {
|
|
316
344
|
const chain = this.#members.get(memberId);
|
|
345
|
+
const had = this.#members.has(memberId) || this.#memberSignPk.has(memberId);
|
|
317
346
|
if (chain) {
|
|
318
347
|
chain.destroy();
|
|
319
348
|
this.#members.delete(memberId);
|
|
320
349
|
}
|
|
321
350
|
this.#forgetKeyIdsOf(memberId);
|
|
322
351
|
this.#memberSignPk.delete(memberId);
|
|
352
|
+
return had;
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
#forgetKeyIdsOf(memberId) {
|
|
@@ -343,13 +373,18 @@ export class GroupSession {
|
|
|
343
373
|
// The signing key rotates with the chain it authenticates. Keeping it would
|
|
344
374
|
// let anyone holding the old public key keep attributing new packets to a
|
|
345
375
|
// chain that was rotated precisely because the room membership changed.
|
|
346
|
-
|
|
376
|
+
//
|
|
377
|
+
// Released rather than zeroed: rotation runs on every membership change, so
|
|
378
|
+
// a room with any churn would otherwise accumulate locked pages for the life
|
|
379
|
+
// of the session.
|
|
380
|
+
freeKey(this.#signSk);
|
|
347
381
|
({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
|
|
348
382
|
}
|
|
349
383
|
|
|
350
384
|
destroy() {
|
|
351
385
|
this.#own.destroy();
|
|
352
|
-
|
|
386
|
+
freeKey(this.#signSk);
|
|
387
|
+
this.#signSk = null;
|
|
353
388
|
for (const chain of this.#members.values()) {
|
|
354
389
|
chain.destroy();
|
|
355
390
|
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -249,8 +249,17 @@ export function createBanPeer(targetNickname, reason = '') {
|
|
|
249
249
|
return { ...base(MSG.BAN_PEER), targetNickname, reason };
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// `sessionId` (optional) names *which* session was removed. `nickname` cannot:
|
|
253
|
+
// it is not unique over time — /nick reassigns it — and a client that removed a
|
|
254
|
+
// peer by name would drop the wrong session whenever two people had ever shared
|
|
255
|
+
// one. Older clients ignore the field; it exists so a kick can be matched to the
|
|
256
|
+
// `peer_left` that follows it and reported as a kick rather than a departure.
|
|
257
|
+
export function createPeerKicked(nickname, reason = '', sessionId = null) {
|
|
258
|
+
const msg = { ...base(MSG.PEER_KICKED), nickname, reason };
|
|
259
|
+
if (sessionId) {
|
|
260
|
+
msg.sessionId = sessionId;
|
|
261
|
+
}
|
|
262
|
+
return msg;
|
|
254
263
|
}
|
|
255
264
|
|
|
256
265
|
export function createPeerMuted(nickname, durationMs) {
|
|
@@ -776,6 +776,27 @@ export class SecureWSServer {
|
|
|
776
776
|
this.#finishRoomSwitch(ws, session, result);
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
+
// One session stopped being in one room. Every way out of a room ends here:
|
|
780
|
+
// leaving, switching, disconnecting, and — since this was the gap — being
|
|
781
|
+
// kicked or banned.
|
|
782
|
+
//
|
|
783
|
+
// Kick and ban used to announce only `peer_kicked`, which carries a nickname
|
|
784
|
+
// and no session. Clients had nothing to remove a member *by*, so the removed
|
|
785
|
+
// peer stayed in every remaining roster. Harmless-looking while the relay path
|
|
786
|
+
// seals one envelope per peer; not harmless at all once a room encrypts once
|
|
787
|
+
// to a shared chain, because "who is still in this room" is then the input to
|
|
788
|
+
// when that chain must be rotated away from someone.
|
|
789
|
+
#emitRoomDeparture(room, sessionId, nickname) {
|
|
790
|
+
if (!room || !sessionId) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
this.#sessionManager.broadcastToRoom(
|
|
794
|
+
room,
|
|
795
|
+
createPeerLeft(sessionId, nickname || 'Unknown', room),
|
|
796
|
+
sessionId,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
779
800
|
// Shared tail of a successful room switch: notify every old room and send
|
|
780
801
|
// the ROOM_CHANGED (with the private flag) to the mover.
|
|
781
802
|
#finishRoomSwitch(ws, session, result) {
|
|
@@ -902,11 +923,14 @@ export class SecureWSServer {
|
|
|
902
923
|
if (result) {
|
|
903
924
|
const targetSession = this.#sessionManager.getSession(targetSessionId);
|
|
904
925
|
|
|
905
|
-
// Notify old room
|
|
926
|
+
// Notify old room. The kick goes first so a client can mark the session
|
|
927
|
+
// before the peer_left lands and report it as a kick rather than a
|
|
928
|
+
// departure; both travel the same socket, so the order holds.
|
|
906
929
|
this.#sessionManager.broadcastToRoom(
|
|
907
930
|
room,
|
|
908
|
-
createPeerKicked(validation.targetNickname, validation.reason),
|
|
931
|
+
createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
|
|
909
932
|
);
|
|
933
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
910
934
|
|
|
911
935
|
// Notify target with room change + kick reason
|
|
912
936
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
@@ -1018,8 +1042,9 @@ export class SecureWSServer {
|
|
|
1018
1042
|
if (result) {
|
|
1019
1043
|
this.#sessionManager.broadcastToRoom(
|
|
1020
1044
|
room,
|
|
1021
|
-
createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
|
|
1045
|
+
createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
|
|
1022
1046
|
);
|
|
1047
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
1023
1048
|
|
|
1024
1049
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
1025
1050
|
targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
|