diff --git a/spec/integ/crypto/megolm-backup.spec.ts b/spec/integ/crypto/megolm-backup.spec.ts index 1e956b52e61..8b1cc0cc0a5 100644 --- a/spec/integ/crypto/megolm-backup.spec.ts +++ b/spec/integ/crypto/megolm-backup.spec.ts @@ -15,17 +15,17 @@ limitations under the License. */ import fetchMock from "fetch-mock-jest"; +import "fake-indexeddb/auto"; -import { logger } from "../../../src/logger"; -import { decodeRecoveryKey } from "../../../src/crypto/recoverykey"; -import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup"; -import { createClient, ICreateClientOpts, IEvent, MatrixClient } from "../../../src"; -import { MatrixEventEvent } from "../../../src/models/event"; +import { IKeyBackupSession } from "../../../src/crypto/keybackup"; +import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient } from "../../../src"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; -import { syncPromise } from "../../test-utils/test-utils"; +import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils"; +import * as testData from "../../test-utils/test-data"; +import { SecureKeyBackup } from "../../../src/common-crypto/SecureKeyBackup"; const ROOM_ID = "!ROOM:ID"; @@ -72,22 +72,14 @@ const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = { }, }; -const CURVE25519_BACKUP_INFO: IKeyBackupInfo = { - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - version: "1", - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - // Will be updated with correct value on the fly - signatures: {}, - }, -}; - -const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d"; - const TEST_USER_ID = "@alice:localhost"; const TEST_DEVICE_ID = "xzcvb"; -describe("megolm key backups", function () { +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; + let aliceClient: MatrixClient; /** an object which intercepts `/sync` requests on the test homeserver */ let syncResponder: SyncResponder; @@ -108,6 +100,7 @@ describe("megolm key backups", function () { syncResponder = new SyncResponder(TEST_HOMESERVER_URL); e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL); e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL); + e2eKeyResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA); e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver); }); @@ -130,12 +123,12 @@ describe("megolm key backups", function () { deviceId: TEST_DEVICE_ID, ...opts, }); - await client.initCrypto(); + await initCrypto(client); return client; } - it("Alice checks key backups when receiving a message she can't decrypt", async function () { + oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () { const syncResponse = { next_batch: 1, rooms: { @@ -150,35 +143,145 @@ describe("megolm key backups", function () { }; fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA); - - // mock for the outgoing key requests that will be sent - fetchMock.put("express:/_matrix/client/r0/sendToDevice/m.room_key_request/:txid", {}); - - // We'll need to add a signature to the backup data, so take a copy to avoid mutating global state. - const backupData = JSON.parse(JSON.stringify(CURVE25519_BACKUP_INFO)); - fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupData); + fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); aliceClient = await initTestClient(); - await aliceClient.crypto!.signObject(backupData.auth_data); - await aliceClient.crypto!.storeSessionBackupPrivateKey(decodeRecoveryKey(RECOVERY_KEY)); - await aliceClient.crypto!.backupManager!.checkAndStart(); + const aliceCrypto = aliceClient.getCrypto()!; + await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64")); // start after saving the private key await aliceClient.startClient(); + // tell Alice to trust the dummy device that signed the backup + await waitForDeviceList(); + await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID); + // @ts-ignore backupManager is an internal property + await aliceCrypto.backupManager.checkAndStart(); + + // Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup. syncResponder.sendOrQueueSyncResponse(syncResponse); await syncPromise(aliceClient); const room = aliceClient.getRoom(ROOM_ID)!; - const event = room.getLiveTimeline().getEvents()[0]; - await new Promise((resolve, reject) => { - event.once(MatrixEventEvent.Decrypted, (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); + await awaitDecryption(event, { waitOnDecryptionFailure: true }); + expect(event.getContent()).toEqual("testytest"); + }); + + oldBackendOnly("getKeyBackupStatus() should give correct status", async function () { + // 404 means that there is no active backup + fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404); + + aliceClient = await initTestClient(); + const aliceCrypto = aliceClient.getCrypto()!; + // @ts-ignore backupManager is an internal property + const aliceBackupManager: SecureKeyBackup = aliceCrypto.backupManager; + await aliceClient.startClient(); + + // tell Alice to trust the dummy device that signed the backup + await waitForDeviceList(); + await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID); + await aliceBackupManager.checkAndStart(); + + // At this point there is no backup + let backupStatus: string | null; + backupStatus = await aliceCrypto.getActiveSessionBackupVersion(); + expect(backupStatus).toBeNull(); + + // Serve a backup with no trusted signature + const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA)); + delete unsignedBackup.auth_data.signatures; + fetchMock.get("express:/_matrix/client/v3/room_keys/version", unsignedBackup, { + overwriteRoutes: true, + }); + + const checked = await aliceBackupManager.checkAndStart(); + expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version); + expect(checked?.trustInfo?.usable).toBeFalsy(); + + backupStatus = await aliceCrypto.getActiveSessionBackupVersion(); + expect(backupStatus).toBeNull(); + + // Add a valid signature to the backup + fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, { + overwriteRoutes: true, + }); + + // check that signaling is working + const backupPromise = new Promise((resolve, reject) => { + aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => { + if (enabled) { + resolve(); + } }); }); - expect(event.getContent()).toEqual("testytest"); + const validCheck = await aliceBackupManager.checkAndStart(); + expect(validCheck?.trustInfo?.usable).toStrictEqual(true); + + await backupPromise; + + backupStatus = await aliceCrypto.getActiveSessionBackupVersion(); + expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version); + }); + + oldBackendOnly("test backup version creation", async function () { + // 404 means that there is no active backup + fetchMock.get("/_matrix/client/v3/room_keys/version", 404); + + aliceClient = await initTestClient(); + await aliceClient.startClient(); + + const preparedBackup = await aliceClient.prepareKeyBackupVersion(); + + // The prepared backup should be signed + // Only device signature as cross signing is not bootstraped + // TODO improvement bootstrap cross signing to check for the added signature + expect(preparedBackup?.auth_data.signatures?.[TEST_USER_ID]).toBeDefined(); + expect(preparedBackup?.auth_data.signatures?.[TEST_USER_ID]?.[`ed25519:${TEST_DEVICE_ID}`]).toBeDefined(); + + // mock backup creation API + const backupVersion = "1"; + const expectedBackupResponse = { + algorithm: preparedBackup.algorithm, + auth_data: preparedBackup.auth_data, + version: backupVersion, + }; + fetchMock.post("express:/_matrix/client/v3/room_keys/version", expectedBackupResponse); + fetchMock.get("express:/_matrix/client/v3/room_keys/version", expectedBackupResponse, { + overwriteRoutes: true, + }); + + const backupEnabled = new Promise((resolve, reject) => { + aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => { + if (enabled) { + resolve(); + } + }); + }); + + await aliceClient.createKeyBackupVersion({ + algorithm: preparedBackup.algorithm, + auth_data: preparedBackup.auth_data, + }); + + await backupEnabled; + + const backupStatus = await aliceClient.getCrypto()!.getActiveSessionBackupVersion(); + expect(backupStatus).toBeDefined(); + expect(backupStatus).toStrictEqual(backupVersion); }); + + /** make sure that the client knows about the dummy device */ + async function waitForDeviceList(): Promise { + // Completing the initial sync will make the device list download outdated device lists (of which our own + // user will be one). + syncResponder.sendOrQueueSyncResponse({}); + // DeviceList has a sleep(5) which we need to make happen + await jest.advanceTimersByTimeAsync(10); + + // The client should now know about the dummy device + const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]); + expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID); + } }); diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index e13b80c54c7..b9fd38143d8 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -124,7 +124,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st describe("Outgoing verification requests for another device", () => { beforeEach(async () => { // pretend that we have another device, which we will verify - e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA); }); // test with (1) the default verification method list, (2) a custom verification method list. @@ -626,7 +626,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st describe("cancellation", () => { beforeEach(async () => { // pretend that we have another device, which we will start verifying - e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA); e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); aliceClient = await startTestClient(); @@ -743,7 +743,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st describe("Incoming verification from another device", () => { beforeEach(async () => { - e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA); aliceClient = await startTestClient(); await waitForDeviceList(); diff --git a/spec/test-utils/E2EKeyResponder.ts b/spec/test-utils/E2EKeyResponder.ts index c232fd819b7..e779e8c5e87 100644 --- a/spec/test-utils/E2EKeyResponder.ts +++ b/spec/test-utils/E2EKeyResponder.ts @@ -89,12 +89,10 @@ export class E2EKeyResponder { /** * Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed * - * @param userId - user the keys belong to - * @param deviceId - device the keys belong to * @param keys - device keys for this device. */ - public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) { - this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys); + public addDeviceKeys(keys: IDeviceKeys) { + this.deviceKeysByUserByDevice.getOrCreate(keys.user_id).set(keys.device_id, keys); } /** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed diff --git a/spec/test-utils/test-data/generate-test-data.py b/spec/test-utils/test-data/generate-test-data.py index d48bb37d786..a5cc8bd8fe7 100755 --- a/spec/test-utils/test-data/generate-test-data.py +++ b/spec/test-utils/test-data/generate-test-data.py @@ -28,7 +28,7 @@ import json from canonicaljson import encode_canonical_json -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ed25519, x25519 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # input data @@ -41,6 +41,8 @@ USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser" SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself" +# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts +B64_BACKUP_DECRYPTION_KEY = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=" def main() -> None: private_key = ed25519.Ed25519PrivateKey.from_private_bytes( @@ -71,30 +73,48 @@ def main() -> None: b64_master_public_key = encode_base64( master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) ) - b64_master_private_key = encode_base64( - MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES - ) + b64_master_private_key = encode_base64(MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES) self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES ) b64_self_signing_public_key = encode_base64( - self_signing_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) - ) - b64_self_signing_private_key = encode_base64( - SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES + self_signing_private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) ) + b64_self_signing_private_key = encode_base64(SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES) user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( USER_CROSS_SIGNING_PRIVATE_KEY_BYTES ) b64_user_signing_public_key = encode_base64( - user_signing_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + user_signing_private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) ) - b64_user_signing_private_key = encode_base64( - USER_CROSS_SIGNING_PRIVATE_KEY_BYTES + b64_user_signing_private_key = encode_base64(USER_CROSS_SIGNING_PRIVATE_KEY_BYTES) + + backup_decryption_key = x25519.X25519PrivateKey.from_private_bytes( + base64.b64decode(B64_BACKUP_DECRYPTION_KEY) + ) + b64_backup_public_key = encode_base64( + backup_decryption_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) ) + backup_data = { + "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2", + "version": "1", + "auth_data": { + "public_key": b64_backup_public_key, + }, + } + # sign with our device key + sig = sign_json(backup_data["auth_data"], private_key) + backup_data["auth_data"]["signatures"] = { + TEST_USER_ID: {f"ed25519:{TEST_DEVICE_ID}": sig} + } + print( f"""\ /* Test data for cryptography tests @@ -104,6 +124,7 @@ def main() -> None: import {{ IDeviceKeys }} from "../../../src/@types/crypto"; import {{ IDownloadKeyResult }} from "../../../src"; +import {{ KeyBackupInfo }} from "../../../src/crypto-api"; /* eslint-disable comma-dangle */ @@ -138,6 +159,12 @@ def main() -> None: export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial = { json.dumps(build_cross_signing_keys_data(), indent=4) }; + +/** base64-encoded backup decryption (private) key */ +export const BACKUP_DECRYPTION_KEY_BASE64 = "{ B64_BACKUP_DECRYPTION_KEY }"; + +/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */ +export const SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) }; """, end="", ) diff --git a/spec/test-utils/test-data/index.ts b/spec/test-utils/test-data/index.ts index c25a2f78ae4..6b904e62d8a 100644 --- a/spec/test-utils/test-data/index.ts +++ b/spec/test-utils/test-data/index.ts @@ -5,6 +5,7 @@ import { IDeviceKeys } from "../../../src/@types/crypto"; import { IDownloadKeyResult } from "../../../src"; +import { KeyBackupInfo } from "../../../src/crypto-api"; /* eslint-disable comma-dangle */ @@ -97,3 +98,20 @@ export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial = { } } }; + +/** base64-encoded backup decryption (private) key */ +export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="; + +/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */ +export const SIGNED_BACKUP_DATA: KeyBackupInfo = { + "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2", + "version": "1", + "auth_data": { + "public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + "signatures": { + "@alice:localhost": { + "ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA" + } + } + } +}; diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 04533eca362..60c6807eed8 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -23,6 +23,7 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto"; import { initRustCrypto } from "../../../src/rust-crypto"; import { CryptoEvent, + DeviceVerification, HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, @@ -351,6 +352,47 @@ describe("RustCrypto", () => { }); }); + describe("setDeviceVerified", () => { + let rustCrypto: RustCrypto; + + beforeEach(async () => { + rustCrypto = await makeTestRustCrypto( + new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl: "http://server/", + prefix: "", + onlyData: true, + }), + testData.TEST_USER_ID, + ); + }); + + it("should mark a device as verified", async () => { + fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} }); + fetchMock.post("path:/_matrix/client/v3/keys/query", { + device_keys: { + [testData.TEST_USER_ID]: { + [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA, + }, + }, + }); + // call onSyncCompleted to kick off the outgoingRequestLoop and download the device list. + rustCrypto.onSyncCompleted({}); + + // before the call, the device should be unverified. + let devices = await rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]); + let device = devices.get(testData.TEST_USER_ID)!.get(testData.TEST_DEVICE_ID); + expect(device!.verified).toEqual(DeviceVerification.Unverified); + + // Now call setDeviceVerified() + await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID); + + // and confirm that the device is now verified + devices = await rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]); + device = devices.get(testData.TEST_USER_ID)!.get(testData.TEST_DEVICE_ID); + expect(device!.verified).toEqual(DeviceVerification.Verified); + }); + }); + describe("getDeviceVerificationStatus", () => { let rustCrypto: RustCrypto; let olmMachine: Mocked; diff --git a/src/client.ts b/src/client.ts index 6ca1a5ba1aa..7facb355e74 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3299,6 +3299,12 @@ export class MatrixClient extends TypedEventEmitter> { - if (!this.crypto) { + if (!this.cryptoBackend) { throw new Error("End-to-end encryption disabled"); } // eslint-disable-next-line camelcase - const { algorithm, auth_data, recovery_key, privateKey } = - await this.crypto.backupManager.prepareKeyBackupVersion(password); + const { algorithm, auth_data, recovery_key, privateKey } = await this.cryptoBackend.prepareKeyBackupVersion( + password, + ); if (opts.secureSecretStorage) { await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey)); @@ -3388,36 +3395,17 @@ export class MatrixClient extends TypedEventEmitter { - if (!this.crypto) { + if (!this.cryptoBackend) { throw new Error("End-to-end encryption disabled"); } - await this.crypto.backupManager.createKeyBackupVersion(info); + await this.cryptoBackend.backupManager.createKeyBackupVersion(info); const data = { algorithm: info.algorithm, auth_data: info.auth_data, }; - // Sign the backup auth data with the device key for backwards compat with - // older devices with cross-signing. This can probably go away very soon in - // favour of just signing with the cross-singing master key. - // XXX: Private member access - await this.crypto.signObject(data.auth_data); - - if ( - this.cryptoCallbacks.getCrossSigningKey && - // XXX: Private member access - this.crypto.crossSigningInfo.getId() - ) { - // now also sign the auth data with the cross-signing master key - // we check for the callback explicitly here because we still want to be able - // to create an un-cross-signed key backup if there is a cross-signing key but - // no callback supplied. - // XXX: Private member access - await this.crypto.crossSigningInfo.signObject(data.auth_data, "master"); - } - const res = await this.http.authedRequest(Method.Post, "/room_keys/version", undefined, data, { prefix: ClientPrefix.V3, }); @@ -3426,7 +3414,7 @@ export class MatrixClient extends TypedEventEmitter; + + /** + * Access the backup manager + * + * @see SecureKeyBackup + */ + readonly backupManager: SecureKeyBackup; } /** The methods which crypto implementations should expose to the Sync api diff --git a/src/common-crypto/SecureKeyBackup.ts b/src/common-crypto/SecureKeyBackup.ts new file mode 100644 index 00000000000..2be909a1e8e --- /dev/null +++ b/src/common-crypto/SecureKeyBackup.ts @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IPreparedKeyBackupVersion, KeyBackupInfo } from "../crypto-api/keybackup"; + +/** + * Interface to server-side key backup + * + * Server-side key backup, aka "secure (key) backup" or "session backup", is a feature in which devices save copies of + * the megolm session keys that they receive on the server. The keys are encrypted with the public part of an asymmetric + * key, which makes it easy for devices to add newly-received session keys. In future, if the user logs in on another + * device which lacks history, the backup can be restored by providing the private part of the key (the "backup + * decryption key"), thus providing access to historical messages. + * + * (The backup decryption key is normally retrieved from server-side-secret-storage (4S) or gossipped between devices + * using secret sharing, rather than being entered directly). + * + * @see https://spec.matrix.org/v1.7/client-server-api/#server-side-key-backups + * @internal + */ +export interface SecureKeyBackup { + /** + * Check the server for an active key backup. + * + * If a key backup is present, and has a valid signature from one of the user's verified devices, start backing up + * to it. + * + * @returns `null` if there was an error checking for an active backup; otherwise, information about the active backup + * (or lack thereof). + */ + checkAndStart(): Promise; + + /** + * Set up the data required to create a new backup version. The backup version + * will not be created and enabled until createKeyBackupVersion is called. + * + * @param password - Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. If null a random recovery key will be created + * + * @returns Object that can be passed to createKeyBackupVersion and + * additionally has a 'recovery_key' member with the user-facing recovery key string. The backup data is not yet signed, the cryptoBackend will do it. + */ + prepareUnsignedKeyBackupVersion( + key?: string | Uint8Array | null, + algorithm?: string | undefined, + ): Promise; + + createKeyBackupVersion(info: KeyBackupInfo): Promise; +} + +/** + * The result of {@link SecureKeyBackup.checkAndStart}. + * + * @internal + */ +export interface KeyBackupCheck { + /** Information from the server about the backup. `undefined` if there is no active backup. */ + backupInfo?: KeyBackupInfo; + + /** Information on whether we trust this backup. */ + trustInfo: BackupTrustInfo; +} + +/** + * Information on whether a given server-side backup is trusted. + * + * @internal + */ +export interface BackupTrustInfo { + /** + * Is this backup trusted? + * + * True if, and only if, there is a valid signature on the backup from a trusted device + */ + readonly usable: boolean; +} diff --git a/src/crypto-api.ts b/src/crypto-api.ts index b3ee1a6a406..3e4ef1ab59e 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -21,6 +21,7 @@ import { UIAuthCallback } from "./interactive-auth"; import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage"; import { VerificationRequest } from "./crypto-api/verification"; import { KeyBackupInfo } from "./crypto-api/keybackup"; +import { IPreparedKeyBackupVersion } from "./crypto/backup"; /** * Public interface to the cryptography parts of the js-sdk @@ -136,6 +137,22 @@ export interface CryptoApi { */ getDeviceVerificationStatus(userId: string, deviceId: string): Promise; + /** + * Mark the given device as locally verified. + * + * Marking a devices as locally verified has much the same effect as completing the verification dance, or receiving + * a cross-signing signature for it. + * + * @param userId - owner of the device + * @param deviceId - unique identifier for the device. + * @param verified - whether to mark the device as verified. Defaults to 'true'. + * + * @throws an error if the device is unknown, or has not published any encryption keys. + * + * @remarks Fires {@link CryptoEvent#DeviceVerificationChanged} + */ + setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise; + /** * Checks whether cross signing: * - is enabled on this account and trusted by this device @@ -308,6 +325,22 @@ export interface CryptoApi { * @param key - the backup decryption key */ storeSessionBackupPrivateKey(key: Uint8Array): Promise; + + /** + * Get the current status of key backup. + * + * @returns If automatic key backups are enabled, the `version` of the active backup. Otherwise, `null`. + */ + getActiveSessionBackupVersion(): Promise; + + /** + * Prepare a backup version, signed with current device and identity (if available). + * Can be used to upload a new backup. + */ + prepareKeyBackupVersion( + key?: string | Uint8Array | null, + algorithm?: string | undefined, + ): Promise; } /** @@ -516,3 +549,4 @@ export interface GeneratedSecretStorageKey { export * from "./crypto-api/verification"; export * from "./crypto-api/keybackup"; +export type { SecureKeyBackup } from "./common-crypto/SecureKeyBackup"; diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts index 629d27aed4f..983474be90c 100644 --- a/src/crypto-api/keybackup.ts +++ b/src/crypto-api/keybackup.ts @@ -31,7 +31,10 @@ export interface Aes256AuthData { } /** - * Extra info of a recovery key + * Information about a server-side key backup. + * + * Returned by `GET /_matrix/client/v3/room_keys/version` + * (see https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion). */ export interface KeyBackupInfo { algorithm: string; @@ -40,3 +43,18 @@ export interface KeyBackupInfo { etag?: string; version?: string; // number contained within } + +export type AuthData = KeyBackupInfo["auth_data"]; + +/** + * Prepared backup data. + * Contains the data needed to create a new version. + */ +/* eslint-disable camelcase */ +export interface IPreparedKeyBackupVersion { + algorithm: string; + auth_data: AuthData; + recovery_key: string; + privateKey: Uint8Array; +} +/* eslint-enable camelcase */ diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index fb4d6bd6e94..f5de976679d 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -36,16 +36,19 @@ import { IKeyBackupInfo, IKeyBackupSession, } from "./keybackup"; +import { AuthData, IPreparedKeyBackupVersion } from "../crypto-api"; import { UnstableValue } from "../NamespacedValue"; import { CryptoEvent } from "./index"; import { crypto } from "./crypto"; import { HTTPError, MatrixError } from "../http-api"; +import { SecureKeyBackup } from "../common-crypto/SecureKeyBackup"; + +// re-export for backward compatibility +export type { IPreparedKeyBackupVersion } from "../crypto-api/keybackup"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms -type AuthData = IKeyBackupInfo["auth_data"]; - type SigInfo = { deviceId: string; valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation @@ -54,6 +57,7 @@ type SigInfo = { deviceTrust?: DeviceTrustLevel; }; +/** @deprecated Prefer {@link BackupTrustInfo} */ export type TrustInfo = { usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device sigs: SigInfo[]; @@ -61,20 +65,12 @@ export type TrustInfo = { trusted_locally?: boolean; }; +/** @deprecated Prefer {@link KeyBackupCheck} */ export interface IKeyBackupCheck { backupInfo?: IKeyBackupInfo; trustInfo: TrustInfo; } -/* eslint-disable camelcase */ -export interface IPreparedKeyBackupVersion { - algorithm: string; - auth_data: AuthData; - recovery_key: string; - privateKey: Uint8Array; -} -/* eslint-enable camelcase */ - /** A function used to get the secret key for a backup. */ type GetKey = () => Promise>; @@ -112,7 +108,7 @@ export interface IKeyBackup { /** * Manages the key backup. */ -export class BackupManager { +export class BackupManager implements SecureKeyBackup { private algorithm: BackupAlgorithm | undefined; public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? @@ -199,9 +195,17 @@ export class BackupManager { return Boolean(this.algorithm); } + /** @deprecated Do not use directly, use CryptoApi.prepareKeyBackupVersion() */ public async prepareKeyBackupVersion( key?: string | Uint8Array | null, algorithm?: string | undefined, + ): Promise { + return this.prepareUnsignedKeyBackupVersion(key, algorithm); + } + + public async prepareUnsignedKeyBackupVersion( + key?: string | Uint8Array | null, + algorithm?: string | undefined, ): Promise { const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; if (!Algorithm) { diff --git a/src/crypto/index.ts b/src/crypto/index.ts index ab588afce9f..e87d0031959 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -92,6 +92,7 @@ import { DeviceVerificationStatus, ImportRoomKeysOpts, VerificationRequest as CryptoApiVerificationRequest, + IPreparedKeyBackupVersion, } from "../crypto-api"; import { Device, DeviceMap } from "../models/device"; import { deviceInfoToDevice } from "./device-converter"; @@ -539,6 +540,29 @@ export class Crypto extends TypedEventEmitter { + const preparedVersion = await this.backupManager.prepareUnsignedKeyBackupVersion(key, algorithm); + + // sign the auth_data with existing device and cross signing keys if available + await this.signObject(preparedVersion.auth_data); + if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { + try { + logger.log("Adding cross-signing signature to key backup"); + await this.crossSigningInfo.signObject(preparedVersion.auth_data, "master"); + } catch (e) { + // This step is not critical (just helpful), so we catch here + // and continue if it fails. + logger.error("Signing key backup with cross-signing keys failed", e); + } + } + + // return the now signed version + return preparedVersion; + } + /** * Initialise the crypto module so that it is ready for use * @@ -977,21 +1001,6 @@ export class Crypto extends TypedEventEmitter => { - if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { - try { - logger.log("Adding cross-signing signature to key backup"); - await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); - } catch (e) { - // This step is not critical (just helpful), so we catch here - // and continue if it fails. - logger.error("Signing key backup with cross-signing keys failed", e); - } - } else { - logger.warn("Cross-signing keys not available, skipping signature on key backup"); - } - }; - const oldSSSSKey = await this.secretStorage.getKey(); const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; const storageExists = @@ -1045,11 +1054,6 @@ export class Crypto extends TypedEventEmitter { + if (this.backupManager.getKeyBackupEnabled()) { + return this.backupManager.version ?? null; + } + return null; + } + /** * Checks that a given cross-signing private key matches a given public key. * This can be used by the getCrossSigningKey callback to verify that the @@ -2174,6 +2184,15 @@ export class Crypto extends TypedEventEmitter { + await this.setDeviceVerification(userId, deviceId, verified); + } + /** * Update the blocked/verified state of the given device * diff --git a/src/rust-crypto/backup.ts b/src/rust-crypto/backup.ts new file mode 100644 index 00000000000..f759c358a6a --- /dev/null +++ b/src/rust-crypto/backup.ts @@ -0,0 +1,44 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { KeyBackupCheck, SecureKeyBackup } from "../common-crypto/SecureKeyBackup"; +import { IPreparedKeyBackupVersion, KeyBackupInfo } from "../crypto-api/keybackup"; + +export class RustBackupManager implements SecureKeyBackup { + public async checkAndStart(): Promise { + return null; + } + + /** + * Get the backup version we are currently backing up to, if any + */ + public async getActiveBackupVersion(): Promise { + // TODO stub + return null; + } + + public async prepareUnsignedKeyBackupVersion( + key?: string | Uint8Array | null | undefined, + algorithm?: string | undefined, + ): Promise { + throw new Error("Method not implemented."); + } + + public async createKeyBackupVersion(info: KeyBackupInfo): Promise { + //TODO stub + return; + } +} diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 125309f95c6..58191fbd487 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -38,6 +38,7 @@ import { CryptoCallbacks, DeviceVerificationStatus, GeneratedSecretStorageKey, + IPreparedKeyBackupVersion, ImportRoomKeyProgressData, ImportRoomKeysOpts, VerificationRequest, @@ -55,6 +56,7 @@ import { RustVerificationRequest, verificationMethodIdentifierToMethod } from ". import { EventType } from "../@types/event"; import { CryptoEvent } from "../crypto"; import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { RustBackupManager } from "./backup"; const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]; @@ -81,6 +83,8 @@ export class RustCrypto extends TypedEventEmitter { @@ -376,6 +381,23 @@ export class RustCrypto extends TypedEventEmitter { + const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice( + new RustSdkCryptoJs.UserId(userId), + new RustSdkCryptoJs.DeviceId(deviceId), + ); + + if (!device) { + throw new Error(`Unknown device ${userId}|${deviceId}`); + } + await device.setLocalTrust(verified ? RustSdkCryptoJs.LocalTrust.Verified : RustSdkCryptoJs.LocalTrust.Unset); + } + /** * Implementation of {@link CryptoApi#getDeviceVerificationStatus}. */ @@ -725,6 +747,28 @@ export class RustCrypto extends TypedEventEmitter { + return await this.backupManager.getActiveBackupVersion(); + } + + public async prepareKeyBackupVersion( + key?: string | Uint8Array | null | undefined, + algorithm?: string | undefined, + ): Promise { + const preparedVersion = await this.backupManager.prepareUnsignedKeyBackupVersion(key, algorithm); + + // sign the auth_data with existing device and cross signing keys if available + await this.signObject(preparedVersion.auth_data); + + // return the now signed version + return preparedVersion; + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation