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.
- package/CHANGELOG.md +187 -0
- package/README.md +8 -6
- package/README.pt-BR.md +8 -6
- package/docs/ARCHITECTURE.md +332 -230
- package/docs/PROTOCOL.md +160 -5
- package/docs/SETUP.md +18 -0
- package/docs/commands.json +15 -7
- package/docs/design/multi-device.md +290 -0
- package/docs/design/sender-keys-on-relay.md +34 -3
- package/package.json +3 -3
- package/src/client/ChatController.js +736 -26
- package/src/client/UI.js +617 -124
- package/src/client/keyboard.js +388 -0
- package/src/crypto/DeviceIdentity.js +307 -0
- package/src/crypto/KeyManager.js +176 -4
- package/src/crypto/TrustStore.js +153 -0
- package/src/p2p/P2PChatController.js +94 -4
- package/src/protocol/messages.js +25 -1
- package/src/protocol/validators.js +16 -0
- package/src/server/SessionManager.js +63 -6
- package/src/server/WebSocketServer.js +40 -1
- package/src/shared/constants.js +9 -1
- package/src/shared/desktopNotify.js +204 -0
- package/src/shared/deviceProvisioning.js +112 -0
- package/src/shared/notifyWorker.js +46 -0
- package/src/shared/tips.js +1 -0
|
@@ -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 };
|