ciphermesh 2.2.0 → 2.3.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/README.md +8 -0
- package/README.pt-BR.md +8 -0
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +36 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +16 -8
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/protocol/messages.js +21 -13
- package/src/protocol/validators.js +6 -1
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +15 -2
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ forwarding, survives CGNAT).
|
|
|
42
42
|
|-----|---------|----------|
|
|
43
43
|
| 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
|
|
45
|
+
| 🛡️ | **Hybrid post-quantum** | X25519 **+ ML-KEM-768** folded into the ratchet root — beats "harvest now, decrypt later" while staying ≥ classical security ([details](docs/ARCHITECTURE.md)) |
|
|
45
46
|
| 🕶️ | **Metadata resistance** | **Sealed sender** — the relay never sees who sent a message — plus fixed-bucket length padding on every ciphertext and opt-in cover traffic (`/cover`) |
|
|
46
47
|
| 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
|
|
47
48
|
| 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
|
|
@@ -94,6 +95,13 @@ Prefer a prebuilt image? Pull the relay from GHCR (published on each release):
|
|
|
94
95
|
docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
**No Node on the host?** Every release ships a standalone relay binary for
|
|
99
|
+
macOS and Linux (arm64/x64) — download it from the
|
|
100
|
+
[releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
|
|
101
|
+
`chmod +x`, run. Nothing to install. (The TUI client still needs Node/npx: it
|
|
102
|
+
depends on blessed, which resolves its widgets at runtime and cannot be
|
|
103
|
+
bundled.)
|
|
104
|
+
|
|
97
105
|
**Everyone** (including the host):
|
|
98
106
|
|
|
99
107
|
```bash
|
package/README.pt-BR.md
CHANGED
|
@@ -42,6 +42,7 @@ forwarding, imune a CGNAT).
|
|
|
42
42
|
|-----|---------|--------|
|
|
43
43
|
| 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
|
|
45
|
+
| 🛡️ | **Pós-quântico híbrido** | X25519 **+ ML-KEM-768** misturado na raiz do ratchet — vence o "grava hoje, decifra depois" mantendo segurança ≥ à clássica ([detalhes](docs/ARCHITECTURE.md)) |
|
|
45
46
|
| 🕶️ | **Resistência a metadados** | **Sealed sender** — o relay nunca vê quem enviou a mensagem — + padding de comprimento em buckets fixos em todo ciphertext e cover traffic opcional (`/cover`) |
|
|
46
47
|
| 🕵️ | **TOFU + SAS** | Alarme de troca de chave (MITM), código de 6 dígitos verificável por voz e badges de confiança **✓/✗** inline ao lado dos nomes |
|
|
47
48
|
| 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
|
|
@@ -94,6 +95,13 @@ Prefere imagem pronta? Baixe o relay do GHCR (publicado a cada release):
|
|
|
94
95
|
docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
**Sem Node na máquina?** Todo release traz um binário standalone do relay para
|
|
99
|
+
macOS e Linux (arm64/x64) — baixe da
|
|
100
|
+
[página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
|
|
101
|
+
`chmod +x`, rode. Nada para instalar. (O cliente TUI ainda precisa de Node/npx:
|
|
102
|
+
ele depende do blessed, que resolve os widgets em runtime e não pode ser
|
|
103
|
+
empacotado.)
|
|
104
|
+
|
|
97
105
|
**Todo mundo** (incluindo quem hospeda):
|
|
98
106
|
|
|
99
107
|
```bash
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Entry point for the standalone relay binary (`bun build --compile`).
|
|
2
|
+
//
|
|
3
|
+
// Server-only on purpose: the TUI client depends on blessed, which resolves
|
|
4
|
+
// its widgets through dynamic requires that no bundler can follow. The relay
|
|
5
|
+
// has no such dependency, and "run a CipherMesh relay without installing
|
|
6
|
+
// Node" is exactly what self-hosters need.
|
|
7
|
+
import '../src/server/index.js';
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -741,6 +741,42 @@ fingerprint = SHA-256(publicKey)
|
|
|
741
741
|
= "A1B2:C3D4:E5F6:7890"
|
|
742
742
|
```
|
|
743
743
|
|
|
744
|
+
### 6.10 Hybrid Post-Quantum Handshake (X25519 + ML-KEM-768)
|
|
745
|
+
|
|
746
|
+
Protects against **"harvest now, decrypt later"**: traffic recorded today stays
|
|
747
|
+
unreadable to a future quantum adversary. The construction is **hybrid** — the
|
|
748
|
+
KEM secret is folded INTO the classical root, never replacing it, so security
|
|
749
|
+
is at least that of X25519 even if ML-KEM were broken (`src/crypto/PQHybrid.js`).
|
|
750
|
+
|
|
751
|
+
```
|
|
752
|
+
Each client publishes an ML-KEM-768 key alongside its X25519 key (join,
|
|
753
|
+
peer lists, peer_joined). Per pair, at ratchet creation:
|
|
754
|
+
|
|
755
|
+
initiator (lower sessionId):
|
|
756
|
+
(ct, ss) = ML-KEM.Encaps(peer.pqPublicKey)
|
|
757
|
+
root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3")
|
|
758
|
+
→ ct rides in the message envelope (sealed to the recipient)
|
|
759
|
+
|
|
760
|
+
responder, on the first envelope carrying ct:
|
|
761
|
+
ss = ML-KEM.Decaps(ct, mySecretKey)
|
|
762
|
+
root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3") ← same value
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
**Why this can't desynchronize:** the mix happens exactly once, at ratchet
|
|
766
|
+
initialization, before any chain key is derived — never mid-stream. There is no
|
|
767
|
+
window in which one side has mixed and the other has not while messages are in
|
|
768
|
+
flight: an envelope without the `ct` simply fails its MAC (fails closed, never
|
|
769
|
+
garbles). Once the peer replies successfully, the initiator stops attaching
|
|
770
|
+
`ct` (~1KB saved per envelope).
|
|
771
|
+
|
|
772
|
+
**Compatibility:** a peer without `pqPublicKey` (pre-2.3 client) gets a
|
|
773
|
+
classical-only session — same behaviour as before. `/trustlist` shows `[PQ]`
|
|
774
|
+
next to peers whose session is hybrid.
|
|
775
|
+
|
|
776
|
+
**Not yet hybrid (documented, planned for v3.1):** sealed-sender envelopes and
|
|
777
|
+
the private-room content layer remain classical; they protect metadata and
|
|
778
|
+
room content respectively, not the message stream's forward secrecy.
|
|
779
|
+
|
|
744
780
|
### 6.9 Private Rooms (password-protected, zero-knowledge)
|
|
745
781
|
|
|
746
782
|
`/create <room> <password>` creates a room the server can gate **without ever
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ciphermesh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"docker:logs": "docker compose logs -f"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@noble/post-quantum": "0.6.1",
|
|
54
55
|
"blessed": "0.1.81",
|
|
55
56
|
"bonjour-service": "1.4.3",
|
|
56
57
|
"boxen": "8.0.1",
|
|
@@ -206,7 +206,9 @@ export class ChatController {
|
|
|
206
206
|
// attached the listener).
|
|
207
207
|
#onConnected() {
|
|
208
208
|
this.#ui.setConnectionState('online');
|
|
209
|
-
this.#connection.send(
|
|
209
|
+
this.#connection.send(
|
|
210
|
+
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
211
|
+
);
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
// ── Connection event handlers ─────────────────────────────────
|
|
@@ -536,7 +538,7 @@ export class ChatController {
|
|
|
536
538
|
// Migrate ratchet from old sessionId to new sessionId
|
|
537
539
|
this.#handshake.migrateRatchet(oldSid, peer.sessionId);
|
|
538
540
|
} else if (!oldSid) {
|
|
539
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
541
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
540
542
|
}
|
|
541
543
|
|
|
542
544
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
@@ -740,7 +742,7 @@ export class ChatController {
|
|
|
740
742
|
publicKey: peer.publicKey,
|
|
741
743
|
rooms: new Set([room]),
|
|
742
744
|
});
|
|
743
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
745
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
744
746
|
}
|
|
745
747
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
746
748
|
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
|
|
@@ -864,6 +866,7 @@ export class ChatController {
|
|
|
864
866
|
ephPub,
|
|
865
867
|
msg.payload.counter,
|
|
866
868
|
msg.payload.previousCounter,
|
|
869
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
867
870
|
);
|
|
868
871
|
}
|
|
869
872
|
|
|
@@ -1516,7 +1519,7 @@ export class ChatController {
|
|
|
1516
1519
|
break;
|
|
1517
1520
|
}
|
|
1518
1521
|
this.#ui.addInfoMessage('Trust status:');
|
|
1519
|
-
for (const p of
|
|
1522
|
+
for (const [sid, p] of this.#peers) {
|
|
1520
1523
|
const record = this.#trustStore.getPeerRecord(p.nickname);
|
|
1521
1524
|
let status;
|
|
1522
1525
|
if (!record) {
|
|
@@ -1526,8 +1529,11 @@ export class ChatController {
|
|
|
1526
1529
|
} else {
|
|
1527
1530
|
status = 'trusted (TOFU)';
|
|
1528
1531
|
}
|
|
1529
|
-
this
|
|
1532
|
+
// [PQ] = this session's ratchet root also includes an ML-KEM secret.
|
|
1533
|
+
const pq = this.#handshake.isHybrid(sid) ? ' {green-fg}[PQ]{/green-fg}' : '';
|
|
1534
|
+
this.#ui.addInfoMessage(` ${p.nickname}: ${status}${pq}`);
|
|
1530
1535
|
}
|
|
1536
|
+
this.#ui.addInfoMessage(' [PQ] = hybrid post-quantum session (X25519 + ML-KEM-768)');
|
|
1531
1537
|
break;
|
|
1532
1538
|
}
|
|
1533
1539
|
|
|
@@ -2440,7 +2446,9 @@ export class ChatController {
|
|
|
2440
2446
|
// "nickname taken"): the server still accepts a JOIN on this socket.
|
|
2441
2447
|
this.#nickname = newNick;
|
|
2442
2448
|
this.#ui.setNickname(newNick);
|
|
2443
|
-
this.#connection.send(
|
|
2449
|
+
this.#connection.send(
|
|
2450
|
+
createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
2451
|
+
);
|
|
2444
2452
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2445
2453
|
break;
|
|
2446
2454
|
}
|
|
@@ -2668,7 +2676,7 @@ export class ChatController {
|
|
|
2668
2676
|
rooms: new Set([msg.room]),
|
|
2669
2677
|
});
|
|
2670
2678
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2671
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
2679
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2672
2680
|
}
|
|
2673
2681
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
2674
2682
|
}
|
|
@@ -2703,7 +2711,7 @@ export class ChatController {
|
|
|
2703
2711
|
rooms: new Set([msg.room]),
|
|
2704
2712
|
});
|
|
2705
2713
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2706
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
2714
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2707
2715
|
}
|
|
2708
2716
|
}
|
|
2709
2717
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import sodium from 'sodium-native';
|
|
2
2
|
import { RATCHET_MAX_SKIP, RATCHET_SKIP_KEY_MAX_AGE_MS } from '../shared/constants.js';
|
|
3
3
|
import { padMessage, unpadSecure } from './MessageCrypto.js';
|
|
4
|
+
import { pqEncapsulate, pqDecapsulate, mixPQIntoRoot } from './PQHybrid.js';
|
|
4
5
|
|
|
5
6
|
const SCALARMULT_BYTES = 32;
|
|
6
7
|
const KEY_SIZE = 32;
|
|
@@ -18,14 +19,20 @@ export class DoubleRatchet {
|
|
|
18
19
|
#skippedKeys; // Map<"ephHex:counter", { msgKey, timestamp }>
|
|
19
20
|
#initialized;
|
|
20
21
|
#needSendRatchet; // true when we need a DH ratchet step before next send
|
|
22
|
+
#pqCiphertext; // KEM ct to advertise while the peer may not have mixed yet
|
|
23
|
+
#pqSecretKey; // our ML-KEM secret key (responder side)
|
|
24
|
+
#pqApplied; // true once a KEM secret is folded into the root
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* @param {string} mySessionId
|
|
24
28
|
* @param {string} peerSessionId
|
|
25
29
|
* @param {Buffer} myStaticSecretKey
|
|
26
30
|
* @param {Buffer} peerStaticPublicKey
|
|
31
|
+
* @param {object} [pq] - hybrid post-quantum material (optional)
|
|
32
|
+
* @param {Buffer} [pq.peerPublicKey] - peer's ML-KEM public key
|
|
33
|
+
* @param {Buffer} [pq.mySecretKey] - our ML-KEM secret key
|
|
27
34
|
*/
|
|
28
|
-
constructor(mySessionId, peerSessionId, myStaticSecretKey, peerStaticPublicKey) {
|
|
35
|
+
constructor(mySessionId, peerSessionId, myStaticSecretKey, peerStaticPublicKey, pq = null) {
|
|
29
36
|
this.#skippedKeys = new Map();
|
|
30
37
|
this.#sendCounter = 0;
|
|
31
38
|
this.#recvCounter = 0;
|
|
@@ -47,6 +54,29 @@ export class DoubleRatchet {
|
|
|
47
54
|
|
|
48
55
|
const isInitiator = mySessionId < peerSessionId;
|
|
49
56
|
|
|
57
|
+
// ── Hybrid post-quantum (optional) ────────────────────────
|
|
58
|
+
// The initiator encapsulates to the peer's ML-KEM key right here and
|
|
59
|
+
// folds the secret into the root BEFORE any chain is derived; the
|
|
60
|
+
// ciphertext rides along in outgoing envelopes so the responder can
|
|
61
|
+
// decapsulate and fold the same secret before its first decrypt. Because
|
|
62
|
+
// the mix only ever happens at initialization, no in-flight message can
|
|
63
|
+
// straddle the change — there is nothing to desynchronize.
|
|
64
|
+
this.#pqCiphertext = null;
|
|
65
|
+
this.#pqSecretKey = pq?.mySecretKey || null;
|
|
66
|
+
this.#pqApplied = false;
|
|
67
|
+
|
|
68
|
+
if (isInitiator && pq?.peerPublicKey) {
|
|
69
|
+
const encaps = pqEncapsulate(pq.peerPublicKey);
|
|
70
|
+
if (encaps) {
|
|
71
|
+
const mixed = mixPQIntoRoot(this.#rootKey, encaps.sharedSecret);
|
|
72
|
+
sodium.sodium_memzero(this.#rootKey);
|
|
73
|
+
this.#rootKey = mixed;
|
|
74
|
+
sodium.sodium_memzero(encaps.sharedSecret);
|
|
75
|
+
this.#pqCiphertext = encaps.ciphertext;
|
|
76
|
+
this.#pqApplied = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
if (isInitiator) {
|
|
51
81
|
// Initiator generates ephemeral keypair immediately
|
|
52
82
|
this.#myEphKeyPair = this.#generateEphemeralKeyPair();
|
|
@@ -185,13 +215,24 @@ export class DoubleRatchet {
|
|
|
185
215
|
// Wipe message key immediately
|
|
186
216
|
sodium.sodium_memzero(messageKey);
|
|
187
217
|
|
|
188
|
-
|
|
218
|
+
const envelope = {
|
|
189
219
|
ciphertext,
|
|
190
220
|
nonce,
|
|
191
221
|
ephemeralPublicKey: this.#myEphKeyPair.publicKey,
|
|
192
222
|
counter,
|
|
193
223
|
previousCounter: this.#previousSendCount,
|
|
194
224
|
};
|
|
225
|
+
// Keep advertising the KEM ciphertext until the peer answers (their reply
|
|
226
|
+
// proves they folded it in). Cheap: ~1KB on a handful of messages.
|
|
227
|
+
if (this.#pqCiphertext) {
|
|
228
|
+
envelope.pqCiphertext = this.#pqCiphertext;
|
|
229
|
+
}
|
|
230
|
+
return envelope;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** True when this ratchet's root includes an ML-KEM secret. */
|
|
234
|
+
get isHybrid() {
|
|
235
|
+
return this.#pqApplied;
|
|
195
236
|
}
|
|
196
237
|
|
|
197
238
|
// ── Decrypt ─────────────────────────────────────────────────
|
|
@@ -203,13 +244,27 @@ export class DoubleRatchet {
|
|
|
203
244
|
* @param {Buffer} ephPub - sender's ephemeral public key
|
|
204
245
|
* @param {number} counter
|
|
205
246
|
* @param {number} prevCounter
|
|
247
|
+
* @param {Buffer} [pqCt] - sender's ML-KEM ciphertext (hybrid handshake)
|
|
206
248
|
* @returns {Buffer|null} plaintext or null on failure
|
|
207
249
|
*/
|
|
208
|
-
decrypt(ciphertext, nonce, ephPub, counter, prevCounter) {
|
|
250
|
+
decrypt(ciphertext, nonce, ephPub, counter, prevCounter, pqCt = null) {
|
|
209
251
|
if (!this.#initialized) {
|
|
210
252
|
return null;
|
|
211
253
|
}
|
|
212
254
|
|
|
255
|
+
// Hybrid: fold the peer's KEM secret into the root before deriving any
|
|
256
|
+
// chain from it. Once only — the initiator did the same at construction.
|
|
257
|
+
if (pqCt && !this.#pqApplied && this.#pqSecretKey) {
|
|
258
|
+
const ss = pqDecapsulate(pqCt, this.#pqSecretKey);
|
|
259
|
+
if (ss) {
|
|
260
|
+
const mixed = mixPQIntoRoot(this.#rootKey, ss);
|
|
261
|
+
sodium.sodium_memzero(this.#rootKey);
|
|
262
|
+
this.#rootKey = mixed;
|
|
263
|
+
sodium.sodium_memzero(ss);
|
|
264
|
+
this.#pqApplied = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
213
268
|
// Reject malformed inputs before any allocation or crypto. A short
|
|
214
269
|
// ciphertext (< MAC), wrong-size nonce/key, or non-integer counter from a
|
|
215
270
|
// hostile peer must return null — never crash (Buffer.alloc(-2)) or desync.
|
|
@@ -347,6 +402,10 @@ export class DoubleRatchet {
|
|
|
347
402
|
this.#recvChainKey = txChainKey;
|
|
348
403
|
this.#recvCounter = txCounter;
|
|
349
404
|
|
|
405
|
+
// A message we could actually decrypt proves the peer's root matches ours,
|
|
406
|
+
// so they already folded the KEM secret — stop paying ~1KB per envelope.
|
|
407
|
+
this.#pqCiphertext = null;
|
|
408
|
+
|
|
350
409
|
this.#cleanupSkippedKeys();
|
|
351
410
|
return result;
|
|
352
411
|
}
|
|
@@ -456,6 +515,8 @@ export class DoubleRatchet {
|
|
|
456
515
|
}
|
|
457
516
|
this.#skippedKeys.clear();
|
|
458
517
|
this.#peerEphPublicKey = null;
|
|
518
|
+
this.#pqCiphertext = null;
|
|
519
|
+
this.#pqSecretKey = null;
|
|
459
520
|
this.#initialized = false;
|
|
460
521
|
}
|
|
461
522
|
|
|
@@ -490,6 +551,10 @@ export class DoubleRatchet {
|
|
|
490
551
|
initialized: this.#initialized,
|
|
491
552
|
needSendRatchet: this.#needSendRatchet,
|
|
492
553
|
skippedKeys: skipped,
|
|
554
|
+
// Hybrid state: the root is already mixed, so `pqApplied` must survive
|
|
555
|
+
// to keep a restored session from folding a second secret in.
|
|
556
|
+
pqApplied: this.#pqApplied,
|
|
557
|
+
pqCiphertext: this.#pqCiphertext?.toString('base64') || null,
|
|
493
558
|
};
|
|
494
559
|
}
|
|
495
560
|
|
|
@@ -533,6 +598,8 @@ export class DoubleRatchet {
|
|
|
533
598
|
r.#sendCounter = data.sendCounter;
|
|
534
599
|
r.#recvCounter = data.recvCounter;
|
|
535
600
|
r.#previousSendCount = data.previousSendCount;
|
|
601
|
+
r.#pqApplied = !!data.pqApplied;
|
|
602
|
+
r.#pqCiphertext = data.pqCiphertext ? Buffer.from(data.pqCiphertext, 'base64') : null;
|
|
536
603
|
|
|
537
604
|
if (r.#myEphKeyPair) {
|
|
538
605
|
sodium.sodium_memzero(r.#myEphKeyPair.secretKey);
|
package/src/crypto/Handshake.js
CHANGED
|
@@ -5,6 +5,7 @@ import { DoubleRatchet } from './DoubleRatchet.js';
|
|
|
5
5
|
export class Handshake {
|
|
6
6
|
#keyManager;
|
|
7
7
|
#peerKeys; // Map<sessionId, Buffer(publicKey)>
|
|
8
|
+
#peerPQKeys; // Map<sessionId, Buffer(ML-KEM public key)>
|
|
8
9
|
#previousPeerKeys; // Map<sessionId, { publicKey, timer }>
|
|
9
10
|
#ratchets; // Map<sessionId, DoubleRatchet>
|
|
10
11
|
#mySessionId;
|
|
@@ -12,11 +13,26 @@ export class Handshake {
|
|
|
12
13
|
constructor(keyManager) {
|
|
13
14
|
this.#keyManager = keyManager;
|
|
14
15
|
this.#peerKeys = new Map();
|
|
16
|
+
this.#peerPQKeys = new Map();
|
|
15
17
|
this.#previousPeerKeys = new Map();
|
|
16
18
|
this.#ratchets = new Map();
|
|
17
19
|
this.#mySessionId = null;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
// Hybrid material handed to every new ratchet: the peer's KEM public key
|
|
23
|
+
// (absent for pre-v3 clients → classical-only) plus our KEM secret key.
|
|
24
|
+
#pqFor(peerId) {
|
|
25
|
+
return {
|
|
26
|
+
peerPublicKey: this.#peerPQKeys.get(peerId) || null,
|
|
27
|
+
mySecretKey: this.#keyManager.pqSecretKey || null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when this peer's ratchet root includes an ML-KEM secret. */
|
|
32
|
+
isHybrid(peerId) {
|
|
33
|
+
return this.#ratchets.get(peerId)?.isHybrid === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* Set our session ID (called after JOIN_ACK).
|
|
22
38
|
* Also initializes ratchets for any already-registered peers.
|
|
@@ -29,7 +45,13 @@ export class Handshake {
|
|
|
29
45
|
if (!this.#ratchets.has(peerId)) {
|
|
30
46
|
this.#ratchets.set(
|
|
31
47
|
peerId,
|
|
32
|
-
new DoubleRatchet(
|
|
48
|
+
new DoubleRatchet(
|
|
49
|
+
this.#mySessionId,
|
|
50
|
+
peerId,
|
|
51
|
+
this.#keyManager.secretKey,
|
|
52
|
+
pubKey,
|
|
53
|
+
this.#pqFor(peerId),
|
|
54
|
+
),
|
|
33
55
|
);
|
|
34
56
|
}
|
|
35
57
|
}
|
|
@@ -37,8 +59,9 @@ export class Handshake {
|
|
|
37
59
|
|
|
38
60
|
/**
|
|
39
61
|
* Register a peer's public key for future encryption/decryption.
|
|
62
|
+
* @param {string|Buffer} [peerPQPublicKey] - their ML-KEM key (hybrid)
|
|
40
63
|
*/
|
|
41
|
-
registerPeer(peerId, peerPublicKey) {
|
|
64
|
+
registerPeer(peerId, peerPublicKey, peerPQPublicKey = null) {
|
|
42
65
|
const pubBuf = Buffer.isBuffer(peerPublicKey)
|
|
43
66
|
? peerPublicKey
|
|
44
67
|
: Buffer.from(peerPublicKey, 'base64');
|
|
@@ -48,12 +71,24 @@ export class Handshake {
|
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
this.#peerKeys.set(peerId, Buffer.from(pubBuf));
|
|
74
|
+
if (peerPQPublicKey) {
|
|
75
|
+
const pq = Buffer.isBuffer(peerPQPublicKey)
|
|
76
|
+
? peerPQPublicKey
|
|
77
|
+
: Buffer.from(peerPQPublicKey, 'base64');
|
|
78
|
+
this.#peerPQKeys.set(peerId, pq);
|
|
79
|
+
}
|
|
51
80
|
|
|
52
81
|
// Create ratchet if we already have our session ID
|
|
53
82
|
if (this.#mySessionId && !this.#ratchets.has(peerId)) {
|
|
54
83
|
this.#ratchets.set(
|
|
55
84
|
peerId,
|
|
56
|
-
new DoubleRatchet(
|
|
85
|
+
new DoubleRatchet(
|
|
86
|
+
this.#mySessionId,
|
|
87
|
+
peerId,
|
|
88
|
+
this.#keyManager.secretKey,
|
|
89
|
+
pubBuf,
|
|
90
|
+
this.#pqFor(peerId),
|
|
91
|
+
),
|
|
57
92
|
);
|
|
58
93
|
}
|
|
59
94
|
}
|
|
@@ -130,6 +165,7 @@ export class Handshake {
|
|
|
130
165
|
*/
|
|
131
166
|
removePeer(peerId) {
|
|
132
167
|
this.#peerKeys.delete(peerId);
|
|
168
|
+
this.#peerPQKeys.delete(peerId);
|
|
133
169
|
const prev = this.#previousPeerKeys.get(peerId);
|
|
134
170
|
if (prev) {
|
|
135
171
|
clearTimeout(prev.timer);
|
|
@@ -157,6 +193,11 @@ export class Handshake {
|
|
|
157
193
|
this.#peerKeys.delete(oldPeerId);
|
|
158
194
|
this.#peerKeys.set(newPeerId, peerKey);
|
|
159
195
|
}
|
|
196
|
+
const pqKey = this.#peerPQKeys.get(oldPeerId);
|
|
197
|
+
if (pqKey) {
|
|
198
|
+
this.#peerPQKeys.delete(oldPeerId);
|
|
199
|
+
this.#peerPQKeys.set(newPeerId, pqKey);
|
|
200
|
+
}
|
|
160
201
|
const prevKey = this.#previousPeerKeys.get(oldPeerId);
|
|
161
202
|
if (prevKey) {
|
|
162
203
|
this.#previousPeerKeys.delete(oldPeerId);
|
package/src/crypto/KeyManager.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import sodium from 'sodium-native';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
|
|
5
|
+
import { generatePQKeyPair } from './PQHybrid.js';
|
|
5
6
|
|
|
6
7
|
export class KeyManager {
|
|
7
8
|
#publicKey;
|
|
@@ -10,6 +11,7 @@ export class KeyManager {
|
|
|
10
11
|
#previousPublicKey;
|
|
11
12
|
#previousSecretKey;
|
|
12
13
|
#graceTimer;
|
|
14
|
+
#pqKeyPair; // ML-KEM-768 — the hybrid half (see crypto/PQHybrid.js)
|
|
13
15
|
|
|
14
16
|
constructor() {
|
|
15
17
|
this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
|
|
@@ -19,10 +21,25 @@ export class KeyManager {
|
|
|
19
21
|
this.#graceTimer = null;
|
|
20
22
|
|
|
21
23
|
sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
|
|
24
|
+
this.#pqKeyPair = generatePQKeyPair();
|
|
22
25
|
|
|
23
26
|
this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// The identity fingerprint stays X25519-only on purpose: it is what users
|
|
30
|
+
// already verified out-of-band, and the KEM key adds no authentication.
|
|
31
|
+
get pqPublicKey() {
|
|
32
|
+
return this.#pqKeyPair.publicKey;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get pqPublicKeyB64() {
|
|
36
|
+
return this.#pqKeyPair.publicKey.toString('base64');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get pqSecretKey() {
|
|
40
|
+
return this.#pqKeyPair.secretKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
get publicKey() {
|
|
27
44
|
return this.#publicKey;
|
|
28
45
|
}
|
|
@@ -114,6 +131,8 @@ export class KeyManager {
|
|
|
114
131
|
return {
|
|
115
132
|
publicKey: this.#publicKey.toString('base64'),
|
|
116
133
|
secretKey: this.#secretKey.toString('base64'),
|
|
134
|
+
pqPublicKey: this.#pqKeyPair.publicKey.toString('base64'),
|
|
135
|
+
pqSecretKey: this.#pqKeyPair.secretKey.toString('base64'),
|
|
117
136
|
};
|
|
118
137
|
}
|
|
119
138
|
|
|
@@ -129,6 +148,14 @@ export class KeyManager {
|
|
|
129
148
|
km.#fingerprint = KeyManager.computeFingerprint(km.#publicKey);
|
|
130
149
|
km.#previousPublicKey = null;
|
|
131
150
|
km.#previousSecretKey = null;
|
|
151
|
+
// Restore the KEM pair when present; older backups simply get the fresh
|
|
152
|
+
// one generated above (peers re-encapsulate on the next handshake).
|
|
153
|
+
if (data.pqPublicKey && data.pqSecretKey) {
|
|
154
|
+
km.#pqKeyPair = {
|
|
155
|
+
publicKey: Buffer.from(data.pqPublicKey, 'base64'),
|
|
156
|
+
secretKey: Buffer.from(data.pqSecretKey, 'base64'),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
132
159
|
return km;
|
|
133
160
|
}
|
|
134
161
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Hybrid post-quantum layer: ML-KEM-768 (FIPS 203) mixed INTO the classical
|
|
2
|
+
// X25519 root key, never replacing it. Security is therefore >= the classical
|
|
3
|
+
// construction: an attacker must break BOTH to read a message. This is the
|
|
4
|
+
// conservative pattern Signal (PQXDH) and Apple (PQ3) adopted, and it targets
|
|
5
|
+
// "harvest now, decrypt later" — traffic recorded today stays unreadable to a
|
|
6
|
+
// future quantum adversary.
|
|
7
|
+
//
|
|
8
|
+
// sodium-native has no ML-KEM, so this uses @noble/post-quantum (audited,
|
|
9
|
+
// pure JS). Only the KEM comes from there; all symmetric crypto stays libsodium.
|
|
10
|
+
import sodium from 'sodium-native';
|
|
11
|
+
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
|
|
12
|
+
|
|
13
|
+
export const PQ_PUBLIC_KEY_SIZE = 1184; // ML-KEM-768 encapsulation key
|
|
14
|
+
export const PQ_SECRET_KEY_SIZE = 2400; // ML-KEM-768 decapsulation key
|
|
15
|
+
export const PQ_CIPHERTEXT_SIZE = 1088;
|
|
16
|
+
export const PQ_SHARED_SECRET_SIZE = 32;
|
|
17
|
+
|
|
18
|
+
const PQ_MIX_CONTEXT = 'ciphermesh/pq-hybrid-v3';
|
|
19
|
+
|
|
20
|
+
/** Generate an ML-KEM-768 keypair. Buffers, to match the rest of the codebase. */
|
|
21
|
+
export function generatePQKeyPair() {
|
|
22
|
+
const { publicKey, secretKey } = ml_kem768.keygen();
|
|
23
|
+
return { publicKey: Buffer.from(publicKey), secretKey: Buffer.from(secretKey) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encapsulate to a peer's ML-KEM public key.
|
|
28
|
+
* @returns {{ ciphertext: Buffer, sharedSecret: Buffer }|null} null on bad input
|
|
29
|
+
*/
|
|
30
|
+
export function pqEncapsulate(peerPublicKey) {
|
|
31
|
+
try {
|
|
32
|
+
const pk = Buffer.isBuffer(peerPublicKey)
|
|
33
|
+
? peerPublicKey
|
|
34
|
+
: Buffer.from(peerPublicKey, 'base64');
|
|
35
|
+
if (pk.length !== PQ_PUBLIC_KEY_SIZE) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const { cipherText, sharedSecret } = ml_kem768.encapsulate(new Uint8Array(pk));
|
|
39
|
+
return { ciphertext: Buffer.from(cipherText), sharedSecret: Buffer.from(sharedSecret) };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decapsulate a ciphertext with our ML-KEM secret key.
|
|
47
|
+
* @returns {Buffer|null} 32-byte shared secret, or null if it doesn't apply
|
|
48
|
+
*/
|
|
49
|
+
export function pqDecapsulate(ciphertext, secretKey) {
|
|
50
|
+
try {
|
|
51
|
+
const ct = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext, 'base64');
|
|
52
|
+
if (ct.length !== PQ_CIPHERTEXT_SIZE || !secretKey) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const ss = ml_kem768.decapsulate(new Uint8Array(ct), new Uint8Array(secretKey));
|
|
56
|
+
return Buffer.from(ss);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fold a KEM shared secret into an existing root key:
|
|
64
|
+
* root' = BLAKE2b(root ‖ ss ‖ context)
|
|
65
|
+
*
|
|
66
|
+
* Because the classical root is an input, the result is at least as strong as
|
|
67
|
+
* the classical key even if ML-KEM were broken outright. Returns a NEW locked
|
|
68
|
+
* buffer; the caller owns zeroing the old root.
|
|
69
|
+
*/
|
|
70
|
+
export function mixPQIntoRoot(rootKey, sharedSecret) {
|
|
71
|
+
const input = Buffer.concat([rootKey, sharedSecret, Buffer.from(PQ_MIX_CONTEXT, 'utf-8')]);
|
|
72
|
+
const mixed = sodium.sodium_malloc(32);
|
|
73
|
+
sodium.crypto_generichash(mixed, input);
|
|
74
|
+
sodium.sodium_memzero(input);
|
|
75
|
+
return mixed;
|
|
76
|
+
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -45,8 +45,14 @@ function base(type) {
|
|
|
45
45
|
return { type, version: PROTOCOL_VERSION, timestamp: Date.now() };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
|
|
49
|
+
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
50
|
+
export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null) {
|
|
51
|
+
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
52
|
+
if (pqPublicKeyB64) {
|
|
53
|
+
msg.pqPublicKey = pqPublicKeyB64;
|
|
54
|
+
}
|
|
55
|
+
return msg;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
|
|
@@ -85,18 +91,20 @@ export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
export function createRatchetedMessage(from, to, payload) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
counter: payload.counter,
|
|
95
|
-
previousCounter: payload.previousCounter,
|
|
96
|
-
ciphertext: payload.ciphertext.toString('base64'),
|
|
97
|
-
nonce: payload.nonce.toString('base64'),
|
|
98
|
-
},
|
|
94
|
+
const inner = {
|
|
95
|
+
ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
|
|
96
|
+
counter: payload.counter,
|
|
97
|
+
previousCounter: payload.previousCounter,
|
|
98
|
+
ciphertext: payload.ciphertext.toString('base64'),
|
|
99
|
+
nonce: payload.nonce.toString('base64'),
|
|
99
100
|
};
|
|
101
|
+
// Hybrid PQ: the KEM ciphertext rides along until the peer has folded it in.
|
|
102
|
+
// It is sealed to the recipient like everything else — the relay sees only
|
|
103
|
+
// an opaque blob.
|
|
104
|
+
if (payload.pqCiphertext) {
|
|
105
|
+
inner.pqCiphertext = payload.pqCiphertext.toString('base64');
|
|
106
|
+
}
|
|
107
|
+
return { ...base(MSG.ENCRYPTED_MESSAGE), from, to, payload: inner };
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
// Sealed-sender envelope (protocol v2): the relay sees only the recipient and an
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ROOM_AUTH_SIG_SIZE,
|
|
8
8
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
9
9
|
} from '../shared/constants.js';
|
|
10
|
+
import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
|
|
10
11
|
|
|
11
12
|
// ── Helpers ────────────────────────────────────────────────────
|
|
12
13
|
function isString(v) {
|
|
@@ -86,7 +87,11 @@ export function validateJoin(msg) {
|
|
|
86
87
|
if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
|
|
87
88
|
return { valid: false, error: 'Invalid public key' };
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
+
// Optional ML-KEM-768 key (hybrid PQ). Absent = classical-only client.
|
|
91
|
+
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
92
|
+
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
93
|
+
}
|
|
94
|
+
return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
// Sealed sender (protocol v2): the relay only ever sees the recipient and an
|
|
@@ -30,12 +30,13 @@ export class SessionManager {
|
|
|
30
30
|
return this.#nicknames.has(nickname.toLowerCase());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
addSession(ws, nickname, publicKey, room = 'general') {
|
|
33
|
+
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
|
|
34
34
|
const sessionId = randomUUID();
|
|
35
35
|
const session = {
|
|
36
36
|
ws,
|
|
37
37
|
nickname,
|
|
38
38
|
publicKey,
|
|
39
|
+
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
39
40
|
connectedAt: Date.now(),
|
|
40
41
|
rooms: new Set(),
|
|
41
42
|
};
|
|
@@ -102,6 +103,7 @@ export class SessionManager {
|
|
|
102
103
|
sessionId: id,
|
|
103
104
|
nickname: session.nickname,
|
|
104
105
|
publicKey: session.publicKey,
|
|
106
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
}
|
|
@@ -259,7 +259,13 @@ export class SecureWSServer {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
const room = 'general';
|
|
262
|
-
const sessionId = this.#sessionManager.addSession(
|
|
262
|
+
const sessionId = this.#sessionManager.addSession(
|
|
263
|
+
ws,
|
|
264
|
+
validation.nickname,
|
|
265
|
+
msg.publicKey,
|
|
266
|
+
room,
|
|
267
|
+
validation.pqPublicKey,
|
|
268
|
+
);
|
|
263
269
|
ws.sessionId = sessionId;
|
|
264
270
|
ws.hasJoined = true;
|
|
265
271
|
clearTimeout(ws.joinTimer);
|
|
@@ -292,6 +298,7 @@ export class SecureWSServer {
|
|
|
292
298
|
sessionId,
|
|
293
299
|
nickname: validation.nickname,
|
|
294
300
|
publicKey: msg.publicKey,
|
|
301
|
+
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
295
302
|
}),
|
|
296
303
|
sessionId,
|
|
297
304
|
);
|
|
@@ -508,7 +515,12 @@ export class SecureWSServer {
|
|
|
508
515
|
this.#sessionManager.broadcastToRoom(
|
|
509
516
|
room,
|
|
510
517
|
createPeerJoined(
|
|
511
|
-
{
|
|
518
|
+
{
|
|
519
|
+
sessionId: ws.sessionId,
|
|
520
|
+
nickname: session.nickname,
|
|
521
|
+
publicKey: session.publicKey,
|
|
522
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
523
|
+
},
|
|
512
524
|
room,
|
|
513
525
|
),
|
|
514
526
|
ws.sessionId,
|
|
@@ -642,6 +654,7 @@ export class SecureWSServer {
|
|
|
642
654
|
sessionId: ws.sessionId,
|
|
643
655
|
nickname: session.nickname,
|
|
644
656
|
publicKey: session.publicKey,
|
|
657
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
645
658
|
},
|
|
646
659
|
result.newRoom,
|
|
647
660
|
),
|