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
package/src/crypto/KeyManager.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/crypto/TrustStore.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/protocol/messages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|