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.
@@ -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
- #nicknames; // Set<nickname> for quick dupe check
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 Set();
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.has(nickname.toLowerCase());
34
+ return (this.#nicknames.get(nickname.toLowerCase())?.size ?? 0) > 0;
31
35
  }
32
36
 
33
- addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null, capabilities = []) {
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
- this.#nicknames.add(nickname.toLowerCase());
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
- this.#nicknames.delete(session.nickname.toLowerCase());
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 (this.#sessionManager.isNicknameTaken(validation.nickname)) {
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
  ),
@@ -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();
@@ -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.',