import { DeviceId, OlmMachine, UserId, DeviceLists, RoomId, Attachment, EncryptedAttachment, } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { MatrixClient } from "../MatrixClient"; import { LogService } from "../logging/LogService"; import { IMegolmEncrypted, IOlmEncrypted, IToDeviceMessage, OTKAlgorithm, OTKCounts, Signatures, } from "../models/Crypto"; import { requiresReady } from "./decorators"; import { RoomTracker } from "./RoomTracker"; import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent"; import { RoomEvent } from "../models/events/RoomEvent"; import { EncryptedFile } from "../models/events/MessageEvent"; import { RustSdkCryptoStorageProvider } from "../storage/RustSdkCryptoStorageProvider"; import { RustEngine, SYNC_LOCK_NAME } from "./RustEngine"; import { MembershipEvent } from "../models/events/MembershipEvent"; /** * Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly * rather than creating one manually. * @category Encryption */ export class CryptoClient { private ready = false; private deviceId: string; private deviceEd25519: string; private deviceCurve25519: string; private roomTracker: RoomTracker; private engine: RustEngine; public constructor(private client: MatrixClient) { this.roomTracker = new RoomTracker(this.client); } private get storage(): RustSdkCryptoStorageProvider { return this.client.cryptoStore; } /** * The device ID for the MatrixClient. */ public get clientDeviceId(): string { return this.deviceId; } /** * Whether or not the crypto client is ready to be used. If not ready, prepare() should be called. * @see prepare */ public get isReady(): boolean { return this.ready; } /** * Prepares the crypto client for usage. * @param {string[]} roomIds The room IDs the MatrixClient is joined to. */ public async prepare(roomIds: string[]) { await this.roomTracker.prepare(roomIds); if (this.ready) return; // stop re-preparing here const storedDeviceId = await this.client.cryptoStore.getDeviceId(); if (storedDeviceId) { this.deviceId = storedDeviceId; } else { const deviceId = (await this.client.getWhoAmI())['device_id']; if (!deviceId) { throw new Error("Encryption not possible: server not revealing device ID"); } this.deviceId = deviceId; await this.client.cryptoStore.setDeviceId(this.deviceId); } LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId); const machine = await OlmMachine.initialize( new UserId(await this.client.getUserId()), new DeviceId(this.deviceId), this.storage.storagePath, "", this.storage.storageType, ); this.engine = new RustEngine(machine, this.client); await this.engine.run(); const identity = this.engine.machine.identityKeys; this.deviceCurve25519 = identity.curve25519.toBase64(); this.deviceEd25519 = identity.ed25519.toBase64(); this.ready = true; } /** * Handles a room event. * @internal * @param roomId The room ID. * @param event The event. */ public async onRoomEvent(roomId: string, event: any) { await this.roomTracker.onRoomEvent(roomId, event); if (typeof event['state_key'] !== 'string') return; if (event['type'] === 'm.room.member') { const membership = new MembershipEvent(event); if (membership.effectiveMembership !== 'join' && membership.effectiveMembership !== 'invite') return; await this.engine.addTrackedUsers([membership.membershipFor]); } else if (event['type'] === 'm.room.encryption') { const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']); await this.engine.addTrackedUsers(members.map(e => e.membershipFor)); } } /** * Handles a room join. * @internal * @param roomId The room ID. */ public async onRoomJoin(roomId: string) { await this.roomTracker.onRoomJoin(roomId); if (await this.isRoomEncrypted(roomId)) { const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']); await this.engine.addTrackedUsers(members.map(e => e.membershipFor)); } } /** * Checks if a room is encrypted. * @param {string} roomId The room ID to check. * @returns {Promise} Resolves to true if encrypted, false otherwise. */ @requiresReady() public async isRoomEncrypted(roomId: string): Promise { const config = await this.roomTracker.getRoomCryptoConfig(roomId); return !!config?.algorithm; } /** * Updates the client's sync-related data. * @param {Array.>} toDeviceMessages The to-device messages received. * @param {OTKCounts} otkCounts The current OTK counts. * @param {OTKAlgorithm[]} unusedFallbackKeyAlgs The unused fallback key algorithms. * @param {string[]} changedDeviceLists The user IDs which had device list changes. * @param {string[]} leftDeviceLists The user IDs which the server believes we no longer need to track. * @returns {Promise} Resolves when complete. */ @requiresReady() public async updateSyncData( toDeviceMessages: IToDeviceMessage[], otkCounts: OTKCounts, unusedFallbackKeyAlgs: OTKAlgorithm[], changedDeviceLists: string[], leftDeviceLists: string[], ): Promise { const deviceMessages = JSON.stringify(toDeviceMessages); const deviceLists = new DeviceLists( changedDeviceLists.map(u => new UserId(u)), leftDeviceLists.map(u => new UserId(u))); await this.engine.lock.acquire(SYNC_LOCK_NAME, async () => { const syncResp = await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs); const decryptedToDeviceMessages = JSON.parse(syncResp); if (Array.isArray(decryptedToDeviceMessages)) { for (const msg of decryptedToDeviceMessages) { this.client.emit("to_device.decrypted", msg); } } await this.engine.run(); }); } /** * Signs an object using the device keys. * @param {object} obj The object to sign. * @returns {Promise} The signatures for the object. */ @requiresReady() public async sign(obj: object): Promise { obj = JSON.parse(JSON.stringify(obj)); const existingSignatures = obj['signatures'] || {}; delete obj['signatures']; delete obj['unsigned']; const container = await this.engine.machine.sign(JSON.stringify(obj)); const userSignature = container.get(new UserId(await this.client.getUserId())); const sig: Signatures = { [await this.client.getUserId()]: {}, }; for (const [key, maybeSignature] of Object.entries(userSignature)) { if (maybeSignature.isValid) { sig[await this.client.getUserId()][key] = maybeSignature.signature.toBase64(); } } return { ...sig, ...existingSignatures, }; } /** * Encrypts the details of a room event, returning an encrypted payload to be sent in an * `m.room.encrypted` event to the room. If needed, this function will send decryption keys * to the appropriate devices in the room (this happens when the Megolm session rotates or * gets created). * @param {string} roomId The room ID to encrypt within. If the room is not encrypted, an * error is thrown. * @param {string} eventType The event type being encrypted. * @param {any} content The event content being encrypted. * @returns {Promise} Resolves to the encrypted content for an `m.room.encrypted` event. */ @requiresReady() public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise { if (!(await this.isRoomEncrypted(roomId))) { throw new Error("Room is not encrypted"); } await this.engine.prepareEncrypt(roomId, await this.roomTracker.getRoomCryptoConfig(roomId)); const encrypted = JSON.parse(await this.engine.machine.encryptRoomEvent(new RoomId(roomId), eventType, JSON.stringify(content))); await this.engine.run(); return encrypted as IMegolmEncrypted; } /** * Decrypts a room event. Currently only supports Megolm-encrypted events (default for this SDK). * @param {EncryptedRoomEvent} event The encrypted event. * @param {string} roomId The room ID where the event was sent. * @returns {Promise>} Resolves to a decrypted room event, or rejects/throws with * an error if the event is undecryptable. */ @requiresReady() public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise> { const decrypted = await this.engine.machine.decryptRoomEvent(JSON.stringify(event.raw), new RoomId(roomId)); const clearEvent = JSON.parse(decrypted.event); return new RoomEvent({ ...event.raw, type: clearEvent.type || "io.t2bot.unknown", content: (typeof (clearEvent.content) === 'object') ? clearEvent.content : {}, }); } /** * Encrypts a file for uploading in a room, returning the encrypted data and information * to include in a message event (except media URL) for sending. * @param {Buffer} file The file to encrypt. * @returns {{buffer: Buffer, file: Omit}} Resolves to the encrypted * contents and file information. */ @requiresReady() public async encryptMedia(file: Buffer): Promise<{ buffer: Buffer, file: Omit }> { const encrypted = Attachment.encrypt(file); const info = JSON.parse(encrypted.mediaEncryptionInfo); return { buffer: Buffer.from(encrypted.encryptedData), file: info, }; } /** * Decrypts a previously-uploaded encrypted file, validating the fields along the way. * @param {EncryptedFile} file The file to decrypt. * @returns {Promise} Resolves to the decrypted file contents. */ @requiresReady() public async decryptMedia(file: EncryptedFile): Promise { const contents = (await this.client.downloadContent(file.url)).data; const encrypted = new EncryptedAttachment( contents, JSON.stringify(file), ); const decrypted = Attachment.decrypt(encrypted); return Buffer.from(decrypted); } }