ciphermesh 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -0
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/docs/ARCHITECTURE.md +12 -6
- package/docs/PROTOCOL.md +230 -5
- package/docs/commands.json +13 -5
- package/docs/design/multi-device.md +290 -0
- package/docs/design/sender-keys-on-relay.md +34 -3
- package/package.json +4 -4
- package/src/client/ChatController.js +963 -43
- package/src/crypto/DeviceIdentity.js +307 -0
- package/src/crypto/KeyManager.js +176 -4
- package/src/crypto/SenderKey.js +44 -9
- package/src/crypto/TrustStore.js +153 -0
- package/src/p2p/P2PChatController.js +73 -0
- package/src/protocol/messages.js +36 -3
- package/src/protocol/validators.js +16 -0
- package/src/server/SessionManager.js +63 -6
- package/src/server/WebSocketServer.js +68 -4
- package/src/shared/constants.js +9 -1
- package/src/shared/deviceProvisioning.js +112 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
|
|
3
|
+
// ── Device identity ─────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Step 1 of multi-device (docs/design/multi-device.md, item 4 of #481). This
|
|
6
|
+
// module has no callers on purpose. The sender-key rollout worked because the
|
|
7
|
+
// asymmetric half landed before the wire that carries it existed — "it cost a
|
|
8
|
+
// field on a wire nobody was using yet" — and the same trick applies here.
|
|
9
|
+
//
|
|
10
|
+
// The model, in one paragraph. Today the X25519 box key *is* the identity: it
|
|
11
|
+
// is what a fingerprint is computed from, what a SAS compares, and what a ban
|
|
12
|
+
// keys on. That works for one machine and breaks for two, because two machines
|
|
13
|
+
// can only share it by sharing the secret — which is what `/backup` plus the
|
|
14
|
+
// restore prompt actually does today, and it cannot be revoked. So identity
|
|
15
|
+
// moves up a level: a long-term **Ed25519 key that only ever signs**, and one
|
|
16
|
+
// **X25519 box key per device**, listed and signed by it. A device is added by
|
|
17
|
+
// signing a longer list; revoked by signing a shorter one with a higher
|
|
18
|
+
// counter.
|
|
19
|
+
//
|
|
20
|
+
// Nothing here encrypts. That separation is the point: a signing key that never
|
|
21
|
+
// touches a message is a key that can be kept somewhere a message key cannot.
|
|
22
|
+
|
|
23
|
+
const DEVICE_ID_SIZE = 16;
|
|
24
|
+
const MAX_DEVICES = 8;
|
|
25
|
+
const MAX_LABEL_LENGTH = 32;
|
|
26
|
+
|
|
27
|
+
// Domain separation. Two different structures must never produce the same
|
|
28
|
+
// bytes to sign, or a signature over one is a signature over the other.
|
|
29
|
+
const LIST_DOMAIN = 'CipherMesh-DeviceList-v1';
|
|
30
|
+
const FINGERPRINT_DOMAIN = 'CipherMesh-IdentityFingerprint-v1';
|
|
31
|
+
|
|
32
|
+
// Release a key allocated with sodium_malloc.
|
|
33
|
+
//
|
|
34
|
+
// Same reasoning as SenderKey.js: sodium_malloc'd pages are mlock'd, the OS
|
|
35
|
+
// caps how much a process may lock, and zeroing alone leaves them locked until
|
|
36
|
+
// a finaliser happens to run. sodium_free zeroes before releasing.
|
|
37
|
+
function freeKey(buf) {
|
|
38
|
+
if (buf) {
|
|
39
|
+
sodium.sodium_free(buf);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A stable name for one device, drawn once and kept.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately *not* derived from the device's box key. A box key rotates
|
|
47
|
+
* (`KeyManager.rotate`) and the device is still the same device; an id derived
|
|
48
|
+
* from the key would rename it on every rotation, which is precisely when a
|
|
49
|
+
* peer most needs to recognise it. Forging an id buys nothing on its own —
|
|
50
|
+
* only a list signed by the identity key binds an id to a key.
|
|
51
|
+
*/
|
|
52
|
+
export function newDeviceId() {
|
|
53
|
+
const buf = Buffer.alloc(DEVICE_ID_SIZE);
|
|
54
|
+
sodium.randombytes_buf(buf);
|
|
55
|
+
return buf.toString('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The long-term signing key. Signs device lists; never encrypts anything.
|
|
60
|
+
*/
|
|
61
|
+
export class DeviceIdentity {
|
|
62
|
+
#publicKey;
|
|
63
|
+
#secretKey;
|
|
64
|
+
|
|
65
|
+
constructor(secretKey = null) {
|
|
66
|
+
this.#publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
|
67
|
+
this.#secretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
|
|
68
|
+
|
|
69
|
+
if (secretKey) {
|
|
70
|
+
// An Ed25519 secret key carries its public half in the last 32 bytes, so
|
|
71
|
+
// restoring one cannot disagree with the public key it is paired with.
|
|
72
|
+
secretKey.copy(this.#secretKey);
|
|
73
|
+
this.#secretKey
|
|
74
|
+
.subarray(sodium.crypto_sign_SECRETKEYBYTES - sodium.crypto_sign_PUBLICKEYBYTES)
|
|
75
|
+
.copy(this.#publicKey);
|
|
76
|
+
} else {
|
|
77
|
+
sodium.crypto_sign_keypair(this.#publicKey, this.#secretKey);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get publicKey() {
|
|
82
|
+
return this.#publicKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get publicKeyB64() {
|
|
86
|
+
return this.#publicKey.toString('base64');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get fingerprint() {
|
|
90
|
+
return identityFingerprint(this.#publicKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Detached signature over arbitrary bytes. */
|
|
94
|
+
sign(bytes) {
|
|
95
|
+
const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
96
|
+
sodium.crypto_sign_detached(signature, bytes, this.#secretKey);
|
|
97
|
+
return signature;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
serialize() {
|
|
101
|
+
return { secretKey: this.#secretKey.toString('base64') };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static deserialize(data) {
|
|
105
|
+
const secretKey = decodeKey(data?.secretKey, sodium.crypto_sign_SECRETKEYBYTES);
|
|
106
|
+
return secretKey ? new DeviceIdentity(secretKey) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
destroy() {
|
|
110
|
+
freeKey(this.#secretKey);
|
|
111
|
+
this.#secretKey = null;
|
|
112
|
+
this.#publicKey = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The identity fingerprint, as a human compares it.
|
|
118
|
+
*
|
|
119
|
+
* 128 bits, not the 32 the box-key fingerprint uses. That difference is
|
|
120
|
+
* deliberate: `KeyManager.computeFingerprint` is a display aid sitting next to
|
|
121
|
+
* a 40-bit SAS that does the real work, whereas the plan (step 4) is for *this*
|
|
122
|
+
* value to become what a person verifies. `computeSAS` already carries a note
|
|
123
|
+
* that 20 bits was grindable offline; 32 is not enough to inherit that job.
|
|
124
|
+
*/
|
|
125
|
+
export function identityFingerprint(publicKey) {
|
|
126
|
+
const key = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey, 'base64');
|
|
127
|
+
const hash = Buffer.alloc(32);
|
|
128
|
+
sodium.crypto_generichash(hash, Buffer.concat([Buffer.from(FINGERPRINT_DOMAIN), key]));
|
|
129
|
+
|
|
130
|
+
const parts = [];
|
|
131
|
+
for (let i = 0; i < 16; i += 2) {
|
|
132
|
+
parts.push(
|
|
133
|
+
hash
|
|
134
|
+
.subarray(i, i + 2)
|
|
135
|
+
.toString('hex')
|
|
136
|
+
.toUpperCase(),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return parts.join(':');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function decodeKey(b64, size) {
|
|
143
|
+
if (typeof b64 !== 'string') {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let buf;
|
|
147
|
+
try {
|
|
148
|
+
buf = Buffer.from(b64, 'base64');
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return buf.length === size ? buf : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Length-prefixed on the *byte* count, so a label with a multi-byte character
|
|
156
|
+
// cannot make two different lists serialise the same way. The separator is
|
|
157
|
+
// cosmetic — the prefixes are what make the encoding injective.
|
|
158
|
+
const lp = (value) => `${Buffer.byteLength(String(value), 'utf-8')}:${value}`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What the signature covers.
|
|
162
|
+
*
|
|
163
|
+
* Everything a relay or a peer could tamper with: which identity the list
|
|
164
|
+
* belongs to, its position in the sequence, how many devices it names, and
|
|
165
|
+
* every field of every device. The count is in there so devices cannot be
|
|
166
|
+
* dropped from the end without breaking the signature.
|
|
167
|
+
*
|
|
168
|
+
* The ML-KEM key is deliberately *not* in a descriptor. A list is a set of
|
|
169
|
+
* claims about identity, and a KEM key is not one — it is transport material,
|
|
170
|
+
* already advertised per session in JOIN, and a device could change it without
|
|
171
|
+
* changing who it is. Carrying it here also cost 1584 bytes of base64 per
|
|
172
|
+
* device, which is the difference between a provisioning grant that fits in a
|
|
173
|
+
* QR code and one that does not.
|
|
174
|
+
*/
|
|
175
|
+
export function deviceListBytes({ identityPk, counter, devices }) {
|
|
176
|
+
const parts = [LIST_DOMAIN, identityPk, String(counter), String(devices.length)];
|
|
177
|
+
for (const device of devices) {
|
|
178
|
+
parts.push(device.deviceId, device.boxPk, device.label, String(device.createdAt));
|
|
179
|
+
}
|
|
180
|
+
return Buffer.from(parts.map(lp).join('|'), 'utf-8');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sign a set of devices as the current list for this identity.
|
|
185
|
+
*
|
|
186
|
+
* `counter` is the caller's responsibility and must only ever go up — see
|
|
187
|
+
* `isNewerList`. A list is a statement about *now*, so adding a device and
|
|
188
|
+
* revoking one are the same operation with a different array.
|
|
189
|
+
*/
|
|
190
|
+
export function signDeviceList(identity, counter, devices) {
|
|
191
|
+
const list = {
|
|
192
|
+
identityPk: identity.publicKeyB64,
|
|
193
|
+
counter,
|
|
194
|
+
devices: devices.map(normaliseDevice),
|
|
195
|
+
};
|
|
196
|
+
const signature = identity.sign(deviceListBytes(list));
|
|
197
|
+
return { ...list, signature: signature.toString('base64') };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normaliseDevice(device) {
|
|
201
|
+
return {
|
|
202
|
+
deviceId: device.deviceId,
|
|
203
|
+
boxPk: device.boxPk,
|
|
204
|
+
label: device.label ?? '',
|
|
205
|
+
createdAt: device.createdAt,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read a device list that arrived from somewhere else.
|
|
211
|
+
*
|
|
212
|
+
* Returns the normalised list, or `null`. Never throws and never partially
|
|
213
|
+
* accepts: a list is one signed statement, so half of it is not usable and
|
|
214
|
+
* "the good devices out of a bad list" is exactly the shape of bug the
|
|
215
|
+
* capability validator was written to avoid.
|
|
216
|
+
*
|
|
217
|
+
* The signature is checked *last*, after the structure, because verifying a
|
|
218
|
+
* signature over a shape we would reject anyway is work an unauthenticated
|
|
219
|
+
* peer gets to ask for.
|
|
220
|
+
*/
|
|
221
|
+
export function verifyDeviceList(envelope) {
|
|
222
|
+
if (!envelope || typeof envelope !== 'object') {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const identityKey = decodeKey(envelope.identityPk, sodium.crypto_sign_PUBLICKEYBYTES);
|
|
227
|
+
const signature = decodeKey(envelope.signature, sodium.crypto_sign_BYTES);
|
|
228
|
+
if (!identityKey || !signature) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!Number.isSafeInteger(envelope.counter) || envelope.counter < 0) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!Array.isArray(envelope.devices) || envelope.devices.length === 0) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
if (envelope.devices.length > MAX_DEVICES) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const devices = [];
|
|
244
|
+
const ids = new Set();
|
|
245
|
+
const boxKeys = new Set();
|
|
246
|
+
|
|
247
|
+
for (const device of envelope.devices) {
|
|
248
|
+
if (!device || typeof device !== 'object') {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (typeof device.deviceId !== 'string' || !/^[0-9a-f]{32}$/.test(device.deviceId)) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
if (!decodeKey(device.boxPk, sodium.crypto_box_PUBLICKEYBYTES)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (typeof device.label !== 'string' || device.label.length > MAX_LABEL_LENGTH) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (!Number.isSafeInteger(device.createdAt) || device.createdAt < 0) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Two entries naming one device, or one key under two device ids, would
|
|
265
|
+
// make "which device is this" ambiguous for every reader.
|
|
266
|
+
if (ids.has(device.deviceId) || boxKeys.has(device.boxPk)) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
ids.add(device.deviceId);
|
|
270
|
+
boxKeys.add(device.boxPk);
|
|
271
|
+
|
|
272
|
+
devices.push(normaliseDevice(device));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const list = { identityPk: envelope.identityPk, counter: envelope.counter, devices };
|
|
276
|
+
if (!sodium.crypto_sign_verify_detached(signature, deviceListBytes(list), identityKey)) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { ...list, signature: envelope.signature };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Should `candidate` replace `held`?
|
|
285
|
+
*
|
|
286
|
+
* Highest counter wins, and it is enforced here — on receipt — rather than
|
|
287
|
+
* trusted from the sender. Without it, a relay that kept a copy of an older
|
|
288
|
+
* list could replay it to put a revoked device back.
|
|
289
|
+
*
|
|
290
|
+
* A candidate for a different identity is never newer; it is a different
|
|
291
|
+
* question, and answering it here would let one identity's list displace
|
|
292
|
+
* another's.
|
|
293
|
+
*/
|
|
294
|
+
export function isNewerList(candidate, held) {
|
|
295
|
+
if (!candidate) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
if (!held) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
if (candidate.identityPk !== held.identityPk) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return candidate.counter > held.counter;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const DEVICE_LIMITS = { MAX_DEVICES, MAX_LABEL_LENGTH, DEVICE_ID_SIZE };
|
package/src/crypto/KeyManager.js
CHANGED
|
@@ -3,7 +3,18 @@ import sodium from 'sodium-native';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
|
|
5
5
|
import { generatePQKeyPair } from './PQHybrid.js';
|
|
6
|
+
import { DeviceIdentity, identityFingerprint, newDeviceId } from './DeviceIdentity.js';
|
|
6
7
|
|
|
8
|
+
// Guarded memory is released with sodium_free, never merely zeroed.
|
|
9
|
+
//
|
|
10
|
+
// sodium_malloc'd pages are mlock'd and the OS caps how much a process may lock
|
|
11
|
+
// (RLIMIT_MEMLOCK — small on Linux, unlimited on macOS, which is why the ceiling
|
|
12
|
+
// is invisible here and fatal in CI). Zeroing leaves the pages locked until the
|
|
13
|
+
// garbage collector runs the buffer's finaliser; sodium_free zeroes *and*
|
|
14
|
+
// releases, so it is strictly stronger than the memzero it replaces. Every
|
|
15
|
+
// caller nulls its reference immediately after, so nothing can reach freed
|
|
16
|
+
// memory. Same reasoning as SenderKey.js, and the same failure it was written
|
|
17
|
+
// for: an allocation that fails lands as a SIGABRT in whatever allocated next.
|
|
7
18
|
export class KeyManager {
|
|
8
19
|
#publicKey;
|
|
9
20
|
#secretKey;
|
|
@@ -12,6 +23,26 @@ export class KeyManager {
|
|
|
12
23
|
#previousSecretKey;
|
|
13
24
|
#graceTimer;
|
|
14
25
|
#pqKeyPair; // ML-KEM-768 — the hybrid half (see crypto/PQHybrid.js)
|
|
26
|
+
// Multi-device, step 2 (docs/design/multi-device.md). Carried, persisted,
|
|
27
|
+
// backed up and advertised — and read by nobody. The box key above is still
|
|
28
|
+
// the identity as far as every fingerprint, SAS and ban is concerned; this
|
|
29
|
+
// pair exists so that the field is already on the wire when step 3 starts
|
|
30
|
+
// signing device lists with it, the way the Ed25519 sender key landed before
|
|
31
|
+
// the send path that needed it.
|
|
32
|
+
// Ed25519 — signs device lists, never encrypts. **Null on a secondary
|
|
33
|
+
// device**, which holds only the public half: the identity secret never
|
|
34
|
+
// leaves the device that provisions. See shared/deviceProvisioning.js.
|
|
35
|
+
#identity;
|
|
36
|
+
#identityPk; // the public half, whether or not we hold the secret
|
|
37
|
+
#grantedList; // a secondary cannot sign, so it publishes what it was given
|
|
38
|
+
#deviceId; // names *this* device across box-key rotations
|
|
39
|
+
#deviceCreatedAt; // when this device id was drawn
|
|
40
|
+
// Version of the device list this device publishes. Persisted, and only ever
|
|
41
|
+
// increased. A peer keeps the highest counter it has seen, so a list that
|
|
42
|
+
// came back with a smaller one is a replay and is ignored — which also means
|
|
43
|
+
// a counter that failed to grow after the descriptor changed would leave
|
|
44
|
+
// every peer holding a list that no longer describes this device.
|
|
45
|
+
#listCounter;
|
|
15
46
|
|
|
16
47
|
constructor() {
|
|
17
48
|
this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
|
|
@@ -22,10 +53,92 @@ export class KeyManager {
|
|
|
22
53
|
|
|
23
54
|
sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
|
|
24
55
|
this.#pqKeyPair = generatePQKeyPair();
|
|
56
|
+
this.#identity = new DeviceIdentity();
|
|
57
|
+
this.#identityPk = this.#identity.publicKeyB64;
|
|
58
|
+
this.#grantedList = null;
|
|
59
|
+
this.#deviceId = newDeviceId();
|
|
60
|
+
this.#deviceCreatedAt = Date.now();
|
|
61
|
+
this.#listCounter = 1;
|
|
25
62
|
|
|
26
63
|
this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
|
|
27
64
|
}
|
|
28
65
|
|
|
66
|
+
/** The signing identity. Survives box-key rotation; that is the point. */
|
|
67
|
+
get identity() {
|
|
68
|
+
return this.#identity;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get identityPublicKeyB64() {
|
|
72
|
+
return this.#identityPk;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when this device holds the identity secret and can sign for it. */
|
|
76
|
+
get isPrimaryDevice() {
|
|
77
|
+
return this.#identity !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The list a secondary was granted, or null on a primary. */
|
|
81
|
+
get grantedList() {
|
|
82
|
+
return this.#grantedList;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Adopt an identity we do not hold the secret for.
|
|
87
|
+
*
|
|
88
|
+
* The caller has already checked that the list is signed by `identityPk` and
|
|
89
|
+
* names this device; this only records the outcome. The identity we generated
|
|
90
|
+
* for ourselves is destroyed rather than kept — two identities on one device
|
|
91
|
+
* is a state nothing else in the code expects, and keeping a secret nobody
|
|
92
|
+
* will ever use is the definition of a liability.
|
|
93
|
+
*/
|
|
94
|
+
adoptIdentity(identityPk, grantedList) {
|
|
95
|
+
this.#identity?.destroy();
|
|
96
|
+
this.#identity = null;
|
|
97
|
+
this.#identityPk = identityPk;
|
|
98
|
+
this.#grantedList = grantedList;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The 128-bit identity fingerprint.
|
|
103
|
+
*
|
|
104
|
+
* Not what `/fingerprint` shows and not what a SAS compares — those stay on
|
|
105
|
+
* the box key until step 4 moves them deliberately, with a migration. Two
|
|
106
|
+
* fingerprints on screen before then would only teach people to compare the
|
|
107
|
+
* wrong one.
|
|
108
|
+
*/
|
|
109
|
+
get identityFingerprint() {
|
|
110
|
+
return identityFingerprint(this.#identityPk);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get deviceId() {
|
|
114
|
+
return this.#deviceId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get listCounter() {
|
|
118
|
+
return this.#listCounter;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The list changed for a reason other than a rotation — adding a device. */
|
|
122
|
+
bumpListCounter() {
|
|
123
|
+
this.#listCounter += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* This device, as a device list names it.
|
|
128
|
+
*
|
|
129
|
+
* The label is empty and stays empty until there is a second device to tell
|
|
130
|
+
* apart. A hostname would be the obvious filler and is exactly the kind of
|
|
131
|
+
* thing this project does not put on a wire.
|
|
132
|
+
*/
|
|
133
|
+
deviceDescriptor() {
|
|
134
|
+
return {
|
|
135
|
+
deviceId: this.#deviceId,
|
|
136
|
+
boxPk: this.publicKeyB64,
|
|
137
|
+
label: '',
|
|
138
|
+
createdAt: this.#deviceCreatedAt,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
29
142
|
// The identity fingerprint stays X25519-only on purpose: it is what users
|
|
30
143
|
// already verified out-of-band, and the KEM key adds no authentication.
|
|
31
144
|
get pqPublicKey() {
|
|
@@ -65,9 +178,24 @@ export class KeyManager {
|
|
|
65
178
|
}
|
|
66
179
|
|
|
67
180
|
/**
|
|
68
|
-
* Generate a new keypair, keeping the old one for a grace period.
|
|
181
|
+
* Generate a new box keypair, keeping the old one for a grace period.
|
|
182
|
+
*
|
|
183
|
+
* The identity key and the device id are deliberately untouched. Rotating the
|
|
184
|
+
* box key is a routine hygiene step; rotating an identity is a claim that you
|
|
185
|
+
* are somebody new, and once device lists exist it would invalidate every one
|
|
186
|
+
* of them. A device that rotates is the same device.
|
|
69
187
|
*/
|
|
70
188
|
rotate() {
|
|
189
|
+
// A secondary cannot rotate. Its box key is named in a list signed by an
|
|
190
|
+
// identity whose secret lives somewhere else, so a new key would be a key
|
|
191
|
+
// nothing vouches for — every peer would see an unproven device under a
|
|
192
|
+
// known name, which is the alarm this whole arc exists to stop firing
|
|
193
|
+
// wrongly. Rotating a secondary needs the primary to re-sign for it, and
|
|
194
|
+
// there is no channel for that yet.
|
|
195
|
+
if (!this.#identity) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
71
199
|
// Clear any existing grace timer
|
|
72
200
|
if (this.#graceTimer) {
|
|
73
201
|
clearTimeout(this.#graceTimer);
|
|
@@ -84,6 +212,10 @@ export class KeyManager {
|
|
|
84
212
|
sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
|
|
85
213
|
|
|
86
214
|
this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
|
|
215
|
+
// The descriptor just changed, so any list already out there describes a
|
|
216
|
+
// key this device no longer uses. Without this the new list would carry the
|
|
217
|
+
// old counter and every peer would discard it as not newer.
|
|
218
|
+
this.#listCounter += 1;
|
|
87
219
|
|
|
88
220
|
// Auto-destroy previous keys after grace period
|
|
89
221
|
this.#graceTimer = setTimeout(() => {
|
|
@@ -94,7 +226,7 @@ export class KeyManager {
|
|
|
94
226
|
|
|
95
227
|
destroyPrevious() {
|
|
96
228
|
if (this.#previousSecretKey) {
|
|
97
|
-
sodium.
|
|
229
|
+
sodium.sodium_free(this.#previousSecretKey);
|
|
98
230
|
}
|
|
99
231
|
this.#previousSecretKey = null;
|
|
100
232
|
this.#previousPublicKey = null;
|
|
@@ -118,9 +250,11 @@ export class KeyManager {
|
|
|
118
250
|
if (this.#graceTimer) {
|
|
119
251
|
clearTimeout(this.#graceTimer);
|
|
120
252
|
}
|
|
253
|
+
this.#identity?.destroy();
|
|
254
|
+
this.#identity = null;
|
|
121
255
|
this.destroyPrevious();
|
|
122
256
|
if (this.#secretKey) {
|
|
123
|
-
sodium.
|
|
257
|
+
sodium.sodium_free(this.#secretKey);
|
|
124
258
|
}
|
|
125
259
|
this.#secretKey = null;
|
|
126
260
|
this.#publicKey = null;
|
|
@@ -133,12 +267,22 @@ export class KeyManager {
|
|
|
133
267
|
secretKey: this.#secretKey.toString('base64'),
|
|
134
268
|
pqPublicKey: this.#pqKeyPair.publicKey.toString('base64'),
|
|
135
269
|
pqSecretKey: this.#pqKeyPair.secretKey.toString('base64'),
|
|
270
|
+
identity: this.#identity ? this.#identity.serialize() : null,
|
|
271
|
+
identityPk: this.#identityPk,
|
|
272
|
+
grantedList: this.#grantedList,
|
|
273
|
+
deviceId: this.#deviceId,
|
|
274
|
+
deviceCreatedAt: this.#deviceCreatedAt,
|
|
275
|
+
listCounter: this.#listCounter,
|
|
136
276
|
};
|
|
137
277
|
}
|
|
138
278
|
|
|
139
279
|
static deserialize(data) {
|
|
140
280
|
const km = new KeyManager(); // generates throwaway keys
|
|
141
|
-
|
|
281
|
+
// sodium_free rather than memzero: the throwaway's pages are mlock'd, and
|
|
282
|
+
// zeroing leaves them locked until a finaliser happens to run. One page is
|
|
283
|
+
// harmless, but this is the pattern that aborted CI in 2.12.0 and it costs
|
|
284
|
+
// nothing to get right.
|
|
285
|
+
sodium.sodium_free(km.#secretKey);
|
|
142
286
|
|
|
143
287
|
km.#publicKey = Buffer.from(data.publicKey, 'base64');
|
|
144
288
|
const tempSec = Buffer.from(data.secretKey, 'base64');
|
|
@@ -156,6 +300,34 @@ export class KeyManager {
|
|
|
156
300
|
secretKey: Buffer.from(data.pqSecretKey, 'base64'),
|
|
157
301
|
};
|
|
158
302
|
}
|
|
303
|
+
// Same shape for the identity: a session or a backup written before this
|
|
304
|
+
// existed simply keeps the fresh one generated above. Losing an identity
|
|
305
|
+
// that nothing reads yet costs nothing; refusing to restore the session
|
|
306
|
+
// would cost the user everything else in it.
|
|
307
|
+
const identity = DeviceIdentity.deserialize(data.identity);
|
|
308
|
+
if (identity) {
|
|
309
|
+
km.#identity.destroy();
|
|
310
|
+
km.#identity = identity;
|
|
311
|
+
km.#identityPk = identity.publicKeyB64;
|
|
312
|
+
} else if (typeof data.identityPk === 'string' && data.grantedList) {
|
|
313
|
+
// A secondary: an identity it does not hold the secret for, and the list
|
|
314
|
+
// it was granted. Restored together or not at all — one without the other
|
|
315
|
+
// is a device that claims an identity it cannot prove it belongs to.
|
|
316
|
+
km.#identity.destroy();
|
|
317
|
+
km.#identity = null;
|
|
318
|
+
km.#identityPk = data.identityPk;
|
|
319
|
+
km.#grantedList = data.grantedList;
|
|
320
|
+
}
|
|
321
|
+
if (typeof data.deviceId === 'string' && /^[0-9a-f]{32}$/.test(data.deviceId)) {
|
|
322
|
+
km.#deviceId = data.deviceId;
|
|
323
|
+
// Only meaningful alongside the id they belong to.
|
|
324
|
+
if (Number.isSafeInteger(data.deviceCreatedAt) && data.deviceCreatedAt >= 0) {
|
|
325
|
+
km.#deviceCreatedAt = data.deviceCreatedAt;
|
|
326
|
+
}
|
|
327
|
+
if (Number.isSafeInteger(data.listCounter) && data.listCounter >= 1) {
|
|
328
|
+
km.#listCounter = data.listCounter;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
159
331
|
return km;
|
|
160
332
|
}
|
|
161
333
|
}
|
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
|
}
|