ciphermesh 2.9.0 → 2.11.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 +126 -0
- package/README.md +5 -3
- package/README.pt-BR.md +2 -2
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PLUGINS.md +53 -14
- package/docs/PROTOCOL.md +486 -0
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +3 -2
- package/src/client/ChatController.js +245 -68
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +16 -10
- package/src/p2p/index.js +6 -1
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- package/src/protocol/validators.js +73 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +101 -1
- package/src/server/config.js +15 -0
- package/src/server/index.js +18 -0
- package/src/server/preflight.js +96 -0
- package/src/shared/PluginManager.js +103 -30
- package/src/shared/config.js +12 -0
- package/src/shared/constants.js +50 -0
- package/src/shared/pluginCommand.js +106 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Sender keys on the relay
|
|
2
|
+
|
|
3
|
+
Status: **design, not implemented.** Written 2026-08-07, straight after
|
|
4
|
+
measuring the problem, so the next session starts from the constraints rather
|
|
5
|
+
than rediscovering them.
|
|
6
|
+
|
|
7
|
+
## The problem, measured
|
|
8
|
+
|
|
9
|
+
`ChatController.#broadcastPayload` loops over every peer in the room and seals
|
|
10
|
+
one envelope each:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
for (const [peerId] of this.#peers) {
|
|
14
|
+
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
15
|
+
...
|
|
16
|
+
this.#sealAndSend(peerPublicKey, msg);
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
One typed line in a room of N people is **N encryptions and N envelopes on the
|
|
21
|
+
wire**. Cost grows linearly with room size, on the sender's CPU and on the
|
|
22
|
+
sender's uplink — the two places least able to absorb it.
|
|
23
|
+
|
|
24
|
+
2.10.0 made this a ceiling rather than a slope. `MAX_BYTES_PER_SECOND` bounds a
|
|
25
|
+
connection at 1 MiB/s by default, and messages are padded into buckets of up to
|
|
26
|
+
32 KiB. A 32 KiB bucket sent to fifty people is 1.6 MiB for one line: the sender
|
|
27
|
+
is throttled, or disconnected, for saying one thing.
|
|
28
|
+
|
|
29
|
+
P2P does not have this problem. `P2PChatController` already uses
|
|
30
|
+
`GroupSession` from `src/crypto/SenderKey.js`, encrypts once, and distributes
|
|
31
|
+
the sender key per member. The code is written and tested; it is only the relay
|
|
32
|
+
path that never adopted it.
|
|
33
|
+
|
|
34
|
+
## What changes
|
|
35
|
+
|
|
36
|
+
1. Each member holds a **sender chain** for the room and distributes its
|
|
37
|
+
`distribution()` to every other member — sealed per member, once, rather
|
|
38
|
+
than per message.
|
|
39
|
+
2. A message is encrypted **once** with the sender's chain and handed to the
|
|
40
|
+
relay with a room destination rather than a peer destination.
|
|
41
|
+
3. The relay fans the single ciphertext out to the room's members. It still
|
|
42
|
+
cannot read anything, and it still never learns the sender under sealed
|
|
43
|
+
sender.
|
|
44
|
+
4. On any membership change, the leaver's departure triggers `rotate()` and a
|
|
45
|
+
redistribution — exactly what `P2PChatController` does today at lines 320
|
|
46
|
+
and 487, with the comment already written there.
|
|
47
|
+
|
|
48
|
+
Cost per message goes from N encryptions and N envelopes to **one and one**.
|
|
49
|
+
Distribution cost is N, but paid on join and on membership change rather than
|
|
50
|
+
on every line.
|
|
51
|
+
|
|
52
|
+
## The hard part: two versions in one room
|
|
53
|
+
|
|
54
|
+
This is a protocol change. A 2.11 client encrypting once to a group and a 2.10
|
|
55
|
+
client expecting an envelope addressed to it **cannot read each other**. The hub
|
|
56
|
+
is public and people upgrade whenever they upgrade, so "everyone updates at
|
|
57
|
+
once" is not available.
|
|
58
|
+
|
|
59
|
+
Rolling this out badly breaks live conversations for strangers. Options, in the
|
|
60
|
+
order they should be considered:
|
|
61
|
+
|
|
62
|
+
- **Negotiate, do not assume.** The JOIN acknowledgement already carries each
|
|
63
|
+
peer's public key; it can carry a capability list too. A sender uses group
|
|
64
|
+
encryption only when *every* member of the room advertises it, and falls back
|
|
65
|
+
to the current per-peer loop otherwise. Costs a room-wide check per send,
|
|
66
|
+
which is cheap and already computed.
|
|
67
|
+
- **Both paths coexist for at least one minor.** Deleting the fan-out in the
|
|
68
|
+
same release that adds sender keys leaves no way back if the new path has a
|
|
69
|
+
bug that only shows at scale — which is exactly the kind of bug it would have.
|
|
70
|
+
- **The relay needs a room-addressed message type** that does not exist yet.
|
|
71
|
+
It must not weaken sealed sender: today the relay learns the recipient and not
|
|
72
|
+
the sender, and a room-addressed envelope must not accidentally invert that.
|
|
73
|
+
|
|
74
|
+
## What to be careful about
|
|
75
|
+
|
|
76
|
+
- **Rotation must be wired to every departure**, not just voluntary leaves.
|
|
77
|
+
`/kick`, `/mute`, `/ban` and a dropped connection all change membership. The
|
|
78
|
+
P2P side rotates on `peer_left`; the relay side has more ways to lose a member.
|
|
79
|
+
- **`SenderKey.rotate()` is a caller responsibility** — its own comment says
|
|
80
|
+
so. The distribution has to follow it or the room silently stops being able to
|
|
81
|
+
read the rotator.
|
|
82
|
+
- **Private rooms add a second layer** (`encryptRoomPayload` with the
|
|
83
|
+
password-derived key). Group encryption goes *inside* that, not instead of it.
|
|
84
|
+
- **The offline queue** stores envelopes addressed to a peer. A room-addressed
|
|
85
|
+
message needs an answer for someone who was offline when it was sent, and
|
|
86
|
+
"they get the sender key on rejoin but not the backlog" is a decision to make
|
|
87
|
+
deliberately rather than discover.
|
|
88
|
+
|
|
89
|
+
**Decided (2026-08-10): room-addressed messages are not queued.** The reason is
|
|
90
|
+
not policy but arithmetic — a sender key handed over on someone's return
|
|
91
|
+
serialises the chain at its *current* counter, so the backlog is unreadable to
|
|
92
|
+
them whatever the relay does with it. Queueing would hold ciphertext nobody can
|
|
93
|
+
open: all of the storage and the liability, none of the delivery. The unicast
|
|
94
|
+
queue survives because an envelope addressed to a peer is still openable when
|
|
95
|
+
they return with the same key. Pinned in `test/group-receive.test.js`.
|
|
96
|
+
|
|
97
|
+
- **Sender keys are symmetric, so any member can forge another.** Not in the
|
|
98
|
+
original list, and it does not matter much in a P2P mesh where membership is
|
|
99
|
+
small and deliberate. It matters on a public hub. **Closed (2026-08-10)** with
|
|
100
|
+
an Ed25519 key per sender chain, distributed alongside the chain and verified
|
|
101
|
+
before the ratchet is touched. Doing it before the send path existed meant it
|
|
102
|
+
cost a field on a wire nobody was using yet.
|
|
103
|
+
|
|
104
|
+
## Suggested order
|
|
105
|
+
|
|
106
|
+
1. Test vectors for `SenderKey` distribution and rotation, so both sides of the
|
|
107
|
+
change are pinned before either moves.
|
|
108
|
+
2. Capability advertisement in JOIN, with the fallback path left untouched.
|
|
109
|
+
3. Group send/receive behind that capability, both paths live.
|
|
110
|
+
4. Rotation wired to every membership change, with a test per route in.
|
|
111
|
+
5. Only then, consider retiring the per-peer loop — a release later, at least.
|
|
112
|
+
|
|
113
|
+
## Why it is worth it
|
|
114
|
+
|
|
115
|
+
It is the one change that is simultaneously a feature, a fix and an
|
|
116
|
+
improvement: it removes a scaling limit the project just made visible to itself,
|
|
117
|
+
it reuses code that already exists and is already tested, and it is the
|
|
118
|
+
difference between the hub holding a room of five and a room of fifty.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ciphermesh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"docker:build": "docker compose build",
|
|
50
50
|
"docker:up": "docker compose up -d",
|
|
51
51
|
"docker:down": "docker compose down",
|
|
52
|
-
"docker:logs": "docker compose logs -f"
|
|
52
|
+
"docker:logs": "docker compose logs -f",
|
|
53
|
+
"commands:build": "node scripts/generate-commands.mjs"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
56
|
"@noble/post-quantum": "0.6.1",
|
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
isRoomWrapped,
|
|
30
30
|
freeRoomSecrets,
|
|
31
31
|
} from '../crypto/RoomKey.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
KEY_ROTATION_INTERVAL_MS,
|
|
34
|
+
EMOJI_MAP,
|
|
35
|
+
COVER_CONSTANT_MS,
|
|
36
|
+
OWN_CAPABILITIES,
|
|
37
|
+
} from '../shared/constants.js';
|
|
38
|
+
import { normalizeCaps, roomSupports } from '../protocol/capabilities.js';
|
|
39
|
+
import { GroupSession } from '../crypto/SenderKey.js';
|
|
33
40
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
34
41
|
import { Handshake } from '../crypto/Handshake.js';
|
|
35
42
|
import { NonceManager } from '../crypto/NonceManager.js';
|
|
@@ -61,6 +68,7 @@ import {
|
|
|
61
68
|
} from '../shared/dnd.js';
|
|
62
69
|
import { saveLastSession } from '../shared/lastSession.js';
|
|
63
70
|
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
71
|
+
import { pluginsCommand } from '../shared/pluginCommand.js';
|
|
64
72
|
import { COMMANDS } from './UI.js';
|
|
65
73
|
|
|
66
74
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
@@ -75,7 +83,10 @@ export class ChatController {
|
|
|
75
83
|
#handshake;
|
|
76
84
|
#nonceManager;
|
|
77
85
|
#sessionId;
|
|
78
|
-
#peers; // Map<sessionId, { nickname, publicKey }>
|
|
86
|
+
#peers; // Map<sessionId, { nickname, publicKey, caps }>
|
|
87
|
+
#groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
|
|
88
|
+
#groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
|
|
89
|
+
#serverCaps = []; // what the relay advertised in join_ack
|
|
79
90
|
#lastTypingSent;
|
|
80
91
|
#peerTypingTimers; // Map<sessionId, timeoutId>
|
|
81
92
|
#fileTransfer;
|
|
@@ -219,7 +230,12 @@ export class ChatController {
|
|
|
219
230
|
this.#reconnectAttempts = 0;
|
|
220
231
|
this.#ui.setConnectionState('online');
|
|
221
232
|
this.#connection.send(
|
|
222
|
-
createJoin(
|
|
233
|
+
createJoin(
|
|
234
|
+
this.#nickname,
|
|
235
|
+
this.#keyManager.publicKeyB64,
|
|
236
|
+
this.#keyManager.pqPublicKeyB64,
|
|
237
|
+
OWN_CAPABILITIES,
|
|
238
|
+
),
|
|
223
239
|
);
|
|
224
240
|
}
|
|
225
241
|
|
|
@@ -453,6 +469,10 @@ export class ChatController {
|
|
|
453
469
|
this.#onEncryptedMessage(msg);
|
|
454
470
|
break;
|
|
455
471
|
|
|
472
|
+
case MSG.GROUP_MESSAGE:
|
|
473
|
+
this.#onGroupMessage(msg);
|
|
474
|
+
break;
|
|
475
|
+
|
|
456
476
|
case MSG.PEER_KEY_UPDATED:
|
|
457
477
|
this.#onPeerKeyUpdated(msg);
|
|
458
478
|
break;
|
|
@@ -541,6 +561,10 @@ export class ChatController {
|
|
|
541
561
|
// ── JOIN_ACK: registered with server ──────────────────────────
|
|
542
562
|
#onJoinAck(msg) {
|
|
543
563
|
this.#sessionId = msg.sessionId;
|
|
564
|
+
// What the relay itself can do. No client can advertise this on its behalf,
|
|
565
|
+
// and the fan-out for a room-addressed message is the relay's job — so a
|
|
566
|
+
// capable room on an older hub is still not a room that can switch paths.
|
|
567
|
+
this.#serverCaps = normalizeCaps(msg.serverCaps);
|
|
544
568
|
const room = msg.room || 'general';
|
|
545
569
|
const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
|
|
546
570
|
|
|
@@ -561,6 +585,7 @@ export class ChatController {
|
|
|
561
585
|
this.#allPeers.set(peer.sessionId, {
|
|
562
586
|
nickname: peer.nickname,
|
|
563
587
|
publicKey: peer.publicKey,
|
|
588
|
+
caps: normalizeCaps(peer.caps),
|
|
564
589
|
rooms: new Set([room]),
|
|
565
590
|
});
|
|
566
591
|
|
|
@@ -698,13 +723,33 @@ export class ChatController {
|
|
|
698
723
|
this.#peers.clear();
|
|
699
724
|
for (const [sid, p] of this.#allPeers) {
|
|
700
725
|
if (p.rooms.has(this.#currentRoom)) {
|
|
701
|
-
this.#peers.set(sid, {
|
|
726
|
+
this.#peers.set(sid, {
|
|
727
|
+
nickname: p.nickname,
|
|
728
|
+
publicKey: p.publicKey,
|
|
729
|
+
caps: p.caps || [],
|
|
730
|
+
});
|
|
702
731
|
}
|
|
703
732
|
}
|
|
704
733
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
705
734
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
706
735
|
}
|
|
707
736
|
|
|
737
|
+
// Can every member of the active room speak `cap`? Nothing calls this on a
|
|
738
|
+
// send path yet — group send/receive is the next step, and this is the switch
|
|
739
|
+
// it will be gated on. Kept here so the negotiation is testable before the
|
|
740
|
+
// feature that depends on it exists; `own` is overridable for exactly that,
|
|
741
|
+
// since this build advertises nothing yet.
|
|
742
|
+
roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
|
|
743
|
+
return roomSupports(this.#peers.values(), cap, own);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Did the relay say it can do this? Separate from the room check on purpose:
|
|
747
|
+
// both have to hold before a send path may switch, and they fail for
|
|
748
|
+
// different reasons — an old peer versus an old hub.
|
|
749
|
+
relaySupportsCapability(cap) {
|
|
750
|
+
return this.#serverCaps.includes(cap);
|
|
751
|
+
}
|
|
752
|
+
|
|
708
753
|
#applyTopicToUI() {
|
|
709
754
|
const topic = this.#buffers.get(this.#currentRoom)?.topic;
|
|
710
755
|
this.#ui.setTopic(topic?.text || null);
|
|
@@ -787,6 +832,7 @@ export class ChatController {
|
|
|
787
832
|
this.#allPeers.set(peer.sessionId, {
|
|
788
833
|
nickname: peer.nickname,
|
|
789
834
|
publicKey: peer.publicKey,
|
|
835
|
+
caps: normalizeCaps(peer.caps),
|
|
790
836
|
rooms: new Set([room]),
|
|
791
837
|
});
|
|
792
838
|
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
@@ -850,11 +896,21 @@ export class ChatController {
|
|
|
850
896
|
}
|
|
851
897
|
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
852
898
|
|
|
899
|
+
// Drop their sender chain for the room they left. Their own rotation is what
|
|
900
|
+
// gives forward secrecy — this only stops us holding a key we can no longer
|
|
901
|
+
// be sent anything on.
|
|
902
|
+
if (room) {
|
|
903
|
+
this.#groups.get(room)?.removeMember(msg.sessionId);
|
|
904
|
+
}
|
|
905
|
+
|
|
853
906
|
if (goneEntirely) {
|
|
854
907
|
this.#hidePeerTyping(msg.sessionId, nickname);
|
|
855
908
|
this.#handshake.removePeer(msg.sessionId);
|
|
856
909
|
this.#nonceManager.removePeer(msg.sessionId);
|
|
857
910
|
this.#allPeers.delete(msg.sessionId);
|
|
911
|
+
for (const group of this.#groups.values()) {
|
|
912
|
+
group.removeMember(msg.sessionId);
|
|
913
|
+
}
|
|
858
914
|
}
|
|
859
915
|
|
|
860
916
|
if (!room || room === this.#currentRoom) {
|
|
@@ -868,8 +924,101 @@ export class ChatController {
|
|
|
868
924
|
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
869
925
|
}
|
|
870
926
|
|
|
927
|
+
// ── Sender keys on the relay: the receive half ────────────────
|
|
928
|
+
//
|
|
929
|
+
// Sending is deliberately absent — #broadcastPayload still seals one envelope
|
|
930
|
+
// per peer. This is the half that has to be in the field first: a room only
|
|
931
|
+
// switches to group sending once every member advertises it, so the ability to
|
|
932
|
+
// read one must ship a release before the ability to write one, or the switch
|
|
933
|
+
// never becomes true for anybody.
|
|
934
|
+
|
|
935
|
+
#getGroup(room) {
|
|
936
|
+
let group = this.#groups.get(room);
|
|
937
|
+
if (!group) {
|
|
938
|
+
group = new GroupSession();
|
|
939
|
+
this.#groups.set(room, group);
|
|
940
|
+
}
|
|
941
|
+
return group;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// A member handed us their sender key. This arrives over the pairwise sealed
|
|
945
|
+
// channel, so `fromSessionId` was authenticated by opening the envelope —
|
|
946
|
+
// never asserted by the relay, which is the whole reason distribution does not
|
|
947
|
+
// ride on the group path itself.
|
|
948
|
+
#onSenderKeyDistribution(fromSessionId, data) {
|
|
949
|
+
if (typeof data.room !== 'string' || !data.dist || typeof data.dist !== 'object') {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.#getGroup(data.room).addMember(fromSessionId, data.dist);
|
|
953
|
+
this.#flushGroupBuffer(data.dist.keyId);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
#onGroupMessage(msg) {
|
|
957
|
+
const group = this.#groups.get(msg.room);
|
|
958
|
+
const from = group ? group.memberForKeyId(msg.keyId) : null;
|
|
959
|
+
|
|
960
|
+
// A label we hold no distribution for. Usually a race — the fan-out beat the
|
|
961
|
+
// sender key through a different path — so hold it rather than drop it.
|
|
962
|
+
if (!from || !this.#allPeers.has(from)) {
|
|
963
|
+
this.#bufferGroupMessage(msg);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const plaintext = group.decrypt(from, {
|
|
968
|
+
keyId: msg.keyId,
|
|
969
|
+
counter: msg.counter,
|
|
970
|
+
ciphertext: msg.ciphertext,
|
|
971
|
+
nonce: msg.nonce,
|
|
972
|
+
signature: msg.signature,
|
|
973
|
+
});
|
|
974
|
+
if (!plaintext) {
|
|
975
|
+
// A replayed counter, a chain that rotated without us being told yet, or a
|
|
976
|
+
// signature that does not check out. The first two resolve on the next
|
|
977
|
+
// distribution; the third never will, and buffering it costs one slot in a
|
|
978
|
+
// bounded map rather than a decision made on too little information here.
|
|
979
|
+
this.#bufferGroupMessage(msg);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Hand it to the one path that knows what a payload means. `payload` is
|
|
984
|
+
// empty because there was no pairwise envelope: the group chain replaced it.
|
|
985
|
+
this.#onEncryptedMessage({ from, payload: {} }, plaintext);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
#bufferGroupMessage(msg) {
|
|
989
|
+
// Bounded twice over: a hostile relay can invent keyIds all day, and each
|
|
990
|
+
// one must not become a place to park memory.
|
|
991
|
+
if (!this.#groupBuffer.has(msg.keyId) && this.#groupBuffer.size >= 32) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
let buf = this.#groupBuffer.get(msg.keyId);
|
|
995
|
+
if (!buf) {
|
|
996
|
+
buf = [];
|
|
997
|
+
this.#groupBuffer.set(msg.keyId, buf);
|
|
998
|
+
}
|
|
999
|
+
if (buf.length < 20) {
|
|
1000
|
+
buf.push(msg);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
#flushGroupBuffer(keyId) {
|
|
1005
|
+
const buf = this.#groupBuffer.get(keyId);
|
|
1006
|
+
if (!buf) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
this.#groupBuffer.delete(keyId);
|
|
1010
|
+
for (const msg of buf) {
|
|
1011
|
+
this.#onGroupMessage(msg);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
871
1015
|
// ── Received encrypted message ────────────────────────────────
|
|
872
|
-
|
|
1016
|
+
// `preDecrypted` is the group path handing over plaintext it already opened
|
|
1017
|
+
// with a sender chain (see #onGroupMessage). Everything after the decryption
|
|
1018
|
+
// step is shared on purpose: a group message must land in history, buffers,
|
|
1019
|
+
// receipts and the action dispatch exactly like a pairwise one, and the way to
|
|
1020
|
+
// guarantee that is for there to be only one copy of it.
|
|
1021
|
+
#onEncryptedMessage(msg, preDecrypted = null) {
|
|
873
1022
|
// Sealed sender: the relay handed us only `to` + an opaque blob. Open it
|
|
874
1023
|
// with our identity key to recover the real sender + payload; from here the
|
|
875
1024
|
// rest of the handler is unchanged. A blob that isn't for us (or is tampered)
|
|
@@ -903,51 +1052,74 @@ export class ChatController {
|
|
|
903
1052
|
return;
|
|
904
1053
|
}
|
|
905
1054
|
|
|
906
|
-
|
|
907
|
-
|
|
1055
|
+
let plaintext = preDecrypted;
|
|
1056
|
+
// A group message is never deniable — deniable sends stay pairwise, as they
|
|
1057
|
+
// do in P2P — and carries no pairwise envelope to inspect.
|
|
1058
|
+
const isDeniable = !preDecrypted && !!msg.payload.deniable;
|
|
908
1059
|
|
|
909
|
-
|
|
910
|
-
|
|
1060
|
+
if (!plaintext) {
|
|
1061
|
+
const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
|
|
1062
|
+
const nonce = Buffer.from(msg.payload.nonce, 'base64');
|
|
911
1063
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1064
|
+
// Deniable message path (symmetric crypto_secretbox)
|
|
1065
|
+
if (isDeniable) {
|
|
1066
|
+
// Anti-replay: deniable sends already use a structured NonceManager nonce.
|
|
1067
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1068
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
|
|
1069
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
|
|
1073
|
+
plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
|
|
1074
|
+
if (!plaintext) {
|
|
1075
|
+
this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, {
|
|
1076
|
+
nickname: peer.nickname,
|
|
1077
|
+
deniable: true,
|
|
1078
|
+
});
|
|
1079
|
+
this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
926
1082
|
}
|
|
927
|
-
}
|
|
928
1083
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1084
|
+
// Ratcheted message path (has ephemeralPublicKey)
|
|
1085
|
+
if (!isDeniable && msg.payload.ephemeralPublicKey) {
|
|
1086
|
+
const ratchet = this.#handshake.getRatchet(msg.from);
|
|
1087
|
+
if (ratchet) {
|
|
1088
|
+
const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
|
|
1089
|
+
plaintext = ratchet.decrypt(
|
|
1090
|
+
ciphertext,
|
|
1091
|
+
nonce,
|
|
1092
|
+
ephPub,
|
|
1093
|
+
msg.payload.counter,
|
|
1094
|
+
msg.payload.previousCounter,
|
|
1095
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
943
1098
|
|
|
944
|
-
|
|
945
|
-
|
|
1099
|
+
// Fallback to static decrypt if ratchet failed
|
|
1100
|
+
if (!plaintext) {
|
|
1101
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1102
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
1103
|
+
this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
plaintext = MessageCrypto.decryptWithFallback(
|
|
1107
|
+
ciphertext,
|
|
1108
|
+
nonce,
|
|
1109
|
+
senderPublicKey,
|
|
1110
|
+
this.#handshake.secretKey,
|
|
1111
|
+
this.#handshake.getPreviousPeerPublicKey(msg.from),
|
|
1112
|
+
this.#handshake.previousSecretKey,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
} else if (!isDeniable) {
|
|
1116
|
+
// Static message path (no ephemeralPublicKey)
|
|
946
1117
|
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
947
1118
|
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
948
|
-
this.#ui.addErrorMessage(`
|
|
1119
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
949
1120
|
return;
|
|
950
1121
|
}
|
|
1122
|
+
|
|
951
1123
|
plaintext = MessageCrypto.decryptWithFallback(
|
|
952
1124
|
ciphertext,
|
|
953
1125
|
nonce,
|
|
@@ -957,22 +1129,6 @@ export class ChatController {
|
|
|
957
1129
|
this.#handshake.previousSecretKey,
|
|
958
1130
|
);
|
|
959
1131
|
}
|
|
960
|
-
} else if (!isDeniable) {
|
|
961
|
-
// Static message path (no ephemeralPublicKey)
|
|
962
|
-
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
963
|
-
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
964
|
-
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
plaintext = MessageCrypto.decryptWithFallback(
|
|
969
|
-
ciphertext,
|
|
970
|
-
nonce,
|
|
971
|
-
senderPublicKey,
|
|
972
|
-
this.#handshake.secretKey,
|
|
973
|
-
this.#handshake.getPreviousPeerPublicKey(msg.from),
|
|
974
|
-
this.#handshake.previousSecretKey,
|
|
975
|
-
);
|
|
976
1132
|
}
|
|
977
1133
|
|
|
978
1134
|
if (!plaintext) {
|
|
@@ -1011,6 +1167,14 @@ export class ChatController {
|
|
|
1011
1167
|
return;
|
|
1012
1168
|
}
|
|
1013
1169
|
|
|
1170
|
+
// A sender key. Never surfaced to the user and never carried on the group
|
|
1171
|
+
// path itself — it has to arrive pairwise, where the envelope proves who
|
|
1172
|
+
// sent it.
|
|
1173
|
+
if (data.action === 'sk_dist') {
|
|
1174
|
+
this.#onSenderKeyDistribution(msg.from, data);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1014
1178
|
// Which buffer this belongs to (the tag rides inside the E2EE envelope).
|
|
1015
1179
|
const msgRoom = this.#roomForIncoming(data, msg.from);
|
|
1016
1180
|
const roomActive = msgRoom === this.#currentRoom;
|
|
@@ -1518,7 +1682,9 @@ export class ChatController {
|
|
|
1518
1682
|
this.#ui.addInfoMessage(
|
|
1519
1683
|
' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
|
|
1520
1684
|
);
|
|
1521
|
-
this.#ui.addInfoMessage(
|
|
1685
|
+
this.#ui.addInfoMessage(
|
|
1686
|
+
' /plugins [allow <file>] - List plugins; approve one before it runs',
|
|
1687
|
+
);
|
|
1522
1688
|
this.#ui.addInfoMessage(' /quit - Leave the chat');
|
|
1523
1689
|
this.#ui.addInfoMessage('Tip: PageUp/PageDown scroll the chat history');
|
|
1524
1690
|
this.#ui.addInfoMessage('Tip: shortcodes like :fire: become emoji — Tab autocompletes');
|
|
@@ -2603,16 +2769,17 @@ export class ChatController {
|
|
|
2603
2769
|
}
|
|
2604
2770
|
|
|
2605
2771
|
case '/plugins': {
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2772
|
+
pluginsCommand(this.#pluginManager, parts.slice(1)).then((lines) => {
|
|
2773
|
+
for (const { kind, text } of lines) {
|
|
2774
|
+
if (kind === 'error') {
|
|
2775
|
+
this.#ui.addErrorMessage(text);
|
|
2776
|
+
} else if (kind === 'system') {
|
|
2777
|
+
this.#ui.addSystemMessage(text);
|
|
2778
|
+
} else {
|
|
2779
|
+
this.#ui.addInfoMessage(text);
|
|
2780
|
+
}
|
|
2614
2781
|
}
|
|
2615
|
-
}
|
|
2782
|
+
});
|
|
2616
2783
|
break;
|
|
2617
2784
|
}
|
|
2618
2785
|
|
|
@@ -2772,7 +2939,12 @@ export class ChatController {
|
|
|
2772
2939
|
this.#nickname = newNick;
|
|
2773
2940
|
this.#ui.setNickname(newNick);
|
|
2774
2941
|
this.#connection.send(
|
|
2775
|
-
createJoin(
|
|
2942
|
+
createJoin(
|
|
2943
|
+
newNick,
|
|
2944
|
+
this.#keyManager.publicKeyB64,
|
|
2945
|
+
this.#keyManager.pqPublicKeyB64,
|
|
2946
|
+
OWN_CAPABILITIES,
|
|
2947
|
+
),
|
|
2776
2948
|
);
|
|
2777
2949
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2778
2950
|
break;
|
|
@@ -3638,6 +3810,11 @@ export class ChatController {
|
|
|
3638
3810
|
this.#historyStore.destroy();
|
|
3639
3811
|
}
|
|
3640
3812
|
this.#fileTransfer.destroy();
|
|
3813
|
+
for (const group of this.#groups.values()) {
|
|
3814
|
+
group.destroy();
|
|
3815
|
+
}
|
|
3816
|
+
this.#groups.clear();
|
|
3817
|
+
this.#groupBuffer.clear();
|
|
3641
3818
|
this.#handshake.destroy();
|
|
3642
3819
|
this.#keyManager.destroy();
|
|
3643
3820
|
this.#connection.close();
|
package/src/client/UI.js
CHANGED
|
@@ -1798,7 +1798,10 @@ export class UI extends EventEmitter {
|
|
|
1798
1798
|
this.#lines.push(line);
|
|
1799
1799
|
this.#chatLog.log(line);
|
|
1800
1800
|
this.#screen.render();
|
|
1801
|
-
|
|
1801
|
+
// The sentinel is written as an escape, not typed as a raw byte: a bare
|
|
1802
|
+
// NUL anywhere in the source makes this entire file count as binary, and
|
|
1803
|
+
// a binary file is skipped by grep and shown without a diff on GitHub.
|
|
1804
|
+
this.#lastSender = isSelfNow ? '\u0000self' : nickname;
|
|
1802
1805
|
if (!isSelfNow) {
|
|
1803
1806
|
this.#noteIncoming(mentioned || isDM);
|
|
1804
1807
|
}
|
package/src/client/index.js
CHANGED
|
@@ -210,7 +210,12 @@ await bootSequence([
|
|
|
210
210
|
'XSalsa20-Poly1305 cipher',
|
|
211
211
|
'Double Ratchet — forward secrecy',
|
|
212
212
|
'TOFU trust store',
|
|
213
|
-
{
|
|
213
|
+
{
|
|
214
|
+
label: 'Loading plugins',
|
|
215
|
+
// Only what the user approved. An unapproved file is left alone —
|
|
216
|
+
// importing it would already be running it.
|
|
217
|
+
task: () => pluginManager.loadAll(undefined, config.pluginsAllowed),
|
|
218
|
+
},
|
|
214
219
|
{
|
|
215
220
|
label: 'Connecting to relay',
|
|
216
221
|
timeoutMs: 3500,
|