diff --git a/.eslintrc.js b/.eslintrc.js index 1a2b5f822f2..7db8e71402f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -74,7 +74,7 @@ module.exports = { "jest/no-standalone-expect": [ "error", { - additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"], + additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"], }, ], }, diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index f4d7fc49f44..1618ae304e7 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -51,6 +51,7 @@ import { escapeRegExp } from "../../../src/utils"; import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter"; import { flushPromises } from "../../test-utils/flushPromises"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; +import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; const ROOM_ID = "!room:id"; @@ -402,6 +403,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the // Rust backend. Once we have full support in the rust sdk, it will go away. const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; + const newBackendOnly = backend !== "rust-sdk" ? test.skip : test; const Olm = global.Olm; @@ -2169,4 +2171,163 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, ); }); }); + + describe("bootstrapSecretStorage", () => { + /** + * Create a fake secret storage key + * Async because `bootstrapSecretStorage` expect an async method + */ + const createSecretStorageKey = jest.fn().mockResolvedValue({ + keyInfo: {}, // Returning undefined here used to cause a crash + privateKey: Uint8Array.of(32, 33), + }); + + /** + * Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type` + * Resolved when a key is uploaded (ie in `body.content.key`) + * https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype + */ + function awaitKeyStoredInAccountData(): Promise { + return new Promise((resolve) => { + // This url is called multiple times during the secret storage bootstrap process + // When we received the newly generated key, we return it + fetchMock.put( + "express:/_matrix/client/r0/user/:userId/account_data/:type", + (url: string, options: RequestInit) => { + const content = JSON.parse(options.body as string); + + if (content.key) { + resolve(content.key); + } + + return {}; + }, + { overwriteRoutes: true }, + ); + }); + } + + /** + * Send in the sync response the provided `secretStorageKey` into the account_data field + * The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events + * https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync + * @param secretStorageKey + */ + function sendSyncResponse(secretStorageKey: string) { + syncResponder.sendOrQueueSyncResponse({ + next_batch: 1, + account_data: { + events: [ + { + type: "m.secret_storage.default_key", + content: { + key: secretStorageKey, + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, + }, + }, + // Needed for secretStorage.getKey or secretStorage.hasKey + { + type: `m.secret_storage.key.${secretStorageKey}`, + content: { + key: secretStorageKey, + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, + }, + }, + ], + }, + }); + } + + beforeEach(async () => { + createSecretStorageKey.mockClear(); + + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + }); + + newBackendOnly("should do no nothing if createSecretStorageKey is not set", async () => { + await aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }); + + // No key was created + expect(createSecretStorageKey).toHaveBeenCalledTimes(0); + }); + + newBackendOnly("should create a new key", async () => { + const bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + const secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Finally, wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId(); + // Check that the uploaded key in stored in the secret storage + expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy(); + // Check that the uploaded key is the default key + expect(defaultKeyId).toBe(secretStorageKey); + }); + + newBackendOnly( + "should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set", + async () => { + const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + const secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // Call again bootstrapSecretStorage + await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); + + // createSecretStorageKey should be called only on the first run of bootstrapSecretStorage + expect(createSecretStorageKey).toHaveBeenCalledTimes(1); + }, + ); + + newBackendOnly( + "should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage", + async () => { + let bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + let secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // Call again bootstrapSecretStorage + bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call + expect(createSecretStorageKey).toHaveBeenCalledTimes(2); + }, + ); + }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 85c18af1a40..bfa16c62b73 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -28,7 +28,7 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult } from "../../../src/@types/crypto"; import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; import { ServerSideSecretStorage } from "../../../src/secret-storage"; -import { ImportRoomKeysOpts } from "../../../src/crypto-api"; +import { CryptoCallbacks, ImportRoomKeysOpts } from "../../../src/crypto-api"; import * as testData from "../../test-utils/test-data"; afterEach(() => { @@ -212,6 +212,7 @@ describe("RustCrypto", () => { TEST_USER, TEST_DEVICE_ID, {} as ServerSideSecretStorage, + {} as CryptoCallbacks, ); rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; }); @@ -334,6 +335,7 @@ describe("RustCrypto", () => { TEST_USER, TEST_DEVICE_ID, {} as ServerSideSecretStorage, + {} as CryptoCallbacks, ); }); @@ -430,6 +432,7 @@ async function makeTestRustCrypto( userId: string = TEST_USER, deviceId: string = TEST_DEVICE_ID, secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage, + cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks, ): Promise { - return await initRustCrypto(http, userId, deviceId, secretStorage); + return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks); } diff --git a/src/client.ts b/src/client.ts index d0d71266206..d3e6c4524db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2223,7 +2223,13 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 068b6b518bf..1a78f11b434 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -18,8 +18,9 @@ import type { IMegolmSessionData } from "./@types/crypto"; import { Room } from "./models/room"; import { DeviceMap } from "./models/device"; import { UIAuthCallback } from "./interactive-auth"; -import { AddSecretStorageKeyOpts } from "./secret-storage"; +import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage"; import { VerificationRequest } from "./crypto-api/verification"; +import { KeyBackupInfo } from "./crypto-api/keybackup"; /** * Public interface to the cryptography parts of the js-sdk @@ -190,6 +191,18 @@ export interface CryptoApi { */ isSecretStorageReady(): Promise; + /** + * Bootstrap the secret storage by creating a new secret storage key and store it in the secret storage. + * + * - Do nothing if an AES key is already stored in the secret storage and `setupNewKeyBackup` is not set; + * - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`. + * - Store this key in the secret storage and set it as the default key. + * - Call `cryptoCallbacks.cacheSecretStorageKey` if provided. + * + * @param opts - Options object. + */ + bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise; + /** * Get the status of our cross-signing keys. * @@ -377,6 +390,72 @@ export interface CrossSigningStatus { }; } +/** + * Crypto callbacks provided by the application + */ +export interface CryptoCallbacks extends SecretStorageCallbacks { + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; + saveCrossSigningKeys?: (keys: Record) => void; + shouldUpgradeDeviceVerifications?: (users: Record) => Promise; + /** + * Called by {@link CryptoApi#bootstrapSecretStorage} + * @param keyId - secret storage key id + * @param keyInfo - secret storage key info + * @param key - private key to store + */ + cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; + onSecretRequested?: ( + userId: string, + deviceId: string, + requestId: string, + secretName: string, + deviceTrust: DeviceVerificationStatus, + ) => Promise; + getDehydrationKey?: ( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise; + getBackupKey?: () => Promise; +} + +/** + * Parameter of {@link CryptoApi#bootstrapSecretStorage} + */ +export interface CreateSecretStorageOpts { + /** + * Function called to await a secret storage key creation flow. + * @returns Promise resolving to an object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + createSecretStorageKey?: () => Promise; + + /** + * The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + */ + keyBackupInfo?: KeyBackupInfo; + + /** + * If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + */ + setupNewKeyBackup?: boolean; + + /** + * Reset even if keys already exist. + */ + setupNewSecretStorage?: boolean; + + /** + * Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Uint8Array + * containing the key, or rejects if the key cannot be obtained. + */ + getKeyBackupPassphrase?: () => Promise; +} + /** Types of cross-signing key */ export enum CrossSigningKey { Master = "master", @@ -396,3 +475,4 @@ export interface GeneratedSecretStorageKey { } export * from "./crypto-api/verification"; +export * from "./crypto-api/keybackup"; diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts new file mode 100644 index 00000000000..629d27aed4f --- /dev/null +++ b/src/crypto-api/keybackup.ts @@ -0,0 +1,42 @@ +/* +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 { ISigned } from "../@types/signed"; + +export interface Curve25519AuthData { + public_key: string; + private_key_salt?: string; + private_key_iterations?: number; + private_key_bits?: number; +} + +export interface Aes256AuthData { + iv: string; + mac: string; + private_key_salt?: string; + private_key_iterations?: number; +} + +/** + * Extra info of a recovery key + */ +export interface KeyBackupInfo { + algorithm: string; + auth_data: ISigned & (Curve25519AuthData | Aes256AuthData); + count?: number; + etag?: string; + version?: string; // number contained within +} diff --git a/src/crypto/api.ts b/src/crypto/api.ts index cb0f71631ad..a0e11a415be 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -15,13 +15,14 @@ limitations under the License. */ import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupInfo } from "./keybackup"; -import { GeneratedSecretStorageKey } from "../crypto-api"; /* re-exports for backwards compatibility. */ // CrossSigningKey is used as a value in `client.ts`, we can't export it as a type export { CrossSigningKey } from "../crypto-api"; -export type { GeneratedSecretStorageKey as IRecoveryKey } from "../crypto-api"; +export type { + GeneratedSecretStorageKey as IRecoveryKey, + CreateSecretStorageOpts as ICreateSecretStorageOpts, +} from "../crypto-api"; export type { ImportRoomKeyProgressData as IImportOpts, @@ -67,38 +68,3 @@ export interface IEncryptedEventInfo { */ mismatchedSender: boolean; } - -export interface ICreateSecretStorageOpts { - /** - * Function called to await a secret storage key creation flow. - * @returns Promise resolving to an object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - */ - createSecretStorageKey?: () => Promise; - - /** - * The current key backup object. If passed, - * the passphrase and recovery key from this backup will be used. - */ - keyBackupInfo?: IKeyBackupInfo; - - /** - * If true, a new key backup version will be - * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo - * is supplied. - */ - setupNewKeyBackup?: boolean; - - /** - * Reset even if keys already exist. - */ - setupNewSecretStorage?: boolean; - - /** - * Function called to get the user's - * current key backup passphrase. Should return a promise that resolves with a Uint8Array - * containing the key, or rejects if the key cannot be obtained. - */ - getKeyBackupPassphrase?: () => Promise; -} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index d9ebd74a9ae..699561f2734 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -80,7 +80,6 @@ import { AccountDataClient, AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, - SecretStorageCallbacks, SecretStorageKeyDescription, SecretStorageKeyObject, SecretStorageKeyTuple, @@ -97,7 +96,10 @@ import { Device, DeviceMap } from "../models/device"; import { deviceInfoToDevice } from "./device-converter"; /* re-exports for backwards compatibility */ -export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api"; +export type { + BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts, + CryptoCallbacks as ICryptoCallbacks, +} from "../crypto-api"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -134,25 +136,6 @@ interface IInitOpts { pickleKey?: string; } -export interface ICryptoCallbacks extends SecretStorageCallbacks { - getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; - saveCrossSigningKeys?: (keys: Record) => void; - shouldUpgradeDeviceVerifications?: (users: Record) => Promise; - cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; - onSecretRequested?: ( - userId: string, - deviceId: string, - requestId: string, - secretName: string, - deviceTrust: DeviceTrustLevel, - ) => Promise; - getDehydrationKey?: ( - keyInfo: SecretStorageKeyDescription, - checkFunc: (key: Uint8Array) => void, - ) => Promise; - getBackupKey?: () => Promise; -} - /* eslint-disable camelcase */ interface IRoomKey { room_id: string; diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 67e213c4a92..f15ab73438f 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ISigned } from "../@types/signed"; import { IEncryptedPayload } from "./aes"; export interface Curve25519SessionData { @@ -35,27 +34,13 @@ export interface IKeyBackupRoomSessions { [sessionId: string]: IKeyBackupSession; } -export interface ICurve25519AuthData { - public_key: string; - private_key_salt?: string; - private_key_iterations?: number; - private_key_bits?: number; -} +// Export for backward compatibility +export type { + Curve25519AuthData as ICurve25519AuthData, + Aes256AuthData as IAes256AuthData, + KeyBackupInfo as IKeyBackupInfo, +} from "../crypto-api/keybackup"; -export interface IAes256AuthData { - iv: string; - mac: string; - private_key_salt?: string; - private_key_iterations?: number; -} - -export interface IKeyBackupInfo { - algorithm: string; - auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData); - count?: number; - etag?: string; - version?: string; // number contained within -} /* eslint-enable camelcase */ export interface IKeyBackupPrepareOpts { diff --git a/src/rust-crypto/index.ts b/src/rust-crypto/index.ts index 80370b3fbe9..8f4aefdaaba 100644 --- a/src/rust-crypto/index.ts +++ b/src/rust-crypto/index.ts @@ -21,6 +21,7 @@ import { logger } from "../logger"; import { RUST_SDK_STORE_PREFIX } from "./constants"; import { IHttpOpts, MatrixHttpApi } from "../http-api"; import { ServerSideSecretStorage } from "../secret-storage"; +import { ICryptoCallbacks } from "../crypto"; /** * Create a new `RustCrypto` implementation @@ -30,12 +31,14 @@ import { ServerSideSecretStorage } from "../secret-storage"; * @param userId - The local user's User ID. * @param deviceId - The local user's Device ID. * @param secretStorage - Interface to server-side secret storage. + * @param cryptoCallbacks - Crypto callbacks provided by the application */ export async function initRustCrypto( http: MatrixHttpApi, userId: string, deviceId: string, secretStorage: ServerSideSecretStorage, + cryptoCallbacks: ICryptoCallbacks, ): Promise { // initialise the rust matrix-sdk-crypto-js, if it hasn't already been done await RustSdkCryptoJs.initAsync(); @@ -49,7 +52,7 @@ export async function initRustCrypto( // TODO: use the pickle key for the passphrase const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass"); - const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage); + const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks); await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) => rustCrypto.onRoomKeysUpdated(sessions), ); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 0405ed42954..033063bc437 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -39,11 +39,13 @@ import { ImportRoomKeyProgressData, ImportRoomKeysOpts, VerificationRequest, + CreateSecretStorageOpts, + CryptoCallbacks, } from "../crypto-api"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; import { Device, DeviceMap } from "../models/device"; -import { AddSecretStorageKeyOpts, ServerSideSecretStorage } from "../secret-storage"; +import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage"; import { CrossSigningIdentity } from "./CrossSigningIdentity"; import { secretStorageContainsCrossSigningKeys } from "./secret-storage"; import { keyFromPassphrase } from "../crypto/key_passphrase"; @@ -90,6 +92,9 @@ export class RustCrypto implements CryptoBackend { /** Interface to server-side secret storage */ private readonly secretStorage: ServerSideSecretStorage, + + /** Crypto callbacks provided by the application */ + private readonly cryptoCallbacks: CryptoCallbacks, ) { this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http); this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor); @@ -375,6 +380,49 @@ export class RustCrypto implements CryptoBackend { return false; } + /** + * Implementation of {@link CryptoApi#bootstrapSecretStorage} + */ + public async bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage, + }: CreateSecretStorageOpts = {}): Promise { + // If createSecretStorageKey is not set, we stop + if (!createSecretStorageKey) return; + + // See if we already have an AES secret-storage key. + const secretStorageKeyTuple = await this.secretStorage.getKey(); + + if (secretStorageKeyTuple) { + const [, keyInfo] = secretStorageKeyTuple; + + // If an AES Key is already stored in the secret storage and setupNewSecretStorage is not set + // we don't want to create a new key + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES && !setupNewSecretStorage) { + return; + } + } + + const recoveryKey = await createSecretStorageKey(); + + // keyInfo is required to continue + if (!recoveryKey.keyInfo) { + throw new Error("missing keyInfo field in the secret storage key created by createSecretStorageKey"); + } + + const secretStorageKeyObject = await this.secretStorage.addKey( + SECRET_STORAGE_ALGORITHM_V1_AES, + recoveryKey.keyInfo, + ); + await this.secretStorage.setDefaultKeyId(secretStorageKeyObject.keyId); + + this.cryptoCallbacks.cacheSecretStorageKey?.( + secretStorageKeyObject.keyId, + secretStorageKeyObject.keyInfo, + recoveryKey.privateKey, + ); + } + /** * Implementation of {@link CryptoApi#getCrossSigningStatus} */