diff --git a/spec/integ/crypto/megolm-backup.spec.ts b/spec/integ/crypto/megolm-backup.spec.ts index 55cdd76b1f1..adc04678d75 100644 --- a/spec/integ/crypto/megolm-backup.spec.ts +++ b/spec/integ/crypto/megolm-backup.spec.ts @@ -23,6 +23,7 @@ import { createClient, Crypto, CryptoEvent, + encodeBase64, ICreateClientOpts, IEvent, IMegolmSessionData, @@ -44,7 +45,7 @@ import * as testData from "../../test-utils/test-data"; import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keybackup"; import { flushPromises } from "../../test-utils/flushPromises"; import { defer, IDeferred } from "../../../src/utils"; -import { DecryptionFailureCode } from "../../../src/crypto-api"; +import { decodeRecoveryKey, DecryptionFailureCode } from "../../../src/crypto-api"; import { KeyBackup } from "../../../src/rust-crypto/backup.ts"; const ROOM_ID = testData.TEST_ROOM_ID; @@ -117,8 +118,10 @@ function mockUploadEmitter( describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => { // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the // Rust backend. Once we have full support in the rust sdk, it will go away. - // const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; - // const newBackendOnly = backend === "libolm" ? test.skip : test; + const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; + const newBackendOnly = backend === "libolm" ? test.skip : test; + + const isNewBackend = backend === "rust-sdk"; let aliceClient: MatrixClient; /** an object which intercepts `/sync` requests on the test homeserver */ @@ -247,9 +250,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe // On the first decryption attempt, decryption fails. await awaitDecryption(event); expect(event.decryptionFailureReason).toEqual( - backend === "libolm" - ? DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID - : DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP, + isNewBackend + ? DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP + : DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, ); // Eventually, decryption succeeds. @@ -314,6 +317,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe beforeEach(async () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); + fetchMock.get( + `path:/_matrix/client/v3/room_keys/version/${testData.SIGNED_BACKUP_DATA.version}`, + testData.SIGNED_BACKUP_DATA, + ); aliceClient = await initTestClient(); aliceCrypto = aliceClient.getCrypto()!; @@ -344,20 +351,29 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe onKeyCached = resolve; }); + await aliceCrypto.storeSessionBackupPrivateKey( + decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58), + check!.backupInfo!.version!, + ); + const result = await advanceTimersUntil( - aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - cacheCompleteCallback: () => onKeyCached(), - }, - ), + isNewBackend + ? aliceCrypto.restoreKeyBackup() + : aliceClient.restoreKeyBackupWithRecoveryKey( + testData.BACKUP_DECRYPTION_KEY_BASE58, + undefined, + undefined, + check!.backupInfo!, + { + cacheCompleteCallback: () => onKeyCached(), + }, + ), ); expect(result.imported).toStrictEqual(1); + if (isNewBackend) return; + await awaitKeyCached; // The key should be now cached @@ -398,8 +414,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe it("Should import full backup in chunks", async function () { const importMockImpl = jest.fn(); - // @ts-ignore - mock a private method for testing purpose - aliceCrypto.importBackedUpRoomKeys = importMockImpl; + if (isNewBackend) { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); + } else { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl); + } // We need several rooms with several sessions to test chunking const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]); @@ -408,17 +429,26 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe const check = await aliceCrypto.checkKeyBackupAndEnable(); - const progressCallback = jest.fn(); - const result = await aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - progressCallback, - }, + await aliceCrypto.storeSessionBackupPrivateKey( + decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58), + check!.backupInfo!.version!, ); + const progressCallback = jest.fn(); + const result = await (isNewBackend + ? aliceCrypto.restoreKeyBackup({ + progressCallback, + }) + : aliceClient.restoreKeyBackupWithRecoveryKey( + testData.BACKUP_DECRYPTION_KEY_BASE58, + undefined, + undefined, + check!.backupInfo!, + { + progressCallback, + }, + )); + expect(result.imported).toStrictEqual(expectedTotal); // Should be called 5 times: 200*4 plus one chunk with the remaining 32 expect(importMockImpl).toHaveBeenCalledTimes(5); @@ -451,8 +481,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe }); it("Should continue to process backup if a chunk import fails and report failures", async function () { - // @ts-ignore - mock a private method for testing purpose - aliceCrypto.importBackedUpRoomKeys = jest + const importMockImpl = jest .fn() .mockImplementationOnce(() => { // Fail to import first chunk @@ -461,22 +490,36 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe // Ok for other chunks .mockResolvedValue(undefined); + if (isNewBackend) { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); + } else { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl); + } + const { response, expectedTotal } = createBackupDownloadResponse([100, 300]); fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response); const check = await aliceCrypto.checkKeyBackupAndEnable(); + await aliceCrypto.storeSessionBackupPrivateKey( + decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58), + check!.backupInfo!.version!, + ); const progressCallback = jest.fn(); - const result = await aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - progressCallback, - }, - ); + const result = await (isNewBackend + ? aliceCrypto.restoreKeyBackup({ progressCallback }) + : aliceClient.restoreKeyBackupWithRecoveryKey( + testData.BACKUP_DECRYPTION_KEY_BASE58, + undefined, + undefined, + check!.backupInfo!, + { + progressCallback, + }, + )); expect(result.total).toStrictEqual(expectedTotal); // A chunk failed to import @@ -527,20 +570,26 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response); const check = await aliceCrypto.checkKeyBackupAndEnable(); - - const result = await aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, + await aliceCrypto.storeSessionBackupPrivateKey( + decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58), + check!.backupInfo!.version!, ); + const result = await (isNewBackend + ? aliceCrypto.restoreKeyBackup() + : aliceClient.restoreKeyBackupWithRecoveryKey( + testData.BACKUP_DECRYPTION_KEY_BASE58, + undefined, + undefined, + check!.backupInfo!, + )); + expect(result.total).toStrictEqual(expectedTotal); // A chunk failed to import expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount); }); - it("recover specific session from backup", async function () { + oldBackendOnly("recover specific session from backup", async function () { fetchMock.get( "express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", testData.CURVE25519_KEY_BACKUP_DATA, @@ -560,7 +609,33 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe expect(result.imported).toStrictEqual(1); }); - it("Fails on bad recovery key", async function () { + newBackendOnly( + "Should get the decryption key from the secret storage and restore the key backup", + async function () { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64); + + const fullBackup = { + rooms: { + [ROOM_ID]: { + sessions: { + [testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA, + }, + }, + }, + }; + fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup); + + await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage(); + const decryptionKey = await aliceCrypto.getSessionBackupPrivateKey(); + expect(encodeBase64(decryptionKey!)).toStrictEqual(testData.BACKUP_DECRYPTION_KEY_BASE64); + + const result = await aliceCrypto.restoreKeyBackup(); + expect(result.imported).toStrictEqual(1); + }, + ); + + oldBackendOnly("Fails on bad recovery key", async function () { const fullBackup = { rooms: { [ROOM_ID]: { @@ -584,6 +659,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe ), ).rejects.toThrow(); }); + + newBackendOnly("Should throw an error if the decryption key is not found in cache", async () => { + await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store"); + }); }); describe("backupLoop", () => { diff --git a/src/client.ts b/src/client.ts index 82fd049273c..46f0ff9c13e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3696,6 +3696,8 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. + */ public async restoreKeyBackupWithPassword( password: string, targetRoomId: string, @@ -3711,6 +3716,9 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. + */ public async restoreKeyBackupWithPassword( password: string, targetRoomId: string, @@ -3718,6 +3726,9 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. + */ public async restoreKeyBackupWithPassword( password: string, targetRoomId: string | undefined, @@ -3741,6 +3752,8 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string, @@ -3793,6 +3811,9 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string, @@ -3800,6 +3821,9 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public restoreKeyBackupWithRecoveryKey( recoveryKey: string, targetRoomId: string | undefined, @@ -3811,24 +3835,42 @@ export class MatrixClient extends TypedEventEmitter; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public async restoreKeyBackupWithCache( targetRoomId: string, targetSessionId: undefined, backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public async restoreKeyBackupWithCache( targetRoomId: string, targetSessionId: string, backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise; + /** + * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. + */ public async restoreKeyBackupWithCache( targetRoomId: string | undefined, targetSessionId: string | undefined, diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index ea6177f7f85..8e288e9a4d4 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -22,7 +22,13 @@ import { DeviceMap } from "../models/device.ts"; import { UIAuthCallback } from "../interactive-auth.ts"; import { PassphraseInfo, SecretStorageCallbacks, SecretStorageKeyDescription } from "../secret-storage.ts"; import { VerificationRequest } from "./verification.ts"; -import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./keybackup.ts"; +import { + BackupTrustInfo, + KeyBackupCheck, + KeyBackupInfo, + KeyBackupRestoreOpts, + KeyBackupRestoreResult, +} from "./keybackup.ts"; import { ISignatures } from "../@types/signed.ts"; import { MatrixEvent } from "../models/event.ts"; @@ -496,6 +502,18 @@ export interface CryptoApi { */ storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise; + /** + * Attempt to fetch the backup decryption key from secret storage. + * + * If the key is found in secret storage, checks it against the latest backup on the server; + * if they match, stores the key in the crypto store by calling {@link storeSessionBackupPrivateKey}, + * which enables automatic restore of individual keys when an Unable-to-decrypt error is encountered. + * + * If we are unable to fetch the key from secret storage, there is no backup on the server, or the key + * does not match, throws an exception. + */ + loadSessionBackupPrivateKeyFromSecretStorage(): Promise; + /** * Get the current status of key backup. * @@ -539,6 +557,36 @@ export interface CryptoApi { */ deleteKeyBackupVersion(version: string): Promise; + /** + * Download and restore the full key backup from the homeserver. + * + * Before calling this method, a decryption key, and the backup version to restore, + * must have been saved in the crypto store. This happens in one of the following ways: + * + * - When a new backup version is created with {@link CryptoApi.resetKeyBackup}, a new key is created and cached. + * - The key can be loaded from secret storage with {@link CryptoApi.loadSessionBackupPrivateKeyFromSecretStorage}. + * - The key can be received from another device via secret sharing, typically as part of the interactive verification flow. + * - The key and backup version can also be set explicitly via {@link CryptoApi.storeSessionBackupPrivateKey}, + * though this is not expected to be a common operation. + * + * Warning: the full key backup may be quite large, so this operation may take several hours to complete. + * Use of {@link KeyBackupRestoreOpts.progressCallback} is recommended. + * + * @param opts + */ + restoreKeyBackup(opts?: KeyBackupRestoreOpts): Promise; + + /** + * Restores a key backup using a passphrase. + * The decoded key (derived from the passphrase) is stored locally by calling {@link CryptoApi#storeSessionBackupPrivateKey}. + * + * @param passphrase - The passphrase to use to restore the key backup. + * @param opts + * + * @deprecated Deriving a backup key from a passphrase is not part of the matrix spec. Instead, a random key is generated and stored/shared via 4S. + */ + restoreKeyBackupWithPassphrase(passphrase: string, opts?: KeyBackupRestoreOpts): Promise; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Dehydrated devices @@ -880,8 +928,8 @@ export class DeviceVerificationStatus { /** * Room key import progress report. - * Used when calling {@link CryptoApi#importRoomKeys} or - * {@link CryptoApi#importRoomKeysAsJson} as the parameter of + * Used when calling {@link CryptoApi#importRoomKeys}, + * {@link CryptoApi#importRoomKeysAsJson} or {@link CryptoApi#restoreKeyBackup} as the parameter of * the progressCallback. Used to display feedback. */ export interface ImportRoomKeyProgressData { diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts index efae30d0ec4..9840159daeb 100644 --- a/src/crypto-api/keybackup.ts +++ b/src/crypto-api/keybackup.ts @@ -16,6 +16,7 @@ limitations under the License. import { ISigned } from "../@types/signed.ts"; import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; +import { ImportRoomKeyProgressData } from "./index.ts"; export interface Curve25519AuthData { public_key: string; @@ -87,3 +88,28 @@ export interface KeyBackupSession void; +} + +/** + * The result of {@link CryptoApi.restoreKeyBackup}. + */ +export interface KeyBackupRestoreResult { + /** + * The total number of keys that were found in the backup. + */ + total: number; + /** + * The number of keys that were imported. + */ + imported: number; +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 2e3bef6eecc..c75589813e7 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -102,6 +102,8 @@ import { OwnDeviceKeys, CryptoEvent as CryptoApiCryptoEvent, CryptoEventHandlerMap as CryptoApiCryptoEventHandlerMap, + KeyBackupRestoreResult, + KeyBackupRestoreOpts, } from "../crypto-api/index.ts"; import { Device, DeviceMap } from "../models/device.ts"; import { deviceInfoToDevice } from "./device-converter.ts"; @@ -1306,6 +1308,13 @@ export class Crypto extends TypedEventEmitter { + throw new Error("Not implmeented"); + } + /** * Get the current status of key backup. * @@ -4308,6 +4317,23 @@ export class Crypto extends TypedEventEmitter { throw new Error("Not implemented"); } + + /** + * Stub function -- restoreKeyBackup is not implemented here, so throw error + */ + public restoreKeyBackup(opts: KeyBackupRestoreOpts): Promise { + throw new Error("Not implemented"); + } + + /** + * Stub function -- restoreKeyBackupWithPassphrase is not implemented here, so throw error + */ + public restoreKeyBackupWithPassphrase( + passphrase: string, + opts: KeyBackupRestoreOpts, + ): Promise { + throw new Error("Not implemented"); + } } /** diff --git a/src/rust-crypto/backup.ts b/src/rust-crypto/backup.ts index 59aa30de372..18a3f7c679d 100644 --- a/src/rust-crypto/backup.ts +++ b/src/rust-crypto/backup.ts @@ -24,6 +24,8 @@ import { KeyBackupInfo, KeyBackupSession, Curve25519SessionData, + KeyBackupRestoreOpts, + KeyBackupRestoreResult, KeyBackupRoomSessions, } from "../crypto-api/keybackup.ts"; import { logger } from "../logger.ts"; @@ -218,7 +220,7 @@ export class RustBackupManager extends TypedEventEmitter { - return await requestKeyBackupVersion(this.http); + public async requestKeyBackupVersion(version?: string): Promise { + return await requestKeyBackupVersion(this.http, version); } /** @@ -591,6 +592,148 @@ export class RustBackupManager extends TypedEventEmitter { + const keyBackup = await this.downloadKeyBackup(backupVersion); + opts?.progressCallback?.({ + stage: "load_keys", + }); + + return this.importKeyBackup(keyBackup, backupVersion, backupDecryptor, opts); + } + + /** + * Call `/room_keys/keys` to download the key backup (room keys) for the given backup version. + * https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keyskeys + * + * @param backupVersion + * @returns The key backup response. + */ + private downloadKeyBackup(backupVersion: string): Promise { + return this.http.authedRequest( + Method.Get, + "/room_keys/keys", + { version: backupVersion }, + undefined, + { + prefix: ClientPrefix.V3, + }, + ); + } + + /** + * Import the room keys from a `/room_keys/keys` call. + * Calls `opts.progressCallback` with the progress of the import. + * + * @param keyBackup - The response from the server containing the keys to import. + * @param backupVersion - The version of the backup info. + * @param backupDecryptor - The backup decryptor to use to decrypt the keys. + * @param opts - Options for the import. + * + * @returns The total number of keys and the total imported. + * + * @private + */ + private async importKeyBackup( + keyBackup: KeyBackup, + backupVersion: string, + backupDecryptor: BackupDecryptor, + opts?: KeyBackupRestoreOpts, + ): Promise { + // We have a full backup here, it can get quite big, so we need to decrypt and import it in chunks. + + const CHUNK_SIZE = 200; + // Get the total count as a first pass + const totalKeyCount = countKeysInBackup(keyBackup); + let totalImported = 0; + let totalFailures = 0; + + /** + * This method is called when we have enough chunks to decrypt. + * It will decrypt the chunks and try to import the room keys. + * @param roomChunks + */ + const handleChunkCallback = async (roomChunks: Map): Promise => { + const currentChunk: IMegolmSessionData[] = []; + for (const roomId of roomChunks.keys()) { + // Decrypt the sessions for the given room + const decryptedSessions = await backupDecryptor.decryptSessions(roomChunks.get(roomId)!); + // Add the decrypted sessions to the current chunk + decryptedSessions.forEach((session) => { + // We set the room_id for each session + session.room_id = roomId; + currentChunk.push(session); + }); + } + + // We have a chunk of decrypted keys: import them + try { + await this.importBackedUpRoomKeys(currentChunk, backupVersion); + totalImported += currentChunk.length; + } catch (e) { + totalFailures += currentChunk.length; + // We failed to import some keys, but we should still try to import the rest? + // Log the error and continue + logger.error("Error importing keys from backup", e); + } + + opts?.progressCallback?.({ + total: totalKeyCount, + successes: totalImported, + stage: "load_keys", + failures: totalFailures, + }); + }; + + let groupChunkCount = 0; + let chunkGroupByRoom: Map = new Map(); + + // Iterate over the rooms and sessions to group them in chunks + // And we call the handleChunkCallback when we have enough chunks to decrypt + for (const [roomId, roomData] of Object.entries(keyBackup.rooms)) { + // If there are no sessions for the room, skip it + if (!roomData.sessions) continue; + + // Initialize a new chunk group for the current room + chunkGroupByRoom.set(roomId, {}); + + for (const [sessionId, session] of Object.entries(roomData.sessions)) { + // We set previously the chunk group for the current room, so we can safely get it + const sessionsForRoom = chunkGroupByRoom.get(roomId)!; + sessionsForRoom[sessionId] = session; + groupChunkCount += 1; + // If we have enough chunks to decrypt, call the block callback + if (groupChunkCount >= CHUNK_SIZE) { + // We have enough chunks to decrypt + await handleChunkCallback(chunkGroupByRoom); + // Reset the chunk group + chunkGroupByRoom = new Map(); + // There might be remaining keys for that room, so add back an entry for the current room. + chunkGroupByRoom.set(roomId, {}); + groupChunkCount = 0; + } + } + } + + // Handle remaining chunk if needed + if (groupChunkCount > 0) { + await handleChunkCallback(chunkGroupByRoom); + } + + return { total: totalKeyCount, imported: totalImported }; + } } /** @@ -657,11 +800,26 @@ export class RustBackupDecryptor implements BackupDecryptor { } } +/** + * Fetch a key backup info from the server. + * + * If `version` is provided, calls `GET /room_keys/version/$version` and gets the backup info for that version. + * See https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keysversionversion. + * + * If not, calls `GET /room_keys/version` and gets the latest backup info. + * See https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keysversion + * + * @param http + * @param version - the specific version of the backup info to fetch + * @returns The key backup info or null if there is no backup. + */ export async function requestKeyBackupVersion( http: MatrixHttpApi, + version?: string, ): Promise { try { - return await http.authedRequest(Method.Get, "/room_keys/version", undefined, undefined, { + const path = version ? encodeUri("/room_keys/version/$version", { $version: version }) : "/room_keys/version"; + return await http.authedRequest(Method.Get, path, undefined, undefined, { prefix: ClientPrefix.V3, }); } catch (e) { @@ -673,6 +831,34 @@ export async function requestKeyBackupVersion( } } +/** + * Checks if the provided decryption key matches the public key of the key backup info. + * + * @param decryptionKey - The decryption key to check. + * @param keyBackupInfo - The key backup info to check against. + * @returns `true` if the decryption key matches the key backup info, `false` otherwise. + */ +export function decryptionKeyMatchesKeyBackupInfo( + decryptionKey: RustSdkCryptoJs.BackupDecryptionKey, + keyBackupInfo: KeyBackupInfo, +): boolean { + const authData = keyBackupInfo.auth_data; + return authData.public_key === decryptionKey.megolmV1PublicKey.publicKeyBase64; +} + +/** + * Counts the total number of keys present in a key backup. + * @param keyBackup - The key backup to count the keys from. + * @returns The total number of keys in the backup. + */ +function countKeysInBackup(keyBackup: KeyBackup): number { + let count = 0; + for (const { sessions } of Object.values(keyBackup.rooms)) { + count += Object.keys(sessions).length; + } + return count; +} + export type RustBackupCryptoEvents = | CryptoEvent.KeyBackupStatus | CryptoEvent.KeyBackupSessionsRemaining diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 2d65e7f5c58..c0ec23425a0 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -46,7 +46,6 @@ import { CrossSigningStatus, CryptoApi, CryptoCallbacks, - Curve25519AuthData, DecryptionFailureCode, DeviceVerificationStatus, EventEncryptionInfo, @@ -66,6 +65,8 @@ import { DeviceIsolationModeKind, CryptoEvent, CryptoEventHandlerMap, + KeyBackupRestoreOpts, + KeyBackupRestoreResult, } from "../crypto-api/index.ts"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts"; @@ -76,16 +77,17 @@ import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts"; import { EventType, MsgType } from "../@types/event.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { RustBackupManager } from "./backup.ts"; +import { decryptionKeyMatchesKeyBackupInfo, RustBackupManager } from "./backup.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { randomString } from "../randomstring.ts"; import { ClientStoppedError } from "../errors.ts"; import { ISignatures } from "../@types/signed.ts"; -import { encodeBase64 } from "../base64.ts"; +import { decodeBase64, encodeBase64 } from "../base64.ts"; import { OutgoingRequestsManager } from "./OutgoingRequestsManager.ts"; import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader.ts"; import { DehydratedDeviceManager } from "./DehydratedDeviceManager.ts"; import { VerificationMethod } from "../types.ts"; +import { keyFromAuthData } from "../common-crypto/key-passphrase.ts"; const ALL_VERIFICATION_METHODS = [ VerificationMethod.Sas, @@ -337,9 +339,7 @@ export class RustCrypto extends TypedEventEmitterbackupInfo.auth_data; - if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) { + if (!decryptionKeyMatchesKeyBackupInfo(backupDecryptionKey, backupInfo)) { throw new Error(`getBackupDecryptor: key backup on server does not match the decryption key`); } @@ -1202,6 +1202,28 @@ export class RustCrypto extends TypedEventEmitter { + const backupKey = await this.secretStorage.get("m.megolm_backup.v1"); + if (!backupKey) { + throw new Error("loadSessionBackupPrivateKeyFromSecretStorage: missing decryption key in secret storage"); + } + + const keyBackupInfo = await this.backupManager.getServerBackupInfo(); + if (!keyBackupInfo || !keyBackupInfo.version) { + throw new Error("loadSessionBackupPrivateKeyFromSecretStorage: unable to get backup version"); + } + + const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(backupKey); + if (!decryptionKeyMatchesKeyBackupInfo(backupDecryptionKey, keyBackupInfo)) { + throw new Error("loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info"); + } + + await this.backupManager.saveBackupDecryptionKey(backupDecryptionKey, keyBackupInfo.version); + } + /** * Get the current status of key backup. * @@ -1280,6 +1302,53 @@ export class RustCrypto extends TypedEventEmitter { + const backupInfo = await this.backupManager.getServerBackupInfo(); + if (!backupInfo?.version) { + throw new Error("No backup info available"); + } + + const privateKey = await keyFromAuthData(backupInfo.auth_data, passphrase); + + // Cache the key + await this.storeSessionBackupPrivateKey(privateKey, backupInfo.version); + return this.restoreKeyBackup(opts); + } + + /** + * Implementation of {@link CryptoApi#restoreKeyBackup}. + */ + public async restoreKeyBackup(opts?: KeyBackupRestoreOpts): Promise { + // Get the decryption key from the crypto store + const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys(); + const { decryptionKey, backupVersion } = backupKeys; + if (!decryptionKey || !backupVersion) throw new Error("No decryption key found in crypto store"); + + const decodedDecryptionKey = decodeBase64(decryptionKey.toBase64()); + + const backupInfo = await this.backupManager.requestKeyBackupVersion(backupVersion); + if (!backupInfo) throw new Error(`Backup version to restore ${backupVersion} not found on server`); + + const backupDecryptor = await this.getBackupDecryptor(backupInfo, decodedDecryptionKey); + + try { + opts?.progressCallback?.({ + stage: "fetch", + }); + + return await this.backupManager.restoreKeyBackup(backupVersion, backupDecryptor, opts); + } finally { + // Free to avoid to keep in memory the decryption key stored in it. To avoid to exposing it to an attacker. + backupDecryptor.free(); + } + } + /** * Implementation of {@link CryptoApi#isDehydrationSupported}. */