ciphermesh 2.12.0 → 2.14.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,388 @@
1
+ // Keyboard-protocol shim.
2
+ //
3
+ // Terminals cannot tell Shift+Enter from Enter unless the application asks them
4
+ // to: with no protocol negotiated both arrive as a bare `\r`. The two ways to
5
+ // ask are the kitty keyboard protocol (`CSI > 1 u`, reported as `CSI 13;2 u`)
6
+ // and xterm's modifyOtherKeys (`CSI > 4 ; 1 m`, reported as `CSI 27;2;13 ~`).
7
+ //
8
+ // Asking is only half of it. blessed's key parser splits input with
9
+ //
10
+ // (?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|…|(?:1;)?(\d+)?([a-zA-Z]))
11
+ //
12
+ // which matches neither a `u` final byte after two parameters nor a `~` after
13
+ // three. Both reports fall through to blessed's `/\x1b./` catch-all, so it
14
+ // consumes `\x1b[` and emits the rest — `13;2u` — as ordinary characters typed
15
+ // into the composer. The same regex is why `\x1b\r` (Alt+Enter) reaches blessed
16
+ // with `key.name === undefined`.
17
+ //
18
+ // So the reports are decoded here, upstream of blessed, on the raw byte stream:
19
+ // Enter with a modifier becomes a newline event, every other enhanced report is
20
+ // rewritten to the legacy encoding blessed already understands, and anything
21
+ // unrecognised is passed through untouched. Nothing new can reach the composer
22
+ // as literal text, which is the property that makes enabling a protocol safe.
23
+
24
+ import { PassThrough } from 'node:stream';
25
+ import { StringDecoder } from 'node:string_decoder';
26
+
27
+ const ESC = '\x1b';
28
+ const PASTE_START = '\x1b[200~';
29
+ const PASTE_END = '\x1b[201~';
30
+
31
+ // Ask for both protocols. Terminals that implement neither ignore both; ones
32
+ // that implement the kitty protocol let it take precedence over modifyOtherKeys.
33
+ // Level 1 of modifyOtherKeys is deliberate — level 2 also re-encodes Ctrl+C and
34
+ // friends, which is a much larger blast radius for a newline.
35
+ export const KEY_PROTOCOL_ENABLE = '\x1b[>4;1m\x1b[>1u';
36
+ export const KEY_PROTOCOL_DISABLE = '\x1b[<u\x1b[>4;0m';
37
+
38
+ // Modifier bits, as reported minus one.
39
+ const MOD_SHIFT = 1;
40
+ const MOD_ALT = 2;
41
+ const MOD_CTRL = 4;
42
+
43
+ // Kitty's private-use range for keys with no Unicode code point. Under the
44
+ // disambiguate flag most of these keep their legacy encoding, but the keypad
45
+ // reports through CSI u, so the ones that produce text are mapped back.
46
+ const KEYPAD = new Map([
47
+ [57399, '0'],
48
+ [57400, '1'],
49
+ [57401, '2'],
50
+ [57402, '3'],
51
+ [57403, '4'],
52
+ [57404, '5'],
53
+ [57405, '6'],
54
+ [57406, '7'],
55
+ [57407, '8'],
56
+ [57408, '9'],
57
+ [57409, '.'],
58
+ [57410, '/'],
59
+ [57411, '*'],
60
+ [57412, '-'],
61
+ [57413, '+'],
62
+ [57414, '\r'], // KP_ENTER
63
+ [57415, '='],
64
+ ]);
65
+
66
+ /**
67
+ * The control byte a Ctrl+<key> chord produces on a legacy terminal, or '' when
68
+ * the chord has no legacy encoding (Ctrl+1, say). Pure, exported for testing.
69
+ */
70
+ export function ctrlByte(code) {
71
+ if (code >= 97 && code <= 122) {
72
+ return String.fromCharCode(code - 96); // ctrl+a … ctrl+z
73
+ }
74
+ if (code >= 65 && code <= 90) {
75
+ return String.fromCharCode(code - 64); // ctrl+A … ctrl+Z
76
+ }
77
+ switch (code) {
78
+ case 32: // ctrl+space → NUL
79
+ case 64: // ctrl+@
80
+ return '\x00';
81
+ case 91: // ctrl+[
82
+ return '\x1b';
83
+ case 92: // ctrl+backslash
84
+ return '\x1c';
85
+ case 93: // ctrl+]
86
+ return '\x1d';
87
+ case 94: // ctrl+^
88
+ return '\x1e';
89
+ case 95: // ctrl+_
90
+ case 47: // ctrl+/
91
+ return '\x1f';
92
+ case 63: // ctrl+?
93
+ return '\x7f';
94
+ default:
95
+ return '';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Rewrite one decoded key report as the bytes a legacy terminal would have
101
+ * sent, so blessed's parser sees input it already knows. Returns '' for reports
102
+ * with no legacy equivalent — dropping them is the point, since letting them
103
+ * through is what types `13;2u` into the composer. Pure, exported for testing.
104
+ *
105
+ * @param {number} code Unicode code point (or a kitty functional key number)
106
+ * @param {number} mods modifier bitmask, already decremented by one
107
+ */
108
+ export function toLegacy(code, mods) {
109
+ const shift = !!(mods & MOD_SHIFT);
110
+ const alt = !!(mods & MOD_ALT);
111
+ const ctrl = !!(mods & MOD_CTRL);
112
+ const meta = (s) => (alt ? ESC + s : s);
113
+
114
+ switch (code) {
115
+ case 13:
116
+ return meta('\r'); // enter — modified enter is intercepted upstream
117
+ case 27:
118
+ return ESC; // escape carries no modifiers in the legacy encoding
119
+ case 9:
120
+ return shift ? '\x1b[Z' : meta('\t');
121
+ case 8:
122
+ case 127:
123
+ return meta('\x7f'); // backspace
124
+ }
125
+
126
+ if (KEYPAD.has(code)) {
127
+ return meta(KEYPAD.get(code));
128
+ }
129
+ // Kitty numbers its functional keys inside the Unicode private-use area, so
130
+ // anything landing there is a key (Caps Lock, media keys, …) rather than a
131
+ // character. Emitting it would put an invisible glyph in the composer.
132
+ if (code >= 0xe000 && code <= 0xf8ff) {
133
+ return '';
134
+ }
135
+ if (code < 32 || code > 0x10ffff) {
136
+ return ''; // control codes we have no encoding for
137
+ }
138
+
139
+ if (ctrl) {
140
+ const byte = ctrlByte(code);
141
+ return byte ? meta(byte) : '';
142
+ }
143
+ // Kitty reports the *unshifted* code point plus a shift bit.
144
+ const base = shift && code >= 97 && code <= 122 ? code - 32 : code;
145
+ return meta(String.fromCodePoint(base));
146
+ }
147
+
148
+ // Match a CSI sequence starting at `from`. Returns the parameter string and
149
+ // final byte, `null` when the sequence is still incomplete (hold and wait for
150
+ // more input), or `false` when it can never become one.
151
+ function matchCsi(text, from) {
152
+ let i = from + 2; // past ESC [
153
+ while (i < text.length) {
154
+ const c = text.charCodeAt(i);
155
+ const isParam = (c >= 0x30 && c <= 0x3f) || (c >= 0x20 && c <= 0x2f); // 0-9;:<=>?!"#$%&'()*+,-./
156
+ if (isParam) {
157
+ i++;
158
+ continue;
159
+ }
160
+ if (c >= 0x40 && c <= 0x7e) {
161
+ return { params: text.slice(from + 2, i), final: text[i], end: i + 1 };
162
+ }
163
+ return false; // not a CSI sequence after all
164
+ }
165
+ return null; // ran out of input mid-sequence
166
+ }
167
+
168
+ // Parameters as numbers, taking the first sub-parameter of each (kitty uses
169
+ // `code:shifted:base` and `mods:event`, and only the first matters here).
170
+ function numericParams(params) {
171
+ return params.split(';').map((p) => {
172
+ const n = Number.parseInt(p.split(':')[0], 10);
173
+ return Number.isNaN(n) ? 0 : n;
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Decode an enhanced key report into `{ code, mods }`, or null if the sequence
179
+ * is not one. Understands the kitty form `CSI <code>[;<mods>] u` and xterm's
180
+ * modifyOtherKeys form `CSI 27;<mods>;<code> ~`. Pure, exported for testing.
181
+ */
182
+ export function parseEnhancedKey(params, final) {
183
+ const nums = numericParams(params);
184
+ if (final === 'u' && !params.startsWith('>') && !params.startsWith('<')) {
185
+ const code = nums[0];
186
+ if (!code) {
187
+ return null;
188
+ }
189
+ return { code, mods: Math.max(0, (nums[1] || 1) - 1) };
190
+ }
191
+ if (final === '~' && nums.length === 3 && nums[0] === 27) {
192
+ return { code: nums[2], mods: Math.max(0, (nums[1] || 1) - 1) };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** Enter plus any modifier means "new line in the composer", never "send". */
198
+ function isNewline(key) {
199
+ return key.code === 13 && (key.mods & (MOD_SHIFT | MOD_ALT | MOD_CTRL)) !== 0;
200
+ }
201
+
202
+ /**
203
+ * Scan a chunk of raw terminal input, splitting it into what blessed should see
204
+ * and the newline requests it never could. Pure and incremental — feed the
205
+ * returned `pending`/`pasting` back in with the next chunk.
206
+ *
207
+ * Bracketed-paste blocks are copied through verbatim: text arriving from the
208
+ * clipboard is data, and a `\x1b\r` inside it is two pasted characters, not a
209
+ * key chord.
210
+ *
211
+ * @returns {{ events: Array<{type: 'data'|'newline', text?: string}>,
212
+ * pending: string, pasting: boolean }}
213
+ */
214
+ export function filterKeys(input, state = {}) {
215
+ const text = (state.pending || '') + input;
216
+ let pasting = !!state.pasting;
217
+ const events = [];
218
+ let out = '';
219
+ let i = 0;
220
+
221
+ const flush = () => {
222
+ if (out) {
223
+ events.push({ type: 'data', text: out });
224
+ out = '';
225
+ }
226
+ };
227
+
228
+ while (i < text.length) {
229
+ if (pasting) {
230
+ const end = text.indexOf(PASTE_END, i);
231
+ if (end === -1) {
232
+ out += text.slice(i);
233
+ i = text.length;
234
+ break;
235
+ }
236
+ out += text.slice(i, end + PASTE_END.length);
237
+ i = end + PASTE_END.length;
238
+ pasting = false;
239
+ continue;
240
+ }
241
+
242
+ if (text[i] !== ESC) {
243
+ let j = i;
244
+ while (j < text.length && text[j] !== ESC) {
245
+ j++;
246
+ }
247
+ out += text.slice(i, j);
248
+ i = j;
249
+ continue;
250
+ }
251
+
252
+ if (text.startsWith(PASTE_START, i)) {
253
+ out += PASTE_START;
254
+ i += PASTE_START.length;
255
+ pasting = true;
256
+ continue;
257
+ }
258
+
259
+ // Alt+Enter as sent by terminals with no protocol negotiated. blessed
260
+ // cannot name this one either, so it is intercepted here for every
261
+ // terminal, protocol or not.
262
+ if (text.startsWith('\x1b\r', i) || text.startsWith('\x1b\n', i)) {
263
+ flush();
264
+ events.push({ type: 'newline' });
265
+ i += 2;
266
+ continue;
267
+ }
268
+
269
+ if (text.startsWith('\x1b[', i)) {
270
+ const csi = matchCsi(text, i);
271
+ if (csi === null) {
272
+ break; // incomplete — hold it for the next chunk
273
+ }
274
+ if (csi === false) {
275
+ out += text[i];
276
+ i++;
277
+ continue;
278
+ }
279
+ const key = parseEnhancedKey(csi.params, csi.final);
280
+ if (!key) {
281
+ out += text.slice(i, csi.end); // arrows, function keys, mouse — untouched
282
+ i = csi.end;
283
+ continue;
284
+ }
285
+ if (isNewline(key)) {
286
+ flush();
287
+ events.push({ type: 'newline' });
288
+ } else {
289
+ out += toLegacy(key.code, key.mods);
290
+ }
291
+ i = csi.end;
292
+ continue;
293
+ }
294
+
295
+ out += text[i];
296
+ i++;
297
+ }
298
+
299
+ flush();
300
+ return { events, pending: text.slice(i), pasting };
301
+ }
302
+
303
+ /**
304
+ * A stdin stand-in for blessed that has already had the enhanced key reports
305
+ * taken out of it. Pass it as `blessed.screen({ input })`.
306
+ *
307
+ * blessed only needs `data`/`keypress` events plus `setRawMode`, `isRaw` and
308
+ * `pause`/`resume` from its input, so the raw-mode calls are forwarded to the
309
+ * real tty and everything else is an ordinary PassThrough.
310
+ */
311
+ export class EnhancedInput extends PassThrough {
312
+ #source;
313
+ #onNewline;
314
+ #onData;
315
+ #decoder = new StringDecoder('utf8');
316
+ #state = { pending: '', pasting: false };
317
+ #flushTimer = null;
318
+
319
+ constructor(source, onNewline) {
320
+ super();
321
+ this.#source = source;
322
+ this.#onNewline = onNewline;
323
+ this.#onData = (chunk) => this.#feed(chunk);
324
+ this.#source.on('data', this.#onData);
325
+ }
326
+
327
+ get isTTY() {
328
+ return !!this.#source.isTTY;
329
+ }
330
+
331
+ get isRaw() {
332
+ return !!this.#source.isRaw;
333
+ }
334
+
335
+ setRawMode(mode) {
336
+ this.#source.setRawMode?.(mode);
337
+ return this;
338
+ }
339
+
340
+ /** Stop consuming the tty (used when the screen is torn down). */
341
+ detach() {
342
+ this.#source.removeListener('data', this.#onData);
343
+ if (this.#flushTimer) {
344
+ clearTimeout(this.#flushTimer);
345
+ this.#flushTimer = null;
346
+ }
347
+ }
348
+
349
+ #feed(chunk) {
350
+ // Decode through a StringDecoder so a multi-byte character split across two
351
+ // reads is not turned into replacement characters on the way back out.
352
+ const text = Buffer.isBuffer(chunk) ? this.#decoder.write(chunk) : String(chunk);
353
+ if (!text) {
354
+ return;
355
+ }
356
+ const { events, pending, pasting } = filterKeys(text, this.#state);
357
+ this.#state = { pending, pasting };
358
+ for (const event of events) {
359
+ if (event.type === 'newline') {
360
+ this.#onNewline?.();
361
+ } else {
362
+ this.push(Buffer.from(event.text, 'utf8'));
363
+ }
364
+ }
365
+ this.#armFlush();
366
+ }
367
+
368
+ // A held partial sequence must never swallow input for good: if nothing
369
+ // completes it, release it as ordinary bytes.
370
+ #armFlush() {
371
+ if (this.#flushTimer) {
372
+ clearTimeout(this.#flushTimer);
373
+ this.#flushTimer = null;
374
+ }
375
+ if (!this.#state.pending) {
376
+ return;
377
+ }
378
+ this.#flushTimer = setTimeout(() => {
379
+ this.#flushTimer = null;
380
+ const held = this.#state.pending;
381
+ this.#state = { pending: '', pasting: this.#state.pasting };
382
+ if (held) {
383
+ this.push(Buffer.from(held, 'utf8'));
384
+ }
385
+ }, 50);
386
+ this.#flushTimer.unref?.();
387
+ }
388
+ }
@@ -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 };