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.
@@ -3,7 +3,18 @@ import sodium from 'sodium-native';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
5
5
  import { generatePQKeyPair } from './PQHybrid.js';
6
+ import { DeviceIdentity, identityFingerprint, newDeviceId } from './DeviceIdentity.js';
6
7
 
8
+ // Guarded memory is released with sodium_free, never merely zeroed.
9
+ //
10
+ // sodium_malloc'd pages are mlock'd and the OS caps how much a process may lock
11
+ // (RLIMIT_MEMLOCK — small on Linux, unlimited on macOS, which is why the ceiling
12
+ // is invisible here and fatal in CI). Zeroing leaves the pages locked until the
13
+ // garbage collector runs the buffer's finaliser; sodium_free zeroes *and*
14
+ // releases, so it is strictly stronger than the memzero it replaces. Every
15
+ // caller nulls its reference immediately after, so nothing can reach freed
16
+ // memory. Same reasoning as SenderKey.js, and the same failure it was written
17
+ // for: an allocation that fails lands as a SIGABRT in whatever allocated next.
7
18
  export class KeyManager {
8
19
  #publicKey;
9
20
  #secretKey;
@@ -12,6 +23,26 @@ export class KeyManager {
12
23
  #previousSecretKey;
13
24
  #graceTimer;
14
25
  #pqKeyPair; // ML-KEM-768 — the hybrid half (see crypto/PQHybrid.js)
26
+ // Multi-device, step 2 (docs/design/multi-device.md). Carried, persisted,
27
+ // backed up and advertised — and read by nobody. The box key above is still
28
+ // the identity as far as every fingerprint, SAS and ban is concerned; this
29
+ // pair exists so that the field is already on the wire when step 3 starts
30
+ // signing device lists with it, the way the Ed25519 sender key landed before
31
+ // the send path that needed it.
32
+ // Ed25519 — signs device lists, never encrypts. **Null on a secondary
33
+ // device**, which holds only the public half: the identity secret never
34
+ // leaves the device that provisions. See shared/deviceProvisioning.js.
35
+ #identity;
36
+ #identityPk; // the public half, whether or not we hold the secret
37
+ #grantedList; // a secondary cannot sign, so it publishes what it was given
38
+ #deviceId; // names *this* device across box-key rotations
39
+ #deviceCreatedAt; // when this device id was drawn
40
+ // Version of the device list this device publishes. Persisted, and only ever
41
+ // increased. A peer keeps the highest counter it has seen, so a list that
42
+ // came back with a smaller one is a replay and is ignored — which also means
43
+ // a counter that failed to grow after the descriptor changed would leave
44
+ // every peer holding a list that no longer describes this device.
45
+ #listCounter;
15
46
 
16
47
  constructor() {
17
48
  this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
@@ -22,10 +53,92 @@ export class KeyManager {
22
53
 
23
54
  sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
24
55
  this.#pqKeyPair = generatePQKeyPair();
56
+ this.#identity = new DeviceIdentity();
57
+ this.#identityPk = this.#identity.publicKeyB64;
58
+ this.#grantedList = null;
59
+ this.#deviceId = newDeviceId();
60
+ this.#deviceCreatedAt = Date.now();
61
+ this.#listCounter = 1;
25
62
 
26
63
  this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
27
64
  }
28
65
 
66
+ /** The signing identity. Survives box-key rotation; that is the point. */
67
+ get identity() {
68
+ return this.#identity;
69
+ }
70
+
71
+ get identityPublicKeyB64() {
72
+ return this.#identityPk;
73
+ }
74
+
75
+ /** True when this device holds the identity secret and can sign for it. */
76
+ get isPrimaryDevice() {
77
+ return this.#identity !== null;
78
+ }
79
+
80
+ /** The list a secondary was granted, or null on a primary. */
81
+ get grantedList() {
82
+ return this.#grantedList;
83
+ }
84
+
85
+ /**
86
+ * Adopt an identity we do not hold the secret for.
87
+ *
88
+ * The caller has already checked that the list is signed by `identityPk` and
89
+ * names this device; this only records the outcome. The identity we generated
90
+ * for ourselves is destroyed rather than kept — two identities on one device
91
+ * is a state nothing else in the code expects, and keeping a secret nobody
92
+ * will ever use is the definition of a liability.
93
+ */
94
+ adoptIdentity(identityPk, grantedList) {
95
+ this.#identity?.destroy();
96
+ this.#identity = null;
97
+ this.#identityPk = identityPk;
98
+ this.#grantedList = grantedList;
99
+ }
100
+
101
+ /**
102
+ * The 128-bit identity fingerprint.
103
+ *
104
+ * Not what `/fingerprint` shows and not what a SAS compares — those stay on
105
+ * the box key until step 4 moves them deliberately, with a migration. Two
106
+ * fingerprints on screen before then would only teach people to compare the
107
+ * wrong one.
108
+ */
109
+ get identityFingerprint() {
110
+ return identityFingerprint(this.#identityPk);
111
+ }
112
+
113
+ get deviceId() {
114
+ return this.#deviceId;
115
+ }
116
+
117
+ get listCounter() {
118
+ return this.#listCounter;
119
+ }
120
+
121
+ /** The list changed for a reason other than a rotation — adding a device. */
122
+ bumpListCounter() {
123
+ this.#listCounter += 1;
124
+ }
125
+
126
+ /**
127
+ * This device, as a device list names it.
128
+ *
129
+ * The label is empty and stays empty until there is a second device to tell
130
+ * apart. A hostname would be the obvious filler and is exactly the kind of
131
+ * thing this project does not put on a wire.
132
+ */
133
+ deviceDescriptor() {
134
+ return {
135
+ deviceId: this.#deviceId,
136
+ boxPk: this.publicKeyB64,
137
+ label: '',
138
+ createdAt: this.#deviceCreatedAt,
139
+ };
140
+ }
141
+
29
142
  // The identity fingerprint stays X25519-only on purpose: it is what users
30
143
  // already verified out-of-band, and the KEM key adds no authentication.
31
144
  get pqPublicKey() {
@@ -65,9 +178,24 @@ export class KeyManager {
65
178
  }
66
179
 
67
180
  /**
68
- * Generate a new keypair, keeping the old one for a grace period.
181
+ * Generate a new box keypair, keeping the old one for a grace period.
182
+ *
183
+ * The identity key and the device id are deliberately untouched. Rotating the
184
+ * box key is a routine hygiene step; rotating an identity is a claim that you
185
+ * are somebody new, and once device lists exist it would invalidate every one
186
+ * of them. A device that rotates is the same device.
69
187
  */
70
188
  rotate() {
189
+ // A secondary cannot rotate. Its box key is named in a list signed by an
190
+ // identity whose secret lives somewhere else, so a new key would be a key
191
+ // nothing vouches for — every peer would see an unproven device under a
192
+ // known name, which is the alarm this whole arc exists to stop firing
193
+ // wrongly. Rotating a secondary needs the primary to re-sign for it, and
194
+ // there is no channel for that yet.
195
+ if (!this.#identity) {
196
+ return;
197
+ }
198
+
71
199
  // Clear any existing grace timer
72
200
  if (this.#graceTimer) {
73
201
  clearTimeout(this.#graceTimer);
@@ -84,6 +212,10 @@ export class KeyManager {
84
212
  sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
85
213
 
86
214
  this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
215
+ // The descriptor just changed, so any list already out there describes a
216
+ // key this device no longer uses. Without this the new list would carry the
217
+ // old counter and every peer would discard it as not newer.
218
+ this.#listCounter += 1;
87
219
 
88
220
  // Auto-destroy previous keys after grace period
89
221
  this.#graceTimer = setTimeout(() => {
@@ -94,7 +226,7 @@ export class KeyManager {
94
226
 
95
227
  destroyPrevious() {
96
228
  if (this.#previousSecretKey) {
97
- sodium.sodium_memzero(this.#previousSecretKey);
229
+ sodium.sodium_free(this.#previousSecretKey);
98
230
  }
99
231
  this.#previousSecretKey = null;
100
232
  this.#previousPublicKey = null;
@@ -118,9 +250,11 @@ export class KeyManager {
118
250
  if (this.#graceTimer) {
119
251
  clearTimeout(this.#graceTimer);
120
252
  }
253
+ this.#identity?.destroy();
254
+ this.#identity = null;
121
255
  this.destroyPrevious();
122
256
  if (this.#secretKey) {
123
- sodium.sodium_memzero(this.#secretKey);
257
+ sodium.sodium_free(this.#secretKey);
124
258
  }
125
259
  this.#secretKey = null;
126
260
  this.#publicKey = null;
@@ -133,12 +267,22 @@ export class KeyManager {
133
267
  secretKey: this.#secretKey.toString('base64'),
134
268
  pqPublicKey: this.#pqKeyPair.publicKey.toString('base64'),
135
269
  pqSecretKey: this.#pqKeyPair.secretKey.toString('base64'),
270
+ identity: this.#identity ? this.#identity.serialize() : null,
271
+ identityPk: this.#identityPk,
272
+ grantedList: this.#grantedList,
273
+ deviceId: this.#deviceId,
274
+ deviceCreatedAt: this.#deviceCreatedAt,
275
+ listCounter: this.#listCounter,
136
276
  };
137
277
  }
138
278
 
139
279
  static deserialize(data) {
140
280
  const km = new KeyManager(); // generates throwaway keys
141
- sodium.sodium_memzero(km.#secretKey); // wipe throwaway
281
+ // sodium_free rather than memzero: the throwaway's pages are mlock'd, and
282
+ // zeroing leaves them locked until a finaliser happens to run. One page is
283
+ // harmless, but this is the pattern that aborted CI in 2.12.0 and it costs
284
+ // nothing to get right.
285
+ sodium.sodium_free(km.#secretKey);
142
286
 
143
287
  km.#publicKey = Buffer.from(data.publicKey, 'base64');
144
288
  const tempSec = Buffer.from(data.secretKey, 'base64');
@@ -156,6 +300,34 @@ export class KeyManager {
156
300
  secretKey: Buffer.from(data.pqSecretKey, 'base64'),
157
301
  };
158
302
  }
303
+ // Same shape for the identity: a session or a backup written before this
304
+ // existed simply keeps the fresh one generated above. Losing an identity
305
+ // that nothing reads yet costs nothing; refusing to restore the session
306
+ // would cost the user everything else in it.
307
+ const identity = DeviceIdentity.deserialize(data.identity);
308
+ if (identity) {
309
+ km.#identity.destroy();
310
+ km.#identity = identity;
311
+ km.#identityPk = identity.publicKeyB64;
312
+ } else if (typeof data.identityPk === 'string' && data.grantedList) {
313
+ // A secondary: an identity it does not hold the secret for, and the list
314
+ // it was granted. Restored together or not at all — one without the other
315
+ // is a device that claims an identity it cannot prove it belongs to.
316
+ km.#identity.destroy();
317
+ km.#identity = null;
318
+ km.#identityPk = data.identityPk;
319
+ km.#grantedList = data.grantedList;
320
+ }
321
+ if (typeof data.deviceId === 'string' && /^[0-9a-f]{32}$/.test(data.deviceId)) {
322
+ km.#deviceId = data.deviceId;
323
+ // Only meaningful alongside the id they belong to.
324
+ if (Number.isSafeInteger(data.deviceCreatedAt) && data.deviceCreatedAt >= 0) {
325
+ km.#deviceCreatedAt = data.deviceCreatedAt;
326
+ }
327
+ if (Number.isSafeInteger(data.listCounter) && data.listCounter >= 1) {
328
+ km.#listCounter = data.listCounter;
329
+ }
330
+ }
159
331
  return km;
160
332
  }
161
333
  }
@@ -9,6 +9,9 @@ const TRUST_FILE = 'trusted-peers.json';
9
9
  export const TrustResult = {
10
10
  NEW_PEER: 'new_peer',
11
11
  TRUSTED: 'trusted',
12
+ // A key that is not the one on the record, but that the identity bound to
13
+ // this record has signed as another of its devices. Not a mismatch.
14
+ KNOWN_DEVICE: 'known_device',
12
15
  MISMATCH: 'mismatch',
13
16
  VERIFIED_MISMATCH: 'verified_mismatch',
14
17
  };
@@ -75,6 +78,18 @@ export class TrustStore {
75
78
 
76
79
  // Compare the FULL public key (256 bits), not the 64-bit fingerprint.
77
80
  // Fall back to the fingerprint only for legacy records without publicKey.
81
+ // Once a synced device list exists, it is the authority on which keys are
82
+ // this person — including about the key the record was built on. A revoked
83
+ // primary that stayed TRUSTED here would make revocation decorative.
84
+ if (Array.isArray(record.devices)) {
85
+ if (record.devices.includes(publicKeyB64)) {
86
+ record.lastSeen = Date.now();
87
+ this.#save();
88
+ return record.publicKey === publicKeyB64 ? TrustResult.TRUSTED : TrustResult.KNOWN_DEVICE;
89
+ }
90
+ return record.verified ? TrustResult.VERIFIED_MISMATCH : TrustResult.MISMATCH;
91
+ }
92
+
78
93
  const matches = record.publicKey
79
94
  ? record.publicKey === publicKeyB64
80
95
  : record.fingerprint === KeyManager.computeFingerprint(Buffer.from(publicKeyB64, 'base64'));
@@ -85,6 +100,18 @@ export class TrustStore {
85
100
  return TrustResult.TRUSTED;
86
101
  }
87
102
 
103
+ // Another device of the same person is not a key that changed.
104
+ //
105
+ // Every key here was put there by a device list signed by the identity
106
+ // bound to this record — see syncDevices, which is the only writer. So this
107
+ // is not "a second key we have seen before", it is "a key the identity you
108
+ // verified says is also them".
109
+ if (record.devices?.includes(publicKeyB64)) {
110
+ record.lastSeen = Date.now();
111
+ this.#save();
112
+ return TrustResult.KNOWN_DEVICE;
113
+ }
114
+
88
115
  if (record.verified) {
89
116
  return TrustResult.VERIFIED_MISMATCH;
90
117
  }
@@ -173,6 +200,132 @@ export class TrustStore {
173
200
  return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
174
201
  }
175
202
 
203
+ /**
204
+ * The same construction over Ed25519 identity keys instead of box keys.
205
+ *
206
+ * A separate domain tag, not the same one with different inputs: two
207
+ * protocols that can produce the same digits from different material are two
208
+ * protocols one of them can be tricked into accepting. Same width, because
209
+ * the reason 40 bits replaced 20 has not changed.
210
+ *
211
+ * Only ever used when *both* sides will use it — see
212
+ * ChatController#identitySasReady. A pair where one side compares identity
213
+ * keys and the other compares box keys would show two different codes to two
214
+ * people who are doing everything right.
215
+ */
216
+ static computeIdentitySAS(myIdentityPk, peerIdentityPk) {
217
+ const mine = Buffer.isBuffer(myIdentityPk) ? myIdentityPk : Buffer.from(myIdentityPk, 'base64');
218
+ const theirs = Buffer.isBuffer(peerIdentityPk)
219
+ ? peerIdentityPk
220
+ : Buffer.from(peerIdentityPk, 'base64');
221
+
222
+ const [first, second] = Buffer.compare(mine, theirs) <= 0 ? [mine, theirs] : [theirs, mine];
223
+ const context = Buffer.from('CipherMesh-IdentitySAS-v1');
224
+ const hash = Buffer.alloc(32);
225
+ sodium.crypto_generichash(hash, Buffer.concat([first, second, context]));
226
+
227
+ let num = 0n;
228
+ for (let i = 0; i < 5; i++) {
229
+ num = (num << 8n) | BigInt(hash[i]);
230
+ }
231
+ const digits = num.toString().padStart(13, '0');
232
+ return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
233
+ }
234
+
235
+ /**
236
+ * Bind an identity key to a peer's record, keeping whatever verification the
237
+ * record already carries.
238
+ *
239
+ * This is the migration, and it is only ever called when the binding is
240
+ * provable: the identity signed a device list naming the box key this record
241
+ * was built on, and that list arrived over a channel only the holder of that
242
+ * box key could have written to. So the identity is vouched for by exactly
243
+ * the thing the user already verified out of band — the same reasoning
244
+ * `autoUpdatePeer` uses to carry verification across a key rotation.
245
+ *
246
+ * Nothing here can *create* verification. A record that was never verified is
247
+ * bound and stays unverified.
248
+ *
249
+ * @returns {'bound'|'unchanged'|'conflict'|'unknown'}
250
+ */
251
+ bindIdentity(nickname, identityPkB64) {
252
+ const record = this.#store.get(nickname.toLowerCase());
253
+ if (!record) {
254
+ return 'unknown';
255
+ }
256
+ if (record.identityPk === identityPkB64) {
257
+ return 'unchanged';
258
+ }
259
+ // A second identity claiming a record that already has one. Never silently
260
+ // overwritten: for a verified record this is the loud case, and it is the
261
+ // shape a takeover would have.
262
+ if (record.identityPk) {
263
+ return 'conflict';
264
+ }
265
+ record.identityPk = identityPkB64;
266
+ record.lastSeen = Date.now();
267
+ this.#save();
268
+ return 'bound';
269
+ }
270
+
271
+ /** The identity bound to a peer, or null. */
272
+ identityFor(nickname) {
273
+ return this.#store.get(nickname.toLowerCase())?.identityPk ?? null;
274
+ }
275
+
276
+ /**
277
+ * Set the device keys an identity has signed for to exactly this set.
278
+ *
279
+ * A replacement, not an accumulation, and that is the whole of revocation on
280
+ * the receiving side: a device that has been removed from the list stops
281
+ * being one of this person's keys here too. Accumulating would leave every
282
+ * revoked key trusted forever, which is worse than not having revocation at
283
+ * all, because it would look like it worked.
284
+ *
285
+ * The only writer of `record.devices`, and it refuses unless the identity
286
+ * asking is the one already bound to the record. Without that check any
287
+ * signed list could write keys onto anybody's record, which is the attack
288
+ * this prevents rather than enables.
289
+ *
290
+ * The record's primary `publicKey` is left alone even when the list no longer
291
+ * names it: it is the historical fact of what the user compared digits over.
292
+ * `checkPeer` stops honouring it, which is the part that matters.
293
+ *
294
+ * @returns {{added: string[], removed: string[]}}
295
+ */
296
+ syncDevices(nickname, identityPkB64, boxKeys) {
297
+ const record = this.#store.get(nickname.toLowerCase());
298
+ if (!record || !record.identityPk || record.identityPk !== identityPkB64) {
299
+ return { added: [], removed: [] };
300
+ }
301
+ const before = new Set(record.devices ?? []);
302
+ const after = new Set(boxKeys);
303
+
304
+ const added = [...after].filter((key) => !before.has(key));
305
+ const removed = [...before].filter((key) => !after.has(key));
306
+
307
+ if (added.length > 0 || removed.length > 0 || !record.devices) {
308
+ record.devices = [...after];
309
+ record.lastSeen = Date.now();
310
+ this.#save();
311
+ }
312
+ return { added, removed };
313
+ }
314
+
315
+ /**
316
+ * Every box key this peer is currently known by.
317
+ *
318
+ * Once a list has been synced it is the answer on its own; before that, the
319
+ * verified key is all there is.
320
+ */
321
+ devicesFor(nickname) {
322
+ const record = this.#store.get(nickname.toLowerCase());
323
+ if (!record) {
324
+ return [];
325
+ }
326
+ return record.devices ? [...record.devices] : [record.publicKey].filter(Boolean);
327
+ }
328
+
176
329
  markVerified(nickname) {
177
330
  const key = nickname.toLowerCase();
178
331
  const record = this.#store.get(key);
@@ -1,11 +1,11 @@
1
1
  import sodium from 'sodium-native';
2
- import notifier from 'node-notifier';
3
2
  import qrcode from 'qrcode-terminal';
4
3
  import { writeFileSync, mkdirSync } from 'node:fs';
5
4
  import { resolve, dirname } from 'node:path';
6
5
  import { tmpdir } from 'node:os';
7
6
  import { exportBackup } from '../crypto/IdentityBackup.js';
8
7
  import { keyArt } from '../shared/keyArt.js';
8
+ import { DesktopNotifier } from '../shared/desktopNotify.js';
9
9
  import {
10
10
  KEY_ROTATION_INTERVAL_MS,
11
11
  EMOJI_MAP,
@@ -47,6 +47,25 @@ import { COMMANDS } from '../client/UI.js';
47
47
  const TYPING_SEND_INTERVAL = 2000;
48
48
  const TYPING_EXPIRE_TIMEOUT = 3000;
49
49
 
50
+ // Commands that exist only on the relay side, and why — so the mesh can say so
51
+ // instead of guessing at a typo.
52
+ //
53
+ // `/device` is the interesting one. Multi-device is not merely unimplemented
54
+ // here: a mesh peer is keyed by *nickname* (`#peers` above is
55
+ // `Map<peerNickname, …>`) and has no session id, so two devices of one person
56
+ // collide on the very thing the peer map is indexed by. Supporting them means
57
+ // re-keying the mesh's whole model of who a peer is, which is a different
58
+ // design from the relay's and deliberately out of scope — see
59
+ // docs/design/multi-device.md.
60
+ const RELAY_ONLY = {
61
+ '/device':
62
+ 'In the mesh a peer is known by nickname, which is exactly what two of ' +
63
+ 'your devices would share, so it is a different design and not built yet.',
64
+ '/create': 'Rooms with an owner exist only where there is a relay to hold one.',
65
+ '/invite': 'There is no address to invite anyone to without a relay.',
66
+ '/nick': 'Your name here is the one you started with; there is no registry to change it in.',
67
+ };
68
+
50
69
  export class P2PChatController {
51
70
  #nickname;
52
71
  #connManager;
@@ -93,6 +112,7 @@ export class P2PChatController {
93
112
  #roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
94
113
  #historyStore; // encrypted local history (opt-in, needs a passphrase)
95
114
  #receiptsEnabled = true; // /receipts — send read confirmations
115
+ #desktopNotifier; // OS notifications, isolated + rate-limited
96
116
  #sentMessageLines = new Map(); // messageId → { lineIndex, baseLine, room }
97
117
  #messageReaders = new Map(); // messageId → Set<nickname>
98
118
  #pendingReceipts = new Map(); // messageId → Set<nickname> acked before we tracked it
@@ -145,6 +165,9 @@ export class P2PChatController {
145
165
  this.#trustStore.importData(restoredState.trust);
146
166
  }
147
167
  this.#auditLog = new AuditLog();
168
+ this.#desktopNotifier = new DesktopNotifier({
169
+ onUnavailable: (reason) => this.#onNotifierUnavailable(reason),
170
+ });
148
171
  this.#historyStore = historyStore;
149
172
  this.#ephemeralMode = false;
150
173
  this.#ephemeralDurationMs = 0;
@@ -734,7 +757,7 @@ export class P2PChatController {
734
757
  }
735
758
 
736
759
  if (notify && (this.#ui.notifyEnabled || mentioned)) {
737
- notifier.notify({
760
+ this.#desktopNotifier.notify({
738
761
  title: mentioned
739
762
  ? `🔔 ${fromNickname} mentioned you`
740
763
  : data.isDM
@@ -746,6 +769,17 @@ export class P2PChatController {
746
769
  }
747
770
  }
748
771
 
772
+ // The OS refused a desktop notification (Windows with notifications turned
773
+ // off for the app is the common case). Say it once, then stay quiet — the
774
+ // notifier has already stopped trying, so the chat never sees it again.
775
+ #onNotifierUnavailable(reason) {
776
+ this.#ui.setNotifyEnabled(false);
777
+ this.#ui.addInfoMessage(
778
+ `Desktop notifications unavailable — ${reason}. Muted for this session; ` +
779
+ 'sound alerts still work. Use /notify on to retry.',
780
+ );
781
+ }
782
+
749
783
  // ── TOFU: Trust On First Use ───────────────────────────────────
750
784
  #checkTrust(nickname, publicKey) {
751
785
  const result = this.#trustStore.checkPeer(nickname, publicKey);
@@ -1250,7 +1284,6 @@ export class P2PChatController {
1250
1284
  this.#ui.addQuoteLine(
1251
1285
  this.#lastReceivedNickname,
1252
1286
  (this.#lastReceivedText || '').slice(0, 80),
1253
- true,
1254
1287
  );
1255
1288
  this.#sendMessageToAll(replyText);
1256
1289
  break;
@@ -1532,14 +1565,17 @@ export class P2PChatController {
1532
1565
  const notifyArg = parts[1]?.toLowerCase();
1533
1566
  if (notifyArg === 'off') {
1534
1567
  this.#ui.setNotifyEnabled(false);
1568
+ this.#desktopNotifier.disable();
1535
1569
  this.#ui.addInfoMessage('Desktop notifications disabled');
1536
1570
  } else if (notifyArg === 'on') {
1537
1571
  this.#ui.setNotifyEnabled(true);
1572
+ this.#desktopNotifier.reset(); // give a previously refusing OS another go
1538
1573
  this.#ui.addInfoMessage('Desktop notifications enabled');
1539
1574
  } else {
1540
1575
  const status = this.#ui.notifyEnabled ? 'enabled' : 'disabled';
1576
+ const blocked = this.#desktopNotifier.available ? '' : ' (blocked by the OS)';
1541
1577
  this.#ui.addInfoMessage(
1542
- `Desktop notifications: ${status}. Use /notify on or /notify off`,
1578
+ `Desktop notifications: ${status}${blocked}. Use /notify on or /notify off`,
1543
1579
  );
1544
1580
  }
1545
1581
  break;
@@ -1996,6 +2032,7 @@ export class P2PChatController {
1996
2032
 
1997
2033
  case '/room':
1998
2034
  this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
2035
+ this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
1999
2036
  break;
2000
2037
 
2001
2038
  case '/tips': {
@@ -2108,6 +2145,14 @@ export class P2PChatController {
2108
2145
  break;
2109
2146
  }
2110
2147
  }
2148
+ // A relay-only command is not a typo, and guessing at one is worse than
2149
+ // saying nothing: `/device` currently suggests `/voice`, which sends
2150
+ // somebody looking in the wrong place entirely.
2151
+ const relayOnly = RELAY_ONLY[cmd];
2152
+ if (relayOnly) {
2153
+ this.#ui.addErrorMessage(`${cmd} needs a relay. ${relayOnly}`);
2154
+ return;
2155
+ }
2111
2156
  const suggestion = suggestCommand(cmd, COMMANDS);
2112
2157
  const hint = suggestion ? ` Did you mean ${suggestion}?` : ' Use /help';
2113
2158
  this.#ui.addErrorMessage(`Unknown command: ${cmd}.${hint}`);
@@ -2375,6 +2420,51 @@ export class P2PChatController {
2375
2420
  this.#broadcastPayload(payload, false, toPeer, room);
2376
2421
  }
2377
2422
 
2423
+ // Which path the next line you type will take, and what is holding it there.
2424
+ //
2425
+ // The mesh half of #481. The saving here is not frames — there is no relay to
2426
+ // fan anything out, so a mesh sends one frame per peer either way — it is
2427
+ // encryptions: one for the room, or one per peer. That is the cost the room
2428
+ // silently pays when something has pushed it back onto the pairwise path, and
2429
+ // until now nothing said so.
2430
+ //
2431
+ // The order matches the decision in #sendMessage, which is the only place
2432
+ // that chooses. Two of the reasons are relay-side concerns that do not exist
2433
+ // here (no hub to be older, no capability negotiation); one is a reason the
2434
+ // relay does not have — constant cover paces messages through pairwise slots
2435
+ // on purpose, because the timing guarantee is what it is for.
2436
+ groupSendStatus() {
2437
+ const inRoom = [...this.#peers.keys()].filter(
2438
+ (nick) => (this.#peerRooms.get(nick) || 'general') === this.#currentRoom,
2439
+ );
2440
+ if (this.#deniableMode) {
2441
+ return { group: false, reason: 'deniable', peers: inRoom.length };
2442
+ }
2443
+ if (this.#coverMode === 'constant') {
2444
+ return { group: false, reason: 'cover', peers: inRoom.length };
2445
+ }
2446
+ if (inRoom.length === 0) {
2447
+ return { group: false, reason: 'alone', peers: 0 };
2448
+ }
2449
+ return { group: true, reason: null, peers: inRoom.length };
2450
+ }
2451
+
2452
+ #describeSendPath() {
2453
+ const status = this.groupSendStatus();
2454
+ if (status.group) {
2455
+ return `one encryption for the room, sent to ${status.peers}`;
2456
+ }
2457
+ const cost = `${status.peers} encryption${status.peers === 1 ? '' : 's'} per message`;
2458
+ switch (status.reason) {
2459
+ case 'deniable':
2460
+ return `${cost} — deniable mode is on, and deniability is pairwise`;
2461
+ case 'cover':
2462
+ return `${cost} — constant cover paces messages through pairwise slots`;
2463
+ default:
2464
+ return 'nothing yet — no one else is in this room';
2465
+ }
2466
+ }
2467
+
2378
2468
  // Encrypt a room message ONCE and send the same ciphertext to every online
2379
2469
  // room peer — real group cryptography (O(1) encryption instead of O(N)).
2380
2470
  #sendRoomGroup(room, payload) {
@@ -50,7 +50,22 @@ function base(type) {
50
50
  // post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
51
51
  // caps (optional): what this client can do beyond the baseline protocol. Omitted
52
52
  // when empty so the wire is byte-identical to a pre-capability client.
53
- export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps = null) {
53
+ // identityKey: the Ed25519 key a device list will one day be signed with
54
+ // (docs/design/multi-device.md, step 2). Advertised now so the field is already
55
+ // in the field when something reads it, exactly as the capability list was.
56
+ //
57
+ // It is **unauthenticated here** and must stay that way in every reader's mind
58
+ // until step 3: the relay forwards it verbatim, so it can substitute one. What
59
+ // makes an identity key trustworthy is a signed device list checked against it,
60
+ // not its presence in a JOIN.
61
+ export function createJoin(
62
+ nickname,
63
+ publicKeyB64,
64
+ pqPublicKeyB64 = null,
65
+ caps = null,
66
+ identityKeyB64 = null,
67
+ deviceList = null,
68
+ ) {
54
69
  const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
55
70
  if (pqPublicKeyB64) {
56
71
  msg.pqPublicKey = pqPublicKeyB64;
@@ -58,6 +73,15 @@ export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps =
58
73
  if (Array.isArray(caps) && caps.length > 0) {
59
74
  msg.caps = [...caps];
60
75
  }
76
+ if (identityKeyB64) {
77
+ msg.identityKey = identityKeyB64;
78
+ }
79
+ // Only needed when the nickname is already held by another of your devices.
80
+ // Sent always when there is one, because a client cannot know in advance
81
+ // whether its other device is already online.
82
+ if (deviceList) {
83
+ msg.deviceList = deviceList;
84
+ }
61
85
  return msg;
62
86
  }
63
87