@xmanrui/dsh-im 4.24.1 → 4.26.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.
Files changed (49) hide show
  1. package/PROACTIVE_DELIVERY.en.md +12 -4
  2. package/PROACTIVE_DELIVERY.md +12 -4
  3. package/README.en.md +11 -3
  4. package/README.md +11 -3
  5. package/THIRD_PARTY_NOTICES.md +2 -0
  6. package/lib/client.js +654 -311
  7. package/lib/index.js +298 -289
  8. package/package.json +7 -8
  9. package/plugin-src/client/channel-logos.js +11 -0
  10. package/plugin-src/client/channels/matrix/api.js +11 -0
  11. package/plugin-src/client/channels/matrix/index.js +151 -0
  12. package/plugin-src/client/channels/matrix/styles.js +36 -0
  13. package/plugin-src/client/global-settings.js +26 -14
  14. package/plugin-src/client/i18n.js +20 -0
  15. package/plugin-src/client/index.js +20 -0
  16. package/plugin-src/client/session-channel-logos.js +2 -1
  17. package/plugin-src/client/styles.js +19 -3
  18. package/plugin-src/client/update-panel.js +31 -11
  19. package/plugin-src/host/channels/matrix/index.mjs +31 -0
  20. package/plugin-src/host/channels/matrix/production.mjs +227 -0
  21. package/plugin-src/host/channels/matrix/rpc.mjs +228 -0
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/startup-error.mjs +2 -1
  24. package/plugin-src/host/delivery-adapter.mjs +18 -0
  25. package/plugin-src/host/delivery-rpc.mjs +7 -2
  26. package/plugin-src/host/delivery-service.mjs +8 -2
  27. package/plugin-src/host/delivery-suggestions.mjs +13 -0
  28. package/plugin-src/host/index.mjs +3 -0
  29. package/scripts/verify-injected-context.mjs +120 -0
  30. package/scripts/verify-lan-management.mjs +1 -1
  31. package/scripts/verify-package.mjs +6 -1
  32. package/src/channels/feishu/feishu-runtime.mjs +13 -3
  33. package/src/channels/matrix/matrix-api.mjs +696 -0
  34. package/src/channels/matrix/matrix-bridge.mjs +20 -0
  35. package/src/channels/matrix/matrix-config-store.mjs +356 -0
  36. package/src/channels/matrix/matrix-controller.mjs +404 -0
  37. package/src/channels/matrix/matrix-crypto-store.mjs +279 -0
  38. package/src/channels/matrix/matrix-crypto.mjs +1014 -0
  39. package/src/channels/matrix/matrix-harness-client.mjs +11 -0
  40. package/src/channels/matrix/matrix-normalize.mjs +357 -0
  41. package/src/channels/matrix/matrix-rich-text.mjs +313 -0
  42. package/src/channels/matrix/matrix-runtime.mjs +900 -0
  43. package/src/channels/shared/command-catalog.mjs +1 -1
  44. package/src/channels/shared/i18n-en/image-input.mjs +1 -0
  45. package/src/channels/shared/i18n-en/matrix.mjs +75 -0
  46. package/src/channels/shared/i18n-en.mjs +2 -0
  47. package/src/channels/shared/injected-context.mjs +3 -3
  48. package/src/channels/shared/session-channel-labels.mjs +1 -0
  49. package/src/channels/shared/text-harness-bridge.mjs +3 -0
@@ -0,0 +1,1014 @@
1
+ // Device-local E2EE orchestration for the Matrix channel on top of the official
2
+ // libolm WASM build (@matrix-org/olm). Covers the PR3 minimal loop: device key
3
+ // registration, Megolm room encryption/decryption, and to-device room key sharing,
4
+ // forwarding and requests. Key backup, SSSS and interactive verification are out of
5
+ // scope by design (docs/方案/Matrix端到端加密可行性调研.md §3).
6
+ import { createRequire } from 'node:module';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ import { t } from '../shared/i18n.mjs';
12
+ import { isMatrixRoomId, isMatrixUserId } from './matrix-api.mjs';
13
+
14
+ export const MATRIX_MEGOLM_ALGORITHM = 'm.megolm.v1_aes_sha2';
15
+ export const MATRIX_OLM_PK_ALGORITHM = 'm.olm.v1.curve25519';
16
+ // libolm's own spelling for the one-time ephemeral key field of a pk-encrypted message.
17
+ const DEFAULTS = Object.freeze({
18
+ rotationPeriodMsgs: 100,
19
+ rotationPeriodMs: 604_800_000,
20
+ oneTimeKeyFloor: 20,
21
+ oneTimeKeyTarget: 60,
22
+ oneTimeKeyMax: 100,
23
+ keyMaintainMs: 21_600_000,
24
+ deviceKeyTtlMs: 900_000,
25
+ roomMembersTtlMs: 120_000,
26
+ requestThrottleMs: 30_000,
27
+ requestMaxTries: 3,
28
+ });
29
+
30
+ const PK_FIELD = 'org.matrix.olm.pk_encryption_key';
31
+ const SIGN_FIELD = 'org.matrix.olm.pk_encryption_signature_payload';
32
+
33
+ let olmLoader = null;
34
+
35
+ export function loadMatrixOlm() {
36
+ if (!olmLoader) {
37
+ olmLoader = (async () => {
38
+ const requireFrom = createRequire(import.meta.url);
39
+ const entry = requireFrom.resolve('@matrix-org/olm');
40
+ const wasmEntry = requireFrom.resolve('@matrix-org/olm/olm.wasm');
41
+ const module = await import(pathToFileURL(entry).href);
42
+ const Olm = module.default ?? module;
43
+ const bytes = await readFile(wasmEntry);
44
+ await Olm.init({ wasmBinary: new Uint8Array(bytes) });
45
+ return Olm;
46
+ })().catch((error) => {
47
+ olmLoader = null;
48
+ throw error;
49
+ });
50
+ }
51
+ return olmLoader;
52
+ }
53
+
54
+ export class MatrixCryptoError extends Error {
55
+ constructor(message, { code = 'matrix-crypto', cause = undefined } = {}) {
56
+ super(message);
57
+ this.name = 'MatrixCryptoError';
58
+ this.code = code;
59
+ if (cause !== undefined) this.cause = cause;
60
+ }
61
+ }
62
+
63
+ function cleanText(value) {
64
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
65
+ }
66
+
67
+ function lowerId(value) {
68
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
69
+ }
70
+
71
+ function deviceKey(userId, deviceId) {
72
+ return `${lowerId(userId)}|${deviceId ?? ''}`;
73
+ }
74
+
75
+ export class MatrixCryptoEngine {
76
+ #api;
77
+ #store;
78
+ #userId;
79
+ #deviceId;
80
+ #logger;
81
+ #limits;
82
+ #now;
83
+ #onPendingMessage = null;
84
+
85
+ #olm = null;
86
+ #account = null;
87
+ #pkDecryption = null;
88
+ #passphrase = null;
89
+ #identity = Object.freeze({ curve25519: '', ed25519: '' });
90
+ #pkEncryptionKey = '';
91
+ #ready = false;
92
+
93
+ #inbound = new Map();
94
+ #outbound = new Map();
95
+ #deviceCache = new Map();
96
+ #memberCache = new Map();
97
+ #seenIndex = new Map();
98
+ #pendingEvents = new Map();
99
+ #requestState = new Map();
100
+ #lastKeyMaintenanceAt = 0;
101
+ #stats = {
102
+ undecryptable: 0,
103
+ forwardedResponses: 0,
104
+ keyUploads: 0,
105
+ roomKeySends: 0,
106
+ shareFailures: 0,
107
+ };
108
+
109
+ constructor({
110
+ api,
111
+ store,
112
+ userId,
113
+ deviceId,
114
+ logger = console,
115
+ limits = {},
116
+ now = () => Date.now(),
117
+ } = {}) {
118
+ if (!api || typeof api.uploadKeys !== 'function' || typeof api.queryKeys !== 'function') {
119
+ throw new TypeError('Matrix crypto engine requires a Matrix api instance');
120
+ }
121
+ if (!store || typeof store.bootstrap !== 'function' || typeof store.apply !== 'function') {
122
+ throw new TypeError('Matrix crypto engine requires a Matrix crypto store');
123
+ }
124
+ if (!isMatrixUserId(userId) || !cleanText(deviceId)) {
125
+ throw new TypeError('Matrix crypto engine requires a verified user id and device id');
126
+ }
127
+ this.#api = api;
128
+ this.#store = store;
129
+ this.#userId = userId;
130
+ this.#deviceId = String(deviceId);
131
+ this.#logger = logger ?? console;
132
+ this.#limits = Object.freeze({ ...DEFAULTS, ...limits });
133
+ this.#now = now;
134
+ }
135
+
136
+ get ready() {
137
+ return this.#ready;
138
+ }
139
+
140
+ get identity() {
141
+ return this.#identity;
142
+ }
143
+
144
+ setPendingMessageHandler(handler) {
145
+ this.#onPendingMessage = typeof handler === 'function' ? handler : null;
146
+ }
147
+
148
+ async start() {
149
+ if (this.#ready) return;
150
+ this.#olm = await loadMatrixOlm();
151
+ const snapshot = this.#store.snapshot;
152
+ if (snapshot) {
153
+ if (snapshot.deviceId !== this.#deviceId) {
154
+ throw new MatrixCryptoError(
155
+ t('Matrix 加密状态绑定于设备 {stored},与当前令牌设备 {active} 不符;请先修复设备配置,切勿删除加密状态文件后静默重建设备。'),
156
+ { code: 'crypto-device-drift' },
157
+ );
158
+ }
159
+ const account = new this.#olm.Account();
160
+ account.unpickle(snapshot.picklingPassphrase, snapshot.accountPickle);
161
+ const pkDecryption = new this.#olm.PkDecryption();
162
+ pkDecryption.unpickle(snapshot.picklingPassphrase, snapshot.pkDecryptionPickle);
163
+ this.#account = account;
164
+ this.#pkDecryption = pkDecryption;
165
+ this.#passphrase = snapshot.picklingPassphrase;
166
+ this.#identity = Object.freeze({
167
+ curve25519: snapshot.accountIdentities.curve25519,
168
+ ed25519: snapshot.accountIdentities.ed25519,
169
+ });
170
+ this.#pkEncryptionKey = snapshot.pkEncryptionKey;
171
+ for (const entry of snapshot.groupInbound) {
172
+ const key = `${entry.roomId}|${entry.senderKey}|${entry.sessionId}`;
173
+ if (this.#inbound.has(key) || key.length > 600) continue;
174
+ try {
175
+ const session = new this.#olm.InboundGroupSession();
176
+ session.unpickle(this.#passphrase, entry.pickle);
177
+ this.#inbound.set(key, { session, lastUsedAt: entry.lastUsedAt || this.#now(), meta: entry });
178
+ } catch (error) {
179
+ this.#logger.warn?.('[dsh-im:matrix] an inbound group session failed to restore and was dropped:', error?.message ?? error);
180
+ }
181
+ }
182
+ for (const [roomId, entry] of Object.entries(snapshot.groupOutbound)) {
183
+ try {
184
+ const session = new this.#olm.OutboundGroupSession(this.#account, {
185
+ algorithm: MATRIX_MEGOLM_ALGORITHM,
186
+ rotation_period_msgs: this.#limits.rotationPeriodMsgs,
187
+ rotation_period_ms: this.#limits.rotationPeriodMs,
188
+ });
189
+ session.unpickle(this.#passphrase, entry.pickle);
190
+ this.#outbound.set(roomId, {
191
+ session,
192
+ sessionId: entry.sessionId,
193
+ sharedWith: new Set(entry.sharedWith),
194
+ sharedOwnerKey: entry.sharedOwnerKey,
195
+ createdAt: entry.createdAt || this.#now(),
196
+ needsRotation: false,
197
+ });
198
+ } catch (error) {
199
+ this.#logger.warn?.('[dsh-im:matrix] an outbound group session failed to restore and will rotate fresh:', error?.message ?? error);
200
+ }
201
+ }
202
+ for (const [key, state] of Object.entries(snapshot.requestState ?? {})) {
203
+ this.#requestState.set(key, { ...state });
204
+ }
205
+ this.#lastKeyMaintenanceAt = snapshot.uploadedKeys?.uploadedAt ?? 0;
206
+ } else {
207
+ const account = new this.#olm.Account();
208
+ account.create();
209
+ const pkDecryption = new this.#olm.PkDecryption();
210
+ const pkPublicKey = pkDecryption.generate_key();
211
+ const identity = JSON.parse(account.identity_keys());
212
+ const passphraseSeed = Buffer.from(randomBytes(32)).toString('hex');
213
+ await this.#store.bootstrap({
214
+ deviceId: this.#deviceId,
215
+ picklingPassphrase: passphraseSeed,
216
+ accountPickle: account.pickle(passphraseSeed),
217
+ accountIdentities: Object.freeze({
218
+ curve25519: String(identity.curve25519 ?? ''),
219
+ ed25519: String(identity.ed25519 ?? ''),
220
+ }),
221
+ pkDecryptionPickle: pkDecryption.pickle(passphraseSeed),
222
+ pkEncryptionKey: String(pkPublicKey ?? ''),
223
+ oneTimeKeyWatermark: 0,
224
+ });
225
+ this.#account = account;
226
+ this.#pkDecryption = pkDecryption;
227
+ this.#passphrase = passphraseSeed;
228
+ this.#identity = Object.freeze({
229
+ curve25519: String(identity.curve25519 ?? ''),
230
+ ed25519: String(identity.ed25519 ?? ''),
231
+ });
232
+ this.#pkEncryptionKey = String(pkPublicKey ?? '');
233
+ }
234
+ if (!this.#identity.ed25519 || !this.#pkEncryptionKey) {
235
+ throw new MatrixCryptoError(t('Matrix 设备加密身份不完整,无法建立端到端加密会话。'), { code: 'crypto-init' });
236
+ }
237
+ await this.#registerKeys(true);
238
+ this.#ready = true;
239
+ }
240
+
241
+ async stop() {
242
+ for (const entry of this.#inbound.values()) entry.session.free?.();
243
+ for (const entry of this.#outbound.values()) entry.session.free?.();
244
+ this.#inbound.clear();
245
+ this.#outbound.clear();
246
+ this.#deviceCache.clear();
247
+ this.#memberCache.clear();
248
+ this.#pendingEvents.clear();
249
+ this.#seenIndex.clear();
250
+ this.#ready = false;
251
+ }
252
+
253
+ async maintain() {
254
+ if (!this.#ready) return;
255
+ const nowMs = this.#now();
256
+ if (nowMs - this.#lastKeyMaintenanceAt < this.#limits.keyMaintainMs) return;
257
+ await this.#registerKeys(false);
258
+ }
259
+
260
+ getStats() {
261
+ return {
262
+ ready: this.#ready,
263
+ deviceId: this.#deviceId,
264
+ ed25519Fingerprint: this.#identity.ed25519.slice(0, 12),
265
+ curve25519Fingerprint: this.#identity.curve25519.slice(0, 12),
266
+ inboundSessions: this.#inbound.size,
267
+ outboundRooms: this.#outbound.size,
268
+ cachedDevices: this.#deviceCache.size,
269
+ pendingEvents: this.#pendingEvents.size,
270
+ throttledRequests: this.#requestState.size,
271
+ ...this.#stats,
272
+ };
273
+ }
274
+
275
+ // ---- outbound ----------------------------------------------------------
276
+
277
+ async encryptForRoom(roomId, content) {
278
+ if (!this.#ready) throw new MatrixCryptoError(t('Matrix 加密引擎尚未就绪。'), { code: 'crypto-init' });
279
+ if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
280
+ const members = await this.#roomMembers(roomId);
281
+ const recipients = await this.#devicesForMembers(members);
282
+ const entry = await this.#getOutbound(roomId);
283
+ const missing = recipients.filter((device) => !entry.sharedWith.has(deviceKey(device.userId, device.deviceId)));
284
+ if (missing.length > 0 || entry.sharedOwnerKey !== this.#identity.ed25519) {
285
+ await this.#shareRoomKey(roomId, entry, missing.length > 0 ? missing : recipients);
286
+ }
287
+ const plaintext = JSON.stringify({
288
+ content,
289
+ room_id: roomId,
290
+ sender_key: this.#identity.ed25519,
291
+ sender_device_id: this.#deviceId,
292
+ sender_claimed_keys: {},
293
+ recipient_claimed_keys: {},
294
+ forwarding_claimed_keys: {},
295
+ });
296
+ let ciphertext;
297
+ try {
298
+ ciphertext = entry.session.encrypt(plaintext);
299
+ } catch (error) {
300
+ // A corrupted or stale session must rotate instead of poisoning the room timeline.
301
+ this.#logger.warn?.('[dsh-im:matrix] outbound group session failed; rotating before retry:', error?.message ?? error);
302
+ this.#rotateOutbound(roomId);
303
+ const fresh = await this.#getOutbound(roomId);
304
+ await this.#shareRoomKey(roomId, fresh, recipients);
305
+ ciphertext = fresh.session.encrypt(plaintext);
306
+ }
307
+ if (this.#messageIndex(entry) >= this.#limits.rotationPeriodMsgs) entry.needsRotation = true;
308
+ this.#persistOutbound(roomId, entry);
309
+ return Object.freeze({
310
+ algorithm: MATRIX_MEGOLM_ALGORITHM,
311
+ sender_key: this.#identity.ed25519,
312
+ device_id: this.#deviceId,
313
+ session_id: entry.sessionId,
314
+ ciphertext,
315
+ });
316
+ }
317
+
318
+ async #getOutbound(roomId) {
319
+ const existing = this.#outbound.get(roomId);
320
+ if (existing && !existing.needsRotation) return existing;
321
+ if (existing) {
322
+ const ageOk = this.#now() - existing.createdAt < this.#limits.rotationPeriodMs;
323
+ const indexOk = this.#messageIndex(existing) < this.#limits.rotationPeriodMsgs;
324
+ if (ageOk && indexOk && !existing.needsRotation) return existing;
325
+ existing.session.free?.();
326
+ this.#outbound.delete(roomId);
327
+ }
328
+ const session = new this.#olm.OutboundGroupSession(this.#account, {
329
+ algorithm: MATRIX_MEGOLM_ALGORITHM,
330
+ rotation_period_msgs: this.#limits.rotationPeriodMsgs,
331
+ rotation_period_ms: this.#limits.rotationPeriodMs,
332
+ });
333
+ session.create();
334
+ const entry = {
335
+ session,
336
+ sessionId: session.session_id(),
337
+ sharedWith: new Set(),
338
+ sharedOwnerKey: this.#identity.ed25519,
339
+ createdAt: this.#now(),
340
+ needsRotation: false,
341
+ };
342
+ this.#outbound.set(roomId, entry);
343
+ return entry;
344
+ }
345
+
346
+ #rotateOutbound(roomId) {
347
+ const existing = this.#outbound.get(roomId);
348
+ if (existing) {
349
+ existing.session.free?.();
350
+ this.#outbound.delete(roomId);
351
+ }
352
+ }
353
+
354
+ #messageIndex(entry) {
355
+ const index = Number(entry.session?.message_index?.() ?? 0);
356
+ return Number.isSafeInteger(index) && index >= 0 ? index : 0;
357
+ }
358
+
359
+ async #shareRoomKey(roomId, entry, recipients) {
360
+ if (recipients.length === 0) {
361
+ entry.sharedWith = new Set();
362
+ this.#persistOutbound(roomId, entry);
363
+ return;
364
+ }
365
+ const sessionKey = entry.session.session_key();
366
+ const messages = {};
367
+ const shared = new Set(entry.sharedWith);
368
+ let sent = 0;
369
+ for (const device of recipients) {
370
+ if (!device.pkEncryptionKey) {
371
+ this.#stats.shareFailures += 1;
372
+ continue;
373
+ }
374
+ const payload = JSON.stringify({
375
+ room_id: roomId,
376
+ session_id: entry.sessionId,
377
+ session_key: sessionKey,
378
+ sender_key: this.#identity.ed25519,
379
+ sender_device_id: this.#deviceId,
380
+ sender_claimed_keys: {},
381
+ forwarding_claimed_keys: {},
382
+ });
383
+ let pkg;
384
+ try {
385
+ const pkEncryption = new this.#olm.PkEncryption();
386
+ pkEncryption.set_recipient_key(device.pkEncryptionKey);
387
+ pkg = pkEncryption.encrypt(payload);
388
+ } catch (error) {
389
+ this.#stats.shareFailures += 1;
390
+ this.#logger.warn?.('[dsh-im:matrix] room key pk-encryption failed for one device:', error?.message ?? error);
391
+ continue;
392
+ }
393
+ const perUser = (messages[device.userId] ??= {});
394
+ perUser[device.deviceId] = {
395
+ algorithm: MATRIX_OLM_PK_ALGORITHM,
396
+ recipient_key: device.pkEncryptionKey,
397
+ sender_key: this.#identity.ed25519,
398
+ sender_claimed_keys: {},
399
+ ciphertext: pkg.ciphertext,
400
+ mac: pkg.mac,
401
+ ephemeral: pkg.ephemeral ?? pkg.ephemeral_key ?? null,
402
+ };
403
+ shared.add(deviceKey(device.userId, device.deviceId));
404
+ sent += 1;
405
+ }
406
+ if (sent > 0) {
407
+ try {
408
+ await this.#api.sendToDevice('m.room_key', messages);
409
+ this.#stats.roomKeySends += 1;
410
+ } catch (error) {
411
+ // Do not claim delivery the messages did not get; the next send retries the share.
412
+ this.#stats.shareFailures += 1;
413
+ this.#logger.warn?.('[dsh-im:matrix] to-device room key submission failed; it will be retried:', error?.message ?? error);
414
+ return;
415
+ }
416
+ }
417
+ entry.sharedWith = shared;
418
+ entry.sharedOwnerKey = this.#identity.ed25519;
419
+ this.#persistOutbound(roomId, entry);
420
+ }
421
+
422
+ async #roomMembers(roomId) {
423
+ const cached = this.#memberCache.get(roomId);
424
+ if (cached && this.#now() - cached.at < this.#limits.roomMembersTtlMs) return cached.members;
425
+ const listing = await this.#api.getJoinedMembers(roomId).catch(() => null);
426
+ const members = Object.freeze(Array.isArray(listing)
427
+ ? listing.filter((entry) => isMatrixUserId(entry) && lowerId(entry) !== lowerId(this.#userId))
428
+ : []);
429
+ this.#memberCache.set(roomId, { at: this.#now(), members });
430
+ return members;
431
+ }
432
+
433
+ async #devicesForMembers(userIds) {
434
+ const devices = [];
435
+ const missing = [];
436
+ for (const userId of userIds) {
437
+ const cached = this.#deviceCache.get(lowerId(userId));
438
+ if (cached && this.#now() - cached.at < this.#limits.deviceKeyTtlMs) {
439
+ for (const device of cached.devices.values()) devices.push({ userId, ...device });
440
+ continue;
441
+ }
442
+ missing.push(userId);
443
+ }
444
+ if (missing.length > 0) await this.#queryDevices(missing);
445
+ for (const userId of missing) {
446
+ const cached = this.#deviceCache.get(lowerId(userId));
447
+ if (!cached) continue;
448
+ for (const device of cached.devices.values()) devices.push({ userId, ...device });
449
+ }
450
+ return devices;
451
+ }
452
+
453
+ async #queryDevices(userIds) {
454
+ if (userIds.length === 0) return;
455
+ const response = await this.#api.queryKeys(userIds).catch(() => null);
456
+ const all = response?.device_keys ?? {};
457
+ for (const userId of userIds) {
458
+ const devices = new Map();
459
+ const perUser = all[userId] ?? {};
460
+ for (const [deviceId, entry] of Object.entries(perUser)) {
461
+ const keys = entry?.keys && typeof entry.keys === 'object' ? entry.keys : {};
462
+ const curve = cleanText(keys.curve25519);
463
+ const ed = cleanText(keys.ed25519);
464
+ const pkEncryptionKey = cleanText(entry?.[PK_FIELD]) ?? cleanText(keys[PK_FIELD]);
465
+ if (!curve || !ed || !pkEncryptionKey) continue;
466
+ const verified = this.#verifyDeviceSignature(userId, deviceId, entry);
467
+ if (!verified) {
468
+ this.#logger.warn?.(`[dsh-im:matrix] device ${deviceId} of ${userId} failed signature verification and was skipped`);
469
+ continue;
470
+ }
471
+ devices.set(deviceId, {
472
+ deviceId,
473
+ curve25519: curve,
474
+ ed25519: ed,
475
+ pkEncryptionKey,
476
+ signatures: entry?.signatures ?? null,
477
+ });
478
+ }
479
+ this.#deviceCache.set(lowerId(userId), { at: this.#now(), devices });
480
+ }
481
+ }
482
+
483
+ #verifyDeviceSignature(userId, deviceId, entry) {
484
+ const signaturePayload = cleanText(entry?.[SIGN_FIELD]);
485
+ const edKey = cleanText(entry?.keys?.ed25519);
486
+ const signature = cleanText(entry?.signatures?.[edKey ?? '']) ?? cleanText(entry?.signatures_ed?.[edKey ?? '']);
487
+ if (!signaturePayload || !edKey || !signature) return false;
488
+ try {
489
+ const utility = new this.#olm.Utility();
490
+ // Measured shape: ed25519_verify(publicKey, message, signature); wrong orders throw INVALID_BASE64.
491
+ utility.ed25519_verify(edKey, signaturePayload, signature);
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ }
496
+ }
497
+
498
+ invalidateRoomSharing(roomId) {
499
+ this.#memberCache.delete(roomId);
500
+ this.#deviceCache.clear();
501
+ const entry = this.#outbound.get(roomId);
502
+ if (entry) {
503
+ entry.sharedWith = new Set();
504
+ this.#persistOutbound(roomId, entry);
505
+ }
506
+ }
507
+
508
+ // ---- inbound -----------------------------------------------------------
509
+
510
+ async handleToDeviceEvents(events) {
511
+ if (!this.#ready || !Array.isArray(events)) return;
512
+ for (const event of events) {
513
+ const type = event?.type;
514
+ try {
515
+ if (type === 'm.room_key') await this.#handleRoomKeyEvent(event);
516
+ else if (type === 'm.forwarded_room_key') await this.#handleForwardedRoomKeyEvent(event);
517
+ else if (type === 'm.room_key_request') await this.#handleRoomKeyRequest(event);
518
+ } catch (error) {
519
+ this.#logger.warn?.('[dsh-im:matrix] a to-device crypto event could not be processed:', error?.message ?? error);
520
+ }
521
+ }
522
+ }
523
+
524
+ #pkDecryptPayload(pkg) {
525
+ const ephemeral = cleanText(pkg?.ephemeral ?? pkg?.ephemeral_key);
526
+ const mac = cleanText(pkg?.mac);
527
+ const ciphertext = cleanText(pkg?.ciphertext);
528
+ if (!ephemeral || !mac || !ciphertext) return null;
529
+ try {
530
+ return this.#pkDecryption.decrypt(ephemeral, mac, ciphertext);
531
+ } catch (error) {
532
+ // The recipient's pk keys cannot open this package (wrong recipient, replay or tamper): drop it
533
+ // quietly as undecryptable rather than leak the olm error text, which carries attacker data.
534
+ void error;
535
+ return null;
536
+ }
537
+ }
538
+
539
+ // Every to-device handler consumes per-device pk packages: objects carrying an olm/megolm
540
+ // ciphertext plus its mac and ephemeral key. A delivered event holds the package as its own
541
+ // content; a relaying homeserver or a sending-side caller may keep the { user: { device: package } }
542
+ // map; and the legacy forwarded-key form nests the packages one level deeper under `keys`.
543
+ #mineToDevicesMessages(content) {
544
+ const payloads = [];
545
+ const pushPackage = (value) => {
546
+ if (value && typeof value === 'object' && typeof value.ciphertext === 'string') payloads.push(value);
547
+ };
548
+ const messages = content?.messages;
549
+ if (messages && typeof messages === 'object' && !Array.isArray(messages)) {
550
+ // Servers key the to-device map by the original casing; also scan case-insensitively.
551
+ const mine = messages[this.#userId] ?? messages[lowerId(this.#userId)];
552
+ const deviceMaps = mine && typeof mine === 'object'
553
+ ? [mine]
554
+ : Object.values(messages).filter((entry) => entry && typeof entry === 'object');
555
+ for (const deviceMap of deviceMaps) {
556
+ for (const value of Object.values(deviceMap)) pushPackage(value);
557
+ }
558
+ }
559
+ pushPackage(content);
560
+ if (payloads.length === 0) {
561
+ const keys = content?.keys;
562
+ if (keys && typeof keys === 'object' && !Array.isArray(keys)) {
563
+ for (const perUser of Object.values(keys)) {
564
+ if (!perUser || typeof perUser !== 'object') continue;
565
+ for (const perDevice of Object.values(perUser)) {
566
+ if (!perDevice || typeof perDevice !== 'object') continue;
567
+ for (const value of Object.values(perDevice)) pushPackage(value);
568
+ }
569
+ }
570
+ }
571
+ }
572
+ return payloads;
573
+ }
574
+
575
+ async #handleRoomKeyEvent(event) {
576
+ const content = event?.content;
577
+ if (cleanText(content?.action) === 'request_cancellation') {
578
+ const sessionId = cleanText(content?.session_id);
579
+ if (sessionId) for (const key of [...this.#requestState.keys()]) {
580
+ if (key.includes(`|${sessionId}`)) this.#requestState.delete(key);
581
+ }
582
+ return;
583
+ }
584
+ for (const pkg of this.#mineToDevicesMessages(content)) {
585
+ const action = cleanText(pkg?.action);
586
+ if (action !== null && action !== 'send') continue;
587
+ const inner = this.#pkDecryptPayload(pkg);
588
+ if (!inner) {
589
+ this.#stats.undecryptable += 1;
590
+ continue;
591
+ }
592
+ let payload;
593
+ try {
594
+ payload = JSON.parse(inner);
595
+ } catch {
596
+ continue;
597
+ }
598
+ const roomId = cleanText(payload.room_id);
599
+ const sessionId = cleanText(payload.session_id);
600
+ const sessionKey = cleanText(payload.session_key);
601
+ const senderKey = cleanText(payload.sender_key) ?? cleanText(pkg.sender_key);
602
+ if (!isMatrixRoomId(roomId) || !sessionId || !sessionKey || !senderKey) continue;
603
+ await this.#importInbound(roomId, senderKey, sessionId, sessionKey, { forwarded: false });
604
+ }
605
+ }
606
+
607
+ async #handleForwardedRoomKeyEvent(event) {
608
+ const pkg = this.#mineToDevicesMessages(event?.content)[0] ?? event?.content;
609
+ const inner = this.#pkDecryptPayload(pkg);
610
+ if (!inner) {
611
+ this.#stats.undecryptable += 1;
612
+ return;
613
+ }
614
+ let payload;
615
+ try {
616
+ payload = JSON.parse(inner);
617
+ } catch {
618
+ return;
619
+ }
620
+ const roomId = cleanText(payload.room_id);
621
+ const sessionId = cleanText(payload.session_id);
622
+ const sessionKey = cleanText(payload.session_key ?? payload.exported_session_key);
623
+ const senderKey = cleanText(payload.sender_key);
624
+ const forwardingKey = cleanText(payload.forwarding_key);
625
+ if (!isMatrixRoomId(roomId) || !sessionId || !sessionKey || !senderKey) return;
626
+ // Only accept forwarded keys from a source whose own key we already trust in this room,
627
+ // which bounds the key-poisoning surface to peers the room key already came through.
628
+ const trusted = this.#inbound.has(`${roomId}|${senderKey}|${sessionId}`)
629
+ || this.#inbound.has(`${roomId}|${forwardingKey ?? ''}|${sessionId}`);
630
+ if (!trusted) {
631
+ this.#logger.warn?.('[dsh-im:matrix] a forwarded room key from an unknown forwarding device was rejected');
632
+ return;
633
+ }
634
+ await this.#importInbound(roomId, senderKey, sessionId, sessionKey, { forwarded: true });
635
+ }
636
+
637
+ async #handleRoomKeyRequest(event) {
638
+ const pkg = this.#mineToDevicesMessages(event?.content)[0] ?? event?.content;
639
+ const inner = this.#pkDecryptPayload(pkg);
640
+ if (!inner) return;
641
+ let payload;
642
+ try {
643
+ payload = JSON.parse(inner);
644
+ } catch {
645
+ return;
646
+ }
647
+ if (cleanText(payload.action) !== 'request') return;
648
+ const roomId = cleanText(payload.room_id);
649
+ const sessionId = cleanText(payload.session_id);
650
+ const requestingUserId = cleanText(payload.requesting_user_id);
651
+ const requestingDeviceId = cleanText(payload.requesting_device_id);
652
+ if (!isMatrixRoomId(roomId) || !sessionId || !requestingUserId || !requestingDeviceId) return;
653
+ if (lowerId(requestingUserId) === lowerId(this.#userId)) return;
654
+ const throttleKey = `${lowerId(requestingUserId)}|${requestingDeviceId}|${roomId}|${sessionId}`;
655
+ const state = this.#requestState.get(throttleKey);
656
+ const nowMs = this.#now();
657
+ if (state && nowMs - state.at < this.#limits.requestThrottleMs && state.tries >= this.#limits.requestMaxTries) return;
658
+ for (const [key, entry] of this.#inbound) {
659
+ if (!key.startsWith(`${roomId}|`) || !key.endsWith(`|${sessionId}`)) continue;
660
+ let exported = null;
661
+ try {
662
+ exported = entry.session.export_session(entry.meta?.firstKnownIndex ?? 0);
663
+ } catch {
664
+ try {
665
+ exported = entry.session.export_session(0);
666
+ } catch {
667
+ exported = null;
668
+ }
669
+ }
670
+ if (!exported) return;
671
+ const devices = await this.#devicesForMembers([requestingUserId]);
672
+ const target = devices.find((device) => device.deviceId === requestingDeviceId);
673
+ if (!target?.pkEncryptionKey) return;
674
+ const body = JSON.stringify({
675
+ action: 'send',
676
+ room_id: roomId,
677
+ session_id: sessionId,
678
+ sender_key: this.#identity.ed25519,
679
+ forwarding_key: this.#identity.ed25519,
680
+ session_key: exported,
681
+ exported: true,
682
+ });
683
+ const pkEncryption = new this.#olm.PkEncryption();
684
+ pkEncryption.set_recipient_key(target.pkEncryptionKey);
685
+ const pkgOut = pkEncryption.encrypt(body);
686
+ await this.#api.sendToDevice('m.forwarded_room_key', {
687
+ [requestingUserId]: {
688
+ [requestingDeviceId]: {
689
+ algorithm: MATRIX_OLM_PK_ALGORITHM,
690
+ recipient_key: target.pkEncryptionKey,
691
+ sender_key: this.#identity.ed25519,
692
+ sender_claimed_keys: {},
693
+ ciphertext: pkgOut.ciphertext,
694
+ mac: pkgOut.mac,
695
+ ephemeral: pkgOut.ephemeral ?? pkgOut.ephemeral_key ?? null,
696
+ },
697
+ },
698
+ }).catch(() => undefined);
699
+ this.#stats.forwardedResponses += 1;
700
+ this.#requestState.set(throttleKey, { at: nowMs, tries: (state?.tries ?? 0) + 1 });
701
+ this.#persistRequestState();
702
+ return;
703
+ }
704
+ }
705
+
706
+ async decryptRoomEvent(roomId, event) {
707
+ if (!this.#ready) return null;
708
+ const content = event?.content;
709
+ const algorithm = cleanText(content?.algorithm);
710
+ const sessionId = cleanText(content?.session_id);
711
+ const senderKey = cleanText(content?.sender_key);
712
+ const ciphertext = cleanText(content?.ciphertext);
713
+ if (!algorithm || !sessionId || !senderKey || !ciphertext) return null;
714
+ if (algorithm !== MATRIX_MEGOLM_ALGORITHM) return null;
715
+ const key = `${roomId}|${senderKey}|${sessionId}`;
716
+ const entry = this.#inbound.get(key);
717
+ if (!entry) {
718
+ this.#stats.undecryptable += 1;
719
+ this.#pendingEvents.set(`${key}|${event?.event_id ?? 'x'}`, { roomId, event, at: this.#now() });
720
+ void this.#requestRoomKey(roomId, event);
721
+ return null;
722
+ }
723
+ const seen = this.#seenIndexes(key);
724
+ let messageIndex = 0;
725
+ let plaintext;
726
+ try {
727
+ const result = entry.session.decrypt(ciphertext);
728
+ if (typeof result === 'string') {
729
+ plaintext = result;
730
+ messageIndex = Number(entry.session.last_message_index?.() ?? 0) || 0;
731
+ } else {
732
+ plaintext = result?.plaintext;
733
+ messageIndex = Number(result?.message_index) || 0;
734
+ }
735
+ } catch {
736
+ this.#stats.undecryptable += 1;
737
+ return null;
738
+ }
739
+ if (seen.has(messageIndex)) {
740
+ // A replayed megolm payload of an already accepted index must not duplicate the message.
741
+ this.#stats.undecryptable += 1;
742
+ return null;
743
+ }
744
+ seen.add(messageIndex);
745
+ if (seen.size > 4_096) seen.delete(seen.values().next().value);
746
+ entry.lastUsedAt = this.#now();
747
+ let payload;
748
+ try {
749
+ payload = JSON.parse(plaintext);
750
+ } catch {
751
+ this.#stats.undecryptable += 1;
752
+ return null;
753
+ }
754
+ if (cleanText(payload.room_id) !== roomId) {
755
+ this.#stats.undecryptable += 1;
756
+ return null;
757
+ }
758
+ if (cleanText(payload.sender_key) && cleanText(payload.sender_key) !== senderKey) {
759
+ this.#stats.undecryptable += 1;
760
+ return null;
761
+ }
762
+ const content2 = payload.content && typeof payload.content === 'object' ? payload.content : null;
763
+ if (!content2) {
764
+ this.#stats.undecryptable += 1;
765
+ return null;
766
+ }
767
+ return {
768
+ content: content2,
769
+ senderKey,
770
+ sessionId,
771
+ senderDeviceId: cleanText(payload.sender_device_id) ?? cleanText(content.device_id) ?? null,
772
+ };
773
+ }
774
+
775
+ #seenIndexes(key) {
776
+ let set = this.#seenIndex.get(key);
777
+ if (!set) {
778
+ set = new Set();
779
+ this.#seenIndex.set(key, set);
780
+ }
781
+ return set;
782
+ }
783
+
784
+ async #requestRoomKey(roomId, event) {
785
+ const sessionId = cleanText(event?.content?.session_id);
786
+ const senderKey = cleanText(event?.content?.sender_key);
787
+ if (!sessionId) return;
788
+ const throttleKey = `req|${roomId}|${sessionId}|${senderKey ?? ''}`;
789
+ const state = this.#requestState.get(throttleKey);
790
+ const nowMs = this.#now();
791
+ if (state && nowMs - state.at < this.#limits.requestThrottleMs && state.tries >= this.#limits.requestMaxTries) return;
792
+ const members = await this.#roomMembers(roomId);
793
+ const devices = await this.#devicesForMembers(members.filter((_, index) => index < 50));
794
+ if (devices.length === 0) return;
795
+ const body = JSON.stringify({
796
+ action: 'request',
797
+ room_id: roomId,
798
+ session_id: sessionId,
799
+ requesting_user_id: this.#userId,
800
+ requesting_device_id: this.#deviceId,
801
+ });
802
+ const messages = {};
803
+ for (const device of devices) {
804
+ if (!device.pkEncryptionKey) continue;
805
+ const pkEncryption = new this.#olm.PkEncryption();
806
+ pkEncryption.set_recipient_key(device.pkEncryptionKey);
807
+ let pkg;
808
+ try {
809
+ pkg = pkEncryption.encrypt(body);
810
+ } catch {
811
+ continue;
812
+ }
813
+ const perUser = (messages[device.userId] ??= {});
814
+ perUser[device.deviceId] = {
815
+ algorithm: MATRIX_OLM_PK_ALGORITHM,
816
+ recipient_key: device.pkEncryptionKey,
817
+ sender_key: this.#identity.ed25519,
818
+ sender_claimed_keys: {},
819
+ ciphertext: pkg.ciphertext,
820
+ mac: pkg.mac,
821
+ ephemeral: pkg.ephemeral ?? pkg.ephemeral_key ?? null,
822
+ };
823
+ }
824
+ if (Object.keys(messages).length === 0) return;
825
+ await this.#api.sendToDevice('m.room_key_request', messages).catch(() => undefined);
826
+ this.#requestState.set(throttleKey, { at: nowMs, tries: (state?.tries ?? 0) + 1 });
827
+ this.#persistRequestState();
828
+ }
829
+
830
+ async #importInbound(roomId, senderKey, sessionId, sessionKey, { forwarded }) {
831
+ const key = `${roomId}|${senderKey}|${sessionId}`;
832
+ const existing = this.#inbound.get(key);
833
+ if (existing) {
834
+ existing.lastUsedAt = this.#now();
835
+ await this.#flushPendingForKey(key);
836
+ return;
837
+ }
838
+ const session = new this.#olm.InboundGroupSession();
839
+ try {
840
+ if (forwarded) session.import_session(sessionKey);
841
+ else session.create(sessionKey);
842
+ } catch {
843
+ try {
844
+ session.import_session(sessionKey);
845
+ } catch (error) {
846
+ session.free?.();
847
+ this.#logger.warn?.('[dsh-im:matrix] an incoming room key could not be imported:', error?.message ?? error);
848
+ return;
849
+ }
850
+ }
851
+ const firstKnownIndex = Number(session.first_known_index?.() ?? 0) || 0;
852
+ this.#inbound.set(key, {
853
+ session,
854
+ lastUsedAt: this.#now(),
855
+ meta: { roomId, senderKey, sessionId, firstKnownIndex },
856
+ });
857
+ this.#trimInbound();
858
+ await this.#persistInbound(key);
859
+ await this.#flushPendingForKey(key);
860
+ }
861
+
862
+ #trimInbound() {
863
+ if (this.#inbound.size <= 900) return;
864
+ const ordered = [...this.#inbound.entries()].sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt);
865
+ for (const [key, entry] of ordered.slice(0, this.#inbound.size - 800)) {
866
+ entry.session.free?.();
867
+ this.#inbound.delete(key);
868
+ this.#seenIndex.delete(key);
869
+ }
870
+ }
871
+
872
+ async #flushPendingForKey(key) {
873
+ if (!this.#onPendingMessage) return;
874
+ const prefix = `${key}|`;
875
+ for (const [pendingKey, pending] of [...this.#pendingEvents]) {
876
+ if (!pendingKey.startsWith(prefix)) continue;
877
+ this.#pendingEvents.delete(pendingKey);
878
+ try {
879
+ await this.#onPendingMessage(pending.roomId, pending.event);
880
+ } catch (error) {
881
+ this.#logger.warn?.('[dsh-im:matrix] a late-decrypted message could not be dispatched:', error?.message ?? error);
882
+ }
883
+ }
884
+ // Keep the buffer bounded even when no handler drains it.
885
+ if (this.#pendingEvents.size > 64) {
886
+ const oldest = [...this.#pendingEvents.keys()].sort((left, right) =>
887
+ (this.#pendingEvents.get(left)?.at ?? 0) - (this.#pendingEvents.get(right)?.at ?? 0));
888
+ for (const stale of oldest.slice(0, this.#pendingEvents.size - 64)) this.#pendingEvents.delete(stale);
889
+ }
890
+ }
891
+
892
+ // ---- persistence -------------------------------------------------------
893
+
894
+ #persistOutbound(roomId, entry) {
895
+ const snapshot = this.#store.snapshot;
896
+ if (!snapshot) return Promise.resolve();
897
+ const groupOutbound = { ...snapshot.groupOutbound };
898
+ try {
899
+ groupOutbound[roomId] = {
900
+ pickle: entry.session.pickle(this.#passphrase),
901
+ sessionId: entry.sessionId,
902
+ sharedOwnerKey: entry.sharedOwnerKey,
903
+ messageIndex: this.#messageIndex(entry),
904
+ sharedWith: [...entry.sharedWith],
905
+ createdAt: entry.createdAt,
906
+ lastUsedAt: this.#now(),
907
+ };
908
+ } catch {
909
+ return Promise.resolve();
910
+ }
911
+ return this.#store.apply({ groupOutbound }).catch(() => undefined);
912
+ }
913
+
914
+ #persistInbound(key) {
915
+ const snapshot = this.#store.snapshot;
916
+ if (!snapshot) return Promise.resolve();
917
+ const entry = this.#inbound.get(key);
918
+ if (!entry) return Promise.resolve();
919
+ let pickle;
920
+ try {
921
+ pickle = entry.session.pickle(this.#passphrase);
922
+ } catch {
923
+ return Promise.resolve();
924
+ }
925
+ const groupInbound = [
926
+ ...snapshot.groupInbound.filter((candidate) =>
927
+ !(candidate.roomId === entry.meta.roomId && candidate.senderKey === entry.meta.senderKey
928
+ && candidate.sessionId === entry.meta.sessionId)),
929
+ {
930
+ roomId: entry.meta.roomId,
931
+ senderKey: entry.meta.senderKey,
932
+ sessionId: entry.meta.sessionId,
933
+ pickle,
934
+ firstKnownIndex: entry.meta.firstKnownIndex ?? 0,
935
+ createdAt: entry.lastUsedAt,
936
+ lastUsedAt: entry.lastUsedAt,
937
+ },
938
+ ].slice(-900);
939
+ return this.#store.apply({ groupInbound }).catch(() => undefined);
940
+ }
941
+
942
+ #persistRequestState() {
943
+ const snapshot = this.#store.snapshot;
944
+ if (!snapshot) return Promise.resolve();
945
+ const requestState = {};
946
+ for (const [key, state] of [...this.#requestState].slice(-200)) requestState[key] = { ...state };
947
+ return this.#store.apply({ requestState }).catch(() => undefined);
948
+ }
949
+
950
+ // ---- key registration ---------------------------------------------------
951
+
952
+ async #registerKeys(force) {
953
+ if (!this.#account) return;
954
+ if (!force && this.#now() - this.#lastKeyMaintenanceAt < this.#limits.keyMaintainMs) return;
955
+ const unpublished = this.#unpublishedCount();
956
+ if (!force && unpublished >= this.#limits.oneTimeKeyFloor) return;
957
+ const missing = Math.max(0, this.#limits.oneTimeKeyTarget - unpublished);
958
+ if (missing > 0) {
959
+ this.#account.generate_one_time_keys(Math.min(missing, this.#limits.oneTimeKeyMax));
960
+ }
961
+ const otkJson = JSON.parse(this.#account.one_time_keys() ?? '{}');
962
+ const otk = otkJson?.curve25519 && typeof otkJson.curve25519 === 'object' ? otkJson.curve25519 : {};
963
+ const fallbackJson = JSON.parse(this.#account.unpublished_fallback_key() ?? '{}');
964
+ if (!fallbackJson?.curve25519 || Object.keys(fallbackJson.curve25519).length === 0) {
965
+ this.#account.generate_fallback_key();
966
+ }
967
+ const fallbackParsed = JSON.parse(this.#account.fallback_key() ?? '{}');
968
+ const fallback = fallbackParsed?.curve25519 && typeof fallbackParsed.curve25519 === 'object'
969
+ ? fallbackParsed.curve25519 : {};
970
+ const fallbackKeys = Object.entries(fallback).map(([keyId, key]) => ({ key_id: keyId, key: String(key) }));
971
+ const signaturePayload = JSON.stringify({
972
+ user_id: this.#userId,
973
+ device_id: this.#deviceId,
974
+ curve25519: this.#identity.curve25519,
975
+ ed25519: this.#identity.ed25519,
976
+ [PK_FIELD]: this.#pkEncryptionKey,
977
+ });
978
+ const signature = this.#account.sign(signaturePayload);
979
+ await this.#api.uploadKeys({
980
+ device_id: this.#deviceId,
981
+ keys: { curve25519: this.#identity.curve25519, ed25519: this.#identity.ed25519 },
982
+ fallback_keys: fallbackKeys,
983
+ one_time_keys: otk,
984
+ signatures: { [this.#identity.ed25519]: signature },
985
+ signatures_ed: { [this.#identity.ed25519]: signature },
986
+ [PK_FIELD]: this.#pkEncryptionKey,
987
+ [SIGN_FIELD]: signaturePayload,
988
+ });
989
+ this.#account.mark_keys_as_published();
990
+ this.#lastKeyMaintenanceAt = this.#now();
991
+ this.#stats.keyUploads += 1;
992
+ const snapshot = this.#store.snapshot;
993
+ if (snapshot) {
994
+ await this.#store.apply({
995
+ oneTimeKeyWatermark: (snapshot.oneTimeKeyWatermark ?? 0) + Object.keys(otk).length,
996
+ uploadedKeys: {
997
+ signaturePayload,
998
+ uploadedAt: this.#lastKeyMaintenanceAt,
999
+ oneTimeKeyCount: Object.keys(otk).length,
1000
+ },
1001
+ }).catch(() => undefined);
1002
+ }
1003
+ }
1004
+
1005
+ #unpublishedCount() {
1006
+ try {
1007
+ const parsed = JSON.parse(this.#account.one_time_keys() ?? '{}');
1008
+ return parsed?.curve25519 && typeof parsed.curve25519 === 'object'
1009
+ ? Object.keys(parsed.curve25519).length : 0;
1010
+ } catch {
1011
+ return 0;
1012
+ }
1013
+ }
1014
+ }