diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 0e525e1f766..708bbc752a2 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 74.14, + functions: 98.95, + lines: 93.84, + statements: 93.88, }, }, diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index b2d0e4f1242..c9f99498023 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -33,7 +33,11 @@ "dependencies": { "@keystonehq/metamask-airgapped-keyring": "^0.13.1", "@metamask/base-controller": "^4.1.1", + "@metamask/browser-passworder": "^4.3.0", + "@metamask/eth-hd-keyring": "^7.0.1", "@metamask/eth-keyring-controller": "^17.0.1", + "@metamask/eth-sig-util": "^7.0.0", + "@metamask/eth-simple-keyring": "^6.0.1", "@metamask/keyring-api": "^3.0.0", "@metamask/message-manager": "^7.3.8", "@metamask/utils": "^8.3.0", diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 6b2daaf262d..c9ceef7398d 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -1007,7 +1007,7 @@ describe('KeyringController', () => { data: '', from: initialState.keyrings[0].accounts[0], }), - ).toThrow("Can't sign an empty message"); + ).rejects.toThrow("Can't sign an empty message"); }); }); diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 117ae1b93de..4acc8c28bbb 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -5,14 +5,15 @@ import type { } from '@keystonehq/metamask-airgapped-keyring'; import type { RestrictedControllerMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { - KeyringController as EthKeyringController, - KeyringType, -} from '@metamask/eth-keyring-controller'; +import * as encryptorUtils from '@metamask/browser-passworder'; +import HDKeyring from '@metamask/eth-hd-keyring'; +import { KeyringType } from '@metamask/eth-keyring-controller'; import type { ExportableKeyEncryptor, GenericEncryptor, } from '@metamask/eth-keyring-controller/dist/types'; +import { normalize } from '@metamask/eth-sig-util'; +import SimpleKeyring from '@metamask/eth-simple-keyring'; import type { EthBaseTransaction, EthBaseUserOperation, @@ -24,8 +25,19 @@ import type { PersonalMessageParams, TypedMessageParams, } from '@metamask/message-manager'; -import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; -import { assertIsStrictHexString, hasProperty } from '@metamask/utils'; +import type { + Eip1024EncryptedData, + Hex, + Json, + KeyringClass, +} from '@metamask/utils'; +import { + assertIsStrictHexString, + hasProperty, + isObject, + isValidHexAddress, + remove0x, +} from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { addHexPrefix, @@ -38,6 +50,9 @@ import { import Wallet, { thirdparty as importers } from 'ethereumjs-wallet'; import type { Patch } from 'immer'; +import { KeyringControllerError } from './constants'; +import { throwError } from './utils'; + const name = 'KeyringController'; /** @@ -248,6 +263,32 @@ export enum SignTypedDataVersion { V4 = 'V4', } +export type SerializedKeyring = { + type: string; + data: Json; +}; + +/** + * Get builder function for `Keyring` + * + * Returns a builder function for `Keyring` with a `type` property. + * + * @param KeyringConstructor - The Keyring class for the builder. + * @returns A builder function for the given Keyring. + */ +export function keyringBuilderFactory(KeyringConstructor: KeyringClass) { + const builder = () => new KeyringConstructor(); + + builder.type = KeyringConstructor.type; + + return builder; +} + +const defaultKeyringBuilders = [ + keyringBuilderFactory(SimpleKeyring), + keyringBuilderFactory(HDKeyring), +]; + export const getDefaultKeyringState = (): KeyringControllerState => { return { isUnlocked: false, @@ -274,6 +315,67 @@ function assertHasUint8ArrayMnemonic( } } +/** + * Assert that the provided encryptor supports + * encryption and encryption key export. + * + * @param encryptor - The encryptor to check. + * @throws If the encryptor does not support key encryption. + */ +function assertIsExportableKeyEncryptor( + encryptor: GenericEncryptor | ExportableKeyEncryptor, +): asserts encryptor is ExportableKeyEncryptor { + if ( + !( + 'importKey' in encryptor && + typeof encryptor.importKey === 'function' && + 'decryptWithKey' in encryptor && + typeof encryptor.decryptWithKey === 'function' && + 'encryptWithKey' in encryptor && + typeof encryptor.encryptWithKey === 'function' + ) + ) { + throw new Error(KeyringControllerError.UnsupportedEncryptionKeyExport); + } +} + +/** + * Checks if the provided value is a serialized keyrings array. + * + * @param array - The value to check. + * @returns True if the value is a serialized keyrings array. + */ +function isSerializedKeyringsArray( + array: unknown, +): array is SerializedKeyring[] { + return ( + typeof array === 'object' && + Array.isArray(array) && + array.every((value) => value.type && value.data) + ); +} + +/** + * Display For Keyring + * + * Is used for adding the current keyrings to the state object. + * + * @param keyring - The keyring to display. + * @returns A keyring display object, with type and accounts properties. + */ +async function displayForKeyring( + keyring: EthKeyring, +): Promise<{ type: string; accounts: string[] }> { + const accounts = await keyring.getAccounts(); + + return { + type: keyring.type, + // Cast to `Hex[]` here is safe here because `addresses` has no nullish + // values, and `normalizeToHex` returns `Hex` unless given a nullish value + accounts: accounts.map(normalize) as Hex[], + }; +} + /** * Controller responsible for establishing and managing user identity. * @@ -298,7 +400,17 @@ export class KeyringController extends BaseController< private readonly setAccountLabel?: (address: string, label: string) => void; - #keyring: EthKeyringController; + #keyringBuilders: { (): EthKeyring; type: string }[]; + + #keyrings: EthKeyring[]; + + #unsupportedKeyrings: SerializedKeyring[]; + + #password?: string; + + #encryptor: GenericEncryptor | ExportableKeyEncryptor; + + #cacheEncryptionKey: boolean; #qrKeyringStateListener?: ( state: ReturnType, @@ -324,6 +436,7 @@ export class KeyringController extends BaseController< updateIdentities, setSelectedAddress, setAccountLabel, + encryptor = encryptorUtils, keyringBuilders, messenger, state, @@ -345,25 +458,20 @@ export class KeyringController extends BaseController< }, }); - if (options.cacheEncryptionKey) { - this.#keyring = new EthKeyringController({ - initState: state, - encryptor: options.encryptor, - keyringBuilders, - cacheEncryptionKey: options.cacheEncryptionKey, - }); - } else { - this.#keyring = new EthKeyringController({ - initState: state, - encryptor: options.encryptor, - keyringBuilders, - cacheEncryptionKey: options.cacheEncryptionKey ?? false, - }); + this.#keyringBuilders = keyringBuilders + ? defaultKeyringBuilders.concat(keyringBuilders) + : defaultKeyringBuilders; + + this.#encryptor = encryptor; + this.#keyrings = []; + this.#unsupportedKeyrings = []; + + // This option allows the controller to cache an exported key + // for use in decrypting and encrypting data without password + this.#cacheEncryptionKey = Boolean(options.cacheEncryptionKey); + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(encryptor); } - this.#keyring.memStore.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.store.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.on('lock', this.#handleLock.bind(this)); - this.#keyring.on('unlock', this.#handleUnlock.bind(this)); this.syncIdentities = syncIdentities; this.updateIdentities = updateIdentities; @@ -385,12 +493,14 @@ export class KeyringController extends BaseController< keyringState: KeyringControllerMemState; addedAccountAddress: string; }> { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; /* istanbul ignore if */ if (!primaryKeyring) { throw new Error('No HD keyring found'); } - const oldAccounts = await this.#keyring.getAccounts(); + const oldAccounts = await this.getAccounts(); if (accountCount && oldAccounts.length !== accountCount) { if (accountCount > oldAccounts.length) { @@ -404,8 +514,8 @@ export class KeyringController extends BaseController< }; } - await this.#keyring.addNewAccount(primaryKeyring); - const newAccounts = await this.#keyring.getAccounts(); + await this.addNewAccountForKeyring(primaryKeyring); + const newAccounts = await this.getAccounts(); await this.verifySeedPhrase(); @@ -445,13 +555,15 @@ export class KeyringController extends BaseController< return existingAccount; } - await this.#keyring.addNewAccount(keyring); + await keyring.addAccounts(1); + await this.persistAllKeyrings(); + const addedAccountAddress = (await this.getAccounts()).find( (selectedAddress) => !oldAccounts.includes(selectedAddress), ); assertIsStrictHexString(addedAccountAddress); - this.updateIdentities(await this.#keyring.getAccounts()); + this.updateIdentities(await this.getAccounts()); return addedAccountAddress; } @@ -462,16 +574,51 @@ export class KeyringController extends BaseController< * @returns Promise resolving to current state when the account is added. */ async addNewAccountWithoutUpdate(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; /* istanbul ignore if */ if (!primaryKeyring) { throw new Error('No HD keyring found'); } - await this.#keyring.addNewAccount(primaryKeyring); + await primaryKeyring.addAccounts(1); + await this.persistAllKeyrings(); await this.verifySeedPhrase(); return this.#getMemState(); } + /** + * Create new vault And with a specific keyring + * + * Destroys any old encrypted storage, + * creates a new encrypted store with the given password, + * creates a new wallet with 1 account. + * + * @fires KeyringController#unlock + * @param password - The password to encrypt the vault with. + * @param keyring - A object containing the params to instantiate a new keyring. + * @param keyring.type - The keyring type. + * @param keyring.opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves to the state. + */ + async createNewVaultWithKeyring( + password: string, + keyring: { + type: string; + opts?: unknown; + }, + ): Promise { + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + this.#password = password; + + await this.#clearKeyrings(); + await this.#createKeyring(keyring.type, keyring.opts); + this.#setUnlocked(); + return this.#getMemState(); + } + /** * Effectively the same as creating a new keychain then populating it * using the given seed phrase. @@ -492,14 +639,14 @@ export class KeyringController extends BaseController< try { this.updateIdentities([]); - await this.#keyring.createNewVaultWithKeyring(password, { + await this.createNewVaultWithKeyring(password, { type: KeyringType.HD, opts: { mnemonic: seed, numberOfAccounts: 1, }, }); - this.updateIdentities(await this.#keyring.getAccounts()); + this.updateIdentities(await this.getAccounts()); return this.#getMemState(); } finally { releaseLock(); @@ -517,7 +664,7 @@ export class KeyringController extends BaseController< try { const accounts = await this.getAccounts(); if (!accounts.length) { - await this.#keyring.createNewVaultWithKeyring(password, { + await this.createNewVaultWithKeyring(password, { type: KeyringType.HD, }); this.updateIdentities(await this.getAccounts()); @@ -544,7 +691,30 @@ export class KeyringController extends BaseController< return this.getOrAddQRKeyring(); } - return this.#keyring.addNewKeyring(type, opts); + const keyring = await this.#newKeyring(type, opts); + + if (!keyring) { + throw new Error(KeyringControllerError.NoKeyring); + } + + if (type === KeyringType.HD && (!isObject(opts) || !opts.mnemonic)) { + if (!keyring.generateRandomMnemonic) { + throw new Error( + KeyringControllerError.UnsupportedGenerateRandomMnemonic, + ); + } + + keyring.generateRandomMnemonic(); + await keyring.addAccounts(1); + } + + const accounts = await keyring.getAccounts(); + await this.#checkForDuplicate(type, accounts); + + this.#keyrings.push(keyring); + await this.persistAllKeyrings(); + + return keyring; } /** @@ -554,7 +724,10 @@ export class KeyringController extends BaseController< * @param password - Password of the keyring. */ async verifyPassword(password: string) { - await this.#keyring.verifyPassword(password); + if (!this.state.vault) { + throw new Error(KeyringControllerError.VaultError); + } + await this.#encryptor.decrypt(password, this.state.vault); } /** @@ -574,8 +747,8 @@ export class KeyringController extends BaseController< */ async exportSeedPhrase(password: string): Promise { await this.verifyPassword(password); - assertHasUint8ArrayMnemonic(this.#keyring.keyrings[0]); - return this.#keyring.keyrings[0].mnemonic; + assertHasUint8ArrayMnemonic(this.#keyrings[0]); + return this.#keyrings[0].mnemonic; } /** @@ -587,7 +760,15 @@ export class KeyringController extends BaseController< */ async exportAccount(password: string, address: string): Promise { await this.verifyPassword(password); - return this.#keyring.exportAccount(address); + + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.exportAccount) { + throw new Error(KeyringControllerError.UnsupportedExportAccount); + } + + return await keyring.exportAccount(normalize(address) as Hex); } /** @@ -595,8 +776,19 @@ export class KeyringController extends BaseController< * * @returns A promise resolving to an array of addresses. */ - getAccounts(): Promise { - return this.#keyring.getAccounts(); + async getAccounts(): Promise { + const keyrings = this.#keyrings; + + const keyringArrays = await Promise.all( + keyrings.map(async (keyring) => keyring.getAccounts()), + ); + const addresses = keyringArrays.reduce((res, arr) => { + return res.concat(arr); + }, []); + + // Cast to `Hex[]` here is safe here because `addresses` has no nullish + // values, and `normalize` returns `Hex` unless given a nullish value + return addresses.map(normalize) as Hex[]; } /** @@ -611,7 +803,15 @@ export class KeyringController extends BaseController< account: string, opts?: Record, ): Promise { - return this.#keyring.getEncryptionPublicKey(account, opts); + const normalizedAddress = normalize(account) as Hex; + const keyring = (await this.getKeyringForAccount( + account, + )) as EthKeyring; + if (!keyring.getEncryptionPublicKey) { + throw new Error(KeyringControllerError.UnsupportedGetEncryptionPublicKey); + } + + return await keyring.getEncryptionPublicKey(normalizedAddress, opts); } /** @@ -626,7 +826,15 @@ export class KeyringController extends BaseController< from: string; data: Eip1024EncryptedData; }): Promise { - return this.#keyring.decryptMessage(messageParams); + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.decryptMessage) { + throw new Error(KeyringControllerError.UnsupportedDecryptMessage); + } + + return keyring.decryptMessage(address, messageParams.data); } /** @@ -640,7 +848,37 @@ export class KeyringController extends BaseController< * @returns Promise resolving to keyring of the `account` if one exists. */ async getKeyringForAccount(account: string): Promise { - return this.#keyring.getKeyringForAccount(account); + // Cast to `Hex` here is safe here because `address` is not nullish. + // `normalizeToHex` returns `Hex` unless given a nullish value. + const hexed = normalize(account) as Hex; + + const candidates = await Promise.all( + this.#keyrings.map(async (keyring) => { + return Promise.all([keyring, keyring.getAccounts()]); + }), + ); + + const winners = candidates.filter((candidate) => { + const accounts = candidate[1].map(normalize); + return accounts.includes(hexed); + }); + + if (winners.length && winners[0]?.length) { + return winners[0][0]; + } + + // Adding more info to the error + let errorInfo = ''; + if (!isValidHexAddress(hexed)) { + errorInfo = 'The address passed in is invalid/empty'; + } else if (!candidates.length) { + errorInfo = 'There are no keyrings'; + } else if (!winners.length) { + errorInfo = 'There are keyrings, but none match the address'; + } + throw new Error( + `${KeyringControllerError.NoKeyring}. Error info: ${errorInfo}`, + ); } /** @@ -653,7 +891,7 @@ export class KeyringController extends BaseController< * @returns An array of keyrings of the given type. */ getKeyringsByType(type: KeyringTypes | string): unknown[] { - return this.#keyring.getKeyringsByType(type); + return this.#keyrings.filter((keyring) => keyring.type === type); } /** @@ -663,7 +901,76 @@ export class KeyringController extends BaseController< * operation completes. */ async persistAllKeyrings(): Promise { - return this.#keyring.persistAllKeyrings(); + const { encryptionKey, encryptionSalt } = this.state; + + if (!this.#password && !encryptionKey) { + throw new Error(KeyringControllerError.MissingCredentials); + } + + const serializedKeyrings = await Promise.all( + this.#keyrings.map(async (keyring) => { + const [type, data] = await Promise.all([ + keyring.type, + keyring.serialize(), + ]); + return { type, data }; + }), + ); + + serializedKeyrings.push(...this.#unsupportedKeyrings); + + let vault: string | undefined; + let newEncryptionKey: string | undefined; + + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(this.#encryptor); + + if (encryptionKey) { + const key = await this.#encryptor.importKey(encryptionKey); + const vaultJSON = await this.#encryptor.encryptWithKey( + key, + serializedKeyrings, + ); + vaultJSON.salt = encryptionSalt; + vault = JSON.stringify(vaultJSON); + } else if (this.#password) { + const { vault: newVault, exportedKeyString } = + await this.#encryptor.encryptWithDetail( + this.#password, + serializedKeyrings, + ); + + vault = newVault; + newEncryptionKey = exportedKeyString; + } + } else { + if (typeof this.#password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + vault = await this.#encryptor.encrypt(this.#password, serializedKeyrings); + } + + if (!vault) { + throw new Error(KeyringControllerError.MissingVaultData); + } + + this.update((state) => { + state.vault = vault; + }); + + // The keyring updates need to be announced before updating the encryptionKey + // so that the updated keyring gets propagated to the extension first. + // Not calling {@link updateMemStoreKeyrings} results in the wrong account being selected + // in the extension. + await this.#updateMemStoreKeyrings(); + if (newEncryptionKey) { + this.update((state) => { + state.encryptionKey = newEncryptionKey; + state.encryptionSalt = JSON.parse(vault as string).salt; + }); + } + + return true; } /** @@ -724,11 +1031,11 @@ export class KeyringController extends BaseController< default: throw new Error(`Unexpected import strategy: '${strategy}'`); } - const newKeyring = await this.#keyring.addNewKeyring(KeyringTypes.simple, [ + const newKeyring = (await this.addNewKeyring(KeyringTypes.simple, [ privateKey, - ]); + ])) as EthKeyring; const accounts = await newKeyring.getAccounts(); - const allAccounts = await this.#keyring.getAccounts(); + const allAccounts = await this.getAccounts(); this.updateIdentities(allAccounts); return { keyringState: this.#getMemState(), @@ -744,7 +1051,28 @@ export class KeyringController extends BaseController< * @returns Promise resolving current state when this account removal completes. */ async removeAccount(address: Hex): Promise { - await this.#keyring.removeAccount(address); + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + // Not all the keyrings support this, so we have to check + if (!keyring.removeAccount) { + throw new Error(KeyringControllerError.UnsupportedRemoveAccount); + } + + // The `removeAccount` method of snaps keyring is async. We have to update + // the interface of the other keyrings to be async as well. + // eslint-disable-next-line @typescript-eslint/await-thenable + await keyring.removeAccount(address); + + const accounts = await keyring.getAccounts(); + // Check if this was the last/only account + if (accounts.length === 0) { + await this.#removeEmptyKeyrings(); + } + + await this.persistAllKeyrings(); + this.messagingSystem.publish(`${name}:accountRemoved`, address); return this.#getMemState(); } @@ -756,7 +1084,16 @@ export class KeyringController extends BaseController< */ async setLocked(): Promise { this.#unsubscribeFromQRKeyringsEvents(); - await this.#keyring.setLocked(); + + this.#password = undefined; + this.update((state) => { + state.isUnlocked = false; + state.keyrings = []; + }); + await this.#clearKeyrings(); + + this.messagingSystem.publish(`${name}:lock`); + return this.#getMemState(); } @@ -766,11 +1103,20 @@ export class KeyringController extends BaseController< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signMessage(messageParams: PersonalMessageParams) { + async signMessage(messageParams: PersonalMessageParams): Promise { if (!messageParams.data) { throw new Error("Can't sign an empty message"); } - return this.#keyring.signMessage(messageParams); + + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signMessage) { + throw new Error(KeyringControllerError.UnsupportedSignMessage); + } + + return await keyring.signMessage(address, messageParams.data); } /** @@ -779,8 +1125,18 @@ export class KeyringController extends BaseController< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signPersonalMessage(messageParams: PersonalMessageParams) { - return this.#keyring.signPersonalMessage(messageParams); + async signPersonalMessage(messageParams: PersonalMessageParams) { + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signPersonalMessage) { + throw new Error(KeyringControllerError.UnsupportedSignPersonalMessage); + } + + const normalizedData = normalize(messageParams.data) as Hex; + + return await keyring.signPersonalMessage(address, normalizedData); } /** @@ -806,15 +1162,22 @@ export class KeyringController extends BaseController< throw new Error(`Unexpected signTypedMessage version: '${version}'`); } - return await this.#keyring.signTypedMessage( - { - from: messageParams.from, - data: - version !== SignTypedDataVersion.V1 && - typeof messageParams.data === 'string' - ? JSON.parse(messageParams.data) - : messageParams.data, - }, + // Cast to `Hex` here is safe here because `messageParams.from` is not nullish. + // `normalize` returns `Hex` unless given a nullish value. + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signTypedData) { + throw new Error(KeyringControllerError.UnsupportedSignTypedMessage); + } + + return await keyring.signTypedData( + address, + version !== SignTypedDataVersion.V1 && + typeof messageParams.data === 'string' + ? JSON.parse(messageParams.data) + : messageParams.data, { version }, ); } catch (error) { @@ -830,12 +1193,20 @@ export class KeyringController extends BaseController< * @param opts - An optional options object. * @returns Promise resolving to a signed transaction string. */ - signTransaction( + async signTransaction( transaction: TypedTransaction, from: string, opts?: Record, ): Promise { - return this.#keyring.signTransaction(transaction, from, opts); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signTransaction) { + throw new Error(KeyringControllerError.UnsupportedSignTransaction); + } + + return await keyring.signTransaction(address, transaction, opts); } /** @@ -849,7 +1220,14 @@ export class KeyringController extends BaseController< from: string, transactions: EthBaseTransaction[], ): Promise { - return await this.#keyring.prepareUserOperation(from, transactions); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + return keyring.prepareUserOperation + ? await keyring.prepareUserOperation(address, transactions) + : throwError(KeyringControllerError.UnsupportedPrepareUserOperation); } /** @@ -864,7 +1242,14 @@ export class KeyringController extends BaseController< from: string, userOp: EthUserOperation, ): Promise { - return await this.#keyring.patchUserOperation(from, userOp); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + return keyring.patchUserOperation + ? await keyring.patchUserOperation(address, userOp) + : throwError(KeyringControllerError.UnsupportedPatchUserOperation); } /** @@ -878,7 +1263,14 @@ export class KeyringController extends BaseController< from: string, userOp: EthUserOperation, ): Promise { - return await this.#keyring.signUserOperation(from, userOp); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + return keyring.signUserOperation + ? await keyring.signUserOperation(address, userOp) + : throwError(KeyringControllerError.UnsupportedSignUserOperation); } /** @@ -893,7 +1285,12 @@ export class KeyringController extends BaseController< encryptionKey: string, encryptionSalt: string, ): Promise { - await this.#keyring.submitEncryptionKey(encryptionKey, encryptionSalt); + this.#keyrings = await this.#unlockKeyrings( + undefined, + encryptionKey, + encryptionSalt, + ); + this.#setUnlocked(); const qrKeyring = this.getQRKeyring(); if (qrKeyring) { @@ -913,8 +1310,10 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the current state. */ async submitPassword(password: string): Promise { - await this.#keyring.submitPassword(password); - const accounts = await this.#keyring.getAccounts(); + this.#keyrings = await this.#unlockKeyrings(password); + this.#setUnlocked(); + + const accounts = await this.getAccounts(); const qrKeyring = this.getQRKeyring(); if (qrKeyring) { @@ -933,7 +1332,9 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the seed phrase as Uint8Array. */ async verifySeedPhrase(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType(KeyringTypes.hd)[0]; + const primaryKeyring = this.getKeyringsByType(KeyringTypes.hd)[0] as + | EthKeyring + | undefined; /* istanbul ignore if */ if (!primaryKeyring) { throw new Error('No HD keyring found.'); @@ -950,9 +1351,7 @@ export class KeyringController extends BaseController< // The HD Keyring Builder is a default keyring builder // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const hdKeyringBuilder = this.#keyring.getKeyringBuilderForType( - KeyringTypes.hd, - )!; + const hdKeyringBuilder = this.#getKeyringBuilderForType(KeyringTypes.hd)!; const hdKeyring = hdKeyringBuilder(); // @ts-expect-error @metamask/eth-hd-keyring correctly handles @@ -986,9 +1385,7 @@ export class KeyringController extends BaseController< */ getQRKeyring(): QRKeyring | undefined { // QRKeyring is not yet compatible with Keyring type from @metamask/utils - return this.#keyring.getKeyringsByType( - KeyringTypes.qr, - )[0] as unknown as QRKeyring; + return this.getKeyringsByType(KeyringTypes.qr)[0] as unknown as QRKeyring; } /** @@ -1004,8 +1401,8 @@ export class KeyringController extends BaseController< // eslint-disable-next-line @typescript-eslint/no-explicit-any async restoreQRKeyring(serialized: any): Promise { (await this.getOrAddQRKeyring()).deserialize(serialized); - await this.#keyring.persistAllKeyrings(); - this.updateIdentities(await this.#keyring.getAccounts()); + await this.persistAllKeyrings(); + this.updateIdentities(await this.getAccounts()); } async resetQRKeyringState(): Promise { @@ -1078,13 +1475,13 @@ export class KeyringController extends BaseController< const keyring = await this.getOrAddQRKeyring(); keyring.setAccountToUnlock(index); - const oldAccounts = await this.#keyring.getAccounts(); + const oldAccounts = await this.getAccounts(); // QRKeyring is not yet compatible with Keyring from // @metamask/utils, but we can use the `addNewAccount` method // as it internally calls `addAccounts` from on the keyring instance, // which is supported by QRKeyring API. - await this.#keyring.addNewAccount(keyring as unknown as EthKeyring); - const newAccounts = await this.#keyring.getAccounts(); + await this.addNewAccountForKeyring(keyring as unknown as EthKeyring); + const newAccounts = await this.getAccounts(); this.updateIdentities(newAccounts); newAccounts.forEach((address: string) => { if (!oldAccounts.includes(address)) { @@ -1094,11 +1491,14 @@ export class KeyringController extends BaseController< this.setSelectedAddress(address); } }); - await this.#keyring.persistAllKeyrings(); + await this.persistAllKeyrings(); } async getAccountKeyringType(account: string): Promise { - return (await this.#keyring.getKeyringForAccount(account)).type; + const keyring = (await this.getKeyringForAccount( + account, + )) as EthKeyring; + return keyring.type; } async forgetQRDevice(): Promise<{ @@ -1106,14 +1506,14 @@ export class KeyringController extends BaseController< remainingAccounts: string[]; }> { const keyring = await this.getOrAddQRKeyring(); - const allAccounts = (await this.#keyring.getAccounts()) as string[]; + const allAccounts = (await this.getAccounts()) as string[]; keyring.forgetDevice(); - const remainingAccounts = (await this.#keyring.getAccounts()) as string[]; + const remainingAccounts = (await this.getAccounts()) as string[]; const removedAccounts = allAccounts.filter( (address: string) => !remainingAccounts.includes(address), ); this.updateIdentities(remainingAccounts); - await this.#keyring.persistAllKeyrings(); + await this.persistAllKeyrings(); return { removedAccounts, remainingAccounts }; } @@ -1183,6 +1583,25 @@ export class KeyringController extends BaseController< ); } + /** + * Get Keyring Class For Type + * + * Searches the current `keyringBuilders` array + * for a Keyring builder whose unique `type` property + * matches the provided `type`, + * returning it if it exists. + * + * @param type - The type whose class to get. + * @returns The class, if it exists. + */ + #getKeyringBuilderForType( + type: string, + ): { (): EthKeyring; type: string } | undefined { + return this.#keyringBuilders.find( + (keyringBuilder) => keyringBuilder.type === type, + ); + } + /** * Add qr hardware keyring. * @@ -1192,9 +1611,19 @@ export class KeyringController extends BaseController< */ async #addQRKeyring(): Promise { // QRKeyring is not yet compatible with Keyring type from @metamask/utils - const qrKeyring = (await this.#keyring.addNewKeyring( - KeyringTypes.qr, - )) as unknown as QRKeyring; + const qrKeyring = (await this.#newKeyring(KeyringTypes.qr, { + accounts: [], + })) as unknown as QRKeyring; + + if (!qrKeyring) { + throw new Error(KeyringControllerError.NoKeyring); + } + + const accounts = await qrKeyring.getAccounts(); + await this.#checkForDuplicate(KeyringTypes.qr, accounts); + + this.#keyrings.push(qrKeyring as unknown as EthKeyring); + await this.persistAllKeyrings(); this.#subscribeToQRKeyringEvents(qrKeyring); @@ -1216,7 +1645,7 @@ export class KeyringController extends BaseController< } #unsubscribeFromQRKeyringsEvents() { - const qrKeyrings = this.#keyring.getKeyringsByType( + const qrKeyrings = this.getKeyringsByType( KeyringTypes.qr, ) as unknown as QRKeyring[]; @@ -1228,40 +1657,306 @@ export class KeyringController extends BaseController< } /** - * Sync controller state with current keyring store - * and memStore states. + * Update memStore Keyrings * - * @fires KeyringController:stateChange + * Updates the in-memory keyrings, without persisting. */ - #fullUpdate() { - const { vault } = this.#keyring.store.getState(); - const { keyrings, isUnlocked, encryptionKey, encryptionSalt } = - this.#keyring.memStore.getState(); - - this.update(() => ({ - vault, - keyrings, - isUnlocked, - encryptionKey, - encryptionSalt, - })); + async #updateMemStoreKeyrings(): Promise { + const keyrings = await Promise.all(this.#keyrings.map(displayForKeyring)); + this.update((state) => { + state.keyrings = keyrings; + }); } /** - * Handle keyring lock event. + * Unlock Keyrings. + * + * Attempts to unlock the persisted encrypted storage, + * initializing the persisted keyrings to RAM. * - * @fires KeyringController:lock + * @param password - The keyring controller password. + * @param encryptionKey - An exported key string to unlock keyrings with. + * @param encryptionSalt - The salt used to encrypt the vault. + * @returns The keyrings array. */ - #handleLock() { - this.messagingSystem.publish(`${name}:lock`); + async #unlockKeyrings( + password: string | undefined, + encryptionKey?: string, + encryptionSalt?: string, + ): Promise[]> { + const encryptedVault = this.state.vault; + if (!encryptedVault) { + throw new Error(KeyringControllerError.VaultError); + } + + await this.#clearKeyrings(); + + let vault; + + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(this.#encryptor); + + if (password) { + const result = await this.#encryptor.decryptWithDetail( + password, + encryptedVault, + ); + vault = result.vault; + this.#password = password; + + this.update((state) => { + state.encryptionKey = result.exportedKeyString; + state.encryptionSalt = result.salt; + }); + } else { + const parsedEncryptedVault = JSON.parse(encryptedVault); + + if (encryptionSalt !== parsedEncryptedVault.salt) { + throw new Error(KeyringControllerError.ExpiredCredentials); + } + + if (typeof encryptionKey !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + + const key = await this.#encryptor.importKey(encryptionKey); + vault = await this.#encryptor.decryptWithKey(key, parsedEncryptedVault); + + // This call is required on the first call because encryptionKey + // is not yet inside the memStore + this.update((state) => { + state.encryptionKey = encryptionKey; + // we can safely assume that encryptionSalt is defined here + // because we compare it with the salt from the vault + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.encryptionSalt = encryptionSalt!; + }); + } + } else { + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + + vault = await this.#encryptor.decrypt(password, encryptedVault); + this.#password = password; + } + + if (!isSerializedKeyringsArray(vault)) { + throw new Error(KeyringControllerError.VaultDataError); + } + + await Promise.all(vault.map(this.#restoreKeyring.bind(this))); + await this.#updateMemStoreKeyrings(); + + if ( + this.#password && + (!this.#cacheEncryptionKey || !encryptionKey) && + this.#encryptor.isVaultUpdated && + !this.#encryptor.isVaultUpdated(encryptedVault) + ) { + // Re-encrypt the vault with safer method if one is available + await this.persistAllKeyrings(); + } + + return this.#keyrings; } /** - * Handle keyring unlock event. + * Create keyring. + * + * - Creates a new vault. + * - Creates a new keyring with at least one account. + * - Makes the first account the selected account. + * @param type - Keyring type to instantiate. + * @param opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves if the operation was successful. + */ + async #createKeyring(type: string, opts?: unknown) { + const keyring = (await this.addNewKeyring(type, opts)) as + | EthKeyring + | undefined; + if (!keyring) { + throw new Error(KeyringControllerError.NoKeyring); + } + + const [firstAccount] = await keyring.getAccounts(); + if (!firstAccount) { + throw new Error(KeyringControllerError.NoFirstAccount); + } + } + + /** + * Instantiate, initialize and return a new keyring + * + * The keyring instantiated is of the given `type`. + * + * @param type - The type of keyring to add. + * @param data - The data to restore a previously serialized keyring. + * @returns The new keyring. + */ + async #newKeyring(type: string, data: unknown): Promise> { + const keyringBuilder = this.#getKeyringBuilderForType(type); + + if (!keyringBuilder) { + throw new Error( + `${KeyringControllerError.NoKeyringBuilder}. Keyring type: ${type}`, + ); + } + + const keyring = keyringBuilder(); + + // @ts-expect-error Enforce data type after updating clients + await keyring.deserialize(data); + + if (keyring.init) { + await keyring.init(); + } + + return keyring; + } + + /** + * Clear Keyrings + * + * Deallocates all currently managed keyrings and accounts. + * Used before initializing a new vault and after locking + * MetaMask. + */ + async #clearKeyrings() { + // clear keyrings from memory + for (const keyring of this.#keyrings) { + await this.#destroyKeyring(keyring); + } + this.#keyrings = []; + this.update((state) => { + state.keyrings = []; + }); + } + + /** + * Restore Keyring Helper + * + * Attempts to initialize a new keyring from the provided serialized payload. + * On success, returns the resulting keyring instance. + * + * @param serialized - The serialized keyring. + * @param serialized.type - Keyring type. + * @param serialized.data - Keyring data. + * @returns The deserialized keyring or undefined if the keyring type is unsupported. + */ + async #restoreKeyring( + serialized: SerializedKeyring, + ): Promise | undefined> { + const { type, data } = serialized; + + let keyring: EthKeyring | undefined; + try { + keyring = await this.#newKeyring(type, data); + } catch (error) { + // Ignore error. + console.error(error); + } + + if (!keyring) { + this.#unsupportedKeyrings.push(serialized); + return undefined; + } + + // getAccounts also validates the accounts for some keyrings + await keyring.getAccounts(); + this.#keyrings.push(keyring); + return keyring; + } + + /** + * Destroy Keyring + * + * Some keyrings support a method called `destroy`, that destroys the + * keyring along with removing all its event listeners and, in some cases, + * clears the keyring bridge iframe from the DOM. + * + * @param keyring - The keyring to destroy. + */ + async #destroyKeyring(keyring: EthKeyring) { + await keyring.destroy?.(); + } + + /** + * Remove empty keyrings. + * + * Loops through the keyrings and removes the ones with empty accounts + * (usually after removing the last / only account) from a keyring. + */ + async #removeEmptyKeyrings(): Promise { + const validKeyrings: EthKeyring[] = []; + + // Since getAccounts returns a Promise + // We need to wait to hear back form each keyring + // in order to decide which ones are now valid (accounts.length > 0) + + await Promise.all( + this.#keyrings.map(async (keyring: EthKeyring) => { + const accounts = await keyring.getAccounts(); + if (accounts.length > 0) { + validKeyrings.push(keyring); + } else { + await this.#destroyKeyring(keyring); + } + }), + ); + this.#keyrings = validKeyrings; + } + + /** + * Checks for duplicate keypairs, using the the first account in the given + * array. Rejects if a duplicate is found. + * + * Only supports 'Simple Key Pair'. + * + * @param type - The key pair type to check for. + * @param newAccountArray - Array of new accounts. + * @returns The account, if no duplicate is found. + */ + async #checkForDuplicate( + type: string, + newAccountArray: string[], + ): Promise { + const accounts = await this.getAccounts(); + + switch (type) { + case KeyringType.Simple: { + const isIncluded = Boolean( + accounts.find( + (key) => + newAccountArray[0] && + (key === newAccountArray[0] || + key === remove0x(newAccountArray[0])), + ), + ); + + if (isIncluded) { + throw new Error(KeyringControllerError.DuplicatedAccount); + } + return newAccountArray; + } + + default: { + return newAccountArray; + } + } + } + + /** + * Unlock Keyrings + * + * Unlocks the keyrings. * * @fires KeyringController:unlock */ - #handleUnlock() { + #setUnlocked(): void { + this.update((state) => { + state.isUnlocked = true; + }); this.messagingSystem.publish(`${name}:unlock`); } diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts new file mode 100644 index 00000000000..71992514f51 --- /dev/null +++ b/packages/keyring-controller/src/constants.ts @@ -0,0 +1,34 @@ +export enum KeyringType { + HD = 'HD Key Tree', + Simple = 'Simple Key Pair', +} + +export enum KeyringControllerError { + NoKeyring = 'KeyringController - No keyring found', + WrongPasswordType = 'KeyringController - Password must be of type string.', + NoFirstAccount = 'KeyringController - First Account not found.', + DuplicatedAccount = 'KeyringController - The account you are trying to import is a duplicate', + VaultError = 'KeyringController - Cannot unlock without a previous vault.', + VaultDataError = 'KeyringController - The decrypted vault has an unexpected shape.', + UnsupportedEncryptionKeyExport = 'KeyringController - The encryptor does not support encryption key export.', + UnsupportedGenerateRandomMnemonic = 'KeyringController - The current keyring does not support the method generateRandomMnemonic.', + UnsupportedExportAccount = '`KeyringController - The keyring for the current address does not support the method exportAccount', + UnsupportedRemoveAccount = '`KeyringController - The keyring for the current address does not support the method removeAccount', + UnsupportedSignTransaction = 'KeyringController - The keyring for the current address does not support the method signTransaction.', + UnsupportedSignMessage = 'KeyringController - The keyring for the current address does not support the method signMessage.', + UnsupportedSignPersonalMessage = 'KeyringController - The keyring for the current address does not support the method signPersonalMessage.', + UnsupportedGetEncryptionPublicKey = 'KeyringController - The keyring for the current address does not support the method getEncryptionPublicKey.', + UnsupportedDecryptMessage = 'KeyringController - The keyring for the current address does not support the method decryptMessage.', + UnsupportedSignTypedMessage = 'KeyringController - The keyring for the current address does not support the method signTypedMessage.', + UnsupportedGetAppKeyAddress = 'KeyringController - The keyring for the current address does not support the method getAppKeyAddress.', + UnsupportedExportAppKeyForAddress = 'KeyringController - The keyring for the current address does not support the method exportAppKeyForAddress.', + UnsupportedPrepareUserOperation = 'KeyringController - The keyring for the current address does not support the method prepareUserOperation.', + UnsupportedPatchUserOperation = 'KeyringController - The keyring for the current address does not support the method patchUserOperation.', + UnsupportedSignUserOperation = 'KeyringController - The keyring for the current address does not support the method signUserOperation.', + NoAccountOnKeychain = "KeyringController - The keychain doesn't have accounts.", + MissingCredentials = 'KeyringController - Cannot persist vault without password and encryption key', + MissingVaultData = 'KeyringController - Cannot persist vault without vault information', + ExpiredCredentials = 'KeyringController - Encryption key and salt provided are expired', + NoKeyringBuilder = 'KeyringController - No keyringBuilder found for keyring', + DataType = 'KeyringController - Incorrect data type provided', +} diff --git a/packages/keyring-controller/src/utils.ts b/packages/keyring-controller/src/utils.ts new file mode 100644 index 00000000000..ea1e41287a6 --- /dev/null +++ b/packages/keyring-controller/src/utils.ts @@ -0,0 +1,8 @@ +/** + * Throws an error. + * + * @param error - Error message or Error object to throw. + */ +export function throwError(error: string | Error): never { + throw typeof error === 'string' ? new Error(error) : error; +} diff --git a/types/@metamask/eth-hd-keyring.d.ts b/types/@metamask/eth-hd-keyring.d.ts new file mode 100644 index 00000000000..957e0663925 --- /dev/null +++ b/types/@metamask/eth-hd-keyring.d.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/unambiguous +declare module '@metamask/eth-hd-keyring'; diff --git a/types/@metamask/eth-simple-keyring.d.ts b/types/@metamask/eth-simple-keyring.d.ts new file mode 100644 index 00000000000..a44ddd1ecfb --- /dev/null +++ b/types/@metamask/eth-simple-keyring.d.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/unambiguous +declare module '@metamask/eth-simple-keyring'; diff --git a/yarn.lock b/yarn.lock index cee65aa6f03..fd385245bb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2243,8 +2243,11 @@ __metadata: "@lavamoat/allow-scripts": ^2.3.1 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 + "@metamask/browser-passworder": ^4.3.0 + "@metamask/eth-hd-keyring": ^7.0.1 "@metamask/eth-keyring-controller": ^17.0.1 "@metamask/eth-sig-util": ^7.0.1 + "@metamask/eth-simple-keyring": ^6.0.1 "@metamask/keyring-api": ^3.0.0 "@metamask/message-manager": ^7.3.8 "@metamask/scure-bip39": ^2.1.1