Files
upstream-rocketchat/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

847 lines
22 KiB
TypeScript

import { Base64 } from '@rocket.chat/base64';
import type {
IE2EEMessage,
IMessage,
IRoom,
ISubscription,
IUser,
AtLeast,
EncryptedMessageContent,
EncryptedContent,
} from '@rocket.chat/core-typings';
import { isEncryptedMessageContent, isFileAttachment, isRemovedFileAttachment } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { Optional } from '@tanstack/react-query';
import EJSON from 'ejson';
import type { E2ERoomState } from './E2ERoomState';
import { Binary } from './binary';
import { decodeEncryptedContent } from './content';
import * as Aes from './crypto/aes';
import * as Rsa from './crypto/rsa';
import { encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText } from './helper';
import { createLogger } from './logger';
import { PrefixedBase64 } from './prefixed';
import { e2e } from './rocketchat.e2e';
import { t } from '../../../app/utils/lib/i18n';
import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig';
import { Messages, Rooms, Subscriptions } from '../../stores';
import { sdk } from '../SDKClient';
import { roomCoordinator } from '../rooms/roomCoordinator';
const log = createLogger('E2E:Room');
const KEY_ID = Symbol('keyID');
const PAUSED = Symbol('PAUSED');
const isRoomKeyAlreadyExistsError = async (error: Response): Promise<boolean> => {
try {
const body = await error.clone().json();
return body?.errorType === 'error-room-e2e-key-already-exists';
} catch {
return false;
}
};
type Mutations = { [k in E2ERoomState]?: E2ERoomState[] };
const permitedMutations: Mutations = {
NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'],
READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'],
ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'],
WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'],
ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'],
};
const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => {
// When state is undefined, allow it to be moved
if (!currentState) {
return nextState;
}
if (currentState === nextState) {
return nextState === 'ERROR' ? 'ERROR' : false;
}
if (!(currentState in permitedMutations)) {
return nextState;
}
if (permitedMutations?.[currentState]?.includes(nextState)) {
return nextState;
}
return false;
};
export class E2ERoom extends Emitter {
state: E2ERoomState | undefined = undefined;
[PAUSED]: boolean | undefined = undefined;
[KEY_ID]: string;
userId: string;
roomId: string;
typeOfRoom: string;
roomKeyId: string | undefined;
groupSessionKey: Aes.Key | null = null;
oldKeys: { E2EKey: Aes.Key | null; ts: Date; e2eKeyId: string }[] | undefined;
sessionKeyExportedString: string | undefined;
sessionKeyExported: Aes.Jwk | undefined;
constructor(userId: string, room: IRoom) {
super();
this.userId = userId;
this.roomId = room._id;
this.typeOfRoom = room.t;
this.roomKeyId = room.e2eKeyId;
this.once('READY', async () => {
await this.decryptOldRoomKeys();
return this.decryptPendingMessages();
});
this.once('READY', () => this.decryptSubscription());
this.on('STATE_CHANGED', () => this.handshake());
this.setState('NOT_STARTED');
}
hasSessionKey() {
return !!this.groupSessionKey;
}
getState() {
return this.state;
}
setState(requestedState: E2ERoomState) {
const span = log.span('setState');
const currentState = this.state;
const nextState = filterMutation(currentState, requestedState);
if (!nextState) {
span.error(`${currentState} -> ${requestedState}`);
return;
}
this.state = nextState;
span.info(`${currentState} -> ${nextState}`);
this.emit('STATE_CHANGED', currentState);
this.emit(nextState, this);
}
isReady() {
return this.state === 'READY';
}
isDisabled() {
return this.state === 'DISABLED';
}
enable() {
if (this.state === 'READY') {
return;
}
this.setState('READY');
}
disable() {
this.setState('DISABLED');
}
pause() {
this[PAUSED] = true;
this.emit('PAUSED', true);
}
resume() {
this[PAUSED] = false;
this.emit('PAUSED', false);
}
keyReceived() {
this.setState('KEYS_RECEIVED');
}
async shouldConvertSentMessages(message: { msg?: string }) {
if (!this.isReady() || this[PAUSED]) {
return false;
}
if (this[PAUSED] === undefined) {
return new Promise((resolve) => {
this.once('PAUSED', resolve);
});
}
if (message.msg?.[0] === '/') {
return false;
}
return true;
}
shouldConvertReceivedMessages() {
return this.isReady();
}
isWaitingKeys() {
return this.state === 'WAITING_KEYS';
}
get keyID() {
return this[KEY_ID];
}
set keyID(keyID) {
this[KEY_ID] = keyID;
}
async decryptSubscription() {
const span = log.span('decryptSubscription');
const subscription = Subscriptions.state.find((record) => record.rid === this.roomId);
if (!subscription) {
span.warn('no subscription found');
return;
}
const { lastMessage } = subscription;
if (lastMessage === undefined) {
span.warn('no lastMessage found');
return;
}
if (lastMessage.t !== 'e2e') {
span.warn('nothing to do');
return;
}
const message = await this.decryptMessage(lastMessage);
if (message.msg !== subscription.lastMessage?.msg) {
span.info('decryptSubscriptions updating lastMessage');
Subscriptions.state.store({
...subscription,
lastMessage: message,
});
}
span.info('decryptSubscriptions Done');
}
async decryptOldRoomKeys() {
const span = log.span('decryptOldRoomKeys');
const sub = Subscriptions.state.find((record) => record.rid === this.roomId);
if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) {
span.info('Nothing to do');
return;
}
const keys = [];
for await (const key of sub.oldRoomKeys) {
try {
const k = await this.decryptSessionKey(key.E2EKey);
keys.push({
...key,
E2EKey: k,
});
} catch (error) {
span.error(
`Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`,
error,
);
keys.push({ ...key, E2EKey: null });
}
}
this.oldKeys = keys;
}
async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) {
const span = log.span('exportOldRoomKeys').set('oldKeys', oldKeys);
if (!oldKeys || oldKeys.length === 0) {
span.info('Nothing to do');
return;
}
const keys = [];
for await (const key of oldKeys) {
try {
if (!key.E2EKey) {
continue;
}
const k = await this.exportSessionKey(key.E2EKey);
keys.push({
...key,
E2EKey: k,
});
} catch (e) {
span.set('error', e);
span.error(
`Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping.`,
);
}
}
span.info(`Done: ${keys.length} keys exported`);
return keys;
}
async decryptPendingMessages() {
await Messages.state.updateAsync(
(record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending',
(record) => this.decryptMessage(record as IE2EEMessage),
);
}
// Initiates E2E Encryption
async handshake() {
const span = log.span('handshake');
if (!e2e.isReady()) {
return;
}
if (this.state !== 'KEYS_RECEIVED' && this.state !== 'NOT_STARTED') {
return;
}
this.setState('ESTABLISHING');
try {
const groupKey = Subscriptions.state.find((record) => record.rid === this.roomId)?.E2EKey;
if (groupKey) {
await this.importGroupKey(groupKey);
this.setState('READY');
span.info('Group key imported');
return;
}
} catch (error) {
this.setState('ERROR');
span.error('Error fetching group key', error);
return;
}
try {
const room = Rooms.state.get(this.roomId);
span.set('room', room);
if (!room?.e2eKeyId) {
this.setState('CREATING_KEYS');
const created = await this.createGroupKey();
if (!created) {
// another member won the race
this.setState('WAITING_KEYS');
return;
}
this.setState('READY');
return;
}
this.setState('WAITING_KEYS');
span.info('Requesting room key');
sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]);
} catch (error) {
span.set('error', error);
span.error('Error during handshake');
this.setState('ERROR');
}
}
isSupportedRoomType(type: string) {
return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E);
}
async decryptSessionKey(key: string) {
return Aes.importKey(JSON.parse(await this.exportSessionKey(key)));
}
async exportSessionKey(key: string) {
const span = log.span('exportSessionKey');
const [prefix, decodedKey] = PrefixedBase64.decode(key);
span.set('prefix', prefix);
if (!e2e.privateKey) {
span.error('Private key not found');
throw new Error('Private key not found');
}
const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey);
span.info('Key decrypted');
return Binary.encode(decryptedKey.buffer);
}
async importGroupKey(groupKey: string) {
const span = log.span('importGroupKey');
const [kid, decodedKey] = PrefixedBase64.decode(groupKey);
span.set('kid', kid);
if (this.keyID === kid && this.groupSessionKey) {
span.info('Key already imported');
return true;
}
// Decrypt obtained encrypted session key
try {
if (!e2e.privateKey) {
throw new Error('Private key not found');
}
const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey);
this.sessionKeyExportedString = Binary.encode(decryptedKey.buffer);
} catch (error) {
span.set('error', error).error('Error decrypting group key');
return false;
}
// When a new e2e room is created, it will be initialized without an e2e key id
// This will prevent new rooms from storing `undefined` as the keyid
if (!this.keyID) {
this.keyID = this.roomKeyId || kid || crypto.randomUUID();
}
// Import session key for use.
try {
const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString));
// Key has been obtained. E2E is now in session.
this.groupSessionKey = key;
span.info('Group key imported');
} catch (error) {
span.set('error', error).error('Error importing group key');
return false;
}
return true;
}
async createNewGroupKey() {
this.groupSessionKey = await Aes.generate();
const sessionKeyExported = await Aes.exportJwk(this.groupSessionKey);
this.sessionKeyExportedString = JSON.stringify(sessionKeyExported);
this.keyID = crypto.randomUUID();
}
private discardGroupKey() {
this.groupSessionKey = null;
this.sessionKeyExportedString = undefined;
this.keyID = undefined as unknown as string;
}
async createGroupKey(): Promise<boolean> {
const span = log.span('createGroupKey');
await this.createNewGroupKey();
try {
await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID });
} catch (error) {
if (error instanceof Response && (await isRoomKeyAlreadyExistsError(error))) {
span.info('Room key already established by another member; discarding local key');
this.discardGroupKey();
return false;
}
throw error;
}
const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!);
if (myKey) {
await sdk.rest.post('/v1/e2e.updateGroupKey', {
rid: this.roomId,
uid: this.userId,
key: myKey,
});
await this.encryptKeyForOtherParticipants();
}
return true;
}
async resetRoomKey() {
const span = log.span('resetRoomKey');
if (!e2e.publicKey) {
span.error('No public key found');
return;
}
this.setState('CREATING_KEYS');
try {
await this.createNewGroupKey();
const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) };
this.setState('READY');
span.set('kid', this.keyID).info('Room key reset successfully');
return e2eNewKeys;
} catch (error) {
span.error('Error resetting room key', error);
throw error;
}
}
onRoomKeyReset(keyID: string) {
const span = log.span('onRoomKeyReset').set('key_id', keyID);
if (this.keyID === keyID) {
span.warn('Key ID matches current key, nothing to do');
return;
}
span.set('new_key_id', keyID).info('Room key has been reset');
this.setState('WAITING_KEYS');
this.keyID = keyID;
this.groupSessionKey = null;
this.sessionKeyExportedString = undefined;
this.sessionKeyExported = undefined;
this.oldKeys = undefined;
}
async encryptKeyForOtherParticipants() {
const span = log.span('encryptKeyForOtherParticipants');
// Encrypt generated session key for every user in room and publish to subscription model.
try {
const mySub = Subscriptions.state.find((record) => record.rid === this.roomId);
const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys);
const users = (await sdk.rest.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid: this.roomId })).users.filter(
(user) => user?.e2e?.public_key,
);
if (!users.length) {
span.info('No users to encrypt the key for');
return;
}
const usersSuggestedGroupKeys: Record<
string,
{
_id: IUser['_id'];
key: string;
oldKeys: ISubscription['suggestedOldRoomKeys'];
}[]
> = { [this.roomId]: [] };
for await (const user of users) {
const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key);
if (!encryptedGroupKey) {
span.warn(`Could not encrypt group key for user ${user._id}`);
return;
}
if (decryptedOldGroupKeys) {
const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key, decryptedOldGroupKeys);
if (oldKeys) {
usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys });
continue;
}
}
usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys: undefined });
}
await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys });
} catch (error) {
return span.set('error', error).error('Error getting room users');
}
}
async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) {
const span = log.span('encryptOldKeysForParticipant');
if (!oldRoomKeys || oldRoomKeys.length === 0) {
span.info('Nothing to do');
return;
}
let userKey;
try {
userKey = await Rsa.importPublicKey(JSON.parse(publicKey));
} catch (error) {
return span.set('error', error).error('Error importing user key');
}
try {
const keys = [];
for await (const oldRoomKey of oldRoomKeys) {
if (!oldRoomKey.E2EKey) {
continue;
}
const encryptedKey = await Rsa.encrypt(userKey, Binary.decode(oldRoomKey.E2EKey));
const encryptedKeyToString = PrefixedBase64.encode([oldRoomKey.e2eKeyId, encryptedKey]);
keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString });
}
return keys;
} catch (error) {
return span.set('error', error).error('failed to encrypt old keys for participant');
}
}
async encryptGroupKeyForParticipant(publicKey: string) {
const span = log.span('encryptGroupKeyForParticipant');
let userKey;
try {
userKey = await Rsa.importPublicKey(JSON.parse(publicKey));
} catch (error) {
return span.set('error', error).error('Error importing user key');
}
// Encrypt session key for this user with his/her public key
try {
const encryptedUserKey = await Rsa.encrypt(userKey, Binary.decode(this.sessionKeyExportedString!));
const encryptedUserKeyToString = PrefixedBase64.encode([this.keyID, encryptedUserKey]);
span.info('Group key encrypted for participant');
return encryptedUserKeyToString;
} catch (error) {
return span.error('Error encrypting user key', error);
}
}
// Encrypts files before upload. I/O is in arraybuffers.
async encryptFile(file: File) {
const span = log.span('encryptFile');
const fileArrayBuffer = await file.arrayBuffer();
const hash = await sha256HashFromArrayBuffer(fileArrayBuffer);
const vector = crypto.getRandomValues(new Uint8Array(16));
const key = await generateAESCTRKey();
let result;
try {
result = await encryptAESCTR(vector, key, fileArrayBuffer);
} catch (error) {
return span.set('error', error).error('Error encrypting group key');
}
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
const fileName = await createSha256HashFromText(file.name);
const encryptedFile = new File([result], fileName);
return {
file: encryptedFile,
key: exportedKey,
iv: Base64.encode(vector),
type: file.type,
hash,
};
}
// Encrypts messages
async encryptText(data: Uint8Array<ArrayBuffer>) {
const span = log.span('encryptText');
try {
if (!this.groupSessionKey) {
throw new Error('No group session key found.');
}
const { iv, ciphertext } = await Aes.encrypt(this.groupSessionKey, data);
const encryptedData = {
kid: this.keyID,
iv: Base64.encode(iv),
ciphertext: Base64.encode(ciphertext),
};
span.info('message encrypted');
return encryptedData;
} catch (error) {
span.error('Error encrypting message', error);
throw error;
}
}
// Helper function for encryption of content
async encryptMessageContent(
contentToBeEncrypted: Pick<IMessage, 'attachments' | 'files' | 'file'> & Optional<Pick<IMessage, 'msg'>, 'msg'>,
) {
const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted));
return {
algorithm: 'rc.v2.aes-sha2',
...(await this.encryptText(data)),
} as const;
}
// Helper function for encryption of content
async encryptMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>): Promise<IE2EEMessage> {
const { msg, attachments, ...rest } = message;
const content = await this.encryptMessageContent({ msg, attachments });
// E2EMessages remove the `msg` property. It's stored in `content` instead.
// Making the property optional can open a small can of worms, but just ignoring it in here should be fine ;)
return {
...rest,
content,
t: 'e2e' as const,
e2e: 'pending' as const,
} as IE2EEMessage;
}
async decryptContent<T extends EncryptedMessageContent>(data: T) {
const content = await this.decrypt(data.content);
Object.assign(data, content);
return data;
}
async decryptMessageContent(message: IE2EEMessage): Promise<IE2EEMessage> {
const { attachments } = message;
const deletedAttachments = attachments?.filter((att) => isRemovedFileAttachment(att)) || [];
const deletedAllAttachment = deletedAttachments.find((att) => !att.fileId);
const content = await this.decrypt(message.content);
Object.assign(message, content);
// If the encrypted message had deleted files and the decrypted message has files, compare both lists to remove from the final result any file that was flagged as deleted
if (!deletedAttachments.length || !message.attachments?.length || !content.attachments?.length) {
return message;
}
message.attachments = message.attachments.map((att) => {
if (!isFileAttachment(att)) {
return att;
}
if (deletedAllAttachment) {
return deletedAllAttachment;
}
const fileId = att.fileId || message.file?._id;
if (!fileId) {
return att;
}
for (const removedAttachment of deletedAttachments) {
if (removedAttachment.fileId === fileId) {
return removedAttachment;
}
}
return att;
});
return message;
}
// Decrypt messages
async decryptMessage(message: IMessage | IE2EEMessage): Promise<IE2EEMessage | IMessage> {
if (message.t !== 'e2e' || message.e2e === 'done') {
return message;
}
if (isEncryptedMessageContent(message)) {
return {
...(await this.decryptMessageContent({ ...message })),
e2e: 'done' as const,
};
}
if (!message.msg) {
return {
...message,
e2e: 'done' as const,
};
}
// TODO(@cardoso): review backward compatibility
const data = await this.decrypt(message.msg);
return {
...message,
msg: typeof data.msg === 'string' ? data.msg : message.msg,
e2e: 'done' as const,
};
}
async decrypt(message: string | EncryptedContent): Promise<Pick<Partial<IMessage>, 'attachments' | 'files' | 'file' | 'msg'>> {
const span = log.span('decrypt').set('rid', this.roomId);
const payload = decodeEncryptedContent(message);
span.set('payload', payload);
const { kid, iv, ciphertext } = payload;
const key = this.retrieveDecryptionKey(kid);
if (!key) {
span.error('No decryption key found.');
return { msg: t('E2E_indecipherable') };
}
span.set('algorithm', key.algorithm.name);
span.set('extractable', key.extractable);
span.set('type', key.type);
span.set('usages', key.usages.toString());
try {
const result = await Aes.decrypt(key, { iv, ciphertext });
const ret: unknown = EJSON.parse(result);
if (typeof ret !== 'object' || ret === null) {
span.error('Decrypted message is not an object');
return { msg: t('E2E_indecipherable') };
}
if ('text' in ret && typeof ret.text === 'string' && !('msg' in ret)) {
const { text, ...rest } = ret;
return { msg: text, ...rest };
}
if ('msg' in ret && typeof ret.msg === 'string') {
const { msg, ...rest } = ret;
return { msg, ...rest };
}
return { ...ret };
} catch (error) {
span.set('error', error).error('Error decrypting message');
return { msg: t('E2E_Key_Error') };
}
}
private retrieveDecryptionKey(kid: string): Aes.Key | null {
const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid);
return oldRoomKey?.E2EKey ?? this.groupSessionKey;
}
provideKeyToUser(keyId: string) {
if (this.keyID !== keyId) {
return;
}
void this.encryptKeyForOtherParticipants();
this.setState('READY');
}
onStateChange(cb: () => void) {
this.on('STATE_CHANGED', cb);
return () => this.off('STATE_CHANGED', cb);
}
async encryptGroupKeyForParticipantsWaitingForTheKeys(users: { _id: IUser['_id']; public_key: string }[]) {
if (!this.isReady()) {
return;
}
const mySub = Subscriptions.state.find((record) => record.rid === this.roomId);
const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys);
const usersWithKeys = await Promise.all(
users.map(async (user) => {
const { _id, public_key } = user;
const key = await this.encryptGroupKeyForParticipant(public_key);
if (decryptedOldGroupKeys) {
const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys);
return { _id, key, oldKeys };
}
return { _id, key };
}),
);
return usersWithKeys;
}
}