@towns-protocol/sdk
Version:
For more details, visit the following resources:
337 lines • 14.7 kB
JavaScript
import { BaseDecryptionExtensions, } from './decryptionExtensions';
import { make_MemberPayload_KeyFulfillment, make_MemberPayload_KeySolicitation } from './types';
import { Permission } from '@towns-protocol/web3';
import { check } from '@towns-protocol/dlog';
import { chunk } from 'lodash-es';
import { isDefined } from './check';
import { isMobileSafari } from './utils';
import { spaceIdFromChannelId, isDMChannelStreamId, isGDMChannelStreamId, isUserDeviceStreamId, isUserInboxStreamId, isUserSettingsStreamId, isUserStreamId, isChannelStreamId, } from './id';
import { checkEventSignature } from './sign';
export class ClientDecryptionExtensions extends BaseDecryptionExtensions {
client;
isMobileSafariBackgrounded = false;
validatedEvents = {};
unpackEnvelopeOpts;
constructor(client, crypto, delegate, userId, userDevice, unpackEnvelopeOpts, logId, opts) {
const upToDateStreams = new Set();
client.streams.getStreams().forEach((stream) => {
if (stream.isUpToDate) {
upToDateStreams.add(stream.streamId);
}
});
super(client, crypto, delegate, userDevice, userId, upToDateStreams, logId, opts);
this.client = client;
this.unpackEnvelopeOpts = unpackEnvelopeOpts;
const onMembershipChange = (streamId, userId) => {
if (userId === this.userId) {
this.retryDecryptionFailures(streamId);
}
};
const onStreamUpToDate = (streamId) => this.setStreamUpToDate(streamId);
const onNewGroupSessions = (sessions, senderId) => this.enqueueNewGroupSessions(sessions, senderId);
const onNewEncryptedContent = (streamId, eventId, content) => this.enqueueNewEncryptedContent(streamId, eventId, content.kind, content.content);
const onKeySolicitation = (streamId, eventHashStr, fromUserId, fromUserAddress, keySolicitation, sigBundle, ephemeral) => this.enqueueKeySolicitation(streamId, eventHashStr, fromUserId, fromUserAddress, keySolicitation, sigBundle, ephemeral);
const onInitKeySolicitations = (streamId, eventHashStr, members, sigBundle) => this.enqueueInitKeySolicitations(streamId, eventHashStr, members, sigBundle);
const onStreamInitialized = (streamId) => {
if (isUserInboxStreamId(streamId)) {
this.enqueueNewMessageDownload();
}
};
const onStreamSyncActive = (active) => {
this.log.info('onStreamSyncActive', active);
if (!active) {
this.resetUpToDateStreams();
}
};
const onEphemeralKeyFulfillment = (event) => {
this.processEphemeralKeyFulfillment(event);
};
client.on('streamUpToDate', onStreamUpToDate);
client.on('newGroupSessions', onNewGroupSessions);
client.on('newEncryptedContent', onNewEncryptedContent);
client.on('newKeySolicitation', onKeySolicitation);
client.on('updatedKeySolicitation', onKeySolicitation);
client.on('initKeySolicitations', onInitKeySolicitations);
client.on('streamNewUserJoined', onMembershipChange);
client.on('streamInitialized', onStreamInitialized);
client.on('streamSyncActive', onStreamSyncActive);
client.on('ephemeralKeyFulfillment', onEphemeralKeyFulfillment);
this._onStopFn = () => {
client.off('streamUpToDate', onStreamUpToDate);
client.off('newGroupSessions', onNewGroupSessions);
client.off('newEncryptedContent', onNewEncryptedContent);
client.off('newKeySolicitation', onKeySolicitation);
client.off('updatedKeySolicitation', onKeySolicitation);
client.off('initKeySolicitations', onInitKeySolicitations);
client.off('streamNewUserJoined', onMembershipChange);
client.off('streamInitialized', onStreamInitialized);
client.off('streamSyncActive', onStreamSyncActive);
client.off('ephemeralKeyFulfillment', onEphemeralKeyFulfillment);
};
this.log.debug('new ClientDecryptionExtensions', { userDevice });
}
hasStream(streamId) {
const stream = this.client.stream(streamId);
return isDefined(stream);
}
isUserInboxStreamUpToDate(upToDateStreams) {
return (this.client.userInboxStreamId !== undefined &&
upToDateStreams.has(this.client.userInboxStreamId));
}
shouldPauseTicking() {
return this.isMobileSafariBackgrounded;
}
async decryptGroupEvent(streamId, eventId, kind, // kind of data
encryptedData) {
return this.client.decryptGroupEvent(streamId, eventId, kind, encryptedData);
}
downloadNewMessages() {
this.log.info('downloadNewInboxMessages');
return this.client.downloadNewInboxMessages();
}
getKeySolicitations(streamId) {
const stream = this.client.stream(streamId);
const nonEphemeralSolicitations = stream?.view.getMembers().joined.get(this.userId)?.solicitations ?? [];
const ephemeralSolicitations = (this.ownEphemeralSolicitations.get(streamId) ?? []).map((s) => ({
deviceKey: s.deviceKey,
fallbackKey: s.fallbackKey,
isNewDevice: s.isNewDevice,
sessionIds: Array.from(s.missingSessionIds),
}));
return [...nonEphemeralSolicitations, ...ephemeralSolicitations];
}
/**
* Override the default implementation to use the number of members in the stream
* to determine the delay time.
*/
getRespondDelayMSForKeySolicitation(streamId, userId, opts) {
const multiplier = userId === this.userId ? 0.5 : 1;
const stream = this.client.stream(streamId);
check(isDefined(stream), 'stream not found');
const numMembers = stream.view.getMembers().joinedParticipants().size;
const maxWaitTimeSeconds = Math.max(5, Math.min(30, numMembers));
const waitTime = maxWaitTimeSeconds * 1000 * Math.random(); // this could be much better
//this.log.debug('getRespondDelayMSForKeySolicitation', { streamId, userId, waitTime })
const delay = waitTime * multiplier;
if (opts.ephemeral) {
if (userId === this.userId) {
return 0;
}
return delay / 2;
}
return delay;
}
async isUserEntitledToKeyExchange(streamId, userId, opts) {
const stream = this.client.stream(streamId);
check(isDefined(stream), 'stream not found');
if (!stream.view.userIsEntitledToKeyExchange(userId)) {
this.log.info(`user ${userId} is not a member of stream ${streamId} and cannot request keys`);
return false;
}
if (stream.view.contentKind === 'channelContent' &&
!(opts?.skipOnChainValidation === true)) {
const channel = stream.view.channelContent;
const entitlements = await this.entitlementDelegate.isEntitled(channel.spaceId, streamId, userId, Permission.Read);
if (!entitlements) {
this.log.info('user is not entitled to key exchange');
return false;
}
}
return true;
}
isValidEvent(item) {
if (this.unpackEnvelopeOpts?.disableSignatureValidation !== true) {
return { isValid: true };
}
const eventId = item.hashStr;
const sigBundle = item.sigBundle;
if (!sigBundle) {
return { isValid: false, reason: 'event not found' };
}
if (!sigBundle.signature) {
return { isValid: false, reason: 'remote event signature not found' };
}
if (this.validatedEvents[eventId]) {
return this.validatedEvents[eventId];
}
try {
checkEventSignature(sigBundle.event, sigBundle.hash, sigBundle.signature);
const result = { isValid: true };
this.validatedEvents[eventId] = result;
return result;
}
catch (err) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const result = { isValid: false, reason: `error: ${err}` };
this.validatedEvents[eventId] = result;
return result;
}
}
onDecryptionError(item, err) {
this.client.stream(item.streamId)?.updateDecryptedContentError(item.eventId, {
missingSession: err.missingSession,
kind: err.kind,
encryptedData: item.encryptedData,
error: err,
});
}
async ackNewGroupSession(_session) {
await this.client.ackInboxStream();
await this.client.setPendingUsernames();
}
async encryptAndShareGroupSessions({ streamId, item, sessions, algorithm, }) {
const chunked = chunk(sessions, 100);
for (const chunk of chunked) {
await this.client.encryptAndShareGroupSessions(streamId, chunk, {
[item.fromUserId]: [
{
deviceKey: item.solicitation.deviceKey,
fallbackKey: item.solicitation.fallbackKey,
},
],
}, algorithm);
}
}
async sendKeySolicitation({ streamId, isNewDevice, missingSessionIds, ephemeral = false, }) {
const keySolicitation = make_MemberPayload_KeySolicitation({
deviceKey: this.userDevice.deviceKey,
fallbackKey: this.userDevice.fallbackKey,
isNewDevice,
sessionIds: missingSessionIds,
});
if (ephemeral) {
// Track own ephemeral solicitation with timer
const item = {
deviceKey: this.userDevice.deviceKey,
fallbackKey: this.userDevice.fallbackKey,
isNewDevice,
missingSessionIds,
};
const timerId = setTimeout(() => {
void this.convertEphemeralToNonEphemeral(streamId);
}, this.ephemeralTimeoutMs);
const existing = this.ownEphemeralSolicitations.get(streamId) || [];
existing.push({
...item,
missingSessionIds: new Set(item.missingSessionIds),
timerId,
timestamp: Date.now(),
});
this.ownEphemeralSolicitations.set(streamId, existing);
}
await this.client.makeEventAndAddToStream(streamId, keySolicitation, { ephemeral });
}
async sendKeyFulfillment({ streamId, userAddress, deviceKey, sessionIds, ephemeral = false, }) {
const fulfillment = make_MemberPayload_KeyFulfillment({
userAddress: userAddress,
deviceKey: deviceKey,
sessionIds: sessionIds,
});
try {
await this.client.makeEventAndAddToStream(streamId, fulfillment, {
ephemeral,
});
}
catch (err) {
return { error: err };
}
return {};
}
async uploadDeviceKeys() {
await this.client.uploadDeviceKeys();
}
onStart() {
if (isMobileSafari()) {
document.addEventListener('visibilitychange', this.mobileSafariPageVisibilityChanged);
}
}
onStop() {
if (isMobileSafari()) {
document.removeEventListener('visibilitychange', this.mobileSafariPageVisibilityChanged);
}
return Promise.resolve();
}
mobileSafariPageVisibilityChanged = () => {
this.log.debug('onMobileSafariBackgrounded', this.isMobileSafariBackgrounded);
this.isMobileSafariBackgrounded = document.visibilityState === 'hidden';
if (!this.isMobileSafariBackgrounded) {
this.checkStartTicking();
}
};
async convertEphemeralToNonEphemeral(streamId) {
const solicitations = this.ownEphemeralSolicitations.get(streamId);
if (!solicitations || solicitations.length === 0) {
return;
}
// Clear all timers for this stream's ephemeral solicitations
for (const solicitation of solicitations) {
if (solicitation.timerId) {
clearTimeout(solicitation.timerId);
}
}
// Combine all missing session IDs from all ephemeral solicitations
const allMissingSessionIds = new Set();
let isNewDevice = false;
for (const solicitation of solicitations) {
solicitation.missingSessionIds.forEach((sessionId) => {
allMissingSessionIds.add(sessionId);
});
if (solicitation.isNewDevice) {
isNewDevice = true;
}
}
// Remove all ephemeral solicitations for this stream
this.ownEphemeralSolicitations.delete(streamId);
this.log.info('converting all ephemeral solicitations to non-ephemeral', streamId, {
count: solicitations.length,
totalSessionIds: allMissingSessionIds.size,
});
// Send combined non-ephemeral solicitation
await this.sendKeySolicitation({
streamId,
isNewDevice,
missingSessionIds: Array.from(allMissingSessionIds).sort(),
ephemeral: false,
});
}
getPriorityForStream(streamId, highPriorityIds, recentStreamIds) {
if (isUserDeviceStreamId(streamId) ||
isUserInboxStreamId(streamId) ||
isUserStreamId(streamId) ||
isUserSettingsStreamId(streamId)) {
return 0;
}
// channel or dm we're currently viewing
const isChannel = isChannelStreamId(streamId);
const isDmOrGdm = isDMChannelStreamId(streamId) || isGDMChannelStreamId(streamId);
if ((isDmOrGdm || isChannel) && highPriorityIds.has(streamId)) {
return 1;
}
// space that we're currently viewing
if (highPriorityIds.has(streamId)) {
return 2;
}
// if you're getting updates for this stream, decrypt them so that you see unread messages
if (recentStreamIds.has(streamId)) {
return 3;
}
// channels in the space we're currently viewing
if (isChannel) {
const spaceId = spaceIdFromChannelId(streamId);
if (highPriorityIds.has(spaceId)) {
return 4;
}
}
// dms
if (isDmOrGdm) {
return 5;
}
// then other channels,
if (isChannel) {
return 6;
}
// then other spaces
return 7;
}
}
//# sourceMappingURL=clientDecryptionExtensions.js.map