import { CrossSigningBootstrapRequests, EncryptionSettings, KeysClaimRequest, OlmMachine, RequestType, RoomId, UserId, EncryptionAlgorithm as RustEncryptionAlgorithm, HistoryVisibility, KeysUploadRequest, KeysQueryRequest, KeysBackupRequest, SignatureUploadRequest, ToDeviceRequest, } from "@ixo/matrix-sdk-crypto-nodejs"; import * as AsyncLock from "async-lock"; import { MatrixClient } from "../MatrixClient"; import { ICryptoRoomInformation } from "./ICryptoRoomInformation"; import { EncryptionAlgorithm } from "../models/Crypto"; import { EncryptionEvent } from "../models/events/EncryptionEvent"; import { BackupManager } from "./BackupManager"; import { LogService } from "../logging/LogService"; /** * @internal */ export const SYNC_LOCK_NAME = "sync"; /** * @internal */ export class RustEngine { public readonly lock = new AsyncLock(); public backupManager: BackupManager | null = null; public constructor(public readonly machine: OlmMachine, private client: MatrixClient) { } /** * Set the backup manager for handling key backup requests. */ public setBackupManager(manager: BackupManager): void { this.backupManager = manager; } public async run() { await this.runOnly(); // run everything, but with syntactic sugar } private async runOnly(...types: RequestType[]) { // Note: we should not be running this until it runs out, so cache the value into a variable const requests = await this.machine.outgoingRequests(); for (const request of requests) { if (types.length && !types.includes(request.type)) continue; switch (request.type) { case RequestType.KeysUpload: await this.processKeysUploadRequest(request); break; case RequestType.KeysQuery: await this.processKeysQueryRequest(request); break; case RequestType.KeysClaim: await this.processKeysClaimRequest(request); break; case RequestType.ToDevice: await this.processToDeviceRequest(request as ToDeviceRequest); break; case RequestType.RoomMessage: throw new Error("Bindings error: Sending room messages is not supported"); case RequestType.SignatureUpload: await this.processSignatureUploadRequest(request as SignatureUploadRequest); break; case RequestType.KeysBackup: await this.processKeysBackupRequest(request as KeysBackupRequest); break; default: throw new Error("Bindings error: Unrecognized request type: " + request.type); } } } public async addTrackedUsers(userIds: string[]) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { const uids = userIds.map(u => new UserId(u)); await this.machine.updateTrackedUsers(uids); const keysClaim = await this.machine.getMissingSessions(uids); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); } /** * Force a fresh /keys/query for the given users, without waiting for the * device tracker to consider them outdated. Used to get an up-to-date view * of a user's devices and cross-signing identity before sharing or * accepting an MSC4268 room key bundle. */ public async forceKeysQueryForUsers(userIds: string[]) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { const uids = userIds.map(u => new UserId(u)); await this.machine.updateTrackedUsers(uids); const request = this.machine.queryKeysForUsers(uids); await this.processKeysQueryRequest(request); }); } /** * Fetch a fresh device list for the given users and establish Olm sessions * with any of their devices we do not have a session with yet. */ public async ensureSessionsForUsers(userIds: string[]) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { const uids = userIds.map(u => new UserId(u)); await this.machine.updateTrackedUsers(uids); const request = this.machine.queryKeysForUsers(uids); await this.processKeysQueryRequest(request); const keysClaim = await this.machine.getMissingSessions(uids); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); } /** * Upload a signature request (e.g. produced by importing cross-signing * secrets, which self-signs the device). */ public async uploadSignatures(request: SignatureUploadRequest) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { await this.processSignatureUploadRequest(request); }); } /** * Send a batch of to-device requests produced by the OlmMachine (outside of * the outgoingRequests loop), marking each as sent. */ public async sendToDeviceRequests(requests: ToDeviceRequest[]) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { for (const request of requests) { await this.processToDeviceRequest(request); } }); } /** * Upload the cross-signing keys produced by `OlmMachine.bootstrapCrossSigning`. * * The signing-keys request has no request ID and must not be marked as sent; * the device-keys and signatures requests go through the normal processors. */ public async processCrossSigningBootstrapRequests(requests: CrossSigningBootstrapRequests) { await this.lock.acquire(SYNC_LOCK_NAME, async () => { if (requests.uploadKeysReq) { await this.processKeysUploadRequest(requests.uploadKeysReq); } await this.client.doRequest( "POST", "/_matrix/client/v3/keys/device_signing/upload", null, JSON.parse(requests.uploadSigningKeysReq), ); await this.processSignatureUploadRequest(requests.uploadSignaturesReq); }); } public async prepareEncrypt(roomId: string, roomInfo: ICryptoRoomInformation) { // TODO: Handle pre-shared invite keys too const members = (await this.client.getJoinedRoomMembers(roomId)).map(u => new UserId(u)); let historyVis = HistoryVisibility.Joined; switch (roomInfo.historyVisibility) { case "world_readable": historyVis = HistoryVisibility.WorldReadable; break; case "invited": historyVis = HistoryVisibility.Invited; break; case "shared": historyVis = HistoryVisibility.Shared; break; case "joined": default: // Default and other cases handled by assignment before switch } const encEv = new EncryptionEvent({ type: "m.room.encryption", content: roomInfo, }); const settings = new EncryptionSettings(); settings.algorithm = roomInfo.algorithm === EncryptionAlgorithm.MegolmV1AesSha2 ? RustEncryptionAlgorithm.MegolmV1AesSha2 : undefined; settings.historyVisibility = historyVis; settings.rotationPeriod = BigInt(encEv.rotationPeriodMs); settings.rotationPeriodMessages = BigInt(encEv.rotationPeriodMessages); await this.lock.acquire(SYNC_LOCK_NAME, async () => { await this.machine.updateTrackedUsers(members); // just in case we missed some await this.runOnly(RequestType.KeysQuery); const keysClaim = await this.machine.getMissingSessions(members); if (keysClaim) { await this.processKeysClaimRequest(keysClaim); } }); await this.lock.acquire(roomId, async () => { const requests = await this.machine.shareRoomKey(new RoomId(roomId), members, settings); for (const req of requests) { await this.actuallyProcessToDeviceRequest(req.txnId, req.eventType, JSON.parse(req.body)["messages"]); } }); } private async processKeysClaimRequest(request: KeysClaimRequest) { const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/claim", null, JSON.parse(request.body)); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processKeysUploadRequest(request: KeysUploadRequest) { const body = JSON.parse(request.body); // delete body["one_time_keys"]; // use this to test MSC3983 const otkIds = body.one_time_keys ? Object.keys(body.one_time_keys) : []; if (otkIds.length > 0) { // Kept at debug for diagnosing OTK conflicts (e.g. "One time key ... already exists" // from Synapse) — enable LOG_LEVEL=DEBUG on a single pod to capture the colliding key ids. LogService.debug("RustEngine", `Uploading ${otkIds.length} OTK(s): [${otkIds.join(", ")}]`); } const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/upload", null, body); const counts = resp?.one_time_key_counts?.signed_curve25519 ?? "unknown"; LogService.debug("RustEngine", `Keys upload response: server OTK count = ${counts}`); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processKeysQueryRequest(request: KeysQueryRequest) { const resp = await this.client.doRequest("POST", "/_matrix/client/v3/keys/query", null, JSON.parse(request.body)); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } private async processToDeviceRequest(request: ToDeviceRequest) { const req = JSON.parse(request.body); // Prefer the request's own accessors: depending on the bindings version // and code path, the body JSON may or may not embed txn_id/event_type. const txnId = request.txnId ?? req.txn_id; const eventType = request.eventType ?? req.event_type; await this.actuallyProcessToDeviceRequest(txnId, eventType, req.messages ?? req); } private async actuallyProcessToDeviceRequest(id: string, type: string, messages: Record>) { const resp = await this.client.sendToDevices(type, messages); await this.machine.markRequestAsSent(id, RequestType.ToDevice, JSON.stringify(resp)); } private async processSignatureUploadRequest(request: SignatureUploadRequest) { // The bindings serialize the request with its `signed_keys` wrapper, but // the wire format of POST /keys/signatures/upload is the bare map of // user ID -> key ID -> signed object. const body = JSON.parse(request.body); const resp = await this.client.doRequest( "POST", "/_matrix/client/v3/keys/signatures/upload", null, body?.["signed_keys"] ?? body, ); // Requests produced outside the outgoingRequests loop (eg cross-signing // bootstrap) are synthetic and carry no transaction ID to mark as sent. if (request.id) { await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } } private async processKeysBackupRequest(request: KeysBackupRequest) { // Only process if we have an active backup version const version = this.backupManager ? await this.backupManager.getActiveBackupVersion() : null; if (!version) { // No active backup, skip this request // Mark as sent with empty response so it doesn't get retried await this.machine.markRequestAsSent(request.id, request.type, "{}"); return; } const resp = await this.client.doRequest( "PUT", "/_matrix/client/v3/room_keys/keys", { version }, JSON.parse(request.body), ); await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp)); } }