diff --git a/spec/integ/crypto/olm-utils.ts b/spec/integ/crypto/olm-utils.ts index 34e2e6df754..3f8144fff5f 100644 --- a/spec/integ/crypto/olm-utils.ts +++ b/spec/integ/crypto/olm-utils.ts @@ -17,10 +17,11 @@ limitations under the License. import Olm from "@matrix-org/olm"; import anotherjson from "another-json"; -import { IContent, IDeviceKeys, IEvent, MatrixClient } from "../../../src"; +import { IContent, IDeviceKeys, IDownloadKeyResult, IEvent, Keys, MatrixClient, SigningKeys } from "../../../src"; import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { ISyncResponder } from "../../test-utils/SyncResponder"; import { syncPromise } from "../../test-utils/test-utils"; +import { KeyBackupInfo } from "../../../src/crypto-api"; /** * @module @@ -60,6 +61,117 @@ export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, d return testDeviceKeys; } +/** + * Bootstrap cross signing for the given Olm account. + * + * Will generate the cross signing keys and sign them with the master key, and returns the `IDownloadKeyResult` + * that can be directly fed into a test e2eKeyResponder. + * + * The cross-signing keys are randomly generated, similar to how the olm account keys are generated. There may not + * be any value in using static vectors, as the device keys change at every test run. + * + * If some `KeyBackupInfo` are provided, the `auth_data` of each backup info will be signed with the + * master key, meaning the backups will be then trusted after verification. + * + * @param olmAccount - The Olm account object to use for signing the device keys. + * @param userId - The user ID to associate with the device keys. + * @param deviceId - The device ID to associate with the device keys. + * @param keyBackupInfo - Optional key backup infos to sign with the master key. + * @returns A valid keys/query response that can be fed into a test e2eKeyResponder. + */ +export function bootstrapCrossSigningTestOlmAccount( + olmAccount: Olm.Account, + userId: string, + deviceId: string, + keyBackupInfo: KeyBackupInfo[] = [], +): Partial { + const olmAliceMSK = new global.Olm.PkSigning(); + const masterPrivkey = olmAliceMSK.generate_seed(); + const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey); + + const olmAliceUSK = new global.Olm.PkSigning(); + const userPrivkey = olmAliceUSK.generate_seed(); + const userPubkey = olmAliceUSK.init_with_seed(userPrivkey); + + const olmAliceSSK = new global.Olm.PkSigning(); + const sskPrivkey = olmAliceSSK.generate_seed(); + const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey); + + const mskInfo: Keys = { + user_id: userId, + usage: ["master"], + keys: { + ["ed25519:" + masterPubkey]: masterPubkey, + }, + }; + + const sskInfo: Partial = { + user_id: userId, + usage: ["self_signing"], + keys: { + ["ed25519:" + sskPubkey]: sskPubkey, + }, + }; + // sign the ssk with the msk + const sskSig = olmAliceMSK.sign(anotherjson.stringify(sskInfo)); + sskInfo.signatures = { + [userId]: { + ["ed25519:" + masterPubkey]: sskSig, + }, + }; + + const uskInfo: Partial = { + user_id: userId, + usage: ["user_signing"], + keys: { + ["ed25519:" + userPubkey]: userPubkey, + }, + }; + + // sign the usk with the msk + const uskSig = olmAliceMSK.sign(anotherjson.stringify(uskInfo)); + uskInfo.signatures = { + [userId]: { + ["ed25519:" + masterPubkey]: uskSig, + }, + }; + + // get the device keys and sign them with the ssk (the device is then cross signed) + const deviceKeys = getTestOlmAccountKeys(olmAccount, userId, deviceId); + + const copy = Object.assign({}, deviceKeys); + delete copy.signatures; + const crossSignature = olmAliceSSK.sign(anotherjson.stringify(copy)); + + // add the signature + deviceKeys.signatures![userId]["ed25519:" + sskPubkey] = crossSignature; + + // if we have some key backup info, sign them with the msk + keyBackupInfo.forEach((info) => { + const unsignedAuthData = Object.assign({}, info.auth_data); + delete unsignedAuthData.signatures; + const backupSignature = olmAliceMSK.sign(anotherjson.stringify(unsignedAuthData)); + + info.auth_data.signatures = { + [userId]: { + ["ed25519:" + masterPubkey]: backupSignature, + }, + }; + }); + + // clean the olm resources as we don't need them anymore + olmAliceMSK.free(); + olmAliceSSK.free(); + olmAliceUSK.free(); + + return { + master_keys: { [userId]: mskInfo }, + user_signing_keys: { [userId]: uskInfo as SigningKeys }, + self_signing_keys: { [userId]: sskInfo as SigningKeys }, + device_keys: { [userId]: { [deviceId]: deviceKeys } }, + }; +} + /** start an Olm session with a given recipient */ export async function createOlmSession( olmAccount: Olm.Account, @@ -218,6 +330,47 @@ export function encryptGroupSessionKey(opts: { }); } +/** + * Test utility to correctly encrypt a secret send event to a test device using the provided p2p session. + * + * @param opts - the options for the secret send event + * @returns the to-device event, ready to be returned in a sync response for the test device. + */ +export function encryptSecretSend(opts: { + /** the sender's user id */ + sender: string; + /** recipient's user id */ + recipient: string; + /** the recipient's curve25519 key */ + recipientCurve25519Key: string; + /** the recipient's ed25519 key */ + recipientEd25519Key: string; + /** sender's olm account */ + olmAccount: Olm.Account; + /** sender's olm session with the recipient */ + p2pSession: Olm.Session; + /** The requestId of the secret request that this secret send is replying. */ + requestId: string; + /** The secret value */ + secret: string; +}): ToDeviceEvent { + const senderKeys = JSON.parse(opts.olmAccount.identity_keys()); + return encryptOlmEvent({ + sender: opts.sender, + senderKey: senderKeys.curve25519, + senderSigningKey: senderKeys.ed25519, + recipient: opts.recipient, + recipientCurve25519Key: opts.recipientCurve25519Key, + recipientEd25519Key: opts.recipientEd25519Key, + p2pSession: opts.p2pSession, + plaincontent: { + request_id: opts.requestId, + secret: opts.secret, + }, + plaintype: "m.secret.send", + }); +} + /** * Establish an Olm Session with the test user * diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 91877f9a3a8..56ef62b19fb 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -26,6 +26,7 @@ import Olm from "@matrix-org/olm"; import { createClient, CryptoEvent, + DeviceVerification, IContent, ICreateClientOpts, IEvent, @@ -43,7 +44,7 @@ import { Verifier, VerifierEvent, } from "../../../src/crypto-api/verification"; -import { escapeRegExp } from "../../../src/utils"; +import { defer, escapeRegExp } from "../../../src/utils"; import { awaitDecryption, CRYPTO_BACKENDS, @@ -54,10 +55,12 @@ import { } from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { + BACKUP_DECRYPTION_KEY_BASE64, BOB_ONE_TIME_KEYS, BOB_SIGNED_CROSS_SIGNING_KEYS_DATA, BOB_SIGNED_TEST_DEVICE_DATA, BOB_TEST_USER_ID, + CURVE25519_KEY_BACKUP_DATA, MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, SIGNED_CROSS_SIGNING_KEYS_DATA, SIGNED_TEST_DEVICE_DATA, @@ -69,7 +72,16 @@ import { import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; -import { createOlmSession, encryptGroupSessionKey, encryptMegolmEvent, ToDeviceEvent } from "./olm-utils"; +import { + bootstrapCrossSigningTestOlmAccount, + createOlmSession, + encryptGroupSessionKey, + encryptMegolmEvent, + encryptSecretSend, + ToDeviceEvent, +} from "./olm-utils"; +import { KeyBackupInfo } from "../../../src/crypto-api"; +import { encodeBase64 } from "../../../src/crypto/olmlib"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations // to ensure that we don't end up with dangling timeouts. @@ -1160,6 +1172,292 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st ); }); + describe("Secrets are gossiped after verification", () => { + // We use a legacy olm session as the existing session. + // This will give us access to low level olm functions in order to + // simulate a backup key request with proper olm encryption. + let testOlmAccount: Olm.Account; + const olmDeviceId = "OLM_DEVICE"; + let usermasterPubKey: string; + + const matchingBackupInfo: KeyBackupInfo = { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + version: "1", + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }; + + const nonMatchingBackupInfo: KeyBackupInfo = { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + version: "1", + auth_data: { + public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo", + }, + }; + + const unknownAlgorithmBackupInfo: KeyBackupInfo = { + algorithm: "m.megolm_backup.foo_bar", + version: "1", + auth_data: { + public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo", + }, + }; + + beforeEach(async () => { + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + await Olm.init(); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + + const bootstrapped = bootstrapCrossSigningTestOlmAccount(testOlmAccount, TEST_USER_ID, olmDeviceId, [ + matchingBackupInfo, + nonMatchingBackupInfo, + ]); + + e2eKeyResponder.addDeviceKeys(bootstrapped.device_keys![TEST_USER_ID]![olmDeviceId]); + e2eKeyResponder.addCrossSigningData(bootstrapped); + + usermasterPubKey = Object.values(bootstrapped.master_keys![TEST_USER_ID].keys)[0]; + + aliceClient = await startTestClient(); + syncResponder.sendOrQueueSyncResponse(getSyncResponse([TEST_USER_ID])); + await syncPromise(aliceClient); + // DeviceList has a sleep(5) which we need to make happen + await jest.advanceTimersByTimeAsync(10); + + // The client should now know about the olm device + const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]); + expect(devices.get(TEST_USER_ID)!.keys()).toContain(olmDeviceId); + }); + + afterEach(async () => { + aliceClient?.stopClient(); + testOlmAccount?.free(); + + // Allow in-flight things to complete before we tear down the test + await jest.runAllTimersAsync(); + + fetchMock.mockReset(); + }); + + newBackendOnly("Should request cross signing keys after verification", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + // The secret must have been requested + await requestPromises.get("m.cross_signing.master"); + await requestPromises.get("m.cross_signing.user_signing"); + await requestPromises.get("m.cross_signing.self_signing"); + }); + + newBackendOnly("Should accept the backup decryption key gossip if valid", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + const requestId = await requestPromises.get("m.megolm_backup.v1"); + + await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo); + + // We are lacking a way to signal that the secret has been received, so we wait a bit.. + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + jest.useFakeTimers(); + + // the backup secret should be cached + const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey(); + expect(cachedKey).toBeTruthy(); + expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64); + }); + + newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + const requestId = await requestPromises.get("m.megolm_backup.v1"); + + await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, nonMatchingBackupInfo); + + // We are lacking a way to signal that the secret has been received, so we wait a bit.. + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + jest.useFakeTimers(); + + // the backup secret should not be cached + const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey(); + expect(cachedKey).toBeNull(); + }); + + newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + const requestId = await requestPromises.get("m.megolm_backup.v1"); + + const infoCopy = Object.assign({}, matchingBackupInfo); + delete infoCopy.auth_data.signatures; + + await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, infoCopy); + + // We are lacking a way to signal that the secret has been received, so we wait a bit.. + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + jest.useFakeTimers(); + + // the backup secret should not be cached + const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey(); + expect(cachedKey).toBeNull(); + }); + + newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + const requestId = await requestPromises.get("m.megolm_backup.v1"); + + await sendBackupGossipAndExpectVersion( + requestId!, + BACKUP_DECRYPTION_KEY_BASE64, + unknownAlgorithmBackupInfo, + ); + + // We are lacking a way to signal that the secret has been received, so we wait a bit.. + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + jest.useFakeTimers(); + + // the backup secret should not be cached + const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey(); + expect(cachedKey).toBeNull(); + }); + + newBackendOnly("Should not accept an invalid backup decryption key", async () => { + const requestPromises = mockSecretRequestAndGetPromises(); + + await doInteractiveVerification(); + + const requestId = await requestPromises.get("m.megolm_backup.v1"); + + await sendBackupGossipAndExpectVersion(requestId!, "InvalidSecret", matchingBackupInfo); + + // We are lacking a way to signal that the secret has been received, so we wait a bit.. + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + jest.useFakeTimers(); + + // the backup secret should not be cached + const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey(); + expect(cachedKey).toBeNull(); + }); + + /** + * Common test setup for gossiping secrets. + * Creates a peer to peer session, sends the secret, mockup the version API, send the secret back from sync, then await for the backup check. + */ + async function sendBackupGossipAndExpectVersion( + requestId: string, + secret: string, + expectBackup: KeyBackupInfo, + ) { + const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver); + + const toDeviceEvent = encryptSecretSend({ + sender: aliceClient.getUserId()!, + recipient: aliceClient.getUserId()!, + recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(), + recipientEd25519Key: e2eKeyReceiver.getSigningKey(), + p2pSession: p2pSession, + olmAccount: testOlmAccount, + requestId: requestId!, + secret: secret, + }); + + const expectBackupCheck = new Promise((resolve) => { + fetchMock.get( + "express:/_matrix/client/v3/room_keys/version", + (url, request) => { + resolve(undefined); + return expectBackup; + }, + { + overwriteRoutes: true, + }, + ); + }); + + fetchMock.get("express:/_matrix/client/v3/room_keys/keys", CURVE25519_KEY_BACKUP_DATA); + + // The dummy device sends the secret + returnToDeviceMessageFromSync(toDeviceEvent); + + await expectBackupCheck; + } + + /** + * Do an interactive verification between alice and the dummy device. + */ + async function doInteractiveVerification(): Promise { + // Do a QR code verification for simplicity + + // Alice sends a m.key.verification.request + const [, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, olmDeviceId), + ]); + const transactionId = request.transactionId!; + + // The dummy device replies with an m.key.verification.ready, indicating it can show a QR code + returnToDeviceMessageFromSync( + buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"], olmDeviceId), + ); + await waitForVerificationRequestChanged(request); + + const currentDeviceKey = e2eKeyReceiver.getSigningKey(); + // the dummy device shows a QR code + const sharedSecret = "SUPERSEKRET"; + // use mode 0x01, self-verifying in which the current device does trust the master key + const mode = 0x01; + const qrCodeBuffer = buildQRCode(transactionId, usermasterPubKey, currentDeviceKey, sharedSecret, mode); + + // Alice scans the QR code + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start"); + const verifier = await request.scanQRCode(qrCodeBuffer); + + await sendToDevicePromise; + + const verificationPromise = verifier.verify(); + // the dummy device confirms that Alice scanned the QR code, by replying with a done + returnToDeviceMessageFromSync(buildDoneMessage(transactionId)); + + // Alice also replies with a 'done' + await expectSendToDeviceMessage("m.key.verification.done"); + + // ... and the whole thing should be done! + await verificationPromise; + + // The other device should now be verified. + const otherDevice = (await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID])) + .get(TEST_USER_ID)! + .get(olmDeviceId); + expect(otherDevice?.verified).toEqual(DeviceVerification.Verified); + } + }); + async function startTestClient(opts: Partial = {}): Promise { const client = createClient({ baseUrl: TEST_HOMESERVER_URL, @@ -1221,6 +1519,52 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }> }); } +/** + * Utility to add all needed mocks for secret requesting (to-device of type `m.secret.request`). + * + * The following secrets are mocked: `m.cross_signing.master`, `m.cross_signing.self_signing`, + * `m.cross_signing.user_signing`, `m.megolm_backup.v1`. + * + * @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested. + */ +function mockSecretRequestAndGetPromises(): Map> { + const mskRequestDefer = defer(); + const sskRequestDefer = defer(); + const uskRequestDefer = defer(); + const backupKeyRequestDefer = defer(); + + fetchMock.put( + new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`), + (url: string, opts: RequestInit): MockResponse => { + const messages = JSON.parse(opts.body as string).messages[TEST_USER_ID]; + // rust crypto broadcasts to all devices, old crypto to a specific device, take the first one + const content = Object.values(messages)[0] as any; + if (content.action == "request") { + const name = content.name; + const requestId = content.request_id; + if (name == "m.cross_signing.user_signing") { + uskRequestDefer.resolve(requestId); + } else if (name == "m.cross_signing.master") { + mskRequestDefer.resolve(requestId); + } else if (name == "m.cross_signing.self_signing") { + sskRequestDefer.resolve(requestId); + } else if (name == "m.megolm_backup.v1") { + backupKeyRequestDefer.resolve(requestId); + } + } + return {}; + }, + { overwriteRoutes: true }, + ); + + const promiseMap = new Map>(); + promiseMap.set("m.cross_signing.master", mskRequestDefer.promise); + promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise); + promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise); + promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise); + return promiseMap; +} + /** wait for the verification request to emit a 'Change' event */ function waitForVerificationRequestChanged(request: VerificationRequest): Promise { return new Promise((resolve) => { @@ -1265,12 +1609,16 @@ function buildRequestMessage(transactionId: string): { type: string; content: ob }; } -/** build an m.key.verification.ready to-device message originating from the dummy device */ -function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } { +/** build an m.key.verification.ready to-device message originating from the given `fromDevice` (default to `TEST_DEVICE_ID` if not provided) */ +function buildReadyMessage( + transactionId: string, + methods: string[], + fromDevice?: string, +): { type: string; content: object } { return { type: "m.key.verification.ready", content: { - from_device: TEST_DEVICE_ID, + from_device: fromDevice || TEST_DEVICE_ID, methods: methods, transaction_id: transactionId, }, @@ -1368,14 +1716,20 @@ function buildDoneMessage(transactionId: string) { }; } -function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array { +function buildQRCode( + transactionId: string, + key1Base64: string, + key2Base64: string, + sharedSecret: string, + mode = 0x02, +): Uint8Array { // https://spec.matrix.org/v1.7/client-server-api/#qr-code-format const qrCodeBuffer = Buffer.alloc(150); // oversize let idx = 0; idx += qrCodeBuffer.write("MATRIX", idx, "ascii"); idx = qrCodeBuffer.writeUInt8(0x02, idx); // version - idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode + idx = qrCodeBuffer.writeUInt8(mode, idx); // mode idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx); idx += qrCodeBuffer.write(transactionId, idx, "ascii"); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 0a28eeb7b5b..e7cbdb2c659 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -64,6 +64,9 @@ describe("initRustCrypto", () => { return { registerRoomKeyUpdatedCallback: jest.fn(), registerUserIdentityUpdatedCallback: jest.fn(), + getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]), + deleteSecretsFromInbox: jest.fn(), + registerReceiveSecretCallback: jest.fn(), outgoingRequests: jest.fn(), } as unknown as Mocked; } @@ -108,6 +111,24 @@ describe("initRustCrypto", () => { expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined); }); + + it("Should get secrets from inbox on start", async () => { + const testOlmMachine = makeTestOlmMachine() as OlmMachine; + jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine); + + await initRustCrypto( + logger, + {} as MatrixClient["http"], + TEST_USER, + TEST_DEVICE_ID, + {} as ServerSideSecretStorage, + {} as CryptoCallbacks, + "storePrefix", + "storePassphrase", + ); + + expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1"); + }); }); describe("RustCrypto", () => { diff --git a/src/rust-crypto/backup.ts b/src/rust-crypto/backup.ts index 9ecb68b3574..784e5b59995 100644 --- a/src/rust-crypto/backup.ts +++ b/src/rust-crypto/backup.ts @@ -95,11 +95,9 @@ export class RustBackupManager extends TypedEventEmitter { + // Currently we only receive the decryption key without any key backup version. It is important to + // check that the secret is valid for the current version before storing it. + // We force a check to ensure to have the latest version. We also want to check that the backup is trusted + // as we don't want to store the secret if the backup is not trusted, and eventually import megolm keys later from an untrusted backup. + const backupCheck = await this.checkKeyBackupAndEnable(true); + + if (!backupCheck?.backupInfo?.version || !backupCheck.trustInfo.trusted) { + // There is no server-side key backup, or the backup is not signed by a trusted cross-signing key or trusted own device. + // This decryption key is useless to us. + logger.warn("Received backup decryption key, but there is no trusted server-side key backup"); + return false; + } + + try { + const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret); + const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(backupCheck.backupInfo, backupDecryptionKey); + if (!privateKeyMatches) { + logger.debug(`onReceiveSecret: backup decryption key does not match current backup version`); + // just ignore the secret + return false; + } + logger.info( + `handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`, + ); + + await this.olmMachine.saveBackupDecryptionKey(backupDecryptionKey, backupCheck.backupInfo.version); + return true; + } catch (e) { + logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e); + } + + return false; + } + private keyBackupCheckInProgress: Promise | null = null; /** Helper for `checkKeyBackup` */ @@ -379,6 +419,25 @@ export class RustBackupManager extends TypedEventEmitter + // Instead of directly checking the secret value, we poll the inbox to get all values for that secret type. + // Once we have all the values, we can safely clear the secret inbox. + rustCrypto.checkSecrets(name), + ); + // Tell the OlmMachine to think about its outgoing requests before we hand control back to the application. // // This is primarily a fudge to get it to correctly populate the `users_for_key_query` list, so that future diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index ddeefbc494e..e0238cc81d6 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1357,6 +1357,51 @@ export class RustCrypto extends TypedEventEmitter { + this.logger.debug(`onReceiveSecret: Received secret ${name}`); + if (name === "m.megolm_backup.v1") { + return await this.backupManager.handleBackupSecretReceived(value); + // XXX at this point we should probably try to download the backup and import the keys, + // or at least retry for the current decryption failures? + // Maybe add some signaling when a new secret is received, and let clients handle it? + // as it's where the restore from backup APIs are exposed. + } + return false; + } + + /** + * Called when a new secret is received in the rust secret inbox. + * + * Will poll the secret inbox and handle the secrets received. + * + * @param name - The name of the secret received. + */ + public async checkSecrets(name: string): Promise { + const pendingValues: string[] = await this.olmMachine.getSecretsFromInbox(name); + for (const value of pendingValues) { + if (await this.handleSecretReceived(name, value)) { + // If we have a valid secret for that name there is no point of processing the other secrets values. + // It's probably the same secret shared by another device. + break; + } + } + + // Important to call this after handling the secrets as good hygiene. + await this.olmMachine.deleteSecretsFromInbox(name); + } + /** * Handle a live event received via /sync. * See {@link ClientEventHandlerMap#event}