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
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
PROTOCOL_VERSION,
|
|
3
3
|
MAX_NICKNAME_LENGTH,
|
|
4
4
|
MAX_PAYLOAD_SIZE,
|
|
5
|
+
IDENTITY_KEY_SIZE,
|
|
5
6
|
PUBLIC_KEY_SIZE,
|
|
6
7
|
ROOM_AUTH_PK_SIZE,
|
|
7
8
|
ROOM_AUTH_SIG_SIZE,
|
|
@@ -131,6 +132,19 @@ export function validateJoin(msg) {
|
|
|
131
132
|
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
132
133
|
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
133
134
|
}
|
|
135
|
+
// Optional Ed25519 identity key (multi-device). Absent = a client from before
|
|
136
|
+
// it existed. Checked for shape only: the relay cannot tell whose key it is,
|
|
137
|
+
// and is not supposed to be able to.
|
|
138
|
+
if (msg.identityKey !== undefined && !isValidBase64(msg.identityKey, IDENTITY_KEY_SIZE)) {
|
|
139
|
+
return { valid: false, error: 'Invalid identity key' };
|
|
140
|
+
}
|
|
141
|
+
// Optional signed device list, used only to claim a nickname another of your
|
|
142
|
+
// own devices already holds. Shape here; the signature is checked where the
|
|
143
|
+
// claim is decided, because only there is it known what identity to check it
|
|
144
|
+
// against.
|
|
145
|
+
if (msg.deviceList !== undefined && (typeof msg.deviceList !== 'object' || !msg.deviceList)) {
|
|
146
|
+
return { valid: false, error: 'Invalid device list' };
|
|
147
|
+
}
|
|
134
148
|
const capabilities = sanitizeCapabilities(msg.caps);
|
|
135
149
|
if (capabilities === null) {
|
|
136
150
|
return {
|
|
@@ -142,6 +156,8 @@ export function validateJoin(msg) {
|
|
|
142
156
|
valid: true,
|
|
143
157
|
nickname: nick,
|
|
144
158
|
pqPublicKey: msg.pqPublicKey || null,
|
|
159
|
+
identityKey: msg.identityKey || null,
|
|
160
|
+
deviceList: msg.deviceList || null,
|
|
145
161
|
capabilities,
|
|
146
162
|
};
|
|
147
163
|
}
|
|
@@ -5,7 +5,11 @@ const log = createLogger('session');
|
|
|
5
5
|
|
|
6
6
|
export class SessionManager {
|
|
7
7
|
#sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
|
|
8
|
-
|
|
8
|
+
// lowercased nickname -> the sessions holding it. A Set of names was enough
|
|
9
|
+
// while a name meant one connection; multi-device means several sessions can
|
|
10
|
+
// share one, and releasing the name when the *first* of them leaves would
|
|
11
|
+
// hand it to a stranger while its owner is still in the room.
|
|
12
|
+
#nicknames; // Map<lowerNickname, Set<sessionId>>
|
|
9
13
|
#recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
|
|
10
14
|
#rooms; // Map<roomName, Set<sessionId>>
|
|
11
15
|
#roomOwners; // Map<roomName, sessionId>
|
|
@@ -15,7 +19,7 @@ export class SessionManager {
|
|
|
15
19
|
|
|
16
20
|
constructor() {
|
|
17
21
|
this.#sessions = new Map();
|
|
18
|
-
this.#nicknames = new
|
|
22
|
+
this.#nicknames = new Map();
|
|
19
23
|
this.#recentlyLeft = new Map();
|
|
20
24
|
this.#rooms = new Map();
|
|
21
25
|
this.#roomOwners = new Map();
|
|
@@ -27,23 +31,68 @@ export class SessionManager {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
isNicknameTaken(nickname) {
|
|
30
|
-
return this.#nicknames.
|
|
34
|
+
return (this.#nicknames.get(nickname.toLowerCase())?.size ?? 0) > 0;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The identity key the sessions under this nickname are using, or null.
|
|
39
|
+
*
|
|
40
|
+
* Null when they disagree — which should not happen, since a second session
|
|
41
|
+
* is only admitted after matching, but a name whose holders cannot agree on
|
|
42
|
+
* an identity is not a name anything else should be admitted to.
|
|
43
|
+
*/
|
|
44
|
+
identityForNickname(nickname) {
|
|
45
|
+
const holders = this.#nicknames.get(nickname.toLowerCase());
|
|
46
|
+
if (!holders || holders.size === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
let identity = null;
|
|
50
|
+
for (const sessionId of holders) {
|
|
51
|
+
const key = this.#sessions.get(sessionId)?.identityKey ?? null;
|
|
52
|
+
if (!key || (identity && identity !== key)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
identity = key;
|
|
56
|
+
}
|
|
57
|
+
return identity;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many sessions currently answer to this nickname. */
|
|
61
|
+
sessionsForNickname(nickname) {
|
|
62
|
+
return this.#nicknames.get(nickname.toLowerCase())?.size ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
addSession(
|
|
66
|
+
ws,
|
|
67
|
+
nickname,
|
|
68
|
+
publicKey,
|
|
69
|
+
room = 'general',
|
|
70
|
+
pqPublicKey = null,
|
|
71
|
+
capabilities = [],
|
|
72
|
+
identityKey = null,
|
|
73
|
+
) {
|
|
34
74
|
const sessionId = randomUUID();
|
|
35
75
|
const session = {
|
|
36
76
|
ws,
|
|
37
77
|
nickname,
|
|
38
78
|
publicKey,
|
|
39
79
|
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
80
|
+
// Ed25519 identity key, relayed verbatim like the rest. The relay cannot
|
|
81
|
+
// check it belongs to this session and must not pretend to: a client
|
|
82
|
+
// trusts an identity key because a signed device list says so, never
|
|
83
|
+
// because a relay repeated it.
|
|
84
|
+
identityKey,
|
|
40
85
|
capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
|
|
41
86
|
connectedAt: Date.now(),
|
|
42
87
|
rooms: new Set(),
|
|
43
88
|
};
|
|
44
89
|
|
|
45
90
|
this.#sessions.set(sessionId, session);
|
|
46
|
-
|
|
91
|
+
const lower = nickname.toLowerCase();
|
|
92
|
+
if (!this.#nicknames.has(lower)) {
|
|
93
|
+
this.#nicknames.set(lower, new Set());
|
|
94
|
+
}
|
|
95
|
+
this.#nicknames.get(lower).add(sessionId);
|
|
47
96
|
this.#joinRoom(sessionId, room);
|
|
48
97
|
|
|
49
98
|
log.info(`${nickname} connected (${sessionId.slice(0, 8)}) in room ${room}`);
|
|
@@ -59,7 +108,14 @@ export class SessionManager {
|
|
|
59
108
|
for (const room of [...session.rooms]) {
|
|
60
109
|
this.#leaveRoom(sessionId, room);
|
|
61
110
|
}
|
|
62
|
-
|
|
111
|
+
const lower = session.nickname.toLowerCase();
|
|
112
|
+
const holders = this.#nicknames.get(lower);
|
|
113
|
+
if (holders) {
|
|
114
|
+
holders.delete(sessionId);
|
|
115
|
+
if (holders.size === 0) {
|
|
116
|
+
this.#nicknames.delete(lower);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
63
119
|
this.#sessions.delete(sessionId);
|
|
64
120
|
this.#muteState.delete(sessionId);
|
|
65
121
|
|
|
@@ -105,6 +161,7 @@ export class SessionManager {
|
|
|
105
161
|
nickname: session.nickname,
|
|
106
162
|
publicKey: session.publicKey,
|
|
107
163
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
164
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
108
165
|
...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
|
|
109
166
|
});
|
|
110
167
|
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
validateBanPeer,
|
|
45
45
|
} from '../protocol/validators.js';
|
|
46
46
|
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
47
|
+
import { DEVICE_LIMITS, verifyDeviceList } from '../crypto/DeviceIdentity.js';
|
|
47
48
|
import { parseServerConfig, clientAddress, normalizeIp } from './config.js';
|
|
48
49
|
|
|
49
50
|
const log = createLogger('ws-server');
|
|
@@ -281,6 +282,37 @@ export class SecureWSServer {
|
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
/**
|
|
286
|
+
* May this JOIN share a nickname that is already in use?
|
|
287
|
+
*
|
|
288
|
+
* Only if it is another device of whoever holds it. The proof is the signed
|
|
289
|
+
* device list in the JOIN: it must be signed by the identity the existing
|
|
290
|
+
* sessions are using, and it must name *this* JOIN's public key.
|
|
291
|
+
*
|
|
292
|
+
* No challenge is needed and none is invented. Replaying a list somebody else
|
|
293
|
+
* published gets you a seat in a room under a name whose messages you cannot
|
|
294
|
+
* read, because you do not hold the box secret the list names — and the peers,
|
|
295
|
+
* who do their own checking, learn nothing from the relay having allowed it.
|
|
296
|
+
* The relay is doing admission control on a nickname here, not attesting to
|
|
297
|
+
* anyone's identity; clients never take its word for that.
|
|
298
|
+
*/
|
|
299
|
+
#isAnotherDeviceOf(validation, publicKey) {
|
|
300
|
+
const identityKey = this.#sessionManager.identityForNickname(validation.nickname);
|
|
301
|
+
if (!identityKey || !validation.identityKey || validation.identityKey !== identityKey) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const list = verifyDeviceList(validation.deviceList);
|
|
305
|
+
if (!list || list.identityPk !== identityKey) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
this.#sessionManager.sessionsForNickname(validation.nickname) >= DEVICE_LIMITS.MAX_DEVICES
|
|
310
|
+
) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return list.devices.some((device) => device.boxPk === publicKey);
|
|
314
|
+
}
|
|
315
|
+
|
|
284
316
|
#handleJoin(ws, msg) {
|
|
285
317
|
if (ws.hasJoined) {
|
|
286
318
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Already in the chat')));
|
|
@@ -293,7 +325,10 @@ export class SecureWSServer {
|
|
|
293
325
|
return;
|
|
294
326
|
}
|
|
295
327
|
|
|
296
|
-
if (
|
|
328
|
+
if (
|
|
329
|
+
this.#sessionManager.isNicknameTaken(validation.nickname) &&
|
|
330
|
+
!this.#isAnotherDeviceOf(validation, msg.publicKey)
|
|
331
|
+
) {
|
|
297
332
|
ws.send(
|
|
298
333
|
JSON.stringify(
|
|
299
334
|
createError(ERR.NICKNAME_TAKEN, `Nickname "${validation.nickname}" is already in use`),
|
|
@@ -310,6 +345,7 @@ export class SecureWSServer {
|
|
|
310
345
|
room,
|
|
311
346
|
validation.pqPublicKey,
|
|
312
347
|
validation.capabilities,
|
|
348
|
+
validation.identityKey,
|
|
313
349
|
);
|
|
314
350
|
ws.sessionId = sessionId;
|
|
315
351
|
ws.hasJoined = true;
|
|
@@ -347,6 +383,7 @@ export class SecureWSServer {
|
|
|
347
383
|
nickname: validation.nickname,
|
|
348
384
|
publicKey: msg.publicKey,
|
|
349
385
|
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
386
|
+
...(validation.identityKey ? { identityKey: validation.identityKey } : {}),
|
|
350
387
|
...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
|
|
351
388
|
}),
|
|
352
389
|
sessionId,
|
|
@@ -665,6 +702,7 @@ export class SecureWSServer {
|
|
|
665
702
|
nickname: session.nickname,
|
|
666
703
|
publicKey: session.publicKey,
|
|
667
704
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
705
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
668
706
|
},
|
|
669
707
|
room,
|
|
670
708
|
),
|
|
@@ -821,6 +859,7 @@ export class SecureWSServer {
|
|
|
821
859
|
nickname: session.nickname,
|
|
822
860
|
publicKey: session.publicKey,
|
|
823
861
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
862
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
824
863
|
},
|
|
825
864
|
result.newRoom,
|
|
826
865
|
),
|
package/src/shared/constants.js
CHANGED
|
@@ -17,11 +17,15 @@ export const CAP = {
|
|
|
17
17
|
// and fan-out land a release before anyone sends, so that by the time a sender
|
|
18
18
|
// exists, every advertised room can already read it.
|
|
19
19
|
SENDER_KEYS: 'sk1',
|
|
20
|
+
// I can read a signed device list handed to me over the pairwise channel.
|
|
21
|
+
// Client-only: distribution rides the sealed channel the relay already
|
|
22
|
+
// carries, so unlike sk1 there is nothing for the relay to agree to.
|
|
23
|
+
DEVICE_LIST: 'dl1',
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
// What this client advertises. SENDER_KEYS is honest here: the receive path
|
|
23
27
|
// exists. Nothing sends group messages yet.
|
|
24
|
-
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
28
|
+
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS, CAP.DEVICE_LIST];
|
|
25
29
|
|
|
26
30
|
// What the relay advertises, in join_ack. A client cannot promise this on the
|
|
27
31
|
// relay's behalf — the fan-out is the relay's job — so a sender has to check the
|
|
@@ -71,6 +75,10 @@ export const MAX_BYTES_BURST = 4_194_304;
|
|
|
71
75
|
// Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
|
|
72
76
|
export const NONCE_SIZE = 24;
|
|
73
77
|
export const PUBLIC_KEY_SIZE = 32;
|
|
78
|
+
// Ed25519, and the same 32 bytes as above by coincidence of the curves rather
|
|
79
|
+
// than by anything shared. Named separately so a future change to one does not
|
|
80
|
+
// silently resize the other.
|
|
81
|
+
export const IDENTITY_KEY_SIZE = 32;
|
|
74
82
|
export const SECRET_KEY_SIZE = 32;
|
|
75
83
|
export const MAC_SIZE = 16;
|
|
76
84
|
export const SHARED_KEY_SIZE = 32;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Desktop notifications, kept away from the chat UI.
|
|
2
|
+
//
|
|
3
|
+
// node-notifier drives SnoreToast on Windows. When notifications are disabled
|
|
4
|
+
// for the application, SnoreToast ignores the stdio pipes node-notifier gives
|
|
5
|
+
// it and writes its diagnostics to the *attached console* instead — the very
|
|
6
|
+
// console blessed is drawing the chat on. The result is unreadable: raw
|
|
7
|
+
// "Notifications are disabled / Reason: DisabledForApplication / Command Line:
|
|
8
|
+
// …snoretoast-x64.exe…" text smeared across the message log, once per message.
|
|
9
|
+
//
|
|
10
|
+
// Three guards, in order of importance:
|
|
11
|
+
// 1. On Windows every notification is delivered by a detached, console-less
|
|
12
|
+
// helper process, so nothing the notifier prints can reach our terminal.
|
|
13
|
+
// 2. The first failure trips a breaker: desktop notifications go quiet for
|
|
14
|
+
// the rest of the session and the caller is told once, in-app.
|
|
15
|
+
// 3. Notifications are throttled, so a burst of messages can't flood the
|
|
16
|
+
// desktop (or, on the broken path, the terminal).
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// Exit code the helper uses for "the OS refused to show the notification".
|
|
22
|
+
export const NOTIFY_FAILED_EXIT = 3;
|
|
23
|
+
|
|
24
|
+
// Minimum gap between two desktop notifications. Sound alerts and the unread
|
|
25
|
+
// pill are unthrottled — this only rate-limits the OS-level popup.
|
|
26
|
+
export const NOTIFY_MIN_INTERVAL_MS = 3000;
|
|
27
|
+
|
|
28
|
+
const WORKER_PATH = fileURLToPath(new URL('./notifyWorker.js', import.meta.url));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when the platform needs the out-of-process delivery path. Only Windows
|
|
32
|
+
* has a notifier that writes to the console behind our back; macOS and Linux
|
|
33
|
+
* back-ends stay in-process (spawning a Node runtime per message would be far
|
|
34
|
+
* more expensive than the notification itself).
|
|
35
|
+
*/
|
|
36
|
+
export function needsIsolation(platform = process.platform) {
|
|
37
|
+
return platform === 'win32';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Desktop notifier with a breaker and a throttle.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {Function} [opts.onUnavailable] called once, with a human-readable
|
|
45
|
+
* reason, the first time the OS refuses a notification.
|
|
46
|
+
* @param {number} [opts.minIntervalMs] throttle window.
|
|
47
|
+
* @param {Function} [opts.now] clock, for tests.
|
|
48
|
+
*/
|
|
49
|
+
export class DesktopNotifier {
|
|
50
|
+
#available = true;
|
|
51
|
+
#reported = false;
|
|
52
|
+
#lastSentAt = 0;
|
|
53
|
+
#onUnavailable;
|
|
54
|
+
#minIntervalMs;
|
|
55
|
+
#now;
|
|
56
|
+
#isolate;
|
|
57
|
+
#send;
|
|
58
|
+
|
|
59
|
+
constructor({
|
|
60
|
+
onUnavailable = null,
|
|
61
|
+
minIntervalMs = NOTIFY_MIN_INTERVAL_MS,
|
|
62
|
+
now = Date.now,
|
|
63
|
+
isolate = needsIsolation(),
|
|
64
|
+
send = null,
|
|
65
|
+
} = {}) {
|
|
66
|
+
this.#onUnavailable = onUnavailable;
|
|
67
|
+
this.#minIntervalMs = minIntervalMs;
|
|
68
|
+
this.#now = now;
|
|
69
|
+
this.#isolate = isolate;
|
|
70
|
+
this.#send = send; // injected transport, for tests
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** False once the OS has told us notifications are not going to work. */
|
|
74
|
+
get available() {
|
|
75
|
+
return this.#available;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fire a desktop notification. Never throws, never blocks, and never lets the
|
|
80
|
+
* platform notifier write to our terminal.
|
|
81
|
+
*
|
|
82
|
+
* @returns {boolean} true if the notification was handed to the OS.
|
|
83
|
+
*/
|
|
84
|
+
notify({ title, message, sound = false }) {
|
|
85
|
+
if (!this.#available) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const at = this.#now();
|
|
89
|
+
if (at - this.#lastSentAt < this.#minIntervalMs) {
|
|
90
|
+
return false; // throttled — the sound alert already fired
|
|
91
|
+
}
|
|
92
|
+
this.#lastSentAt = at;
|
|
93
|
+
|
|
94
|
+
const options = { title: String(title ?? ''), message: String(message ?? ''), sound: !!sound };
|
|
95
|
+
try {
|
|
96
|
+
if (this.#send) {
|
|
97
|
+
this.#send(options, (err) => this.#onResult(err));
|
|
98
|
+
} else if (this.#isolate) {
|
|
99
|
+
this.#sendIsolated(options);
|
|
100
|
+
} else {
|
|
101
|
+
this.#sendInProcess(options);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
this.#onResult(err);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Stop trying for the rest of the session (used by `/notify off`). */
|
|
111
|
+
disable() {
|
|
112
|
+
this.#available = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Re-arm the breaker. `/notify on` means the user believes they fixed the OS
|
|
117
|
+
* setting we tripped on, so give the platform another chance — including the
|
|
118
|
+
* one-off in-app warning if it fails again.
|
|
119
|
+
*/
|
|
120
|
+
reset() {
|
|
121
|
+
this.#available = true;
|
|
122
|
+
this.#reported = false;
|
|
123
|
+
this.#lastSentAt = 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Windows: a detached, hidden helper with no stdio and no console of its own.
|
|
127
|
+
// Anything SnoreToast decides to print goes nowhere near the chat.
|
|
128
|
+
#sendIsolated(options) {
|
|
129
|
+
const child = spawn(process.execPath, [WORKER_PATH, JSON.stringify(options)], {
|
|
130
|
+
detached: true,
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
stdio: 'ignore',
|
|
133
|
+
});
|
|
134
|
+
child.on('error', (err) => this.#onResult(err));
|
|
135
|
+
child.on('exit', (code) => {
|
|
136
|
+
if (code === NOTIFY_FAILED_EXIT) {
|
|
137
|
+
this.#onResult(new Error('the operating system refused the notification'));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
child.unref();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// macOS / Linux: in-process, but always with a callback so a failure is
|
|
144
|
+
// handled instead of surfacing as an unhandled 'error' event.
|
|
145
|
+
#sendInProcess(options) {
|
|
146
|
+
import('node-notifier')
|
|
147
|
+
.then(({ default: notifier }) => {
|
|
148
|
+
notifier.notify(options, (err) => this.#onResult(err));
|
|
149
|
+
})
|
|
150
|
+
.catch((err) => this.#onResult(err));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#onResult(err) {
|
|
154
|
+
if (!isFailure(err)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.#available = false;
|
|
158
|
+
if (this.#reported) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.#reported = true;
|
|
162
|
+
this.#onUnavailable?.(describeFailure(err));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether what the notifier handed back is a real failure.
|
|
168
|
+
*
|
|
169
|
+
* node-notifier's `fileCommand` reports a non-zero exit as an Error but also
|
|
170
|
+
* passes plain stderr through as the "error" argument on success — a chatty
|
|
171
|
+
* `notify-send` or `terminal-notifier` build would otherwise mute notifications
|
|
172
|
+
* for the whole session. Errors always count; loose text only when it names a
|
|
173
|
+
* failure. Pure, exported for testing.
|
|
174
|
+
*/
|
|
175
|
+
export function isFailure(err) {
|
|
176
|
+
if (!err) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (err instanceof Error) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
const text = String(err).trim();
|
|
183
|
+
return (
|
|
184
|
+
text !== '' && /\b(disabled|denied|not found|no such|fail|error|refus|invalid)/i.test(text)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Turn whatever the platform notifier reported into one short sentence. The raw
|
|
190
|
+
* text is multi-line and full of absolute paths — exactly what we don't want in
|
|
191
|
+
* the chat log. Pure, exported for testing.
|
|
192
|
+
*/
|
|
193
|
+
export function describeFailure(err) {
|
|
194
|
+
const raw = String(err?.message ?? err ?? '')
|
|
195
|
+
.replace(/\s+/g, ' ')
|
|
196
|
+
.trim();
|
|
197
|
+
if (/disabledforapplication|notifications are disabled/i.test(raw)) {
|
|
198
|
+
return 'the OS has notifications turned off for this app';
|
|
199
|
+
}
|
|
200
|
+
if (/not found on system|enoent/i.test(raw)) {
|
|
201
|
+
return 'no notification backend is installed';
|
|
202
|
+
}
|
|
203
|
+
return raw.slice(0, 120) || 'the OS rejected it';
|
|
204
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ── Provisioning a second device ────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Step 5 of multi-device (docs/design/multi-device.md, item 4 of #481).
|
|
4
|
+
//
|
|
5
|
+
// **The identity secret never moves.** A second device generates its own box
|
|
6
|
+
// keypair and receives only the identity's *public* key plus a device list
|
|
7
|
+
// signed by it. That costs one thing and buys two: a secondary cannot add or
|
|
8
|
+
// revoke devices — only the device holding the identity secret can — and a
|
|
9
|
+
// stolen phone is a stolen phone rather than a stolen identity. Today's
|
|
10
|
+
// `/backup` does the opposite, copying the whole identity, which is the
|
|
11
|
+
// configuration this arc exists to replace.
|
|
12
|
+
//
|
|
13
|
+
// Because neither side can sign for the other, provisioning is two hops and
|
|
14
|
+
// there is no way around that: the new device has to say what its key is before
|
|
15
|
+
// the identity can sign for it, and it has to be told what identity it now
|
|
16
|
+
// belongs to afterwards.
|
|
17
|
+
//
|
|
18
|
+
// B: /device request → ciphermesh-device://request/… (~150 bytes)
|
|
19
|
+
// A: /device add <request> → ciphermesh-device://grant/… (~740 bytes)
|
|
20
|
+
// B: /device accept <grant>
|
|
21
|
+
//
|
|
22
|
+
// A byte-mode QR code holds about 2 200 characters, so both fit with room for
|
|
23
|
+
// more devices. Neither is secret: a request is a public key, and a grant is a signed statement that
|
|
24
|
+
// was going to be broadcast to every peer anyway. Interception achieves
|
|
25
|
+
// nothing; substitution is caught, because a grant only applies if it names the
|
|
26
|
+
// exact device that asked.
|
|
27
|
+
|
|
28
|
+
const SCHEME = 'ciphermesh-device://';
|
|
29
|
+
const MAX_ENCODED = 8192; // a grant with the maximum eight devices, with room
|
|
30
|
+
|
|
31
|
+
const DEVICE_ID = /^[0-9a-f]{32}$/;
|
|
32
|
+
|
|
33
|
+
function encode(kind, body) {
|
|
34
|
+
const json = Buffer.from(JSON.stringify(body), 'utf-8');
|
|
35
|
+
return `${SCHEME}${kind}/${json.toString('base64url')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decode(kind, text) {
|
|
39
|
+
if (typeof text !== 'string') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const prefix = `${SCHEME}${kind}/`;
|
|
43
|
+
if (!text.startsWith(prefix) || text.length > MAX_ENCODED) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(
|
|
48
|
+
Buffer.from(text.slice(prefix.length), 'base64url').toString('utf-8'),
|
|
49
|
+
);
|
|
50
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a new device says about itself: the two fields the identity has to sign
|
|
58
|
+
* over. Nothing secret, and nothing the peer could not have learned anyway.
|
|
59
|
+
*/
|
|
60
|
+
export function buildDeviceRequest({ deviceId, boxPk, label = '' }) {
|
|
61
|
+
if (!DEVICE_ID.test(deviceId ?? '') || typeof boxPk !== 'string' || boxPk.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return encode('request', { deviceId, boxPk, label: String(label).slice(0, 32) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {{deviceId: string, boxPk: string, label: string}|null} */
|
|
68
|
+
export function parseDeviceRequest(text) {
|
|
69
|
+
const body = decode('request', text);
|
|
70
|
+
if (!body || !DEVICE_ID.test(body.deviceId ?? '')) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (typeof body.boxPk !== 'string' || body.boxPk.length === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (body.label !== undefined && typeof body.label !== 'string') {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { deviceId: body.deviceId, boxPk: body.boxPk, label: body.label ?? '' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What the identity hands back: which identity this device now belongs to, and
|
|
84
|
+
* the signed list saying so.
|
|
85
|
+
*
|
|
86
|
+
* The identity key is in here as well as inside the list because the receiving
|
|
87
|
+
* device has no other way to learn it — and it is checked against the list's
|
|
88
|
+
* own `identityPk` on the way in, so the two cannot disagree.
|
|
89
|
+
*/
|
|
90
|
+
export function buildDeviceGrant({ identityPk, list }) {
|
|
91
|
+
if (typeof identityPk !== 'string' || !list || typeof list !== 'object') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return encode('grant', { identityPk, list });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @returns {{identityPk: string, list: object}|null} */
|
|
98
|
+
export function parseDeviceGrant(text) {
|
|
99
|
+
const body = decode('grant', text);
|
|
100
|
+
if (!body || typeof body.identityPk !== 'string') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (!body.list || typeof body.list !== 'object' || Array.isArray(body.list)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// A grant whose envelope disagrees with the list it carries is malformed, not
|
|
107
|
+
// a decision to make later.
|
|
108
|
+
if (body.list.identityPk !== body.identityPk) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return { identityPk: body.identityPk, list: body.list };
|
|
112
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-shot desktop-notification helper.
|
|
3
|
+
//
|
|
4
|
+
// Spawned detached, hidden and with stdio ignored (see desktopNotify.js), so it
|
|
5
|
+
// owns no console: when SnoreToast writes its "Notifications are disabled"
|
|
6
|
+
// diagnostics past the stdio pipes, they land in this process's void instead of
|
|
7
|
+
// on top of the chat UI.
|
|
8
|
+
//
|
|
9
|
+
// Reads a single JSON argument ({ title, message, sound }) and exits 0 on
|
|
10
|
+
// success, NOTIFY_FAILED_EXIT when the OS refused the notification.
|
|
11
|
+
|
|
12
|
+
import notifier from 'node-notifier';
|
|
13
|
+
import { NOTIFY_FAILED_EXIT } from './desktopNotify.js';
|
|
14
|
+
|
|
15
|
+
// The platform callback only fires when the toast is dismissed or times out on
|
|
16
|
+
// screen, which can be tens of seconds — so this cap is a leak guard, not a
|
|
17
|
+
// verdict. Exiting 0 here means "handed over, outcome unknown"; only an actual
|
|
18
|
+
// error from the notifier is allowed to trip the caller's breaker.
|
|
19
|
+
const TIMEOUT_MS = 8000;
|
|
20
|
+
|
|
21
|
+
function main() {
|
|
22
|
+
let options;
|
|
23
|
+
try {
|
|
24
|
+
options = JSON.parse(process.argv[2] || '{}');
|
|
25
|
+
} catch {
|
|
26
|
+
process.exit(NOTIFY_FAILED_EXIT);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const timer = setTimeout(() => process.exit(0), TIMEOUT_MS);
|
|
30
|
+
timer.unref();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
notifier.notify(
|
|
34
|
+
{
|
|
35
|
+
title: String(options.title ?? ''),
|
|
36
|
+
message: String(options.message ?? ''),
|
|
37
|
+
sound: !!options.sound,
|
|
38
|
+
},
|
|
39
|
+
(err) => process.exit(err ? NOTIFY_FAILED_EXIT : 0),
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
process.exit(NOTIFY_FAILED_EXIT);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
main();
|
package/src/shared/tips.js
CHANGED
|
@@ -9,6 +9,7 @@ export const TIPS = [
|
|
|
9
9
|
'/panic wipes every on-disk secret and exits — for a lost or seized device.',
|
|
10
10
|
'/backup saves your identity + verified peers as an encrypted file.',
|
|
11
11
|
'Ctrl+K opens the command palette; Ctrl+E the emoji picker; PgUp/PgDn scrolls.',
|
|
12
|
+
'Shift+Enter starts a new line without sending; Alt+Enter and Ctrl+J do too.',
|
|
12
13
|
'A ✗ next to a name means their key changed since you last saw it — verify before trusting.',
|
|
13
14
|
'/deniable on switches to plausibly-deniable messages (no cryptographic proof you sent them).',
|
|
14
15
|
'No server? Run it in P2P mode — peers find each other on the LAN via mDNS, no relay at all.',
|