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..1ddb01194c9 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,13 @@ 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(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(