diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.js index 5084263e6..02e4b1a5e 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.js @@ -16,11 +16,20 @@ limitations under the License. import '../../olm-loader'; import * as olmlib from "../../../src/crypto/olmlib"; -import {SECRET_STORAGE_ALGORITHM_V1} from "../../../src/crypto/SecretStorage"; +import {SECRET_STORAGE_ALGORITHM_V1_AES} from "../../../src/crypto/SecretStorage"; import {MatrixEvent} from "../../../src/models/event"; import {TestClient} from '../../TestClient'; import {makeTestClients} from './verification/util'; +import * as utils from "../../../src/utils"; + +try { + const crypto = require('crypto'); + utils.setCrypto(crypto); +} catch (err) { + console.log('nodejs was compiled without crypto support'); +} + async function makeTestClient(userInfo, options) { const client = (new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, @@ -51,9 +60,8 @@ describe("Secrets", function() { }); it("should store and retrieve a secret", async function() { - const decryption = new global.Olm.PkDecryption(); - const pubkey = decryption.generate_key(); - const privkey = decryption.get_private_key(); + const key = new Uint8Array(16); + for (let i = 0; i < 16; i++) key[i] = i; const signing = new global.Olm.PkSigning(); const signingKey = signing.generate_seed(); @@ -69,7 +77,7 @@ describe("Secrets", function() { const getKey = jest.fn(e => { expect(Object.keys(e.keys)).toEqual(["abc"]); - return ['abc', privkey]; + return ['abc', key]; }); const alice = await makeTestClient( @@ -100,8 +108,7 @@ describe("Secrets", function() { }; const keyAccountData = { - algorithm: SECRET_STORAGE_ALGORITHM_V1, - pubkey: pubkey, + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, }; await alice._crypto._crossSigningInfo.signObject(keyAccountData, 'master'); @@ -112,11 +119,11 @@ describe("Secrets", function() { }), ]); - expect(await secretStorage.isStored("foo")).toBe(false); + expect(await secretStorage.isStored("foo")).toBeFalsy(); await secretStorage.store("foo", "bar", ["abc"]); - expect(await secretStorage.isStored("foo")).toBe(true); + expect(await secretStorage.isStored("foo")).toBeTruthy(); expect(await secretStorage.get("foo")).toBe("bar"); expect(getKey).toHaveBeenCalled(); @@ -149,6 +156,13 @@ describe("Secrets", function() { }); it("should encrypt with default key if keys is null", async function() { + const key = new Uint8Array(16); + for (let i = 0; i < 16; i++) key[i] = i; + const getKey = jest.fn(e => { + expect(Object.keys(e.keys)).toEqual([newKeyId]); + return [newKeyId, key]; + }); + let keys = {}; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, @@ -156,6 +170,7 @@ describe("Secrets", function() { cryptoCallbacks: { getCrossSigningKey: t => keys[t], saveCrossSigningKeys: k => keys = k, + getSecretStorageKey: getKey, }, }, ); @@ -170,7 +185,7 @@ describe("Secrets", function() { alice.resetCrossSigningKeys(); const newKeyId = await alice.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1, + SECRET_STORAGE_ALGORITHM_V1_AES, ); // we don't await on this because it waits for the event to come down the sync // which won't happen in the test setup @@ -252,11 +267,22 @@ describe("Secrets", function() { }); it("bootstraps when no storage or cross-signing keys locally", async function() { + const key = new Uint8Array(16); + for (let i = 0; i < 16; i++) key[i] = i; + const getKey = jest.fn(e => { + return [Object.keys(e.keys)[0], key]; + }); + const bob = await makeTestClient( { userId: "@bob:example.com", deviceId: "bob1", }, + { + cryptoCallbacks: { + getSecretStorageKey: getKey, + }, + }, ); bob.uploadDeviceSigningKeys = async () => {}; bob.uploadKeySignatures = async () => {}; diff --git a/src/client.js b/src/client.js index 776d35bd8..c8e903577 100644 --- a/src/client.js +++ b/src/client.js @@ -217,8 +217,10 @@ function keyFromRecoverySession(session, decryptionKey) { * Args: * {object} keys Information about the keys: * { - * : { - * pubkey: {UInt8Array} + * keys: { + * : { + * pubkey: {UInt8Array} + * }, ... * } * } * {string} name the name of the value we want to read out of SSSS, for UI purposes. @@ -1247,7 +1249,9 @@ MatrixClient.prototype.checkEventSenderTrust = async function(event) { * @param {boolean} checkKey check if the secret is encrypted by a trusted * key * - * @return {boolean} whether or not the secret is stored + * @return {object?} map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key */ /** @@ -1295,6 +1299,7 @@ wrapCryptoFuncs(MatrixClient, [ "bootstrapSecretStorage", "addSecretStorageKey", "hasSecretStorageKey", + "secretStorageKeyNeedsUpgrade", "storeSecret", "getSecret", "isSecretStored", @@ -1581,7 +1586,9 @@ MatrixClient.prototype.prepareKeyBackupVersion = async function( /** * Check whether the key backup private key is stored in secret storage. - * @return {Promise} Whether the backup key is stored. + * @return {Promise} map of key name to key info the secret is + * encrypted with, or null if it is not present or not encrypted with a + * trusted key */ MatrixClient.prototype.isKeyBackupKeyStored = async function() { return this.isSecretStored("m.megolm_backup.v1", false /* checkKey */); @@ -1734,6 +1741,35 @@ MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) { } }; +/** + * Get the raw key for a key backup from the password + * Used when migrating key backups into SSSS + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param {string} password Passphrase + * @param {object} backupInfo Backup metadata from `checkKeyBackup` + * @return {Promise} key backup key + */ +MatrixClient.prototype.keyBackupKeyFromPassword = function( + password, backupInfo, +) { + return keyFromAuthData(backupInfo.auth_data, password); +}; + +/** + * Get the raw key for a key backup from the recovery key + * Used when migrating key backups into SSSS + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param {string} recoveryKey The recovery key + * @return {Buffer} key backup key + */ +MatrixClient.prototype.keyBackupKeyFromRecoveryKey = function(recoveryKey) { + return decodeRecoveryKey(recoveryKey); +}; + MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY'; /** diff --git a/src/crypto/CrossSigning.js b/src/crypto/CrossSigning.js index 4dc464e82..93c284b38 100644 --- a/src/crypto/CrossSigning.js +++ b/src/crypto/CrossSigning.js @@ -141,14 +141,28 @@ export class CrossSigningInfo extends EventEmitter { * want to know this anyway... * * @param {SecretStorage} secretStorage The secret store using account data - * @returns {boolean} Whether all private keys were found in storage + * @returns {object} map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key */ async isStoredInSecretStorage(secretStorage) { - let stored = true; - for (const type of ["master", "self_signing", "user_signing"]) { - stored &= await secretStorage.isStored(`m.cross_signing.${type}`, false); + // check what SSSS keys have encrypted the master key (if any) + const stored = + await secretStorage.isStored("m.cross_signing.master", false) || {}; + // then check which of those SSSS keys have also encrypted the SSK and USK + function intersect(s) { + for (const k of Object.keys(stored)) { + if (!s[k]) { + delete stored[k]; + } + } } - return stored; + for (const type of ["self_signing", "user_signing"]) { + intersect( + await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}, + ); + } + return Object.keys(stored).length ? stored : null; } /** diff --git a/src/crypto/SecretStorage.js b/src/crypto/SecretStorage.js index 799cf8d1d..0c6de6236 100644 --- a/src/crypto/SecretStorage.js +++ b/src/crypto/SecretStorage.js @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 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. @@ -19,8 +19,13 @@ import {logger} from '../logger'; import * as olmlib from './olmlib'; import {pkVerify} from './olmlib'; import {randomString} from '../randomstring'; +import {encryptAES, decryptAES} from './aes'; -export const SECRET_STORAGE_ALGORITHM_V1 = "m.secret_storage.v1.curve25519-aes-sha2"; +export const SECRET_STORAGE_ALGORITHM_V1_AES + = "m.secret_storage.v1.aes-hmac-sha2"; +// don't use curve25519 for writing data. +export const SECRET_STORAGE_ALGORITHM_V1_CURVE25519 + = "m.secret_storage.v1.curve25519-aes-sha2"; /** * Implements Secure Secret Storage and Sharing (MSC1946) @@ -85,20 +90,12 @@ export class SecretStorage extends EventEmitter { } switch (algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: + case SECRET_STORAGE_ALGORITHM_V1_AES: { const decryption = new global.Olm.PkDecryption(); try { - const { passphrase, pubkey } = opts; - // Copies in public key details of the form generated by - // the Crypto module's `createRecoveryKeyFromPassphrase`. - if (passphrase && pubkey) { - keyData.passphrase = passphrase; - keyData.pubkey = pubkey; - } else if (pubkey) { - keyData.pubkey = pubkey; - } else { - keyData.pubkey = decryption.generate_key(); + if (opts.passphrase) { + keyData.passphrase = opts.passphrase; } } finally { decryption.free(); @@ -155,6 +152,28 @@ export class SecretStorage extends EventEmitter { ); } + /** + * Get the key information for a given ID. + * + * @param {string} [keyId = default key's ID] The ID of the key to check + * for. Defaults to the default key ID if not provided. + * @returns {Array?} If the key was found, the return value is an array of + * the form [keyId, keyInfo]. Otherwise, null is returned. + */ + async getKey(keyId) { + if (!keyId) { + keyId = await this.getDefaultKeyId(); + } + if (!keyId) { + return null; + } + + const keyInfo = await this._baseApis.getAccountDataFromServer( + "m.secret_storage.key." + keyId, + ); + return keyInfo ? [keyId, keyInfo] : null; + } + /** * Check whether we have a key with a given ID. * @@ -163,16 +182,16 @@ export class SecretStorage extends EventEmitter { * @return {boolean} Whether we have the key. */ async hasKey(keyId) { - if (!keyId) { - keyId = await this.getDefaultKeyId(); - } - if (!keyId) { + return !!(await this.getKey(keyId)); + } + + async keyNeedsUpgrade(keyId) { + const keyInfo = await this.getKey(keyId); + if (keyInfo && keyInfo[1].algorithm === SECRET_STORAGE_ALGORITHM_V1_CURVE25519) { + return true; + } else { return false; } - - return !!this._baseApis.getAccountDataFromServer( - "m.secret_storage.key." + keyId, - ); } /** @@ -207,24 +226,13 @@ export class SecretStorage extends EventEmitter { throw new Error("Unknown key: " + keyId); } - // check signature of key info - pkVerify( - keyInfo, - this._crossSigningInfo.getId('master'), - this._crossSigningInfo.userId, - ); - // encrypt secret, based on the algorithm switch (keyInfo.algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: + case SECRET_STORAGE_ALGORITHM_V1_AES: { - const encryption = new global.Olm.PkEncryption(); - try { - encryption.set_recipient_key(keyInfo.pubkey); - encrypted[keyId] = encryption.encrypt(secret); - } finally { - encryption.free(); - } + const keys = {[keyId]: keyInfo}; + const [, encryption] = await this._getSecretStorageKey(keys, name); + encrypted[keyId] = await encryption.encrypt(secret); break; } default: @@ -238,29 +246,6 @@ export class SecretStorage extends EventEmitter { await this._baseApis.setAccountData(name, {encrypted}); } - /** - * Store a secret defined to be the same as the given key. - * No secret information will be stored, instead the secret will - * be stored with a marker to say that the contents of the secret is - * the value of the given key. - * This is useful for migration from systems that predate SSSS such as - * key backup. - * - * @param {string} name The name of the secret - * @param {string} keyId The ID of the key whose value will be the - * value of the secret - * @returns {Promise} resolved when account data is saved - */ - storePassthrough(name, keyId) { - return this._baseApis.setAccountData(name, { - encrypted: { - [keyId]: { - passthrough: true, - }, - }, - }); - } - /** * Temporary method to fix up existing accounts where secrets * are incorrectly stored without the 'encrypted' level @@ -317,7 +302,12 @@ export class SecretStorage extends EventEmitter { ); const encInfo = secretInfo.encrypted[keyId]; switch (keyInfo.algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: + case SECRET_STORAGE_ALGORITHM_V1_AES: + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + keys[keyId] = keyInfo; + } + break; + case SECRET_STORAGE_ALGORITHM_V1_CURVE25519: if ( keyInfo.pubkey && ( (encInfo.ciphertext && encInfo.mac && encInfo.ephemeral) || @@ -344,15 +334,9 @@ export class SecretStorage extends EventEmitter { // since we just want to return the key itself. if (encInfo.passthrough) return decryption.get_private_key(); - // decrypt secret - switch (keys[keyId].algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: - return decryption.decrypt( - encInfo.ephemeral, encInfo.mac, encInfo.ciphertext, - ); - } + return await decryption.decrypt(encInfo); } finally { - if (decryption) decryption.free(); + if (decryption && decryption.free) decryption.free(); } } @@ -362,22 +346,26 @@ export class SecretStorage extends EventEmitter { * @param {string} name the name of the secret * @param {boolean} checkKey check if the secret is encrypted by a trusted key * - * @return {boolean} whether or not the secret is stored + * @return {object?} map of key name to key info the secret is encrypted + * with, or null if it is not present or not encrypted with a trusted + * key */ async isStored(name, checkKey) { // check if secret exists let secretInfo = await this._baseApis.getAccountDataFromServer(name); - if (!secretInfo) return false; + if (!secretInfo) return null; if (!secretInfo.encrypted) { // try to fix it up secretInfo = await this._fixupStoredSecret(name, secretInfo); if (!secretInfo || !secretInfo.encrypted) { - return false; + return null; } } if (checkKey === undefined) checkKey = true; + const ret = {}; + // check if secret is encrypted by a known/trusted secret and // encryption looks sane for (const keyId of Object.keys(secretInfo.encrypted)) { @@ -385,32 +373,55 @@ export class SecretStorage extends EventEmitter { const keyInfo = await this._baseApis.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); - if (!keyInfo) return false; + if (!keyInfo) continue; const encInfo = secretInfo.encrypted[keyId]; - if (checkKey) { - pkVerify( - keyInfo, - this._crossSigningInfo.getId('master'), - this._crossSigningInfo.userId, - ); - } // We don't actually need the decryption object if it's a passthrough // since we just want to return the key itself. - if (encInfo.passthrough) return true; + if (encInfo.passthrough) { + try { + pkVerify( + keyInfo, + this._crossSigningInfo.getId('master'), + this._crossSigningInfo.userId, + ); + } catch (e) { + // not trusted, so move on to the next key + continue; + } + ret[keyId] = keyInfo; + continue; + } switch (keyInfo.algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: + case SECRET_STORAGE_ALGORITHM_V1_AES: + if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { + ret[keyId] = keyInfo; + } + break; + case SECRET_STORAGE_ALGORITHM_V1_CURVE25519: if (keyInfo.pubkey && encInfo.ciphertext && encInfo.mac && encInfo.ephemeral) { - return true; + if (checkKey) { + try { + pkVerify( + keyInfo, + this._crossSigningInfo.getId('master'), + this._crossSigningInfo.userId, + ); + } catch (e) { + // not trusted, so move on to the next key + continue; + } + } + ret[keyId] = keyInfo; } break; default: // do nothing if we don't understand the encryption algorithm } } - return false; + return Object.keys(ret).length ? ret : null; } /** @@ -607,26 +618,48 @@ export class SecretStorage extends EventEmitter { } switch (keys[keyId].algorithm) { - case SECRET_STORAGE_ALGORITHM_V1: - { - const decryption = new global.Olm.PkDecryption(); - let pubkey; - try { - pubkey = decryption.init_with_private_key(privateKey); - } catch (e) { - decryption.free(); - throw new Error("getSecretStorageKey callback returned invalid key"); - } - if (pubkey !== keys[keyId].pubkey) { - decryption.free(); - throw new Error( - "getSecretStorageKey callback returned incorrect key", - ); - } - return [keyId, decryption]; + case SECRET_STORAGE_ALGORITHM_V1_AES: + { + const decryption = { + encrypt: async function(secret) { + return await encryptAES(secret, privateKey, name); + }, + decrypt: async function(encInfo) { + return await decryptAES(encInfo, privateKey, name); + }, + }; + return [keyId, decryption]; + } + case SECRET_STORAGE_ALGORITHM_V1_CURVE25519: + { + const pkDecryption = new global.Olm.PkDecryption(); + let pubkey; + try { + pubkey = pkDecryption.init_with_private_key(privateKey); + } catch (e) { + pkDecryption.free(); + throw new Error("getSecretStorageKey callback returned invalid key"); } - default: - throw new Error("Unknown key type: " + keys[keyId].algorithm); + if (pubkey !== keys[keyId].pubkey) { + pkDecryption.free(); + throw new Error( + "getSecretStorageKey callback returned incorrect key", + ); + } + const decryption = { + free: pkDecryption.free.bind(pkDecryption), + decrypt: async function(encInfo) { + return pkDecryption.decrypt( + encInfo.ephemeral, encInfo.mac, encInfo.ciphertext, + ); + }, + // needed for passthrough + get_private_key: pkDecryption.get_private_key.bind(pkDecryption), + }; + return [keyId, decryption]; + } + default: + throw new Error("Unknown key type: " + keys[keyId].algorithm); } } } diff --git a/src/crypto/aes.js b/src/crypto/aes.js new file mode 100644 index 000000000..5ce7d77c9 --- /dev/null +++ b/src/crypto/aes.js @@ -0,0 +1,239 @@ +/* +Copyright 2020 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 {getCrypto} from '../utils'; +import {decodeBase64, encodeBase64} from './olmlib'; + +const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? + (window.crypto.subtle || window.crypto.webkitSubtle) : null; + +// salt for HKDF, with 8 bytes of zeros +const zerosalt = new Uint8Array(8); + +/** + * encrypt a string in Node.js + * + * @param {string} data the plaintext to encrypt + * @param {Uint8Array} key the encryption key to use + * @param {string} name the name of the secret + */ +async function encryptNode(data, key, name) { + const crypto = getCrypto(); + if (!crypto) { + throw new Error("No usable crypto implementation"); + } + + const iv = crypto.randomBytes(16); + + // 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] = deriveKeysNode(key, name); + + const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); + const ciphertext = cipher.update(data, "utf-8", "base64") + + cipher.final("base64"); + + const hmac = crypto.createHmac("sha256", hmacKey) + .update(ciphertext, "base64").digest("base64"); + + return { + iv: encodeBase64(iv), + ciphertext: ciphertext, + mac: hmac, + }; +} + +/** + * decrypt a string in Node.js + * + * @param {object} data the encrypted data + * @param {string} data.ciphertext the ciphertext in base64 + * @param {string} data.iv the initialization vector in base64 + * @param {string} data.mac the HMAC in base64 + * @param {Uint8Array} key the encryption key to use + * @param {string} name the name of the secret + */ +async function decryptNode(data, key, name) { + const crypto = getCrypto(); + if (!crypto) { + throw new Error("No usable crypto implementation"); + } + + const [aesKey, hmacKey] = deriveKeysNode(key, name); + + const hmac = crypto.createHmac("sha256", hmacKey) + .update(data.ciphertext, "base64").digest("base64"); + + if (hmac !== data.mac) { + throw new Error(`Error decrypting secret ${name}: bad MAC`); + } + + const decipher = crypto.createDecipheriv( + "aes-256-ctr", aesKey, decodeBase64(data.iv), + ); + return decipher.update(data.ciphertext, "base64", "utf-8") + + decipher.final("utf-8"); +} + +function deriveKeysNode(key, name) { + const crypto = getCrypto(); + const prk = crypto.createHmac("sha256", zerosalt) + .update(key).digest(); + + const b = Buffer.alloc(1, 1); + const aesKey = crypto.createHmac("sha256", prk) + .update(name, "utf-8").update(b).digest(); + b[0] = 2; + const hmacKey = crypto.createHmac("sha256", prk) + .update(aesKey).update(name, "utf-8").update(b).digest(); + + return [aesKey, hmacKey]; +} + +/** + * encrypt a string in Node.js + * + * @param {string} data the plaintext to encrypt + * @param {Uint8Array} key the encryption key to use + * @param {string} name the name of the secret + */ +async function encryptBrowser(data, key, name) { + const iv = new Uint8Array(16); + window.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 deriveKeysBrowser(key, name); + const encodedData = new TextEncoder().encode(data); + + const ciphertext = await subtleCrypto.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aesKey, + encodedData, + ); + + const hmac = await subtleCrypto.sign( + {name: 'HMAC'}, + hmacKey, + ciphertext, + ); + + return { + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + mac: encodeBase64(hmac), + }; +} + +/** + * decrypt a string in the browser + * + * @param {object} data the encrypted data + * @param {string} data.ciphertext the ciphertext in base64 + * @param {string} data.iv the initialization vector in base64 + * @param {string} data.mac the HMAC in base64 + * @param {Uint8Array} key the encryption key to use + * @param {string} name the name of the secret + */ +async function decryptBrowser(data, key, name) { + const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); + + const ciphertext = decodeBase64(data.ciphertext); + + if (!await subtleCrypto.verify( + {name: "HMAC"}, + hmacKey, + decodeBase64(data.mac), + ciphertext, + )) { + throw new Error(`Error decrypting secret ${name}: bad MAC`); + } + + const plaintext = await subtleCrypto.decrypt( + { + name: "AES-CTR", + counter: decodeBase64(data.iv), + length: 64, + }, + aesKey, + ciphertext, + ); + + return new TextDecoder().decode(new Uint8Array(plaintext)); +} + +async function deriveKeysBrowser(key, name) { + const hkdfkey = await subtleCrypto.importKey( + 'raw', + key, + {name: "HKDF"}, + false, + ["deriveBits"], + ); + const keybits = await subtleCrypto.deriveBits( + { + name: "HKDF", + salt: zerosalt, + info: (new TextEncoder().encode(name)), + hash: "SHA-256", + }, + hkdfkey, + 512, + ); + + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + + const aesProm = subtleCrypto.importKey( + 'raw', + aesKey, + {name: 'AES-CTR'}, + false, + ['encrypt', 'decrypt'], + ); + + const hmacProm = subtleCrypto.importKey( + 'raw', + hmacKey, + { + name: 'HMAC', + hash: {name: 'SHA-256'}, + }, + false, + ['sign', 'verify'], + ); + + return await Promise.all([aesProm, hmacProm]); +} + +export function encryptAES(...args) { + return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args); +} + +export function decryptAES(...args) { + return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args); +} + diff --git a/src/crypto/index.js b/src/crypto/index.js index 27e6982f7..46c48e9a1 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -39,7 +39,7 @@ import { UserTrustLevel, createCryptoStoreCacheCallbacks, } from './CrossSigning'; -import {SECRET_STORAGE_ALGORITHM_V1, SecretStorage} from './SecretStorage'; +import {SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage} from './SecretStorage'; import {OutgoingRoomKeyRequestManager} from './OutgoingRoomKeyRequestManager'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import { @@ -371,6 +371,9 @@ Crypto.prototype.createRecoveryKeyFromPassphrase = async function(password) { * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo * is supplied. * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. + * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Buffer + * containing the key, or rejects if the key cannot be obtained. * Returns: * {Promise} A promise which resolves to key creation data for * SecretStorage#addKey: an object with `passphrase` and/or `pubkey` fields. @@ -381,6 +384,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({ keyBackupInfo, setupNewKeyBackup, setupNewSecretStorage, + getKeyBackupPassphrase, } = {}) { logger.log("Bootstrapping Secure Secret Storage"); @@ -395,10 +399,82 @@ Crypto.prototype.bootstrapSecretStorage = async function({ // use temporary callbacks to weave them through the various APIs. const appCallbacks = Object.assign({}, this._baseApis._cryptoCallbacks); + // the ID of the new SSSS key, if we create one + let newKeyId = null; + + // cache SSSS keys so that we don't need to constantly pester the user about it + const ssssKeys = {}; + + this._baseApis._cryptoCallbacks.getSecretStorageKey = + async ({keys}, name) => { + // if we already have a key that works, return it + for (const keyId of Object.keys(keys)) { + if (ssssKeys[keyId]) { + return [keyId, ssssKeys[keyId]]; + } + } + + // otherwise, prompt the user and cache it + const key = await appCallbacks.getSecretStorageKey({keys}, name); + if (key) { + const [keyId, keyData] = key; + ssssKeys[keyId] = keyData; + } + return key; + }; + try { - const inStorage = !setupNewSecretStorage && - await this._crossSigningInfo.isStoredInSecretStorage(this._secretStorage); - if (!this._crossSigningInfo.getId() || !inStorage) { + const decryptionKeys = + await this._crossSigningInfo.isStoredInSecretStorage(this._secretStorage); + const inStorage = !setupNewSecretStorage && decryptionKeys; + if (decryptionKeys && !(Object.values(decryptionKeys).some( + info => info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES, + ))) { + // we already have cross-signing keys, but they're encrypted using + // the old algorithm + logger.log("Switching to symmetric"); + const keys = {}; + // fetch the cross-signing private keys (needed to sign the new + // SSSS key). We store the cross-signing keys, and temporarily set + // a callback so that when the private key is needed while setting + // things up, we can provide it. + this._baseApis._cryptoCallbacks.getCrossSigningKey = + name => crossSigningPrivateKeys[name]; + for (const type of ["master", "self_signing", "user_signing"]) { + const secretName = `m.cross_signing.${type}`; + const secret = await this.getSecret(secretName); + keys[type] = secret; + crossSigningPrivateKeys[type] = olmlib.decodeBase64(secret); + } + await this.checkOwnCrossSigningTrust(); + const opts = {}; + let oldKeyId = null; + for (const [keyId, keyInfo] of Object.entries(decryptionKeys)) { + // See if the old key was generated from a passphrase. If + // yes, use the same settings. + if (keyId in ssssKeys) { + oldKeyId = keyId; + if (keyInfo.passphrase) { + opts.passphrase = keyInfo.passphrase; + } + break; + } + } + // create new symmetric SSSS key and set it as default + newKeyId = await this.addSecretStorageKey( + SECRET_STORAGE_ALGORITHM_V1_AES, opts, + ); + if (oldKeyId) { + ssssKeys[newKeyId] = ssssKeys[oldKeyId]; + } + await this.setDefaultSecretStorageKeyId(newKeyId); + // re-encrypt all the keys with the new key + for (const type of ["master", "self_signing", "user_signing"]) { + const secretName = `m.cross_signing.${type}`; + await this.storeSecret(secretName, keys[type], [newKeyId]); + } + } else if (!this._crossSigningInfo.getId() || !inStorage) { + // create new cross-signing keys if necessary. logger.log( "Cross-signing public and/or private keys not found, " + "checking secret storage for private keys", @@ -424,40 +500,50 @@ Crypto.prototype.bootstrapSecretStorage = async function({ logger.log("Cross signing keys are present in secret storage"); } - // Check if Secure Secret Storage has a default key. If we don't have one, create - // the default key (which will also be signed by the cross-signing master key). - if (setupNewSecretStorage || !await this.hasSecretStorageKey()) { - let newKeyId; + // Check if we need to create a new secret storage key + // - we're resetting secret storage + // - we don't have a default secret storage key yet + // - our default secret storage key is using an older algorithm + // We will also run this part if we created a new secret storage key + // above, so that we can (re-)encrypt the backup with it. + const defaultSSSSKey = await this.getSecretStorageKey(); + if (setupNewSecretStorage || newKeyId || !defaultSSSSKey + || defaultSSSSKey[1].algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) { if (keyBackupInfo) { + // if we already have a backup key, use the same key as the + // secret storage key logger.log("Secret storage default key not found, using key backup key"); - const opts = { - pubkey: keyBackupInfo.auth_data.public_key, - }; - if ( - keyBackupInfo.auth_data.private_key_salt && - keyBackupInfo.auth_data.private_key_iterations - ) { - opts.passphrase = { - algorithm: "m.pbkdf2", - iterations: keyBackupInfo.auth_data.private_key_iterations, - salt: keyBackupInfo.auth_data.private_key_salt, - }; + const backupKey = await getKeyBackupPassphrase(); + + if (!newKeyId) { + const opts = {}; + + if ( + keyBackupInfo.auth_data.private_key_salt && + keyBackupInfo.auth_data.private_key_iterations + ) { + opts.passphrase = { + algorithm: "m.pbkdf2", + iterations: keyBackupInfo.auth_data.private_key_iterations, + salt: keyBackupInfo.auth_data.private_key_salt, + bits: 256, + }; + } + + newKeyId = await this.addSecretStorageKey( + SECRET_STORAGE_ALGORITHM_V1_AES, opts, + ); + this.setDefaultSecretStorageKeyId(newKeyId); + // use the backup key as the new ssss key + ssssKeys[newKeyId] = backupKey; } - newKeyId = await this.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1, opts, - ); - - // Add an entry for the backup key in SSSS as a 'passthrough' key - // (ie. the secret is the key itself). - this._secretStorage.storePassthrough('m.megolm_backup.v1', newKeyId); - // if this key backup is trusted, sign it with the cross signing key // so the key backup can be trusted via cross-signing. const backupSigStatus = await this.checkKeyBackup(keyBackupInfo); if (backupSigStatus.trustInfo.usable) { - console.log("Adding cross signing signature to key backup"); + logger.log("Adding cross signing signature to key backup"); await this._crossSigningInfo.signObject( keyBackupInfo.auth_data, "master", ); @@ -466,20 +552,32 @@ Crypto.prototype.bootstrapSecretStorage = async function({ undefined, keyBackupInfo, {prefix: httpApi.PREFIX_UNSTABLE}, ); + await this.storeSecret( + "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], + ); } else { - console.log( + logger.log( "Key backup is NOT TRUSTED: NOT adding cross signing signature", ); } } else { - logger.log("Secret storage default key not found, creating new key"); - const keyOptions = await createSecretStorageKey(); - newKeyId = await this.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1, - keyOptions, - ); + if (!newKeyId) { + logger.log("Secret storage default key not found, creating new key"); + const keyOptions = await createSecretStorageKey(); + newKeyId = await this.addSecretStorageKey( + SECRET_STORAGE_ALGORITHM_V1_AES, + keyOptions, + ); + await this.setDefaultSecretStorageKeyId(newKeyId); + } + if (await this.isSecretStored("m.megolm_backup.v1")) { + // we created a new SSSS, and we previously encrypted the + // backup key with the old SSSS key, so re-encrypt with the + // new key + const backupKey = await this.getSecret("m.megolm_backup.v1"); + await this.storeSecret("m.megolm_backup.v1", backupKey, [newKeyId]); + } } - await this.setDefaultSecretStorageKeyId(newKeyId); } else { logger.log("Have secret storage key"); } @@ -535,6 +633,14 @@ Crypto.prototype.hasSecretStorageKey = function(keyID) { return this._secretStorage.hasKey(keyID); }; +Crypto.prototype.secretStorageKeyNeedsUpgrade = function(keyID) { + return this._secretStorage.keyNeedsUpgrade(keyID); +}; + +Crypto.prototype.getSecretStorageKey = function(keyID) { + return this._secretStorage.getKey(keyID); +}; + Crypto.prototype.storeSecret = function(name, secret, keys) { return this._secretStorage.store(name, secret, keys); }; diff --git a/src/crypto/key_passphrase.js b/src/crypto/key_passphrase.js index 3e8455472..47c964726 100644 --- a/src/crypto/key_passphrase.js +++ b/src/crypto/key_passphrase.js @@ -19,6 +19,8 @@ import {randomString} from '../randomstring'; const DEFAULT_ITERATIONS = 500000; +const DEFAULT_BITSIZE = 256; + export async function keyFromAuthData(authData, password) { if (!global.Olm) { throw new Error("Olm is not available"); @@ -34,6 +36,7 @@ export async function keyFromAuthData(authData, password) { return await deriveKey( password, authData.private_key_salt, authData.private_key_iterations, + authData.private_key_bits || DEFAULT_BITSIZE, ); } @@ -44,12 +47,12 @@ export async function keyFromPassphrase(password) { const salt = randomString(32); - const key = await deriveKey(password, salt, DEFAULT_ITERATIONS); + const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); return { key, salt, iterations: DEFAULT_ITERATIONS }; } -export async function deriveKey(password, salt, iterations) { +export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { const subtleCrypto = global.crypto.subtle; const TextEncoder = global.TextEncoder; if (!subtleCrypto || !TextEncoder) { @@ -73,7 +76,7 @@ export async function deriveKey(password, salt, iterations) { hash: 'SHA-512', }, key, - global.Olm.PRIVATE_KEY_LENGTH * 8, + numBits, ); return new Uint8Array(keybits); diff --git a/src/index.ts b/src/index.ts index 18fdba545..6d7c2839b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,5 +21,12 @@ import request from "request"; matrixcs.request(request); utils.runPolyfills(); +try { + const crypto = require('crypto'); + utils.setCrypto(crypto); +} catch (err) { + console.log('nodejs was compiled without crypto support'); +} + export * from "./matrix"; export default matrixcs; diff --git a/src/utils.ts b/src/utils.ts index ec2113a91..1e31dff88 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -734,3 +734,17 @@ export async function promiseMapSeries( export function promiseTry(fn: () => T): Promise { return new Promise((resolve) => resolve(fn())); } + +// We need to be able to access the Node.js crypto library from within the +// Matrix SDK without needing to `require("crypto")`, which will fail in +// browsers. So `index.ts` will call `setCrypto` to store it, and when we need +// it, we can call `getCrypto`. +let crypto: Object; + +export function setCrypto(c: Object) { + crypto = c; +} + +export function getCrypto(): Object { + return crypto; +}