diff --git a/spec/unit/common-crypto/key-passphrase.spec.ts b/spec/unit/common-crypto/key-passphrase.spec.ts new file mode 100644 index 0000000000..933b5f1834 --- /dev/null +++ b/spec/unit/common-crypto/key-passphrase.spec.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2024 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 { keyFromAuthData } from "../../../src/common-crypto/key-passphrase.ts"; + +describe("key-passphrase", () => { + describe("keyFromAuthData", () => { + it("should throw an error if salt or iterations are missing", async () => { + // missing salt + expect(() => keyFromAuthData({ private_key_iterations: 5 }, "passphrase")).toThrow( + "Salt and/or iterations not found: this backup cannot be restored with a passphrase", + ); + + // missing iterations + expect(() => keyFromAuthData({ private_key_salt: "salt" }, "passphrase")).toThrow( + "Salt and/or iterations not found: this backup cannot be restored with a passphrase", + ); + }); + + it("should derive key from auth data", async () => { + const key = await keyFromAuthData({ private_key_salt: "salt", private_key_iterations: 5 }, "passphrase"); + expect(key).toBeDefined(); + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index 6746e505bc..9273281a07 100644 --- a/src/client.ts +++ b/src/client.ts @@ -85,7 +85,6 @@ import { isCryptoAvailable, } from "./crypto/index.ts"; import { DeviceInfo } from "./crypto/deviceinfo.ts"; -import { keyFromAuthData } from "./crypto/key_passphrase.ts"; import { User, UserEvent, UserEventHandlerMap } from "./models/user.ts"; import { getHttpUriForMxc } from "./content-repo.ts"; import { SearchResult } from "./models/search-result.ts"; @@ -244,6 +243,7 @@ import { RoomMessageEventContent, StickerEventContent } from "./@types/events.ts import { ImageInfo } from "./@types/media.ts"; import { Capabilities, ServerCapabilities } from "./serverCapabilities.ts"; import { sha256 } from "./digest.ts"; +import { keyFromAuthData } from "./common-crypto/key-passphrase.ts"; export type Store = IStore; @@ -3656,6 +3656,7 @@ export class MatrixClient extends TypedEventEmitter { return keyFromAuthData(backupInfo.auth_data, password); diff --git a/src/common-crypto/key-passphrase.ts b/src/common-crypto/key-passphrase.ts new file mode 100644 index 0000000000..27b1df6458 --- /dev/null +++ b/src/common-crypto/key-passphrase.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2024 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 { deriveRecoveryKeyFromPassphrase } from "../crypto-api/index.ts"; + +/* eslint-disable camelcase */ +interface IAuthData { + private_key_salt?: string; + private_key_iterations?: number; + private_key_bits?: number; +} + +/** + * Derive a backup key from a passphrase using the salt and iterations from the auth data. + * @param authData - The auth data containing the salt and iterations + * @param passphrase - The passphrase to derive the key from + * @deprecated Deriving a backup key from a passphrase is not part of the matrix spec. Instead, a random key is generated and stored/shared via 4S. + */ +export function keyFromAuthData(authData: IAuthData, passphrase: string): Promise { + if (!authData.private_key_salt || !authData.private_key_iterations) { + throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase"); + } + + return deriveRecoveryKeyFromPassphrase( + passphrase, + authData.private_key_salt, + authData.private_key_iterations, + authData.private_key_bits, + ); +} diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 1b1340aa18..0faf89a767 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -968,3 +968,4 @@ export interface OwnDeviceKeys { export * from "./verification.ts"; export * from "./keybackup.ts"; export * from "./recovery-key.ts"; +export * from "./key-passphrase.ts"; diff --git a/src/crypto-api/key-passphrase.ts b/src/crypto-api/key-passphrase.ts new file mode 100644 index 0000000000..1bd8745017 --- /dev/null +++ b/src/crypto-api/key-passphrase.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2024 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. + */ + +const DEFAULT_BIT_SIZE = 256; + +/** + * Derive a recovery key from a passphrase and salt using PBKDF2. + * @see https://spec.matrix.org/v1.11/client-server-api/#deriving-keys-from-passphrases + * + * @param passphrase - The passphrase to derive the key from + * @param salt - The salt to use in the derivation + * @param iterations - The number of iterations to use in the derivation + * @param numBits - The number of bits to derive + */ +export async function deriveRecoveryKeyFromPassphrase( + passphrase: string, + salt: string, + iterations: number, + numBits = DEFAULT_BIT_SIZE, +): Promise { + if (!globalThis.crypto.subtle || !TextEncoder) { + throw new Error("Password-based backup is not available on this platform"); + } + + const key = await globalThis.crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + { name: "PBKDF2" }, + false, + ["deriveBits"], + ); + + const keybits = await globalThis.crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: new TextEncoder().encode(salt), + iterations: iterations, + hash: "SHA-512", + }, + key, + numBits, + ); + + return new Uint8Array(keybits); +} diff --git a/src/crypto/key_passphrase.ts b/src/crypto/key_passphrase.ts index 85b9c48fbc..4f9035473b 100644 --- a/src/crypto/key_passphrase.ts +++ b/src/crypto/key_passphrase.ts @@ -15,74 +15,28 @@ limitations under the License. */ import { randomString } from "../randomstring.ts"; +import { deriveRecoveryKeyFromPassphrase } from "../crypto-api/index.ts"; const DEFAULT_ITERATIONS = 500000; -const DEFAULT_BITSIZE = 256; - -/* eslint-disable camelcase */ -interface IAuthData { - private_key_salt?: string; - private_key_iterations?: number; - private_key_bits?: number; -} -/* eslint-enable camelcase */ - interface IKey { key: Uint8Array; salt: string; iterations: number; } -export function keyFromAuthData(authData: IAuthData, password: string): Promise { - if (!authData.private_key_salt || !authData.private_key_iterations) { - throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase"); - } - - return deriveKey( - password, - authData.private_key_salt, - authData.private_key_iterations, - authData.private_key_bits || DEFAULT_BITSIZE, - ); -} - -export async function keyFromPassphrase(password: string): Promise { +/** + * Generate a new recovery key, based on a passphrase. + * @param passphrase - The passphrase to generate the key from + */ +export async function keyFromPassphrase(passphrase: string): Promise { const salt = randomString(32); - const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); + const key = await deriveRecoveryKeyFromPassphrase(passphrase, salt, DEFAULT_ITERATIONS); return { key, salt, iterations: DEFAULT_ITERATIONS }; } -export async function deriveKey( - password: string, - salt: string, - iterations: number, - numBits = DEFAULT_BITSIZE, -): Promise { - if (!globalThis.crypto.subtle || !TextEncoder) { - throw new Error("Password-based backup is not available on this platform"); - } - - const key = await globalThis.crypto.subtle.importKey( - "raw", - new TextEncoder().encode(password), - { name: "PBKDF2" }, - false, - ["deriveBits"], - ); - - const keybits = await globalThis.crypto.subtle.deriveBits( - { - name: "PBKDF2", - salt: new TextEncoder().encode(salt), - iterations: iterations, - hash: "SHA-512", - }, - key, - numBits, - ); - - return new Uint8Array(keybits); -} +// Re-export the key passphrase functions to avoid breaking changes +export { deriveRecoveryKeyFromPassphrase as deriveKey }; +export { keyFromAuthData } from "../common-crypto/key-passphrase.ts"; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 45d63dd820..0a66133811 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -59,6 +59,7 @@ import { UserVerificationStatus, VerificationRequest, encodeRecoveryKey, + deriveRecoveryKeyFromPassphrase, } from "../crypto-api/index.ts"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts"; @@ -66,7 +67,6 @@ import { Device, DeviceMap } from "../models/device.ts"; import { SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage.ts"; import { CrossSigningIdentity } from "./CrossSigningIdentity.ts"; import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage.ts"; -import { keyFromPassphrase } from "../crypto/key_passphrase.ts"; import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts"; import { EventType, MsgType } from "../@types/event.ts"; import { CryptoEvent } from "../crypto/index.ts"; @@ -100,6 +100,11 @@ interface ISignableObject { * @internal */ export class RustCrypto extends TypedEventEmitter implements CryptoBackend { + /** + * The number of iterations to use when deriving a recovery key from a passphrase. + */ + private readonly RECOVERY_KEY_DERIVATION_ITERATIONS = 500000; + private _trustCrossSignedDevices = true; /** whether {@link stop} has been called */ @@ -879,17 +884,24 @@ export class RustCrypto extends TypedEventEmitter { if (password) { // Generate the key from the passphrase - const derivation = await keyFromPassphrase(password); + // first we generate a random salt + const salt = randomString(32); + // then we derive the key from the passphrase + const recoveryKey = await deriveRecoveryKeyFromPassphrase( + password, + salt, + this.RECOVERY_KEY_DERIVATION_ITERATIONS, + ); return { keyInfo: { passphrase: { algorithm: "m.pbkdf2", - iterations: derivation.iterations, - salt: derivation.salt, + iterations: this.RECOVERY_KEY_DERIVATION_ITERATIONS, + salt, }, }, - privateKey: derivation.key, - encodedPrivateKey: encodeRecoveryKey(derivation.key), + privateKey: recoveryKey, + encodedPrivateKey: encodeRecoveryKey(recoveryKey), }; } else { // Using the navigator crypto API to generate the private key