diff --git a/README.md b/README.md index 338012c9f50..7a9146030c1 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ As well as the primary entry point (`matrix-js-sdk`), there are several other en | `matrix-js-sdk/lib/crypto-api` | Cryptography functionality. | | `matrix-js-sdk/lib/types` | Low-level types, reflecting data structures defined in the Matrix spec. | | `matrix-js-sdk/lib/testing` | Test utilities, which may be useful in test code but should not be used in production code. | +| `matrix-js-sdk/lib/utils/*.js` | A set of modules exporting standalone functions (and their types). | ## Examples diff --git a/spec/integ/crypto/cross-signing.spec.ts b/spec/integ/crypto/cross-signing.spec.ts index b81bf6f00f4..95b0f756e0a 100644 --- a/spec/integ/crypto/cross-signing.spec.ts +++ b/spec/integ/crypto/cross-signing.spec.ts @@ -21,7 +21,7 @@ import { IDBFactory } from "fake-indexeddb"; import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils"; import { AuthDict, createClient, CryptoEvent, MatrixClient } from "../../../src"; import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints"; -import { encryptAES } from "../../../src/crypto/aes"; +import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts"; import { CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api"; import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder"; @@ -169,17 +169,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s mockInitialApiRequests(aliceClient.getHomeserverUrl()); // Encrypt the private keys and return them in the /sync response as if they are in Secret Storage - const masterKey = await encryptAES( + const masterKey = await encryptAESSecretStorageItem( MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64, encryptionKey, "m.cross_signing.master", ); - const selfSigningKey = await encryptAES( + const selfSigningKey = await encryptAESSecretStorageItem( SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64, encryptionKey, "m.cross_signing.self_signing", ); - const userSigningKey = await encryptAES( + const userSigningKey = await encryptAESSecretStorageItem( USER_CROSS_SIGNING_PRIVATE_KEY_BASE64, encryptionKey, "m.cross_signing.user_signing", diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 835c593d8e5..f09657c88b1 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -20,7 +20,7 @@ import { IObject } from "../../../src/crypto/olmlib"; import { MatrixEvent } from "../../../src/models/event"; import { TestClient } from "../../TestClient"; import { makeTestClients } from "./verification/util"; -import { encryptAES } from "../../../src/crypto/aes"; +import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts"; import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils"; import { logger } from "../../../src/logger"; import { ClientEvent, ICreateClientOpts, MatrixClient } from "../../../src/client"; @@ -612,7 +612,7 @@ describe("Secrets", function () { type: "m.megolm_backup.v1", content: { encrypted: { - key_id: await encryptAES( + key_id: await encryptAESSecretStorageItem( "123,45,6,7,89,1,234,56,78,90,12,34,5,67,8,90", secretStorageKeys.key_id, "m.megolm_backup.v1", diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 07501a47c95..0b16ea1ee5c 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -69,7 +69,7 @@ import { logger } from "../../../src/logger"; import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager"; import { ClientEvent, ClientEventHandlerMap } from "../../../src/client"; import { Curve25519AuthData } from "../../../src/crypto-api/keybackup"; -import { encryptAES } from "../../../src/crypto/aes"; +import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts"; import { CryptoStore, SecretStorePrivateKeys } from "../../../src/crypto/store/base"; const TEST_USER = "@alice:example.com"; @@ -425,7 +425,7 @@ describe("initRustCrypto", () => { }, 10000); async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) { - const encryptedKey = await encryptAES(encodeBase64(key), Buffer.from(pickleKey), type); + const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), Buffer.from(pickleKey), type); store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey); } diff --git a/spec/unit/secret-storage.spec.ts b/spec/unit/secret-storage.spec.ts index e2f07b79d19..b71da45e2f8 100644 --- a/spec/unit/secret-storage.spec.ts +++ b/spec/unit/secret-storage.spec.ts @@ -25,8 +25,8 @@ import { ServerSideSecretStorageImpl, trimTrailingEquals, } from "../../src/secret-storage"; -import { calculateKeyCheck } from "../../src/crypto/aes"; import { randomString } from "../../src/randomstring"; +import { calculateKeyCheck } from "../../src/calculateKeyCheck.ts"; describe("ServerSideSecretStorageImpl", function () { describe(".addKey", function () { diff --git a/src/@types/AESEncryptedSecretStoragePayload.ts b/src/@types/AESEncryptedSecretStoragePayload.ts new file mode 100644 index 00000000000..5f33680b257 --- /dev/null +++ b/src/@types/AESEncryptedSecretStoragePayload.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * An AES-encrypted secret storage payload. + * See https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2-1 + */ +export interface AESEncryptedSecretStoragePayload { + [key: string]: any; // extensible + /** the initialization vector in base64 */ + iv: string; + /** the ciphertext in base64 */ + ciphertext: string; + /** the HMAC in base64 */ + mac: string; +} diff --git a/src/calculateKeyCheck.ts b/src/calculateKeyCheck.ts new file mode 100644 index 00000000000..7e979a71263 --- /dev/null +++ b/src/calculateKeyCheck.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +// string of zeroes, for calculating the key check +import encryptAESSecretStorageItem from "./utils/encryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "./@types/AESEncryptedSecretStoragePayload.ts"; + +const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + +/** + * Calculate the MAC for checking the key. + * See https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2, steps 3 and 4. + * + * @param key - the key to use + * @param iv - The initialization vector as a base64-encoded string. + * If omitted, a random initialization vector will be created. + * @returns An object that contains, `mac` and `iv` properties. + */ +export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise { + return encryptAESSecretStorageItem(ZERO_STR, key, "", iv); +} diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts index 3209de6c320..8a82a5f4c13 100644 --- a/src/crypto-api/keybackup.ts +++ b/src/crypto-api/keybackup.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { ISigned } from "../@types/signed.ts"; -import { IEncryptedPayload } from "../crypto/aes.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; export interface Curve25519AuthData { public_key: string; @@ -77,7 +77,7 @@ export interface Curve25519SessionData { } /* eslint-disable camelcase */ -export interface KeyBackupSession { +export interface KeyBackupSession { first_message_index: number; forwarded_count: number; is_verified: boolean; diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index a4caf1c53a1..175b878b762 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -22,7 +22,6 @@ import type { PkSigning } from "@matrix-org/olm"; import { IObject, pkSign, pkVerify } from "./olmlib.ts"; import { logger } from "../logger.ts"; import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts"; -import { decryptAES, encryptAES } from "./aes.ts"; import { DeviceInfo } from "./deviceinfo.ts"; import { ISignedKey, MatrixClient } from "../client.ts"; import { OlmDevice } from "./OlmDevice.ts"; @@ -36,6 +35,8 @@ import { UserVerificationStatus as UserTrustLevel, } from "../crypto-api/index.ts"; import { decodeBase64, encodeBase64 } from "../base64.ts"; +import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; // backwards-compatibility re-exports export { UserTrustLevel }; @@ -662,7 +663,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O if (key && key.ciphertext) { const pickleKey = Buffer.from(olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, type); + const decrypted = await decryptAESSecretStorageItem(key, pickleKey, type); return decodeBase64(decrypted); } else { return key; @@ -676,7 +677,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); } const pickleKey = Buffer.from(olmDevice.pickleKey); - const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); + const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), pickleKey, type); return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { store.storeSecretStorePrivateKey(txn, type, encryptedKey); }); diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index 2fc32aec412..f435d8c15e5 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -14,153 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { decodeBase64, encodeBase64 } from "../base64.ts"; - -// salt for HKDF, with 8 bytes of zeros -const zeroSalt = new Uint8Array(8); - -export interface IEncryptedPayload { - [key: string]: any; // extensible - /** the initialization vector in base64 */ - iv: string; - /** the ciphertext in base64 */ - ciphertext: string; - /** the HMAC in base64 */ - mac: string; -} - -/** - * Encrypt a string using AES-CTR. - * - * @param data - the plaintext to encrypt - * @param key - the encryption key to use as an input to the HKDF function which is used to derive the AES key for - * encryption. Obviously, the same key must be provided when decrypting. - * @param name - the name of the secret. Used as an input to the HKDF operation which is used to derive the AES key, - * so again the same value must be provided when decrypting. - * @param ivStr - the base64-encoded initialization vector to use. If not supplied, a random one will be generated. - * - * @returns The encrypted result, including the ciphertext itself, the initialization vector (as supplied in `ivStr`, - * or generated), and an HMAC on the ciphertext — all base64-encoded. - */ -export async function encryptAES( - data: string, - key: Uint8Array, - name: string, - ivStr?: string, -): Promise { - let iv: Uint8Array; - if (ivStr) { - iv = decodeBase64(ivStr); - } else { - iv = new Uint8Array(16); - globalThis.crypto.getRandomValues(iv); - - // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary - // (which would mean we wouldn't be able to decrypt on Android). The loss - // of a single bit of iv is a price we have to pay. - iv[8] &= 0x7f; - } - - const [aesKey, hmacKey] = await deriveKeys(key, name); - const encodedData = new TextEncoder().encode(data); - - const ciphertext = await globalThis.crypto.subtle.encrypt( - { - name: "AES-CTR", - counter: iv, - length: 64, - }, - aesKey, - encodedData, - ); - - const hmac = await globalThis.crypto.subtle.sign({ name: "HMAC" }, hmacKey, ciphertext); - - return { - iv: encodeBase64(iv), - ciphertext: encodeBase64(ciphertext), - mac: encodeBase64(hmac), - }; -} - -/** - * Decrypt an AES-encrypted string. - * - * @param data - the encrypted data, returned by {@link encryptAES}. - * @param key - the encryption key to use as an input to the HKDF function which is used to derive the AES key. Must - * be the same as provided to {@link encryptAES}. - * @param name - the name of the secret. Also used as an input to the HKDF operation which is used to derive the AES - * key, so again must be the same as provided to {@link encryptAES}. - */ -export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { - const [aesKey, hmacKey] = await deriveKeys(key, name); - - const ciphertext = decodeBase64(data.ciphertext); - - if (!(await globalThis.crypto.subtle.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) { - throw new Error(`Error decrypting secret ${name}: bad MAC`); - } - - const plaintext = await globalThis.crypto.subtle.decrypt( - { - name: "AES-CTR", - counter: decodeBase64(data.iv), - length: 64, - }, - aesKey, - ciphertext, - ); - - return new TextDecoder().decode(new Uint8Array(plaintext)); -} - -async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { - const hkdfkey = await globalThis.crypto.subtle.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]); - const keybits = await globalThis.crypto.subtle.deriveBits( - { - name: "HKDF", - salt: zeroSalt, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 - info: new TextEncoder().encode(name), - hash: "SHA-256", - }, - hkdfkey, - 512, - ); - - const aesKey = keybits.slice(0, 32); - const hmacKey = keybits.slice(32); - - const aesProm = globalThis.crypto.subtle.importKey("raw", aesKey, { name: "AES-CTR" }, false, [ - "encrypt", - "decrypt", - ]); - - const hmacProm = globalThis.crypto.subtle.importKey( - "raw", - hmacKey, - { - name: "HMAC", - hash: { name: "SHA-256" }, - }, - false, - ["sign", "verify"], - ); - - return Promise.all([aesProm, hmacProm]); -} - -// string of zeroes, for calculating the key check -const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; - -/** Calculate the MAC for checking the key. - * - * @param key - the key to use - * @param iv - The initialization vector as a base64-encoded string. - * If omitted, a random initialization vector will be created. - * @returns An object that contains, `mac` and `iv` properties. - */ -export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise { - return encryptAES(ZERO_STR, key, "", iv); -} +import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; + +// Export for backwards compatibility +export type { AESEncryptedSecretStoragePayload as IEncryptedPayload }; +// Export with new names instead of using `as` to not break react-sdk tests +export const encryptAES = encryptAESSecretStorageItem; +export const decryptAES = decryptAESSecretStorageItem; +export { calculateKeyCheck } from "../calculateKeyCheck.ts"; diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 2dc374f9868..7caf069ab4a 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -27,7 +27,6 @@ import { DeviceTrustLevel } from "./CrossSigning.ts"; import { keyFromPassphrase } from "./key_passphrase.ts"; import { encodeUri, safeSet, sleep } from "../utils.ts"; import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes.ts"; import { Curve25519SessionData, IAes256AuthData, @@ -41,6 +40,10 @@ import { ClientPrefix, HTTPError, MatrixError, Method } from "../http-api/index. import { BackupTrustInfo } from "../crypto-api/keybackup.ts"; import { BackupDecryptor } from "../common-crypto/CryptoBackend.ts"; import { encodeRecoveryKey } from "../crypto-api/index.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; +import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; +import { calculateKeyCheck } from "../calculateKeyCheck.ts"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms @@ -94,7 +97,7 @@ interface BackupAlgorithmClass { interface BackupAlgorithm { untrusted: boolean; - encryptSession(data: Record): Promise; + encryptSession(data: Record): Promise; decryptSessions(ciphertexts: Record): Promise; authData: AuthData; keyMatches(key: ArrayLike): Promise; @@ -825,22 +828,24 @@ export class Aes256 implements BackupAlgorithm { return false; } - public encryptSession(data: Record): Promise { + public encryptSession(data: Record): Promise { const plainText: Record = Object.assign({}, data); delete plainText.session_id; delete plainText.room_id; delete plainText.first_known_index; - return encryptAES(JSON.stringify(plainText), this.key, data.session_id); + return encryptAESSecretStorageItem(JSON.stringify(plainText), this.key, data.session_id); } public async decryptSessions( - sessions: Record>, + sessions: Record>, ): Promise { const keys: IMegolmSessionData[] = []; for (const [sessionId, sessionData] of Object.entries(sessions)) { try { - const decrypted = JSON.parse(await decryptAES(sessionData.session_data, this.key, sessionId)); + const decrypted = JSON.parse( + await decryptAESSecretStorageItem(sessionData.session_data, this.key, sessionId), + ); decrypted.session_id = sessionId; keys.push(decrypted); } catch (e) { diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index 01dafa83c71..4cfc1193a0b 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -19,11 +19,12 @@ import anotherjson from "another-json"; import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto.ts"; import { decodeBase64, encodeBase64 } from "../base64.ts"; import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts"; -import { decryptAES, encryptAES } from "./aes.ts"; import { logger } from "../logger.ts"; import { Crypto } from "./index.ts"; import { Method } from "../http-api/index.ts"; import { SecretStorageKeyDescription } from "../secret-storage.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; +import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; export interface IDehydratedDevice { device_id: string; // eslint-disable-line camelcase @@ -61,7 +62,7 @@ export class DehydrationManager { if (result) { const { key, keyInfo, deviceDisplayName, time } = result; const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); + const decrypted = await decryptAESSecretStorageItem(key, pickleKey, DEHYDRATION_ALGORITHM); this.key = decodeBase64(decrypted); this.keyInfo = keyInfo; this.deviceDisplayName = deviceDisplayName; @@ -141,7 +142,7 @@ export class DehydrationManager { const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); // update the crypto store with the timestamp - const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); + const key = await encryptAESSecretStorageItem(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { keyInfo: this.keyInfo, diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 4eed467758d..932ce3adf48 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -47,7 +47,6 @@ import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChan import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel.ts"; import { IllegalMethod } from "./verification/IllegalMethod.ts"; import { KeySignatureUploadError } from "../errors.ts"; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes.ts"; import { DehydrationManager } from "./dehydration.ts"; import { BackupManager, LibOlmBackupDecryptor, backupTrustInfoFromLegacyTrustInfo } from "./backup.ts"; import { IStore } from "../store/index.ts"; @@ -107,6 +106,10 @@ import { deviceInfoToDevice } from "./device-converter.ts"; import { ClientPrefix, MatrixError, Method } from "../http-api/index.ts"; import { decodeBase64, encodeBase64 } from "../base64.ts"; import { KnownMembership } from "../@types/membership.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; +import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; +import { calculateKeyCheck } from "../calculateKeyCheck.ts"; /* re-exports for backwards compatibility */ export type { @@ -1322,11 +1325,13 @@ export class Crypto extends TypedEventEmitter { - const encodedKey = await new Promise((resolve) => { - this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); - }); - }); + const encodedKey = await new Promise( + (resolve) => { + this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); + }); + }, + ); let key: Uint8Array | null = null; @@ -1337,7 +1342,7 @@ export class Crypto extends TypedEventEmitter { this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); }); diff --git a/src/crypto/store/base.ts b/src/crypto/store/base.ts index daf11b5b0b3..6bbd139d19b 100644 --- a/src/crypto/store/base.ts +++ b/src/crypto/store/base.ts @@ -25,8 +25,8 @@ import { Logger } from "../../logger.ts"; import { InboundGroupSessionData } from "../OlmDevice.ts"; import { MatrixEvent } from "../../models/event.ts"; import { DehydrationManager } from "../dehydration.ts"; -import { IEncryptedPayload } from "../aes.ts"; import { CrossSigningKeyInfo } from "../../crypto-api/index.ts"; +import { AESEncryptedSecretStoragePayload } from "../../@types/AESEncryptedSecretStoragePayload.ts"; /** * Internal module. Definitions for storage for the crypto module @@ -35,11 +35,11 @@ import { CrossSigningKeyInfo } from "../../crypto-api/index.ts"; export interface SecretStorePrivateKeys { "dehydration": { keyInfo: DehydrationManager["keyInfo"]; - key: IEncryptedPayload; + key: AESEncryptedSecretStoragePayload; deviceDisplayName: string; time: number; } | null; - "m.megolm_backup.v1": IEncryptedPayload; + "m.megolm_backup.v1": AESEncryptedSecretStoragePayload; } /** diff --git a/src/rust-crypto/backup.ts b/src/rust-crypto/backup.ts index c4407da21f8..b83a8e7a712 100644 --- a/src/rust-crypto/backup.ts +++ b/src/rust-crypto/backup.ts @@ -33,10 +33,10 @@ import { encodeUri, logDuration } from "../utils.ts"; import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts"; import { sleep } from "../utils.ts"; import { BackupDecryptor } from "../common-crypto/CryptoBackend.ts"; -import { IEncryptedPayload } from "../crypto/aes.ts"; import { ImportRoomKeyProgressData, ImportRoomKeysOpts } from "../crypto-api/index.ts"; import { IKeyBackupInfo } from "../crypto/keybackup.ts"; import { IKeyBackup } from "../crypto/backup.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; /** Authentification of the backup info, depends on algorithm */ type AuthData = KeyBackupInfo["auth_data"]; @@ -622,7 +622,7 @@ export class RustBackupDecryptor implements BackupDecryptor { * Implements {@link BackupDecryptor#decryptSessions} */ public async decryptSessions( - ciphertexts: Record>, + ciphertexts: Record>, ): Promise { const keys: IMegolmSessionData[] = []; for (const [sessionId, sessionData] of Object.entries(ciphertexts)) { diff --git a/src/rust-crypto/libolm_migration.ts b/src/rust-crypto/libolm_migration.ts index 50d92a626e1..4a9784485b1 100644 --- a/src/rust-crypto/libolm_migration.ts +++ b/src/rust-crypto/libolm_migration.ts @@ -19,7 +19,6 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; import { Logger } from "../logger.ts"; import { CryptoStore, MigrationState, SecretStorePrivateKeys } from "../crypto/store/base.ts"; import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts"; -import { decryptAES, IEncryptedPayload } from "../crypto/aes.ts"; import { IHttpOpts, MatrixHttpApi } from "../http-api/index.ts"; import { requestKeyBackupVersion } from "./backup.ts"; import { IRoomEncryption } from "../crypto/RoomList.ts"; @@ -28,6 +27,8 @@ import { RustCrypto } from "./rust-crypto.ts"; import { KeyBackupInfo } from "../crypto-api/keybackup.ts"; import { sleep } from "../utils.ts"; import { encodeBase64 } from "../base64.ts"; +import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; /** * Determine if any data needs migrating from the legacy store, and do so. @@ -421,7 +422,7 @@ async function getAndDecryptCachedSecretKey( }); if (key && key.ciphertext && key.iv && key.mac) { - return await decryptAES(key as IEncryptedPayload, legacyPickleKey, name); + return await decryptAESSecretStorageItem(key as AESEncryptedSecretStoragePayload, legacyPickleKey, name); } else if (key instanceof Uint8Array) { // This is a legacy backward compatibility case where the key was stored in clear. return encodeBase64(key); diff --git a/src/secret-storage.ts b/src/secret-storage.ts index 82dd4272e8a..f192ddfd7f5 100644 --- a/src/secret-storage.ts +++ b/src/secret-storage.ts @@ -23,9 +23,12 @@ limitations under the License. import { TypedEventEmitter } from "./models/typed-event-emitter.ts"; import { ClientEvent, ClientEventHandlerMap } from "./client.ts"; import { MatrixEvent } from "./models/event.ts"; -import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./crypto/aes.ts"; import { randomString } from "./randomstring.ts"; import { logger } from "./logger.ts"; +import encryptAESSecretStorageItem from "./utils/encryptAESSecretStorageItem.ts"; +import decryptAESSecretStorageItem from "./utils/decryptAESSecretStorageItem.ts"; +import { AESEncryptedSecretStoragePayload } from "./@types/AESEncryptedSecretStoragePayload.ts"; +import { calculateKeyCheck } from "./crypto/aes.ts"; export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; @@ -200,13 +203,13 @@ export interface SecretStorageCallbacks { interface SecretInfo { encrypted: { - [keyId: string]: IEncryptedPayload; + [keyId: string]: AESEncryptedSecretStoragePayload; }; } interface Decryptors { - encrypt: (plaintext: string) => Promise; - decrypt: (ciphertext: IEncryptedPayload) => Promise; + encrypt: (plaintext: string) => Promise; + decrypt: (ciphertext: AESEncryptedSecretStoragePayload) => Promise; } /** @@ -491,7 +494,7 @@ export class ServerSideSecretStorageImpl implements ServerSideSecretStorage { * @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key. */ public async store(name: string, secret: string, keys?: string[] | null): Promise { - const encrypted: Record = {}; + const encrypted: Record = {}; if (!keys) { const defaultKeyId = await this.getDefaultKeyId(); @@ -638,11 +641,11 @@ export class ServerSideSecretStorageImpl implements ServerSideSecretStorage { if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { const decryption = { - encrypt: function (secret: string): Promise { - return encryptAES(secret, privateKey, name); + encrypt: function (secret: string): Promise { + return encryptAESSecretStorageItem(secret, privateKey, name); }, - decrypt: function (encInfo: IEncryptedPayload): Promise { - return decryptAES(encInfo, privateKey, name); + decrypt: function (encInfo: AESEncryptedSecretStoragePayload): Promise { + return decryptAESSecretStorageItem(encInfo, privateKey, name); }, }; return [keyId, decryption]; diff --git a/src/types.ts b/src/types.ts index 24f1620427c..6266f63167f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ export * from "./@types/membership.ts"; export type * from "./@types/event.ts"; export type * from "./@types/events.ts"; export type * from "./@types/state_events.ts"; +export type * from "./@types/AESEncryptedSecretStoragePayload.ts"; /** The different methods for device and user verification */ export enum VerificationMethod { diff --git a/src/utils/decryptAESSecretStorageItem.ts b/src/utils/decryptAESSecretStorageItem.ts new file mode 100644 index 00000000000..b48c16ee062 --- /dev/null +++ b/src/utils/decryptAESSecretStorageItem.ts @@ -0,0 +1,54 @@ +/* + * 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 { decodeBase64 } from "../base64.ts"; +import { deriveKeys } from "./internal/deriveKeys.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; + +/** + * Decrypt an AES-encrypted Secret Storage item. + * + * @param data - the encrypted data, returned by {@link utils/encryptAESSecretStorageItem.default | encryptAESSecretStorageItem}. + * @param key - the encryption key to use as an input to the HKDF function which is used to derive the AES key. Must + * be the same as provided to {@link utils/encryptAESSecretStorageItem.default | encryptAESSecretStorageItem}. + * @param name - the name of the secret. Also used as an input to the HKDF operation which is used to derive the AES + * key, so again must be the same as provided to {@link utils/encryptAESSecretStorageItem.default | encryptAESSecretStorageItem}. + */ +export default async function decryptAESSecretStorageItem( + data: AESEncryptedSecretStoragePayload, + key: Uint8Array, + name: string, +): Promise { + const [aesKey, hmacKey] = await deriveKeys(key, name); + + const ciphertext = decodeBase64(data.ciphertext); + + if (!(await globalThis.crypto.subtle.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) { + throw new Error(`Error decrypting secret ${name}: bad MAC`); + } + + const plaintext = await globalThis.crypto.subtle.decrypt( + { + name: "AES-CTR", + counter: decodeBase64(data.iv), + length: 64, + }, + aesKey, + ciphertext, + ); + + return new TextDecoder().decode(new Uint8Array(plaintext)); +} diff --git a/src/utils/encryptAESSecretStorageItem.ts b/src/utils/encryptAESSecretStorageItem.ts new file mode 100644 index 00000000000..064e914a125 --- /dev/null +++ b/src/utils/encryptAESSecretStorageItem.ts @@ -0,0 +1,73 @@ +/* + * 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 { decodeBase64, encodeBase64 } from "../base64.ts"; +import { deriveKeys } from "./internal/deriveKeys.ts"; +import { AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; + +/** + * Encrypt a string as a secret storage item, using AES-CTR. + * + * @param data - the plaintext to encrypt + * @param key - the encryption key to use as an input to the HKDF function which is used to derive the AES key for + * encryption. Obviously, the same key must be provided when decrypting. + * @param name - the name of the secret. Used as an input to the HKDF operation which is used to derive the AES key, + * so again the same value must be provided when decrypting. + * @param ivStr - the base64-encoded initialization vector to use. If not supplied, a random one will be generated. + * + * @returns The encrypted result, including the ciphertext itself, the initialization vector (as supplied in `ivStr`, + * or generated), and an HMAC on the ciphertext — all base64-encoded. + */ +export default async function encryptAESSecretStorageItem( + data: string, + key: Uint8Array, + name: string, + ivStr?: string, +): Promise { + let iv: Uint8Array; + if (ivStr) { + iv = decodeBase64(ivStr); + } else { + iv = new Uint8Array(16); + globalThis.crypto.getRandomValues(iv); + + // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary + // (which would mean we wouldn't be able to decrypt on Android). The loss + // of a single bit of iv is a price we have to pay. + iv[8] &= 0x7f; + } + + const [aesKey, hmacKey] = await deriveKeys(key, name); + const encodedData = new TextEncoder().encode(data); + + const ciphertext = await globalThis.crypto.subtle.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aesKey, + encodedData, + ); + + const hmac = await globalThis.crypto.subtle.sign({ name: "HMAC" }, hmacKey, ciphertext); + + return { + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + mac: encodeBase64(hmac), + }; +} diff --git a/src/utils/internal/deriveKeys.ts b/src/utils/internal/deriveKeys.ts new file mode 100644 index 00000000000..13249f846a0 --- /dev/null +++ b/src/utils/internal/deriveKeys.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +// salt for HKDF, with 8 bytes of zeros +const zeroSalt = new Uint8Array(8); + +/** + * Derive AES and HMAC keys from a master key. + * + * This is used for deriving secret storage keys: see https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2 (step 1). + * + * @param key + * @param name + */ +export async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { + const hkdfkey = await globalThis.crypto.subtle.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]); + const keybits = await globalThis.crypto.subtle.deriveBits( + { + name: "HKDF", + salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + info: new TextEncoder().encode(name), + hash: "SHA-256", + }, + hkdfkey, + 512, + ); + + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + + const aesProm = globalThis.crypto.subtle.importKey("raw", aesKey, { name: "AES-CTR" }, false, [ + "encrypt", + "decrypt", + ]); + + const hmacProm = globalThis.crypto.subtle.importKey( + "raw", + hmacKey, + { + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, + ["sign", "verify"], + ); + + return Promise.all([aesProm, hmacProm]); +} diff --git a/typedoc.json b/typedoc.json index 69426dc7a3e..75b85b15bf8 100644 --- a/typedoc.json +++ b/typedoc.json @@ -2,7 +2,7 @@ "$schema": "https://typedoc.org/schema.json", "plugin": ["typedoc-plugin-mdn-links", "typedoc-plugin-missing-exports", "typedoc-plugin-coverage"], "coverageLabel": "TypeDoc", - "entryPoints": ["src/matrix.ts", "src/types.ts", "src/testing.ts"], + "entryPoints": ["src/matrix.ts", "src/types.ts", "src/testing.ts", "src/utils/*.ts"], "excludeExternals": true, "out": "_docs" }