From 49d7b619c8ee873b194a9e6aab3b4114dd06138e Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 24 Jan 2024 13:18:30 +0100 Subject: [PATCH] merge eth-keyring-controller logic --- packages/keyring-controller/jest.config.js | 8 +- packages/keyring-controller/package.json | 4 + .../src/KeyringController.test.ts | 2 +- .../src/KeyringController.ts | 933 +++++++++++++++--- packages/keyring-controller/src/constants.ts | 34 + packages/keyring-controller/src/utils.ts | 8 + types/@metamask/eth-hd-keyring.d.ts | 2 + types/@metamask/eth-simple-keyring.d.ts | 2 + yarn.lock | 3 + 9 files changed, 875 insertions(+), 121 deletions(-) create mode 100644 packages/keyring-controller/src/constants.ts create mode 100644 packages/keyring-controller/src/utils.ts create mode 100644 types/@metamask/eth-hd-keyring.d.ts create mode 100644 types/@metamask/eth-simple-keyring.d.ts 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 f81f3199eb6..cbe6ef65c08 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.0", + "@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.7", "@metamask/preferences-controller": "^6.0.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 c1a12d3f253..8417061ef99 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, @@ -25,8 +26,19 @@ import type { TypedMessageParams, } from '@metamask/message-manager'; import type { PreferencesController } from '@metamask/preferences-controller'; -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, @@ -39,6 +51,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'; /** @@ -249,6 +264,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), +]; + const defaultState: KeyringControllerState = { isUnlocked: false, keyrings: [], @@ -273,6 +314,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. * @@ -297,7 +399,17 @@ export class KeyringController extends BaseController< private readonly setAccountLabel?: PreferencesController['setAccountLabel']; - #keyring: EthKeyringController; + #keyringBuilders: { (): EthKeyring; type: string }[]; + + #keyrings: EthKeyring[]; + + #unsupportedKeyrings: SerializedKeyring[]; + + #password?: string; + + #encryptor: GenericEncryptor | ExportableKeyEncryptor; + + #cacheEncryptionKey: boolean; #qrKeyringStateListener?: ( state: ReturnType, @@ -323,6 +435,7 @@ export class KeyringController extends BaseController< updateIdentities, setSelectedAddress, setAccountLabel, + encryptor = encryptorUtils, keyringBuilders, messenger, state, @@ -344,25 +457,26 @@ 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)); + + // TODO: Substitute these + // 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; @@ -384,12 +498,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) { @@ -403,8 +519,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(); @@ -444,13 +560,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; } @@ -461,16 +579,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. @@ -491,14 +644,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(); @@ -516,7 +669,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()); @@ -543,7 +696,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; } /** @@ -553,7 +729,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); } /** @@ -573,8 +752,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; } /** @@ -586,7 +765,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); } /** @@ -594,8 +781,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[]; } /** @@ -610,7 +808,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); } /** @@ -625,7 +831,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); } /** @@ -639,7 +853,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}`, + ); } /** @@ -652,7 +896,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); } /** @@ -662,7 +906,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; } /** @@ -723,11 +1036,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(), @@ -743,7 +1056,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(); } @@ -755,7 +1089,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(); } @@ -765,11 +1108,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); } /** @@ -778,8 +1130,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); } /** @@ -805,15 +1167,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) { @@ -829,12 +1198,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); } /** @@ -848,7 +1225,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); } /** @@ -863,7 +1247,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); } /** @@ -877,7 +1268,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); } /** @@ -892,7 +1290,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) { @@ -912,8 +1315,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) { @@ -932,7 +1337,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.'); @@ -949,9 +1356,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 @@ -985,9 +1390,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; } /** @@ -1003,8 +1406,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 { @@ -1077,13 +1480,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)) { @@ -1093,11 +1496,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<{ @@ -1105,14 +1511,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 }; } @@ -1182,6 +1588,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. * @@ -1191,9 +1616,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); @@ -1215,7 +1650,7 @@ export class KeyringController extends BaseController< } #unsubscribeFromQRKeyringsEvents() { - const qrKeyrings = this.#keyring.getKeyringsByType( + const qrKeyrings = this.getKeyringsByType( KeyringTypes.qr, ) as unknown as QRKeyring[]; @@ -1227,40 +1662,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; + } + + /** + * 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; } /** - * Handle keyring unlock event. + * 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 9e777bb98fc..3c5fd80a4d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2241,8 +2241,11 @@ __metadata: "@lavamoat/allow-scripts": ^2.3.1 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.0 + "@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.7 "@metamask/preferences-controller": ^6.0.0