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.
@@ -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 };
@@ -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.sodium_memzero(this.#previousSecretKey);
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.sodium_memzero(this.#secretKey);
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
- sodium.sodium_memzero(km.#secretKey); // wipe throwaway
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
  }
@@ -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
- sodium.sodium_memzero(this.#chainKey);
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 must sodium_memzero the returned key.
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
- sodium.sodium_memzero(this.#chainKey);
170
+ freeKey(this.#chainKey);
171
+ this.#chainKey = null;
149
172
  for (const key of this.#skipped.values()) {
150
- sodium.sodium_memzero(key);
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
- sodium.sodium_memzero(messageKey);
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
- sodium.sodium_memzero(messageKey);
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
- sodium.sodium_memzero(messageKey);
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
- sodium.sodium_memzero(this.#signSk);
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
- sodium.sodium_memzero(this.#signSk);
386
+ freeKey(this.#signSk);
387
+ this.#signSk = null;
353
388
  for (const chain of this.#members.values()) {
354
389
  chain.destroy();
355
390
  }