@towns-protocol/sdk
Version:
For more details, visit the following resources:
844 lines • 36.7 kB
JavaScript
import { SessionKeysSchema, } from '@towns-protocol/proto';
import { shortenHexString, dlog, dlogError, check, bin_toHexString, } from '@towns-protocol/dlog';
import { GroupEncryptionAlgorithmId, parseGroupEncryptionAlgorithmId, } from '@towns-protocol/encryption';
import { create, fromJsonString } from '@bufbuild/protobuf';
import { sortedArraysEqual } from './observable/utils';
import { isDefined } from './check';
export var DecryptionStatus;
(function (DecryptionStatus) {
DecryptionStatus["initializing"] = "initializing";
DecryptionStatus["updating"] = "updating";
DecryptionStatus["working"] = "working";
DecryptionStatus["idle"] = "idle";
DecryptionStatus["done"] = "done";
})(DecryptionStatus || (DecryptionStatus = {}));
class StreamTasks {
encryptedContent = new Array();
keySolicitations = new Array();
isMissingKeys = false;
keySolicitationsNeedsSort = false;
sortKeySolicitations() {
this.keySolicitations.sort((a, b) => a.respondAfter - b.respondAfter);
this.keySolicitationsNeedsSort = false;
}
isEmpty() {
return (this.encryptedContent.length === 0 &&
this.keySolicitations.length === 0 &&
!this.isMissingKeys);
}
}
class StreamQueues {
streams = new Map();
getStreamIds() {
return Array.from(this.streams.keys());
}
getQueue(streamId) {
let tasks = this.streams.get(streamId);
if (!tasks) {
tasks = new StreamTasks();
this.streams.set(streamId, tasks);
}
return tasks;
}
isEmpty() {
for (const tasks of this.streams.values()) {
if (!tasks.isEmpty()) {
return false;
}
}
return true;
}
toString() {
const counts = Array.from(this.streams.entries()).reduce((acc, [_, stream]) => {
acc['encryptedContent'] =
(acc['encryptedContent'] ?? 0) + stream.encryptedContent.length;
acc['streamsMissingKeys'] =
(acc['streamsMissingKeys'] ?? 0) + (stream.isMissingKeys ? 1 : 0);
acc['keySolicitations'] =
(acc['keySolicitations'] ?? 0) + stream.keySolicitations.length;
return acc;
}, {});
return Object.entries(counts)
.map(([key, count]) => `${key}: ${count}`)
.join(', ');
}
}
class QueueRunner {
kind;
timeoutId;
inProgress;
streamId;
checkStartTicking;
logError;
tag;
constructor(kind) {
this.kind = kind;
}
toString() {
return `${this.kind}${this.tag ? ` ${this.tag}` : ''}${this.streamId ? ` ${this.streamId}` : ''}`;
}
run(promise, streamId, tag) {
this.tag = tag;
this.inProgress = promise;
this.streamId = streamId;
this.inProgress
.catch((e) => this.logError?.(`ProcessTick ${this.kind} Error`, e))
.finally(() => {
this.timeoutId = undefined;
this.inProgress = undefined;
this.streamId = undefined;
this.tag = undefined;
setTimeout(() => this.checkStartTicking?.());
});
}
async stop() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
if (this.inProgress) {
try {
await this.inProgress;
}
catch (e) {
this.logError?.(`ProcessTick Error while stopping ${this.kind}`, e);
}
finally {
this.inProgress = undefined;
}
}
}
}
/**
*
* Responsibilities:
* 1. Download new to-device messages that happened while we were offline
* 2. Decrypt new to-device messages
* 3. Decrypt encrypted content
* 4. Retry decryption failures, request keys for failed decryption
* 5. Respond to key solicitations
*
*
* Notes:
* If in the future we started snapshotting the eventNum of the last message sent by every user,
* we could use that to determine the order we send out keys, and the order that we reply to key solicitations.
*
* It should be easy to introduce a priority stream, where we decrypt messages from that stream first, before
* anything else, so the messages show up quicky in the ui that the user is looking at.
*
* We need code to purge bad sessions (if someones sends us the wrong key, or a key that doesn't decrypt the message)
*/
export class BaseDecryptionExtensions {
_status = DecryptionStatus.initializing;
mainQueues = {
priorityTasks: new Array(),
newGroupSession: new Array(),
ownKeySolicitations: new Array(),
ephemeralKeySolicitations: new Array(),
};
streamQueues = new StreamQueues();
mainQueueRunners = {
priority: new QueueRunner('priority'),
newGroupSessions: new QueueRunner('newGroupSession'),
ownKeySolicitations: new QueueRunner('ownKeySolicitations'),
ephemeralKeySolicitations: new QueueRunner('ephemeralKeySolicitations'),
};
streamQueueRunners = [
new QueueRunner('stream1'),
new QueueRunner('stream2'),
new QueueRunner('stream3'),
];
allQueueRunners = [...Object.values(this.mainQueueRunners), ...this.streamQueueRunners];
upToDateStreams = new Set();
highPriorityIds = new Set();
recentStreamIds = [];
decryptionFailures = {}; // streamId: sessionId: EncryptedContentItem[]
ownEphemeralSolicitations = new Map(); // key: streamId, value: array of solicitations
started = false;
numRecentStreamIds = 5;
emitter;
checkStartTimeoutId;
_onStopFn;
log;
crypto;
entitlementDelegate;
userDevice;
userId;
ephemeralTimeoutMs = 30000; // Default 30 seconds
constructor(emitter, crypto, entitlementDelegate, userDevice, userId, upToDateStreams, inLogId) {
this.emitter = emitter;
this.crypto = crypto;
this.entitlementDelegate = entitlementDelegate;
this.userDevice = userDevice;
this.userId = userId;
// initialize with a set of up-to-date streams
// ready for processing
this.upToDateStreams = upToDateStreams;
const shortKey = shortenHexString(userDevice.deviceKey);
const logId = `${inLogId}:${shortKey}`;
this.log = {
debug: dlog('csb:decryption:debug', { defaultEnabled: false }).extend(logId),
info: dlog('csb:decryption', { defaultEnabled: true }).extend(logId),
error: dlogError('csb:decryption:error').extend(logId),
};
this.log.debug('new DecryptionExtensions', { userDevice });
// initialize the queue runners
for (const queueRunner of this.allQueueRunners) {
queueRunner.logError = this.log.error;
queueRunner.checkStartTicking = () => this.checkStartTicking();
}
}
enqueueNewGroupSessions(sessions, _senderId) {
this.log.debug('enqueueNewGroupSessions', sessions);
const streamId = bin_toHexString(sessions.streamId);
this.mainQueues.newGroupSession.push({ streamId, sessions });
this.checkStartTicking();
}
enqueueNewEncryptedContent(streamId, eventId, kind, // kind of encrypted data
encryptedData) {
// dms, channels, gdms ("we're in the wrong package")
if (streamId.startsWith('20') || streamId.startsWith('88') || streamId.startsWith('77')) {
this.recentStreamIds.push(streamId);
if (this.recentStreamIds.length > this.numRecentStreamIds) {
this.recentStreamIds.shift();
}
}
this.streamQueues.getQueue(streamId).encryptedContent.push({
streamId,
eventId,
kind,
encryptedData,
});
this.checkStartTicking();
}
enqueueInitKeySolicitations(streamId, eventHashStr, members, sigBundle) {
const streamQueue = this.streamQueues.getQueue(streamId);
streamQueue.keySolicitations = [];
this.mainQueues.ownKeySolicitations = this.mainQueues.ownKeySolicitations.filter((x) => x.streamId !== streamId);
for (const member of members) {
const { userId: fromUserId, userAddress: fromUserAddress } = member;
for (const keySolicitation of member.solicitations) {
if (keySolicitation.deviceKey === this.userDevice.deviceKey) {
continue;
}
if (keySolicitation.sessionIds.length === 0) {
continue;
}
const selectedQueue = fromUserId === this.userId
? this.mainQueues.ownKeySolicitations
: streamQueue.keySolicitations;
selectedQueue.push({
streamId,
fromUserId,
fromUserAddress,
solicitation: keySolicitation,
respondAfter: Date.now() +
this.getRespondDelayMSForKeySolicitation(streamId, fromUserId, {
ephemeral: false,
}),
sigBundle,
hashStr: eventHashStr,
});
}
}
streamQueue.keySolicitationsNeedsSort = true;
this.checkStartTicking();
}
enqueueKeySolicitation(streamId, eventHashStr, fromUserId, fromUserAddress, keySolicitation, sigBundle, ephemeral = false) {
if (keySolicitation.deviceKey === this.userDevice.deviceKey) {
//this.log.debug('ignoring key solicitation for our own device')
return;
}
// Non-ephemeral solicitation handling (existing logic)
const streamQueue = this.streamQueues.getQueue(streamId);
const selectedQueue = ephemeral
? this.mainQueues.ephemeralKeySolicitations
: fromUserId === this.userId
? this.mainQueues.ownKeySolicitations
: streamQueue.keySolicitations;
const index = selectedQueue.findIndex((x) => x.streamId === streamId && x.solicitation.deviceKey === keySolicitation.deviceKey);
if (index > -1) {
selectedQueue.splice(index, 1);
}
if (keySolicitation.sessionIds.length > 0 || keySolicitation.isNewDevice) {
//this.log.debug('new key solicitation', { fromUserId, streamId, keySolicitation })
streamQueue.keySolicitationsNeedsSort = true;
selectedQueue.push({
streamId,
fromUserId,
fromUserAddress,
solicitation: keySolicitation,
respondAfter: Date.now() +
this.getRespondDelayMSForKeySolicitation(streamId, fromUserId, { ephemeral }),
sigBundle,
hashStr: eventHashStr,
ephemeral,
});
this.checkStartTicking();
}
else if (index > -1) {
//this.log.debug('cleared key solicitation', keySolicitation)
}
}
setStreamUpToDate(streamId) {
//this.log.debug('streamUpToDate', streamId)
this.upToDateStreams.add(streamId);
this.checkStartTicking();
}
resetUpToDateStreams() {
this.upToDateStreams.clear();
this.checkStartTicking();
}
retryDecryptionFailures(streamId) {
const streamQueue = this.streamQueues.getQueue(streamId);
if (this.decryptionFailures[streamId] &&
Object.keys(this.decryptionFailures[streamId]).length > 0) {
this.log.debug('membership change, re-enqueuing decryption failures for stream', streamId);
streamQueue.isMissingKeys = true;
this.checkStartTicking();
}
}
start() {
check(!this.started, 'start() called twice, please re-instantiate instead');
this.log.debug('starting');
this.started = true;
// let the subclass override and do any custom startup tasks
this.onStart();
// enqueue a task to upload device keys
this.mainQueues.priorityTasks.push(() => this.uploadDeviceKeys());
// enqueue a task to download new to-device messages
this.enqueueNewMessageDownload();
// start the tick loop
this.checkStartTicking();
}
// enqueue a task to download new to-device messages, should be safe to call multiple times
enqueueNewMessageDownload() {
this.mainQueues.priorityTasks.push(() => this.downloadNewMessages());
}
onStart() {
// let the subclass override and do any custom startup tasks
}
async stop() {
this._onStopFn?.();
this._onStopFn = undefined;
// Clean up ephemeral solicitation timers
for (const solicitations of this.ownEphemeralSolicitations.values()) {
for (const solicitation of solicitations) {
if (solicitation.timerId) {
clearTimeout(solicitation.timerId);
}
}
}
this.ownEphemeralSolicitations.clear();
// let the subclass override and do any custom shutdown tasks
await this.onStop();
await this.stopTicking();
}
onStop() {
// let the subclass override and do any custom shutdown tasks
return Promise.resolve();
}
get status() {
return this._status;
}
setStatus(status) {
if (this._status !== status) {
this.log.debug(`status changed ${status}`);
this._status = status;
this.emitter.emit('decryptionExtStatusChanged', status);
}
}
compareStreamIds(a, b) {
const recentStreamIds = new Set(this.recentStreamIds);
return (this.getPriorityForStream(a, this.highPriorityIds, recentStreamIds) -
this.getPriorityForStream(b, this.highPriorityIds, recentStreamIds));
}
lastPrintedAt = 0;
checkStartTicking() {
if (!this.started ||
!this._onStopFn ||
!this.isUserInboxStreamUpToDate(this.upToDateStreams) ||
this.shouldPauseTicking()) {
return;
}
if (!Object.values(this.mainQueues).find((q) => q.length > 0) &&
this.streamQueues.isEmpty()) {
this.log.debug('no more work to do, setting status to done');
this.setStatus(DecryptionStatus.done);
return;
}
const streamIds = this.streamQueues
.getStreamIds()
.filter((x) => !this.streamQueues.getQueue(x).isEmpty())
.sort((a, b) => this.compareStreamIds(a, b));
const logDebugInfo = Date.now() - this.lastPrintedAt > 30000;
if (logDebugInfo) {
this.log.info(`status: ${this.status} queues: ${Object.entries(this.mainQueues)
.map(([key, q]) => `${key}: ${q.length}`)
.join(', ')} ${this.streamQueues.toString()}`);
const first4Priority = streamIds
.filter((x) => this.upToDateStreams.has(x))
.slice(0, 4)
.join(', ');
const first4Blocked = streamIds
.filter((x) => !this.upToDateStreams.has(x))
.slice(0, 4)
.join(', ');
if (first4Priority.length > 0 || first4Blocked.length > 0) {
this.log.info(`priorityTasks: ${first4Priority} waitingFor: ${first4Blocked}`);
}
this.lastPrintedAt = Date.now();
}
this.tick(streamIds);
if (logDebugInfo) {
const runners = this.allQueueRunners.filter((x) => isDefined(x.inProgress));
if (runners.length > 0) {
this.log.info(`runners: ${runners.map((x) => x.toString()).join(', ')}`);
}
}
// it's possible that we're not doing work, but we have more things to do but "respondAfter" hasn't been reached
clearTimeout(this.checkStartTimeoutId);
this.checkStartTimeoutId = setTimeout(() => {
this.checkStartTicking();
}, 100);
}
async stopTicking() {
clearTimeout(this.checkStartTimeoutId);
for (const queueRunner of this.allQueueRunners) {
await queueRunner.stop();
}
}
// just do one thing then return
tick(streamIds) {
const now = Date.now();
// update the priority queue
if (this.mainQueueRunners.priority.inProgress) {
return; // if the main queue is in progress, don't do anything else
}
const priorityTask = this.mainQueues.priorityTasks.shift();
if (priorityTask) {
this.setStatus(DecryptionStatus.updating);
this.mainQueueRunners.priority.run(priorityTask());
return; // if the priority queue is in progress, don't do anything else
}
// update any new group sessions
if (this.mainQueueRunners.newGroupSessions.inProgress) {
return; // if the new group sessions queue is in progress, don't do anything else
}
const session = this.mainQueues.newGroupSession.shift();
if (session) {
this.setStatus(DecryptionStatus.working);
this.mainQueueRunners.newGroupSessions.run(this.processNewGroupSession(session));
return; // if the new group sessions queue is in progress, don't do anything else
}
// run the rest of the processes in parallel
if (!this.mainQueueRunners.ownKeySolicitations.inProgress) {
const ownSolicitation = this.mainQueues.ownKeySolicitations.shift();
if (ownSolicitation) {
this.log.debug(' processing own key solicitation');
this.setStatus(DecryptionStatus.working);
this.mainQueueRunners.ownKeySolicitations.run(this.processKeySolicitation(ownSolicitation));
}
}
if (!this.mainQueueRunners.ephemeralKeySolicitations.inProgress) {
if (this.mainQueues.ephemeralKeySolicitations.length > 0) {
this.mainQueues.ephemeralKeySolicitations.sort((a, b) => a.respondAfter - b.respondAfter);
const ephemeralSolicitation = dequeueUpToDate(this.mainQueues.ephemeralKeySolicitations, now, (x) => x.respondAfter, this.upToDateStreams);
if (ephemeralSolicitation) {
this.log.debug(' processing ephemeral key solicitation');
this.setStatus(DecryptionStatus.working);
this.mainQueueRunners.ephemeralKeySolicitations.run(this.processKeySolicitation(ephemeralSolicitation));
}
}
}
// grab open stream queues
const openRunners = this.streamQueueRunners.filter((x) => !x.inProgress);
const inProgressStreamIds = this.streamQueueRunners.map((x) => x.streamId).filter(isDefined);
for (const streamId of streamIds) {
if (openRunners.length === 0) {
return; // exit tick
}
if (inProgressStreamIds.includes(streamId)) {
continue;
}
const streamQueue = this.streamQueues.getQueue(streamId);
const encryptedContent = streamQueue.encryptedContent.shift();
if (encryptedContent) {
this.setStatus(DecryptionStatus.working);
const runner = openRunners.shift();
runner.run(this.processEncryptedContentItem(encryptedContent), streamId, 'decrypting');
continue;
}
// if the stream is not up to date, don't move forward
// it might be useful to post key solicitations, but without knowing the
// state of the stream it's not a good idea
if (!this.upToDateStreams.has(streamId)) {
continue;
}
if (streamQueue.isMissingKeys) {
this.setStatus(DecryptionStatus.working);
streamQueue.isMissingKeys = false;
const runner = openRunners.shift();
runner.run(this.processMissingKeys(streamId), streamId, 'missingKeys');
continue;
}
if (streamQueue.keySolicitationsNeedsSort) {
streamQueue.sortKeySolicitations();
}
const keySolicitation = dequeueUpToDate(streamQueue.keySolicitations, now, (x) => x.respondAfter, this.upToDateStreams);
if (keySolicitation) {
this.setStatus(DecryptionStatus.working);
const runner = openRunners.shift();
runner.run(this.processKeySolicitation(keySolicitation), streamId, 'keySolicitation');
continue;
}
}
if (this.allQueueRunners.every((x) => !x.inProgress)) {
this.setStatus(DecryptionStatus.idle);
}
}
/**
* processNewGroupSession
* process new group sessions that were sent to our to device stream inbox
* re-enqueue any decryption failures with matching session id
*/
async processNewGroupSession(sessionItem) {
const { streamId, sessions: session } = sessionItem;
// check if this message is to our device
const ciphertext = session.ciphertexts[this.userDevice.deviceKey];
if (!ciphertext) {
this.log.debug('skipping, no session for our device');
return;
}
this.log.debug('processNewGroupSession', session);
// check if it contains any keys we need, default to GroupEncryption if the algorithm is not set
const parsed = parseGroupEncryptionAlgorithmId(session.algorithm, GroupEncryptionAlgorithmId.GroupEncryption);
if (parsed.kind === 'unrecognized') {
// todo dispatch event to update the error message
this.log.error('skipping, invalid algorithm', session.algorithm);
return;
}
const algorithm = parsed.value;
const neededKeyIndexs = [];
for (let i = 0; i < session.sessionIds.length; i++) {
const sessionId = session.sessionIds[i];
const hasKeys = await this.crypto.hasSessionKey(streamId, sessionId, algorithm);
if (!hasKeys) {
neededKeyIndexs.push(i);
}
}
if (!neededKeyIndexs.length) {
this.log.debug('skipping, we have all the keys');
return;
}
// decrypt the message
const cleartext = await this.crypto.decryptWithDeviceKey(ciphertext, session.senderKey);
const sessionKeys = fromJsonString(SessionKeysSchema, cleartext);
check(sessionKeys.keys.length === session.sessionIds.length, 'bad sessionKeys');
// make group sessions
const sessions = neededKeyIndexs.map((i) => ({
streamId: streamId,
sessionId: session.sessionIds[i],
sessionKey: sessionKeys.keys[i],
algorithm: algorithm,
}));
// import the sessions
this.log.debug('importing group sessions streamId:', streamId, 'count: ', sessions.length, session.sessionIds);
try {
await this.crypto.importSessionKeys(streamId, sessions);
// re-enqueue any decryption failures with these ids
const streamQueue = this.streamQueues.getQueue(streamId);
for (const session of sessions) {
if (this.decryptionFailures[streamId]?.[session.sessionId]) {
streamQueue.encryptedContent.push(...this.decryptionFailures[streamId][session.sessionId]);
delete this.decryptionFailures[streamId][session.sessionId];
}
}
}
catch (e) {
// don't re-enqueue to prevent infinite loops if this session is truely corrupted
// we will keep requesting it on each boot until it goes out of the scroll window
this.log.error('failed to import sessions', { sessionItem, error: e });
}
// if we processed them all, ack the stream
if (this.mainQueues.newGroupSession.length === 0) {
await this.ackNewGroupSession(session);
}
}
/**
* processEncryptedContentItem
* try to decrypt encrytped content
*/
async processEncryptedContentItem(item) {
this.log.debug('processEncryptedContentItem', item);
try {
await this.decryptGroupEvent(item.streamId, item.eventId, item.kind, item.encryptedData);
}
catch (err) {
this.log.debug('processEncryptedContentItem error', err, 'item:', item);
const sessionNotFound = isSessionNotFoundError(err);
this.onDecryptionError(item, {
missingSession: sessionNotFound,
kind: item.kind,
encryptedData: item.encryptedData,
error: err,
});
if (sessionNotFound) {
const streamId = item.streamId;
const sessionId = item.encryptedData.sessionId && item.encryptedData.sessionId.length > 0
? item.encryptedData.sessionId
: bin_toHexString(item.encryptedData.sessionIdBytes);
if (sessionId.length === 0) {
this.log.error('session id length is 0 for failed decryption', {
err,
streamId: item.streamId,
eventId: item.eventId,
});
return;
}
if (!this.decryptionFailures[streamId]) {
this.decryptionFailures[streamId] = { [sessionId]: [item] };
}
else if (!this.decryptionFailures[streamId][sessionId]) {
this.decryptionFailures[streamId][sessionId] = [item];
}
else if (!this.decryptionFailures[streamId][sessionId].includes(item)) {
this.decryptionFailures[streamId][sessionId].push(item);
}
const streamQueue = this.streamQueues.getQueue(streamId);
streamQueue.isMissingKeys = true;
}
else {
this.log.info('failed to decrypt', err, 'eventId', item.eventId, 'streamId', item.streamId);
}
}
}
/**
* processMissingKeys
* process missing keys and send key solicitations to streams
*/
async processMissingKeys(streamId) {
this.log.debug('processing missing keys', streamId);
const missingSessionIds = takeFirst(100, Object.keys(this.decryptionFailures[streamId] ?? {}).sort());
// 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
if (!missingSessionIds.length) {
this.log.debug('processing missing keys', streamId, 'no missing keys');
return;
}
if (!this.hasStream(streamId)) {
this.log.debug('processing missing keys', streamId, 'stream not found');
return;
}
const isEntitled = await this.isUserEntitledToKeyExchange(streamId, this.userId, {
skipOnChainValidation: true,
});
if (!isEntitled) {
this.log.debug('processing missing keys', streamId, 'user is not member of stream');
return;
}
const solicitedEvents = this.getKeySolicitations(streamId);
const existingKeyRequest = solicitedEvents.find((x) => x.deviceKey === this.userDevice.deviceKey);
if (existingKeyRequest?.isNewDevice ||
sortedArraysEqual(existingKeyRequest?.sessionIds ?? [], missingSessionIds)) {
this.log.debug('processing missing keys already requested keys for this session', existingKeyRequest);
return;
}
const knownSessionIds = await this.crypto.getGroupSessionIds(streamId);
const isNewDevice = knownSessionIds.length === 0;
this.log.debug('requesting keys (ephemeral)', streamId, 'isNewDevice', isNewDevice, 'sessionIds:', missingSessionIds.length);
// Send ephemeral solicitation first
await this.sendKeySolicitation({
streamId,
isNewDevice,
missingSessionIds,
ephemeral: true,
});
}
/**
* processKeySolicitation
* process incoming key solicitations and send keys and key fulfillments
*/
async processKeySolicitation(item) {
this.log.debug('processing key solicitation', item.streamId, item);
const streamId = item.streamId;
check(this.hasStream(streamId), 'stream not found');
const { isValid, reason } = this.isValidEvent(item);
if (!isValid) {
this.log.error('processing key solicitation: invalid event id', {
streamId,
eventId: item.hashStr,
reason,
});
return;
}
const knownSessionIds = await this.crypto.getGroupSessionIds(streamId);
// todo split this up by algorithm so that we can send all the new hybrid keys
knownSessionIds.sort();
const requestedSessionIds = new Set(item.solicitation.sessionIds.sort());
const replySessionIds = item.solicitation.isNewDevice
? knownSessionIds
: knownSessionIds.filter((x) => requestedSessionIds.has(x));
if (replySessionIds.length === 0) {
this.log.debug('processing key solicitation: no keys to reply with');
return;
}
const isUserEntitledToKeyExchange = await this.isUserEntitledToKeyExchange(streamId, item.fromUserId);
if (!isUserEntitledToKeyExchange) {
return;
}
const allSessions = [];
for (const sessionId of replySessionIds) {
const groupSession = await this.crypto.exportGroupSession(streamId, sessionId);
if (groupSession) {
allSessions.push(groupSession);
}
}
this.log.debug('processing key solicitation with', item.streamId, {
to: item.fromUserId,
toDevice: item.solicitation.deviceKey,
requestedCount: item.solicitation.sessionIds.length,
replyIds: replySessionIds.length,
sessions: allSessions.length,
ephemeral: item.ephemeral,
});
if (allSessions.length === 0) {
return;
}
// send a single key fulfillment for all algorithms
const { error } = await this.sendKeyFulfillment({
streamId,
userAddress: item.fromUserAddress,
deviceKey: item.solicitation.deviceKey,
sessionIds: allSessions
.map((x) => x.sessionId)
.filter((x) => requestedSessionIds.has(x))
.sort(),
ephemeral: item.ephemeral,
});
// if the key fulfillment failed, someone else already sent a key fulfillment
if (error) {
if (!error.msg.includes('DUPLICATE_EVENT') && !error.msg.includes('NOT_FOUND')) {
// duplicate events are expected, we can ignore them, others are not
this.log.error('failed to send key fulfillment', error);
}
return;
}
// if the key fulfillment succeeded, send one group session payload for each algorithm
const sessions = allSessions.reduce((acc, session) => {
if (!acc[session.algorithm]) {
acc[session.algorithm] = [];
}
acc[session.algorithm].push(session);
return acc;
}, {});
// send one key fulfillment for each algorithm
for (const kv of Object.entries(sessions)) {
const algorithm = kv[0];
const sessions = kv[1];
await this.encryptAndShareGroupSessions({
streamId,
item,
sessions,
algorithm,
});
}
}
processEphemeralKeyFulfillment(event) {
if (event.deviceKey === this.userDevice.deviceKey) {
const solicitations = this.ownEphemeralSolicitations.get(event.streamId);
if (solicitations) {
// Process each solicitation and remove those that are fully fulfilled
const remainingSolicitations = solicitations.filter((solicitation) => {
// Remove fulfilled session IDs from the set
event.sessionIds.forEach((id) => {
solicitation.missingSessionIds.delete(id);
});
if (solicitation.missingSessionIds.size === 0) {
// All sessions fulfilled, cancel timer
if (solicitation.timerId) {
clearTimeout(solicitation.timerId);
}
this.log.debug('ephemeral solicitation fully fulfilled', event.streamId);
return false; // Remove this solicitation
}
else {
// Still has remaining session IDs
this.log.debug('ephemeral solicitation partially fulfilled', event.streamId, 'remaining:', solicitation.missingSessionIds.size);
return true; // Keep this solicitation
}
});
if (remainingSolicitations.length === 0) {
this.ownEphemeralSolicitations.delete(event.streamId);
}
else {
this.ownEphemeralSolicitations.set(event.streamId, remainingSolicitations);
}
}
}
// Cancel any pending ephemeral solicitations to prevent over-fulfilling
const streamQueue = this.mainQueues;
// Remove solicitations that are completely fulfilled
streamQueue.ephemeralKeySolicitations = streamQueue.ephemeralKeySolicitations.filter((solicitation) => {
if (solicitation.solicitation.deviceKey !== event.deviceKey) {
return true; // not the same device, keep it
}
// Check if all requested sessions are fulfilled by this fulfillment
const remainingSessionIds = solicitation.solicitation.sessionIds.filter((id) => !event.sessionIds.includes(id));
if (remainingSessionIds.length === 0) {
// All sessions fulfilled, remove this solicitation to prevent duplicate responses
this.log.debug('cancelling fully fulfilled ephemeral solicitation', event.streamId, solicitation.fromUserId, 'device:', solicitation.solicitation.deviceKey);
return false;
}
else {
// Update with remaining session IDs
solicitation.solicitation.sessionIds = remainingSessionIds;
this.log.debug('partially fulfilled ephemeral solicitation', event.streamId, solicitation.fromUserId, 'remaining:', remainingSessionIds.length, 'respondingIn', Date.now() - solicitation.respondAfter);
return true;
}
});
}
/**
* can be overridden to add a delay to the key solicitation response
*/
getRespondDelayMSForKeySolicitation(_streamId, _userId, _opts) {
return 0;
}
setHighPriorityStreams(streamIds) {
this.highPriorityIds = new Set(streamIds);
}
}
export function makeSessionKeys(sessions) {
const sessionKeys = sessions.map((s) => s.sessionKey);
return create(SessionKeysSchema, {
keys: sessionKeys,
});
}
/// Returns the first item from the array,
/// if dateFn is provided, returns the first item where dateFn(item) <= now
function dequeueUpToDate(items, now, dateFn, upToDateStreams) {
if (items.length === 0) {
return undefined;
}
if (dateFn(items[0]) > now) {
return undefined;
}
const index = items.findIndex((x) => dateFn(x) <= now && upToDateStreams.has(x.streamId));
if (index === -1) {
return undefined;
}
return items.splice(index, 1)[0];
}
function takeFirst(count, array) {
const result = [];
for (let i = 0; i < count && i < array.length; i++) {
result.push(array[i]);
}
return result;
}
function isSessionNotFoundError(err) {
if (err !== null && typeof err === 'object' && 'message' in err) {
return err.message.toLowerCase().includes('session not found');
}
return false;
}
//# sourceMappingURL=decryptionExtensions.js.map