@towns-protocol/encryption 0.0.252 → 0.0.255
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/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/decryptionExtensions.d.ts +0 -200
- package/dist/decryptionExtensions.d.ts.map +0 -1
- package/dist/decryptionExtensions.js +0 -688
- package/dist/decryptionExtensions.js.map +0 -1
- package/dist/tests/decryptionExtensions.test.d.ts +0 -2
- package/dist/tests/decryptionExtensions.test.d.ts.map +0 -1
- package/dist/tests/decryptionExtensions.test.js +0 -355
- package/dist/tests/decryptionExtensions.test.js.map +0 -1
|
@@ -1,688 +0,0 @@
|
|
|
1
|
-
import { SessionKeysSchema, } from '@towns-protocol/proto';
|
|
2
|
-
import { shortenHexString, dlog, dlogError, check, bin_toHexString, } from '@towns-protocol/dlog';
|
|
3
|
-
import { GroupEncryptionAlgorithmId, parseGroupEncryptionAlgorithmId, } from './olmLib';
|
|
4
|
-
import { create, fromJsonString } from '@bufbuild/protobuf';
|
|
5
|
-
export var DecryptionStatus;
|
|
6
|
-
(function (DecryptionStatus) {
|
|
7
|
-
DecryptionStatus["initializing"] = "initializing";
|
|
8
|
-
DecryptionStatus["updating"] = "updating";
|
|
9
|
-
DecryptionStatus["working"] = "working";
|
|
10
|
-
DecryptionStatus["idle"] = "idle";
|
|
11
|
-
DecryptionStatus["done"] = "done";
|
|
12
|
-
})(DecryptionStatus || (DecryptionStatus = {}));
|
|
13
|
-
class StreamTasks {
|
|
14
|
-
encryptedContent = new Array();
|
|
15
|
-
keySolicitations = new Array();
|
|
16
|
-
isMissingKeys = false;
|
|
17
|
-
keySolicitationsNeedsSort = false;
|
|
18
|
-
sortKeySolicitations() {
|
|
19
|
-
this.keySolicitations.sort((a, b) => a.respondAfter - b.respondAfter);
|
|
20
|
-
this.keySolicitationsNeedsSort = false;
|
|
21
|
-
}
|
|
22
|
-
isEmpty() {
|
|
23
|
-
return (this.encryptedContent.length === 0 &&
|
|
24
|
-
this.keySolicitations.length === 0 &&
|
|
25
|
-
!this.isMissingKeys);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
class StreamQueues {
|
|
29
|
-
streams = new Map();
|
|
30
|
-
getStreamIds() {
|
|
31
|
-
return Array.from(this.streams.keys());
|
|
32
|
-
}
|
|
33
|
-
getQueue(streamId) {
|
|
34
|
-
let tasks = this.streams.get(streamId);
|
|
35
|
-
if (!tasks) {
|
|
36
|
-
tasks = new StreamTasks();
|
|
37
|
-
this.streams.set(streamId, tasks);
|
|
38
|
-
}
|
|
39
|
-
return tasks;
|
|
40
|
-
}
|
|
41
|
-
isEmpty() {
|
|
42
|
-
for (const tasks of this.streams.values()) {
|
|
43
|
-
if (!tasks.isEmpty()) {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
toString() {
|
|
50
|
-
const counts = Array.from(this.streams.entries()).reduce((acc, [_, stream]) => {
|
|
51
|
-
acc['encryptedContent'] =
|
|
52
|
-
(acc['encryptedContent'] ?? 0) + stream.encryptedContent.length;
|
|
53
|
-
acc['streamsMissingKeys'] =
|
|
54
|
-
(acc['streamsMissingKeys'] ?? 0) + (stream.isMissingKeys ? 1 : 0);
|
|
55
|
-
acc['keySolicitations'] =
|
|
56
|
-
(acc['keySolicitations'] ?? 0) + stream.keySolicitations.length;
|
|
57
|
-
return acc;
|
|
58
|
-
}, {});
|
|
59
|
-
return Object.entries(counts)
|
|
60
|
-
.map(([key, count]) => `${key}: ${count}`)
|
|
61
|
-
.join(', ');
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
* Responsibilities:
|
|
67
|
-
* 1. Download new to-device messages that happened while we were offline
|
|
68
|
-
* 2. Decrypt new to-device messages
|
|
69
|
-
* 3. Decrypt encrypted content
|
|
70
|
-
* 4. Retry decryption failures, request keys for failed decryption
|
|
71
|
-
* 5. Respond to key solicitations
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* Notes:
|
|
75
|
-
* If in the future we started snapshotting the eventNum of the last message sent by every user,
|
|
76
|
-
* we could use that to determine the order we send out keys, and the order that we reply to key solicitations.
|
|
77
|
-
*
|
|
78
|
-
* It should be easy to introduce a priority stream, where we decrypt messages from that stream first, before
|
|
79
|
-
* anything else, so the messages show up quicky in the ui that the user is looking at.
|
|
80
|
-
*
|
|
81
|
-
* We need code to purge bad sessions (if someones sends us the wrong key, or a key that doesn't decrypt the message)
|
|
82
|
-
*/
|
|
83
|
-
export class BaseDecryptionExtensions {
|
|
84
|
-
_status = DecryptionStatus.initializing;
|
|
85
|
-
mainQueues = {
|
|
86
|
-
priorityTasks: new Array(),
|
|
87
|
-
newGroupSession: new Array(),
|
|
88
|
-
ownKeySolicitations: new Array(),
|
|
89
|
-
};
|
|
90
|
-
streamQueues = new StreamQueues();
|
|
91
|
-
upToDateStreams = new Set();
|
|
92
|
-
highPriorityIds = new Set();
|
|
93
|
-
recentStreamIds = [];
|
|
94
|
-
decryptionFailures = {}; // streamId: sessionId: EncryptedContentItem[]
|
|
95
|
-
inProgressTick;
|
|
96
|
-
timeoutId;
|
|
97
|
-
delayMs = 1;
|
|
98
|
-
started = false;
|
|
99
|
-
numRecentStreamIds = 5;
|
|
100
|
-
emitter;
|
|
101
|
-
_onStopFn;
|
|
102
|
-
log;
|
|
103
|
-
crypto;
|
|
104
|
-
entitlementDelegate;
|
|
105
|
-
userDevice;
|
|
106
|
-
userId;
|
|
107
|
-
constructor(emitter, crypto, entitlementDelegate, userDevice, userId, upToDateStreams, inLogId) {
|
|
108
|
-
this.emitter = emitter;
|
|
109
|
-
this.crypto = crypto;
|
|
110
|
-
this.entitlementDelegate = entitlementDelegate;
|
|
111
|
-
this.userDevice = userDevice;
|
|
112
|
-
this.userId = userId;
|
|
113
|
-
// initialize with a set of up-to-date streams
|
|
114
|
-
// ready for processing
|
|
115
|
-
this.upToDateStreams = upToDateStreams;
|
|
116
|
-
const shortKey = shortenHexString(userDevice.deviceKey);
|
|
117
|
-
const logId = `${inLogId}:${shortKey}`;
|
|
118
|
-
this.log = {
|
|
119
|
-
debug: dlog('csb:decryption:debug', { defaultEnabled: false }).extend(logId),
|
|
120
|
-
info: dlog('csb:decryption', { defaultEnabled: true }).extend(logId),
|
|
121
|
-
error: dlogError('csb:decryption:error').extend(logId),
|
|
122
|
-
};
|
|
123
|
-
this.log.debug('new DecryptionExtensions', { userDevice });
|
|
124
|
-
}
|
|
125
|
-
enqueueNewGroupSessions(sessions, _senderId) {
|
|
126
|
-
this.log.debug('enqueueNewGroupSessions', sessions);
|
|
127
|
-
const streamId = bin_toHexString(sessions.streamId);
|
|
128
|
-
this.mainQueues.newGroupSession.push({ streamId, sessions });
|
|
129
|
-
this.checkStartTicking();
|
|
130
|
-
}
|
|
131
|
-
enqueueNewEncryptedContent(streamId, eventId, kind, // kind of encrypted data
|
|
132
|
-
encryptedData) {
|
|
133
|
-
// dms, channels, gdms ("we're in the wrong package")
|
|
134
|
-
if (streamId.startsWith('20') || streamId.startsWith('88') || streamId.startsWith('77')) {
|
|
135
|
-
this.recentStreamIds.push(streamId);
|
|
136
|
-
if (this.recentStreamIds.length > this.numRecentStreamIds) {
|
|
137
|
-
this.recentStreamIds.shift();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
this.streamQueues.getQueue(streamId).encryptedContent.push({
|
|
141
|
-
streamId,
|
|
142
|
-
eventId,
|
|
143
|
-
kind,
|
|
144
|
-
encryptedData,
|
|
145
|
-
});
|
|
146
|
-
this.checkStartTicking();
|
|
147
|
-
}
|
|
148
|
-
enqueueInitKeySolicitations(streamId, eventHashStr, members, sigBundle) {
|
|
149
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
150
|
-
streamQueue.keySolicitations = [];
|
|
151
|
-
this.mainQueues.ownKeySolicitations = this.mainQueues.ownKeySolicitations.filter((x) => x.streamId !== streamId);
|
|
152
|
-
for (const member of members) {
|
|
153
|
-
const { userId: fromUserId, userAddress: fromUserAddress } = member;
|
|
154
|
-
for (const keySolicitation of member.solicitations) {
|
|
155
|
-
if (keySolicitation.deviceKey === this.userDevice.deviceKey) {
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (keySolicitation.sessionIds.length === 0) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const selectedQueue = fromUserId === this.userId
|
|
162
|
-
? this.mainQueues.ownKeySolicitations
|
|
163
|
-
: streamQueue.keySolicitations;
|
|
164
|
-
selectedQueue.push({
|
|
165
|
-
streamId,
|
|
166
|
-
fromUserId,
|
|
167
|
-
fromUserAddress,
|
|
168
|
-
solicitation: keySolicitation,
|
|
169
|
-
respondAfter: Date.now() + this.getRespondDelayMSForKeySolicitation(streamId, fromUserId),
|
|
170
|
-
sigBundle,
|
|
171
|
-
hashStr: eventHashStr,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
streamQueue.keySolicitationsNeedsSort = true;
|
|
176
|
-
this.checkStartTicking();
|
|
177
|
-
}
|
|
178
|
-
enqueueKeySolicitation(streamId, eventHashStr, fromUserId, fromUserAddress, keySolicitation, sigBundle) {
|
|
179
|
-
if (keySolicitation.deviceKey === this.userDevice.deviceKey) {
|
|
180
|
-
//this.log.debug('ignoring key solicitation for our own device')
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
184
|
-
const selectedQueue = fromUserId === this.userId
|
|
185
|
-
? this.mainQueues.ownKeySolicitations
|
|
186
|
-
: streamQueue.keySolicitations;
|
|
187
|
-
const index = selectedQueue.findIndex((x) => x.streamId === streamId && x.solicitation.deviceKey === keySolicitation.deviceKey);
|
|
188
|
-
if (index > -1) {
|
|
189
|
-
selectedQueue.splice(index, 1);
|
|
190
|
-
}
|
|
191
|
-
if (keySolicitation.sessionIds.length > 0 || keySolicitation.isNewDevice) {
|
|
192
|
-
//this.log.debug('new key solicitation', { fromUserId, streamId, keySolicitation })
|
|
193
|
-
streamQueue.keySolicitationsNeedsSort = true;
|
|
194
|
-
selectedQueue.push({
|
|
195
|
-
streamId,
|
|
196
|
-
fromUserId,
|
|
197
|
-
fromUserAddress,
|
|
198
|
-
solicitation: keySolicitation,
|
|
199
|
-
respondAfter: Date.now() + this.getRespondDelayMSForKeySolicitation(streamId, fromUserId),
|
|
200
|
-
sigBundle,
|
|
201
|
-
hashStr: eventHashStr,
|
|
202
|
-
});
|
|
203
|
-
this.checkStartTicking();
|
|
204
|
-
}
|
|
205
|
-
else if (index > -1) {
|
|
206
|
-
//this.log.debug('cleared key solicitation', keySolicitation)
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
setStreamUpToDate(streamId) {
|
|
210
|
-
//this.log.debug('streamUpToDate', streamId)
|
|
211
|
-
this.upToDateStreams.add(streamId);
|
|
212
|
-
this.checkStartTicking();
|
|
213
|
-
}
|
|
214
|
-
resetUpToDateStreams() {
|
|
215
|
-
this.upToDateStreams.clear();
|
|
216
|
-
this.checkStartTicking();
|
|
217
|
-
}
|
|
218
|
-
retryDecryptionFailures(streamId) {
|
|
219
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
220
|
-
if (this.decryptionFailures[streamId] &&
|
|
221
|
-
Object.keys(this.decryptionFailures[streamId]).length > 0) {
|
|
222
|
-
this.log.debug('membership change, re-enqueuing decryption failures for stream', streamId);
|
|
223
|
-
streamQueue.isMissingKeys = true;
|
|
224
|
-
this.checkStartTicking();
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
start() {
|
|
228
|
-
check(!this.started, 'start() called twice, please re-instantiate instead');
|
|
229
|
-
this.log.debug('starting');
|
|
230
|
-
this.started = true;
|
|
231
|
-
// let the subclass override and do any custom startup tasks
|
|
232
|
-
this.onStart();
|
|
233
|
-
// enqueue a task to upload device keys
|
|
234
|
-
this.mainQueues.priorityTasks.push(() => this.uploadDeviceKeys());
|
|
235
|
-
// enqueue a task to download new to-device messages
|
|
236
|
-
this.enqueueNewMessageDownload();
|
|
237
|
-
// start the tick loop
|
|
238
|
-
this.checkStartTicking();
|
|
239
|
-
}
|
|
240
|
-
// enqueue a task to download new to-device messages, should be safe to call multiple times
|
|
241
|
-
enqueueNewMessageDownload() {
|
|
242
|
-
this.mainQueues.priorityTasks.push(() => this.downloadNewMessages());
|
|
243
|
-
}
|
|
244
|
-
onStart() {
|
|
245
|
-
// let the subclass override and do any custom startup tasks
|
|
246
|
-
}
|
|
247
|
-
async stop() {
|
|
248
|
-
this._onStopFn?.();
|
|
249
|
-
this._onStopFn = undefined;
|
|
250
|
-
// let the subclass override and do any custom shutdown tasks
|
|
251
|
-
await this.onStop();
|
|
252
|
-
await this.stopTicking();
|
|
253
|
-
}
|
|
254
|
-
onStop() {
|
|
255
|
-
// let the subclass override and do any custom shutdown tasks
|
|
256
|
-
return Promise.resolve();
|
|
257
|
-
}
|
|
258
|
-
get status() {
|
|
259
|
-
return this._status;
|
|
260
|
-
}
|
|
261
|
-
setStatus(status) {
|
|
262
|
-
if (this._status !== status) {
|
|
263
|
-
this.log.debug(`status changed ${status}`);
|
|
264
|
-
this._status = status;
|
|
265
|
-
this.emitter.emit('decryptionExtStatusChanged', status);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
compareStreamIds(a, b) {
|
|
269
|
-
const recentStreamIds = new Set(this.recentStreamIds);
|
|
270
|
-
return (this.getPriorityForStream(a, this.highPriorityIds, recentStreamIds) -
|
|
271
|
-
this.getPriorityForStream(b, this.highPriorityIds, recentStreamIds));
|
|
272
|
-
}
|
|
273
|
-
lastPrintedAt = 0;
|
|
274
|
-
checkStartTicking() {
|
|
275
|
-
if (!this.started ||
|
|
276
|
-
this.timeoutId ||
|
|
277
|
-
!this._onStopFn ||
|
|
278
|
-
!this.isUserInboxStreamUpToDate(this.upToDateStreams) ||
|
|
279
|
-
this.shouldPauseTicking()) {
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
if (!Object.values(this.mainQueues).find((q) => q.length > 0) &&
|
|
283
|
-
this.streamQueues.isEmpty()) {
|
|
284
|
-
this.setStatus(DecryptionStatus.done);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (Date.now() - this.lastPrintedAt > 30000) {
|
|
288
|
-
this.log.info(`status: ${this.status} queues: ${Object.entries(this.mainQueues)
|
|
289
|
-
.map(([key, q]) => `${key}: ${q.length}`)
|
|
290
|
-
.join(', ')} ${this.streamQueues.toString()}`);
|
|
291
|
-
const streamIds = Array.from(this.streamQueues.streams.entries())
|
|
292
|
-
.filter(([_, value]) => !value.isEmpty())
|
|
293
|
-
.map(([key, _]) => key)
|
|
294
|
-
.sort((a, b) => this.compareStreamIds(a, b));
|
|
295
|
-
const first4Priority = streamIds
|
|
296
|
-
.filter((x) => this.upToDateStreams.has(x))
|
|
297
|
-
.slice(0, 4)
|
|
298
|
-
.join(', ');
|
|
299
|
-
const first4Blocked = streamIds
|
|
300
|
-
.filter((x) => !this.upToDateStreams.has(x))
|
|
301
|
-
.slice(0, 4)
|
|
302
|
-
.join(', ');
|
|
303
|
-
if (first4Priority.length > 0 || first4Blocked.length > 0) {
|
|
304
|
-
this.log.info(`priorityTasks: ${first4Priority} waitingFor: ${first4Blocked}`);
|
|
305
|
-
}
|
|
306
|
-
this.lastPrintedAt = Date.now();
|
|
307
|
-
}
|
|
308
|
-
this.timeoutId = setTimeout(() => {
|
|
309
|
-
this.inProgressTick = this.tick();
|
|
310
|
-
this.inProgressTick
|
|
311
|
-
.catch((e) => this.log.error('ProcessTick Error', e))
|
|
312
|
-
.finally(() => {
|
|
313
|
-
this.timeoutId = undefined;
|
|
314
|
-
setTimeout(() => this.checkStartTicking());
|
|
315
|
-
});
|
|
316
|
-
}, this.getDelayMs());
|
|
317
|
-
}
|
|
318
|
-
async stopTicking() {
|
|
319
|
-
if (this.timeoutId) {
|
|
320
|
-
clearTimeout(this.timeoutId);
|
|
321
|
-
this.timeoutId = undefined;
|
|
322
|
-
}
|
|
323
|
-
if (this.inProgressTick) {
|
|
324
|
-
try {
|
|
325
|
-
await this.inProgressTick;
|
|
326
|
-
}
|
|
327
|
-
catch (e) {
|
|
328
|
-
this.log.error('ProcessTick Error while stopping', e);
|
|
329
|
-
}
|
|
330
|
-
finally {
|
|
331
|
-
this.inProgressTick = undefined;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
getDelayMs() {
|
|
336
|
-
if (this.mainQueues.newGroupSession.length > 0) {
|
|
337
|
-
return 0;
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
return this.delayMs;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
// just do one thing then return
|
|
344
|
-
tick() {
|
|
345
|
-
const now = Date.now();
|
|
346
|
-
const priorityTask = this.mainQueues.priorityTasks.shift();
|
|
347
|
-
if (priorityTask) {
|
|
348
|
-
this.setStatus(DecryptionStatus.updating);
|
|
349
|
-
return priorityTask();
|
|
350
|
-
}
|
|
351
|
-
// update any new group sessions
|
|
352
|
-
const session = this.mainQueues.newGroupSession.shift();
|
|
353
|
-
if (session) {
|
|
354
|
-
this.setStatus(DecryptionStatus.working);
|
|
355
|
-
return this.processNewGroupSession(session);
|
|
356
|
-
}
|
|
357
|
-
const ownSolicitation = this.mainQueues.ownKeySolicitations.shift();
|
|
358
|
-
if (ownSolicitation) {
|
|
359
|
-
this.log.debug(' processing own key solicitation');
|
|
360
|
-
this.setStatus(DecryptionStatus.working);
|
|
361
|
-
return this.processKeySolicitation(ownSolicitation);
|
|
362
|
-
}
|
|
363
|
-
const streamIds = this.streamQueues.getStreamIds();
|
|
364
|
-
streamIds.sort((a, b) => this.compareStreamIds(a, b));
|
|
365
|
-
for (const streamId of streamIds) {
|
|
366
|
-
if (!this.upToDateStreams.has(streamId)) {
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
370
|
-
const encryptedContent = streamQueue.encryptedContent.shift();
|
|
371
|
-
if (encryptedContent) {
|
|
372
|
-
this.setStatus(DecryptionStatus.working);
|
|
373
|
-
return this.processEncryptedContentItem(encryptedContent);
|
|
374
|
-
}
|
|
375
|
-
if (streamQueue.isMissingKeys) {
|
|
376
|
-
this.setStatus(DecryptionStatus.working);
|
|
377
|
-
streamQueue.isMissingKeys = false;
|
|
378
|
-
return this.processMissingKeys(streamId);
|
|
379
|
-
}
|
|
380
|
-
if (streamQueue.keySolicitationsNeedsSort) {
|
|
381
|
-
streamQueue.sortKeySolicitations();
|
|
382
|
-
}
|
|
383
|
-
const keySolicitation = dequeueUpToDate(streamQueue.keySolicitations, now, (x) => x.respondAfter, this.upToDateStreams);
|
|
384
|
-
if (keySolicitation) {
|
|
385
|
-
this.setStatus(DecryptionStatus.working);
|
|
386
|
-
return this.processKeySolicitation(keySolicitation);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
this.setStatus(DecryptionStatus.idle);
|
|
390
|
-
return Promise.resolve();
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* processNewGroupSession
|
|
394
|
-
* process new group sessions that were sent to our to device stream inbox
|
|
395
|
-
* re-enqueue any decryption failures with matching session id
|
|
396
|
-
*/
|
|
397
|
-
async processNewGroupSession(sessionItem) {
|
|
398
|
-
const { streamId, sessions: session } = sessionItem;
|
|
399
|
-
// check if this message is to our device
|
|
400
|
-
const ciphertext = session.ciphertexts[this.userDevice.deviceKey];
|
|
401
|
-
if (!ciphertext) {
|
|
402
|
-
this.log.debug('skipping, no session for our device');
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
this.log.debug('processNewGroupSession', session);
|
|
406
|
-
// check if it contains any keys we need, default to GroupEncryption if the algorithm is not set
|
|
407
|
-
const parsed = parseGroupEncryptionAlgorithmId(session.algorithm, GroupEncryptionAlgorithmId.GroupEncryption);
|
|
408
|
-
if (parsed.kind === 'unrecognized') {
|
|
409
|
-
// todo dispatch event to update the error message
|
|
410
|
-
this.log.error('skipping, invalid algorithm', session.algorithm);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
const algorithm = parsed.value;
|
|
414
|
-
const neededKeyIndexs = [];
|
|
415
|
-
for (let i = 0; i < session.sessionIds.length; i++) {
|
|
416
|
-
const sessionId = session.sessionIds[i];
|
|
417
|
-
const hasKeys = await this.crypto.hasSessionKey(streamId, sessionId, algorithm);
|
|
418
|
-
if (!hasKeys) {
|
|
419
|
-
neededKeyIndexs.push(i);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
if (!neededKeyIndexs.length) {
|
|
423
|
-
this.log.debug('skipping, we have all the keys');
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
// decrypt the message
|
|
427
|
-
const cleartext = await this.crypto.decryptWithDeviceKey(ciphertext, session.senderKey);
|
|
428
|
-
const sessionKeys = fromJsonString(SessionKeysSchema, cleartext);
|
|
429
|
-
check(sessionKeys.keys.length === session.sessionIds.length, 'bad sessionKeys');
|
|
430
|
-
// make group sessions
|
|
431
|
-
const sessions = neededKeyIndexs.map((i) => ({
|
|
432
|
-
streamId: streamId,
|
|
433
|
-
sessionId: session.sessionIds[i],
|
|
434
|
-
sessionKey: sessionKeys.keys[i],
|
|
435
|
-
algorithm: algorithm,
|
|
436
|
-
}));
|
|
437
|
-
// import the sessions
|
|
438
|
-
this.log.debug('importing group sessions streamId:', streamId, 'count: ', sessions.length, session.sessionIds);
|
|
439
|
-
try {
|
|
440
|
-
await this.crypto.importSessionKeys(streamId, sessions);
|
|
441
|
-
// re-enqueue any decryption failures with these ids
|
|
442
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
443
|
-
for (const session of sessions) {
|
|
444
|
-
if (this.decryptionFailures[streamId]?.[session.sessionId]) {
|
|
445
|
-
streamQueue.encryptedContent.push(...this.decryptionFailures[streamId][session.sessionId]);
|
|
446
|
-
delete this.decryptionFailures[streamId][session.sessionId];
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
catch (e) {
|
|
451
|
-
// don't re-enqueue to prevent infinite loops if this session is truely corrupted
|
|
452
|
-
// we will keep requesting it on each boot until it goes out of the scroll window
|
|
453
|
-
this.log.error('failed to import sessions', { sessionItem, error: e });
|
|
454
|
-
}
|
|
455
|
-
// if we processed them all, ack the stream
|
|
456
|
-
if (this.mainQueues.newGroupSession.length === 0) {
|
|
457
|
-
await this.ackNewGroupSession(session);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* processEncryptedContentItem
|
|
462
|
-
* try to decrypt encrytped content
|
|
463
|
-
*/
|
|
464
|
-
async processEncryptedContentItem(item) {
|
|
465
|
-
this.log.debug('processEncryptedContentItem', item);
|
|
466
|
-
try {
|
|
467
|
-
await this.decryptGroupEvent(item.streamId, item.eventId, item.kind, item.encryptedData);
|
|
468
|
-
}
|
|
469
|
-
catch (err) {
|
|
470
|
-
const sessionNotFound = isSessionNotFoundError(err);
|
|
471
|
-
this.onDecryptionError(item, {
|
|
472
|
-
missingSession: sessionNotFound,
|
|
473
|
-
kind: item.kind,
|
|
474
|
-
encryptedData: item.encryptedData,
|
|
475
|
-
error: err,
|
|
476
|
-
});
|
|
477
|
-
if (sessionNotFound) {
|
|
478
|
-
const streamId = item.streamId;
|
|
479
|
-
const sessionId = item.encryptedData.sessionId && item.encryptedData.sessionId.length > 0
|
|
480
|
-
? item.encryptedData.sessionId
|
|
481
|
-
: bin_toHexString(item.encryptedData.sessionIdBytes);
|
|
482
|
-
if (sessionId.length === 0) {
|
|
483
|
-
this.log.error('session id length is 0 for failed decryption', {
|
|
484
|
-
err,
|
|
485
|
-
streamId: item.streamId,
|
|
486
|
-
eventId: item.eventId,
|
|
487
|
-
});
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
if (!this.decryptionFailures[streamId]) {
|
|
491
|
-
this.decryptionFailures[streamId] = { [sessionId]: [item] };
|
|
492
|
-
}
|
|
493
|
-
else if (!this.decryptionFailures[streamId][sessionId]) {
|
|
494
|
-
this.decryptionFailures[streamId][sessionId] = [item];
|
|
495
|
-
}
|
|
496
|
-
else if (!this.decryptionFailures[streamId][sessionId].includes(item)) {
|
|
497
|
-
this.decryptionFailures[streamId][sessionId].push(item);
|
|
498
|
-
}
|
|
499
|
-
const streamQueue = this.streamQueues.getQueue(streamId);
|
|
500
|
-
streamQueue.isMissingKeys = true;
|
|
501
|
-
}
|
|
502
|
-
else {
|
|
503
|
-
this.log.info('failed to decrypt', err, 'streamId', item.streamId);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* processMissingKeys
|
|
509
|
-
* process missing keys and send key solicitations to streams
|
|
510
|
-
*/
|
|
511
|
-
async processMissingKeys(streamId) {
|
|
512
|
-
this.log.debug('processing missing keys', streamId);
|
|
513
|
-
const missingSessionIds = takeFirst(100, Object.keys(this.decryptionFailures[streamId] ?? {}).sort());
|
|
514
|
-
// limit to 100 keys for now todo revisit https://linear.app/hnt-labs/issue/HNT-3936/revisit-how-we-limit-the-number-of-session-ids-that-we-request
|
|
515
|
-
if (!missingSessionIds.length) {
|
|
516
|
-
this.log.debug('processing missing keys', streamId, 'no missing keys');
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
if (!this.hasStream(streamId)) {
|
|
520
|
-
this.log.debug('processing missing keys', streamId, 'stream not found');
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
const isEntitled = await this.isUserEntitledToKeyExchange(streamId, this.userId, {
|
|
524
|
-
skipOnChainValidation: true,
|
|
525
|
-
});
|
|
526
|
-
if (!isEntitled) {
|
|
527
|
-
this.log.debug('processing missing keys', streamId, 'user is not member of stream');
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const solicitedEvents = this.getKeySolicitations(streamId);
|
|
531
|
-
const existingKeyRequest = solicitedEvents.find((x) => x.deviceKey === this.userDevice.deviceKey);
|
|
532
|
-
if (existingKeyRequest?.isNewDevice ||
|
|
533
|
-
sortedArraysEqual(existingKeyRequest?.sessionIds ?? [], missingSessionIds)) {
|
|
534
|
-
this.log.debug('processing missing keys already requested keys for this session', existingKeyRequest);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const knownSessionIds = await this.crypto.getGroupSessionIds(streamId);
|
|
538
|
-
const isNewDevice = knownSessionIds.length === 0;
|
|
539
|
-
this.log.debug('requesting keys', streamId, 'isNewDevice', isNewDevice, 'sessionIds:', missingSessionIds.length);
|
|
540
|
-
await this.sendKeySolicitation({
|
|
541
|
-
streamId,
|
|
542
|
-
isNewDevice,
|
|
543
|
-
missingSessionIds,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* processKeySolicitation
|
|
548
|
-
* process incoming key solicitations and send keys and key fulfillments
|
|
549
|
-
*/
|
|
550
|
-
async processKeySolicitation(item) {
|
|
551
|
-
this.log.debug('processing key solicitation', item.streamId, item);
|
|
552
|
-
const streamId = item.streamId;
|
|
553
|
-
check(this.hasStream(streamId), 'stream not found');
|
|
554
|
-
const { isValid, reason } = this.isValidEvent(item);
|
|
555
|
-
if (!isValid) {
|
|
556
|
-
this.log.error('processing key solicitation: invalid event id', {
|
|
557
|
-
streamId,
|
|
558
|
-
eventId: item.hashStr,
|
|
559
|
-
reason,
|
|
560
|
-
});
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
const knownSessionIds = await this.crypto.getGroupSessionIds(streamId);
|
|
564
|
-
// todo split this up by algorithm so that we can send all the new hybrid keys
|
|
565
|
-
knownSessionIds.sort();
|
|
566
|
-
const requestedSessionIds = new Set(item.solicitation.sessionIds.sort());
|
|
567
|
-
const replySessionIds = item.solicitation.isNewDevice
|
|
568
|
-
? knownSessionIds
|
|
569
|
-
: knownSessionIds.filter((x) => requestedSessionIds.has(x));
|
|
570
|
-
if (replySessionIds.length === 0) {
|
|
571
|
-
this.log.debug('processing key solicitation: no keys to reply with');
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
const isUserEntitledToKeyExchange = await this.isUserEntitledToKeyExchange(streamId, item.fromUserId);
|
|
575
|
-
if (!isUserEntitledToKeyExchange) {
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
const allSessions = [];
|
|
579
|
-
for (const sessionId of replySessionIds) {
|
|
580
|
-
const groupSession = await this.crypto.exportGroupSession(streamId, sessionId);
|
|
581
|
-
if (groupSession) {
|
|
582
|
-
allSessions.push(groupSession);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
this.log.debug('processing key solicitation with', item.streamId, {
|
|
586
|
-
to: item.fromUserId,
|
|
587
|
-
toDevice: item.solicitation.deviceKey,
|
|
588
|
-
requestedCount: item.solicitation.sessionIds.length,
|
|
589
|
-
replyIds: replySessionIds.length,
|
|
590
|
-
sessions: allSessions.length,
|
|
591
|
-
});
|
|
592
|
-
if (allSessions.length === 0) {
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
// send a single key fulfillment for all algorithms
|
|
596
|
-
const { error } = await this.sendKeyFulfillment({
|
|
597
|
-
streamId,
|
|
598
|
-
userAddress: item.fromUserAddress,
|
|
599
|
-
deviceKey: item.solicitation.deviceKey,
|
|
600
|
-
sessionIds: allSessions
|
|
601
|
-
.map((x) => x.sessionId)
|
|
602
|
-
.filter((x) => requestedSessionIds.has(x))
|
|
603
|
-
.sort(),
|
|
604
|
-
});
|
|
605
|
-
// if the key fulfillment failed, someone else already sent a key fulfillment
|
|
606
|
-
if (error) {
|
|
607
|
-
if (!error.msg.includes('DUPLICATE_EVENT') && !error.msg.includes('NOT_FOUND')) {
|
|
608
|
-
// duplicate events are expected, we can ignore them, others are not
|
|
609
|
-
this.log.error('failed to send key fulfillment', error);
|
|
610
|
-
}
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
// if the key fulfillment succeeded, send one group session payload for each algorithm
|
|
614
|
-
const sessions = allSessions.reduce((acc, session) => {
|
|
615
|
-
if (!acc[session.algorithm]) {
|
|
616
|
-
acc[session.algorithm] = [];
|
|
617
|
-
}
|
|
618
|
-
acc[session.algorithm].push(session);
|
|
619
|
-
return acc;
|
|
620
|
-
}, {});
|
|
621
|
-
// send one key fulfillment for each algorithm
|
|
622
|
-
for (const kv of Object.entries(sessions)) {
|
|
623
|
-
const algorithm = kv[0];
|
|
624
|
-
const sessions = kv[1];
|
|
625
|
-
await this.encryptAndShareGroupSessions({
|
|
626
|
-
streamId,
|
|
627
|
-
item,
|
|
628
|
-
sessions,
|
|
629
|
-
algorithm,
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* can be overridden to add a delay to the key solicitation response
|
|
635
|
-
*/
|
|
636
|
-
getRespondDelayMSForKeySolicitation(_streamId, _userId) {
|
|
637
|
-
return 0;
|
|
638
|
-
}
|
|
639
|
-
setHighPriorityStreams(streamIds) {
|
|
640
|
-
this.highPriorityIds = new Set(streamIds);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
export function makeSessionKeys(sessions) {
|
|
644
|
-
const sessionKeys = sessions.map((s) => s.sessionKey);
|
|
645
|
-
return create(SessionKeysSchema, {
|
|
646
|
-
keys: sessionKeys,
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
/// Returns the first item from the array,
|
|
650
|
-
/// if dateFn is provided, returns the first item where dateFn(item) <= now
|
|
651
|
-
function dequeueUpToDate(items, now, dateFn, upToDateStreams) {
|
|
652
|
-
if (items.length === 0) {
|
|
653
|
-
return undefined;
|
|
654
|
-
}
|
|
655
|
-
if (dateFn(items[0]) > now) {
|
|
656
|
-
return undefined;
|
|
657
|
-
}
|
|
658
|
-
const index = items.findIndex((x) => dateFn(x) <= now && upToDateStreams.has(x.streamId));
|
|
659
|
-
if (index === -1) {
|
|
660
|
-
return undefined;
|
|
661
|
-
}
|
|
662
|
-
return items.splice(index, 1)[0];
|
|
663
|
-
}
|
|
664
|
-
function sortedArraysEqual(a, b) {
|
|
665
|
-
if (a.length !== b.length) {
|
|
666
|
-
return false;
|
|
667
|
-
}
|
|
668
|
-
for (let i = 0; i < a.length; i++) {
|
|
669
|
-
if (a[i] !== b[i]) {
|
|
670
|
-
return false;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
return true;
|
|
674
|
-
}
|
|
675
|
-
function takeFirst(count, array) {
|
|
676
|
-
const result = [];
|
|
677
|
-
for (let i = 0; i < count && i < array.length; i++) {
|
|
678
|
-
result.push(array[i]);
|
|
679
|
-
}
|
|
680
|
-
return result;
|
|
681
|
-
}
|
|
682
|
-
function isSessionNotFoundError(err) {
|
|
683
|
-
if (err !== null && typeof err === 'object' && 'message' in err) {
|
|
684
|
-
return err.message.toLowerCase().includes('session not found');
|
|
685
|
-
}
|
|
686
|
-
return false;
|
|
687
|
-
}
|
|
688
|
-
//# sourceMappingURL=decryptionExtensions.js.map
|