From 167884c39752a4d7f7f8ffffc7f1af7da6275904 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 25 Mar 2024 20:25:50 +0000 Subject: [PATCH 1/5] Add `PerSessionKeyBackupDownloader.isKeyBackupDownloadConfigured()` --- .../PerSessionKeyBackupDownloader.spec.ts | 20 +++++++++++++++++++ .../PerSessionKeyBackupDownloader.ts | 15 +++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts b/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts index 8b1b0f75c2c..1acbfbbeb79 100644 --- a/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts +++ b/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts @@ -155,8 +155,11 @@ describe("PerSessionKeyBackupDownloader", () => { downloader.onDecryptionKeyMissingError(roomId, sessionId); + // `isKeyBackupDownloadConfigured` is false until the config is proven. + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); await expectAPICall; await awaitKeyImported.promise; + expect(downloader.isKeyBackupDownloadConfigured()).toBe(true); expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1); }); @@ -313,6 +316,9 @@ describe("PerSessionKeyBackupDownloader", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null)); + + // isKeyBackupDownloadConfigured remains false + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); }); it("Should not query server if backup not active", async () => { @@ -328,6 +334,9 @@ describe("PerSessionKeyBackupDownloader", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null)); + + // isKeyBackupDownloadConfigured remains false + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); }); it("Should stop if backup key is not cached", async () => { @@ -344,6 +353,9 @@ describe("PerSessionKeyBackupDownloader", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null)); + + // isKeyBackupDownloadConfigured remains false + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); }); it("Should stop if backup key cached as wrong version", async () => { @@ -363,6 +375,9 @@ describe("PerSessionKeyBackupDownloader", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null)); + + // isKeyBackupDownloadConfigured remains false + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); }); it("Should stop if backup key version does not match the active one", async () => { @@ -382,6 +397,9 @@ describe("PerSessionKeyBackupDownloader", () => { expect(getConfigSpy).toHaveBeenCalledTimes(1); expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null)); + + // isKeyBackupDownloadConfigured remains false + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); }); }); @@ -410,6 +428,7 @@ describe("PerSessionKeyBackupDownloader", () => { // @ts-ignore access to private property expect(downloader.hasConfigurationProblem).toEqual(true); + expect(downloader.isKeyBackupDownloadConfigured()).toBe(false); // Now the backup becomes trusted mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!); @@ -423,6 +442,7 @@ describe("PerSessionKeyBackupDownloader", () => { mockEmitter.emit(CryptoEvent.KeyBackupStatus, true); await jest.runAllTimersAsync(); + expect(downloader.isKeyBackupDownloadConfigured()).toBe(true); await a0Imported; await a1Imported; diff --git a/src/rust-crypto/PerSessionKeyBackupDownloader.ts b/src/rust-crypto/PerSessionKeyBackupDownloader.ts index c8283c65488..51565de021a 100644 --- a/src/rust-crypto/PerSessionKeyBackupDownloader.ts +++ b/src/rust-crypto/PerSessionKeyBackupDownloader.ts @@ -76,7 +76,11 @@ type Configuration = { export class PerSessionKeyBackupDownloader { private stopped = false; - /** The version and decryption key to use with current backup if all set up correctly */ + /** + * The version and decryption key to use with current backup if all set up correctly. + * + * Will not be set unless `hasConfigurationProblem` is `false`. + */ private configuration: Configuration | null = null; /** We remember when a session was requested and not found in backup to avoid query again too soon. @@ -119,6 +123,15 @@ export class PerSessionKeyBackupDownloader { backupManager.on(CryptoEvent.KeyBackupDecryptionKeyCached, this.onBackupStatusChanged); } + /** + * Check if key download is successfully configured and active. + * + * @return `true` if key download is correctly configured and active; otherwise `false`. + */ + public isKeyBackupDownloadConfigured(): boolean { + return this.configuration !== null; + } + /** * Called when a MissingRoomKey or UnknownMessageIndex decryption error is encountered. * From 897f5e2396890b3cad3d59bdb70b618d5c66cb88 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 12 Apr 2024 16:55:58 +0100 Subject: [PATCH 2/5] Add new `RustBackupManager.getServerBackupInfo` ... and a convenience method in PerSessionKeyBackupDownloader to access it. --- .../PerSessionKeyBackupDownloader.spec.ts | 8 ++--- .../PerSessionKeyBackupDownloader.ts | 13 ++++++-- src/rust-crypto/backup.ts | 33 +++++++++++++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts b/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts index 1acbfbbeb79..b81e17e5e5d 100644 --- a/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts +++ b/spec/unit/rust-crypto/PerSessionKeyBackupDownloader.spec.ts @@ -90,7 +90,7 @@ describe("PerSessionKeyBackupDownloader", () => { mockRustBackupManager = { getActiveBackupVersion: jest.fn(), - requestKeyBackupVersion: jest.fn(), + getServerBackupInfo: jest.fn(), importBackedUpRoomKeys: jest.fn(), createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor), on: jest.fn().mockImplementation((event, listener) => { @@ -135,7 +135,7 @@ describe("PerSessionKeyBackupDownloader", () => { decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64), } as unknown as RustSdkCryptoJs.BackupKeys); - mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); + mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); }); it("Should download and import a missing key from backup", async () => { @@ -406,7 +406,7 @@ describe("PerSessionKeyBackupDownloader", () => { describe("Given Backup state update", () => { it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => { // there is a backup - mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); + mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); // but at this point it's not trusted and we don't have the key mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null); @@ -454,7 +454,7 @@ describe("PerSessionKeyBackupDownloader", () => { describe("Error cases", () => { beforeEach(async () => { // there is a backup - mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); + mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA); // It's trusted mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!); // And we have the key in cache diff --git a/src/rust-crypto/PerSessionKeyBackupDownloader.ts b/src/rust-crypto/PerSessionKeyBackupDownloader.ts index 51565de021a..7642d52f9d9 100644 --- a/src/rust-crypto/PerSessionKeyBackupDownloader.ts +++ b/src/rust-crypto/PerSessionKeyBackupDownloader.ts @@ -17,7 +17,7 @@ limitations under the License. import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { Curve25519AuthData, KeyBackupSession } from "../crypto-api/keybackup"; +import { Curve25519AuthData, KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup"; import { Logger } from "../logger"; import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api"; import { RustBackupManager } from "./backup"; @@ -132,6 +132,15 @@ export class PerSessionKeyBackupDownloader { return this.configuration !== null; } + /** + * Return the details of the latest backup on the server, when we last checked. + * + * This is just a convenience method to expose {@link RustBackupManager.getServerBackupInfo}. + */ + public async getServerBackupInfo(): Promise { + return await this.backupManager.getServerBackupInfo(); + } + /** * Called when a MissingRoomKey or UnknownMessageIndex decryption error is encountered. * @@ -423,7 +432,7 @@ export class PerSessionKeyBackupDownloader { private async internalCheckFromServer(): Promise { let currentServerVersion = null; try { - currentServerVersion = await this.backupManager.requestKeyBackupVersion(); + currentServerVersion = await this.backupManager.getServerBackupInfo(); } catch (e) { this.logger.debug(`Backup: error while checking server version: ${e}`); this.hasConfigurationProblem = true; diff --git a/src/rust-crypto/backup.ts b/src/rust-crypto/backup.ts index 0fb1640320b..5c9c5772e2b 100644 --- a/src/rust-crypto/backup.ts +++ b/src/rust-crypto/backup.ts @@ -58,6 +58,16 @@ interface KeyBackupCreationInfo { export class RustBackupManager extends TypedEventEmitter { /** Have we checked if there is a backup on the server which we can use */ private checkedForBackup = false; + + /** + * The latest backup version on the server, when we last checked. + * + * If there was no backup on the server, `null`. If our attempt to check resulted in an error, `undefined`. + * + * Note that the backup was not necessarily verified. + */ + private serverBackupInfo: KeyBackupInfo | null | undefined = undefined; + private activeBackupVersion: string | null = null; private stopped = false; @@ -89,6 +99,21 @@ export class RustBackupManager extends TypedEventEmitter { + // Do a validity check if we haven't already done one. The check is likely to fail if we don't yet have the + // backup keys -- but as a side-effect, it will populate `serverBackupInfo`. + await this.checkKeyBackupAndEnable(false); + return this.serverBackupInfo; + } + /** * Determine if a key backup can be trusted. * @@ -242,18 +267,21 @@ export class RustBackupManager extends TypedEventEmitter { logger.log("Checking key backup status..."); - let backupInfo: KeyBackupInfo | null = null; + let backupInfo: KeyBackupInfo | null | undefined; try { backupInfo = await this.requestKeyBackupVersion(); } catch (e) { logger.warn("Error checking for active key backup", e); + this.serverBackupInfo = undefined; return null; } this.checkedForBackup = true; if (backupInfo && !backupInfo.version) { logger.warn("active backup lacks a useful 'version'; ignoring it"); + backupInfo = undefined; } + this.serverBackupInfo = backupInfo; const activeVersion = await this.getActiveBackupVersion(); @@ -462,12 +490,13 @@ export class RustBackupManager extends TypedEventEmitter { + private async requestKeyBackupVersion(): Promise { return await requestKeyBackupVersion(this.http); } From c8c94f9e12f66550343d17a77efc1d881a2fd71f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 16 Apr 2024 13:26:30 +0100 Subject: [PATCH 3/5] Crypto.spec: move `useRealTimers` to global `afterEach` ... so that we don't need to remember to do it everywhere. --- spec/integ/crypto/crypto.spec.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 53686dc1b32..529b9305175 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -108,6 +108,8 @@ afterEach(() => { // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state // eslint-disable-next-line no-global-assign indexedDB = new IDBFactory(); + + jest.useRealTimers(); }); /** @@ -997,10 +999,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, return encryptedMessage; } - afterEach(() => { - jest.useRealTimers(); - }); - newBackendOnly("should rotate the session after 2 messages", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); @@ -2184,10 +2182,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); }); - afterEach(() => { - jest.useRealTimers(); - }); - function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> { return new Promise((resolve) => { const listener = (url: string, options: RequestInit) => { @@ -2250,10 +2244,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); describe("getUserDeviceInfo", () => { - afterEach(() => { - jest.useRealTimers(); - }); - // From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery // Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification const queryResponseBody = { @@ -2742,10 +2732,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); }); - afterEach(() => { - jest.useRealTimers(); - }); - it("Should be able to restore from 4S after bootstrap", async () => { const backupVersion = "1"; await bootstrapSecurity(backupVersion); From a16906b89f276ff169cab28f64e2a64b30f88c6a Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 16 Apr 2024 13:31:10 +0100 Subject: [PATCH 4/5] Use fake timers for UTD error code tests This doesn't have any effect on the tests, but *does* stop jest from hanging when you run the tests in in-band mode. It shouldn't *really* be needed, but using fake timers gives more reproducible tests, and I don't have the time/patience to debug why it is needed. --- spec/integ/crypto/crypto.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 529b9305175..7573804e569 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -469,6 +469,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); describe("Unable to decrypt error codes", function () { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); + }); + it("Decryption fails with UISI error", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); From d01ef4b6d339f9c994876f291fd8260873d58800 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 16 Apr 2024 13:33:13 +0100 Subject: [PATCH 5/5] Use new error codes for UTDs from historical events --- spec/integ/crypto/crypto.spec.ts | 98 ++++++++++++++++++++++++- spec/integ/crypto/megolm-backup.spec.ts | 12 ++- src/crypto-api.ts | 18 +++++ src/rust-crypto/rust-crypto.ts | 38 +++++++++- 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 7573804e569..1c9ebb12781 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -480,11 +480,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails. const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted); + // Ensure that the timestamp post-dates the creation of our device + const encryptedEvent = { + ...testData.ENCRYPTED_EVENT, + origin_server_ts: Date.now(), + }; + const syncResponse = { next_batch: 1, rooms: { join: { - [testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } }, + [testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } }, }, }, }; @@ -504,12 +510,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]); - // Alice gets both the events in a single sync + // Ensure that the timestamp post-dates the creation of our device + const encryptedEvent = { + ...testData.ENCRYPTED_EVENT, + origin_server_ts: Date.now(), + }; + const syncResponse = { next_batch: 1, rooms: { join: { - [testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } }, + [testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } }, }, }, }; @@ -521,6 +532,87 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX); }); + describe("Historical events", () => { + async function sendEventAndAwaitDecryption(): Promise { + // A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails. + const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted); + + // Ensure that the timestamp pre-dates the creation of our device: set it to 24 hours ago + const encryptedEvent = { + ...testData.ENCRYPTED_EVENT, + origin_server_ts: Date.now() - 24 * 3600 * 1000, + }; + + const syncResponse = { + next_batch: 1, + rooms: { + join: { + [testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } }, + }, + }, + }; + + syncResponder.sendOrQueueSyncResponse(syncResponse); + return await awaitDecryption; + } + + newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => { + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + const ev = await sendEventAndAwaitDecryption(); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + }); + + newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => { + fetchMock.get("path:/_matrix/client/v3/room_keys/version", {}); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + const ev = await sendEventAndAwaitDecryption(); + expect(ev.decryptionFailureReason).toEqual( + DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + ); + }); + + newBackendOnly("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => { + // The test backup data is signed by a dummy device. We'll need to tell Alice about the device, and + // later, tell her to trust it, so that she trusts the backup. + const e2eResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + e2eResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA); + fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); + await startClientAndAwaitFirstSync(); + + await aliceClient + .getCrypto()! + .storeSessionBackupPrivateKey( + Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"), + testData.SIGNED_BACKUP_DATA.version!, + ); + + // Tell Alice to trust the dummy device that signed the backup + const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]); + expect(devices.get(TEST_USER_ID)!.keys()).toContain(testData.TEST_DEVICE_ID); + await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID); + + // Tell Alice to check and enable backup + await aliceClient.getCrypto()!.checkKeyBackupAndEnable(); + + // Sanity: Alice should now have working backup. + expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual( + testData.SIGNED_BACKUP_DATA.version, + ); + + // Finally! we can check what happens when we get an event. + const ev = await sendEventAndAwaitDecryption(); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP); + }); + }); + it("Decryption fails with Unable to decrypt for other errors", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); diff --git a/spec/integ/crypto/megolm-backup.spec.ts b/spec/integ/crypto/megolm-backup.spec.ts index 31fae626097..66f7892daa5 100644 --- a/spec/integ/crypto/megolm-backup.spec.ts +++ b/spec/integ/crypto/megolm-backup.spec.ts @@ -45,6 +45,7 @@ import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keyback import { IKeyBackup } from "../../../src/crypto/backup"; import { flushPromises } from "../../test-utils/flushPromises"; import { defer, IDeferred } from "../../../src/utils"; +import { DecryptionFailureCode } from "../../../src/crypto-api"; const ROOM_ID = testData.TEST_ROOM_ID; @@ -242,8 +243,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe const room = aliceClient.getRoom(ROOM_ID)!; const event = room.getLiveTimeline().getEvents()[0]; - await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true })); + // 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, + ); + + // Eventually, decryption succeeds. + await awaitDecryption(event, { waitOnDecryptionFailure: true }); expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content); }); diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 0f78bd76a1a..1c3a3a6356c 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -542,6 +542,24 @@ export enum DecryptionFailureCode { /** Message was encrypted with a Megolm session which has been shared with us, but in a later ratchet state. */ OLM_UNKNOWN_MESSAGE_INDEX = "OLM_UNKNOWN_MESSAGE_INDEX", + /** + * Message was sent before the current device was created; there is no key backup on the server, so this + * decryption failure is expected. + */ + HISTORICAL_MESSAGE_NO_KEY_BACKUP = "HISTORICAL_MESSAGE_NO_KEY_BACKUP", + + /** + * Message was sent before the current device was created; there was a key backup on the server, but we don't + * seem to have access to the backup. (Probably we don't have the right key.) + */ + HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED = "HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED", + + /** + * Message was sent before the current device was created; there was a (usable) key backup on the server, but we + * still can't decrypt. (Either the session isn't in the backup, or we just haven't gotten around to checking yet.) + */ + HISTORICAL_MESSAGE_WORKING_BACKUP = "HISTORICAL_MESSAGE_WORKING_BACKUP", + /** Unknown or unclassified error. */ UNKNOWN_ERROR = "UNKNOWN_ERROR", diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 713c113866a..ee00aba4c88 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1707,7 +1707,7 @@ class EventDecryptor { }; } catch (err) { if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) { - this.onMegolmDecryptionError(event, err); + this.onMegolmDecryptionError(event, err, await this.perSessionBackupDownloader.getServerBackupInfo()); } else { throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "Unknown error"); } @@ -1718,9 +1718,19 @@ class EventDecryptor { * Handle a `MegolmDecryptionError` returned by the rust SDK. * * Fires off a request to the `perSessionBackupDownloader`, if appropriate, and then throws a `DecryptionError`. + * + * @param event - The event which could not be decrypted. + * @param err - The error from the Rust SDK. + * @param serverBackupInfo - Details about the current backup from the server. `null` if there is no backup. + * `undefined` if our attempt to check failed. */ - private onMegolmDecryptionError(event: MatrixEvent, err: RustSdkCryptoJs.MegolmDecryptionError): never { + private onMegolmDecryptionError( + event: MatrixEvent, + err: RustSdkCryptoJs.MegolmDecryptionError, + serverBackupInfo: KeyBackupInfo | null | undefined, + ): never { const content = event.getWireContent(); + const errorDetails = { session: content.sender_key + "|" + content.session_id }; // If the error looks like it might be recoverable from backup, queue up a request to try that. if ( @@ -1728,9 +1738,31 @@ class EventDecryptor { err.code === RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex ) { this.perSessionBackupDownloader.onDecryptionKeyMissingError(event.getRoomId()!, content.session_id!); + + // If the event was sent before this device was created, we use some different error codes. + if (event.getTs() <= this.olmMachine.deviceCreationTimeMs) { + if (serverBackupInfo === null) { + throw new DecryptionError( + DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, + "This message was sent before this device logged in, and there is no key backup on the server.", + errorDetails, + ); + } else if (!this.perSessionBackupDownloader.isKeyBackupDownloadConfigured()) { + throw new DecryptionError( + DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + "This message was sent before this device logged in, and key backup is not working.", + errorDetails, + ); + } else { + throw new DecryptionError( + DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP, + "This message was sent before this device logged in. Key backup is working, but we still do not (yet) have the key.", + errorDetails, + ); + } + } } - const errorDetails = { session: content.sender_key + "|" + content.session_id }; switch (err.code) { case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey: throw new DecryptionError(