diff --git a/app/scripts/background.js b/app/scripts/background.js index 3aff0170bf2f..622e888f9630 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -687,7 +687,7 @@ export function setupController( METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE, updateBadge, ); - controller.encryptionPublicKeyManager.on( + controller.encryptionPublicKeyController.hub.on( METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE, updateBadge, ); @@ -727,17 +727,12 @@ export function setupController( function getUnapprovedTransactionCount() { const { unapprovedDecryptMsgCount } = controller.decryptMessageManager; - const { unapprovedEncryptionPublicKeyMsgCount } = - controller.encryptionPublicKeyManager; const pendingApprovalCount = controller.approvalController.getTotalApprovalCount(); const waitingForUnlockCount = controller.appStateController.waitingForUnlock.length; return ( - unapprovedDecryptMsgCount + - unapprovedEncryptionPublicKeyMsgCount + - pendingApprovalCount + - waitingForUnlockCount + unapprovedDecryptMsgCount + pendingApprovalCount + waitingForUnlockCount ); } @@ -767,14 +762,9 @@ export function setupController( REJECT_NOTIFICATION_CLOSE, ), ); - controller.encryptionPublicKeyManager.messages - .filter((msg) => msg.status === 'unapproved') - .forEach((tx) => - controller.encryptionPublicKeyManager.rejectMsg( - tx.id, - REJECT_NOTIFICATION_CLOSE, - ), - ); + controller.encryptionPublicKeyController.rejectUnapproved( + REJECT_NOTIFICATION_CLOSE, + ); // Finally, resolve snap dialog approvals on Flask and reject all the others managed by the ApprovalController. Object.values(controller.approvalController.state.pendingApprovals).forEach( diff --git a/app/scripts/controllers/encryption-public-key.test.ts b/app/scripts/controllers/encryption-public-key.test.ts new file mode 100644 index 000000000000..cc5b61cc12e8 --- /dev/null +++ b/app/scripts/controllers/encryption-public-key.test.ts @@ -0,0 +1,400 @@ +import { EncryptionPublicKeyManager } from '@metamask/message-manager'; +import { + AbstractMessage, + OriginalRequest, +} from '@metamask/message-manager/dist/AbstractMessageManager'; +import { KeyringType } from '../../../shared/constants/keyring'; +import { MetaMetricsEventCategory } from '../../../shared/constants/metametrics'; +import EncryptionPublicKeyController, { + EncryptionPublicKeyControllerMessenger, + EncryptionPublicKeyControllerOptions, +} from './encryption-public-key'; + +jest.mock('@metamask/message-manager', () => ({ + EncryptionPublicKeyManager: jest.fn(), +})); + +const messageIdMock = '123'; +const messageIdMock2 = '456'; +const stateMock = { test: 123 }; +const addressMock = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; +const publicKeyMock = '32762347862378feb87123781623a='; +const keyringMock = { type: KeyringType.hdKeyTree }; + +const messageParamsMock = { + from: addressMock, + origin: 'http://test.com', + data: addressMock, + metamaskId: messageIdMock, +}; + +const messageMock = { + id: messageIdMock, + time: 123, + status: 'unapproved', + type: 'testType', + rawSig: undefined, +} as any as AbstractMessage; + +const coreMessageMock = { + ...messageMock, + messageParams: messageParamsMock, +}; + +const stateMessageMock = { + ...messageMock, + msgParams: addressMock, + origin: messageParamsMock.origin, +}; + +const requestMock = { + origin: 'http://test2.com', +} as OriginalRequest; + +const createMessengerMock = () => + ({ + registerActionHandler: jest.fn(), + publish: jest.fn(), + call: jest.fn(), + } as any as jest.Mocked); + +const createEncryptionPublicKeyManagerMock = () => + ({ + getUnapprovedMessages: jest.fn(), + getUnapprovedMessagesCount: jest.fn(), + addUnapprovedMessageAsync: jest.fn(), + approveMessage: jest.fn(), + setMessageStatusAndResult: jest.fn(), + rejectMessage: jest.fn(), + subscribe: jest.fn(), + update: jest.fn(), + hub: { + on: jest.fn(), + }, + } as any as jest.Mocked); + +const createKeyringControllerMock = () => ({ + getKeyringForAccount: jest.fn(), + getEncryptionPublicKey: jest.fn(), +}); + +describe('EncryptionPublicKeyController', () => { + let encryptionPublicKeyController: EncryptionPublicKeyController; + + const encryptionPublicKeyManagerConstructorMock = + EncryptionPublicKeyManager as jest.MockedClass< + typeof EncryptionPublicKeyManager + >; + const encryptionPublicKeyManagerMock = + createEncryptionPublicKeyManagerMock(); + const messengerMock = createMessengerMock(); + const keyringControllerMock = createKeyringControllerMock(); + const getStateMock = jest.fn(); + const metricsEventMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + encryptionPublicKeyManagerConstructorMock.mockReturnValue( + encryptionPublicKeyManagerMock, + ); + + encryptionPublicKeyController = new EncryptionPublicKeyController({ + messenger: messengerMock as any, + keyringController: keyringControllerMock as any, + getState: getStateMock as any, + metricsEvent: metricsEventMock as any, + } as EncryptionPublicKeyControllerOptions); + }); + + describe('unapprovedMsgCount', () => { + it('returns value from message manager getter', () => { + encryptionPublicKeyManagerMock.getUnapprovedMessagesCount.mockReturnValueOnce( + 10, + ); + expect(encryptionPublicKeyController.unapprovedMsgCount).toBe(10); + }); + }); + + describe('resetState', () => { + it('sets state to initial state', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + encryptionPublicKeyController.update(() => ({ + unapprovedEncryptionPublicKeyMsgs: { + [messageIdMock]: messageMock, + } as any, + unapprovedEncryptionPublicKeyMsgCount: 1, + })); + + encryptionPublicKeyController.resetState(); + + expect(encryptionPublicKeyController.state).toEqual({ + unapprovedEncryptionPublicKeyMsgs: {}, + unapprovedEncryptionPublicKeyMsgCount: 0, + }); + }); + }); + + describe('rejectUnapproved', () => { + beforeEach(() => { + const messages = { + [messageIdMock]: messageMock, + [messageIdMock2]: messageMock, + }; + encryptionPublicKeyManagerMock.getUnapprovedMessages.mockReturnValueOnce( + messages as any, + ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + encryptionPublicKeyController.update(() => ({ + unapprovedEncryptionPublicKeyMsgs: messages as any, + })); + }); + + it('rejects all messages in the message manager', () => { + encryptionPublicKeyController.rejectUnapproved('Test Reason'); + expect( + encryptionPublicKeyManagerMock.rejectMessage, + ).toHaveBeenCalledTimes(2); + expect(encryptionPublicKeyManagerMock.rejectMessage).toHaveBeenCalledWith( + messageIdMock, + ); + expect(encryptionPublicKeyManagerMock.rejectMessage).toHaveBeenCalledWith( + messageIdMock2, + ); + }); + + it('fires metrics event with reject reason', () => { + encryptionPublicKeyController.rejectUnapproved('Test Reason'); + expect(metricsEventMock).toHaveBeenCalledTimes(2); + expect(metricsEventMock).toHaveBeenLastCalledWith({ + event: 'Test Reason', + category: MetaMetricsEventCategory.Messages, + properties: { + action: 'Encryption public key Request', + }, + }); + }); + }); + + describe('clearUnapproved', () => { + it('resets state in all message managers', () => { + encryptionPublicKeyController.clearUnapproved(); + + const defaultState = { + unapprovedMessages: {}, + unapprovedMessagesCount: 0, + }; + + expect(encryptionPublicKeyManagerMock.update).toHaveBeenCalledTimes(1); + expect(encryptionPublicKeyManagerMock.update).toHaveBeenCalledWith( + defaultState, + ); + }); + }); + + describe('newRequestEncryptionPublicKey', () => { + it.each([ + ['Ledger', KeyringType.ledger], + ['Trezor', KeyringType.trezor], + ['Lattice', KeyringType.lattice], + ['QR hardware', KeyringType.qr], + ])( + 'throws if keyring is not supported', + async (keyringName, keyringType) => { + keyringControllerMock.getKeyringForAccount.mockResolvedValueOnce({ + type: keyringType, + }); + + await expect( + encryptionPublicKeyController.newRequestEncryptionPublicKey( + addressMock, + requestMock, + ), + ).rejects.toThrowError( + `${keyringName} does not support eth_getEncryptionPublicKey.`, + ); + }, + ); + + it('adds message to message manager', async () => { + keyringControllerMock.getKeyringForAccount.mockResolvedValueOnce( + keyringMock, + ); + + await encryptionPublicKeyController.newRequestEncryptionPublicKey( + addressMock, + requestMock, + ); + + expect( + encryptionPublicKeyManagerMock.addUnapprovedMessageAsync, + ).toHaveBeenCalledTimes(1); + expect( + encryptionPublicKeyManagerMock.addUnapprovedMessageAsync, + ).toHaveBeenCalledWith({ from: addressMock }, requestMock); + }); + }); + + describe('encryptionPublicKey', () => { + beforeEach(() => { + encryptionPublicKeyManagerMock.approveMessage.mockResolvedValueOnce({ + from: messageParamsMock.data, + }); + + keyringControllerMock.getEncryptionPublicKey.mockResolvedValueOnce( + publicKeyMock, + ); + }); + + it('approves message and signs', async () => { + await encryptionPublicKeyController.encryptionPublicKey( + messageParamsMock, + ); + + expect( + keyringControllerMock.getEncryptionPublicKey, + ).toHaveBeenCalledTimes(1); + expect(keyringControllerMock.getEncryptionPublicKey).toHaveBeenCalledWith( + messageParamsMock.data, + ); + + expect( + encryptionPublicKeyManagerMock.setMessageStatusAndResult, + ).toHaveBeenCalledTimes(1); + expect( + encryptionPublicKeyManagerMock.setMessageStatusAndResult, + ).toHaveBeenCalledWith( + messageParamsMock.metamaskId, + publicKeyMock, + 'received', + ); + }); + + it('returns current state', async () => { + getStateMock.mockReturnValueOnce(stateMock); + expect( + await encryptionPublicKeyController.encryptionPublicKey( + messageParamsMock, + ), + ).toEqual(stateMock); + }); + + it('accepts approval', async () => { + await encryptionPublicKeyController.encryptionPublicKey( + messageParamsMock, + ); + + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + messageParamsMock.metamaskId, + ); + }); + + it('rejects message on error', async () => { + keyringControllerMock.getEncryptionPublicKey.mockReset(); + keyringControllerMock.getEncryptionPublicKey.mockRejectedValue( + new Error('Test Error'), + ); + + await expect( + encryptionPublicKeyController.encryptionPublicKey(messageParamsMock), + ).rejects.toThrow('Test Error'); + + expect( + encryptionPublicKeyManagerMock.rejectMessage, + ).toHaveBeenCalledTimes(1); + expect(encryptionPublicKeyManagerMock.rejectMessage).toHaveBeenCalledWith( + messageParamsMock.metamaskId, + ); + }); + + it('rejects approval on error', async () => { + keyringControllerMock.getEncryptionPublicKey.mockReset(); + keyringControllerMock.getEncryptionPublicKey.mockRejectedValue( + new Error('Test Error'), + ); + + await expect( + encryptionPublicKeyController.encryptionPublicKey(messageParamsMock), + ).rejects.toThrow('Test Error'); + + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( + 'ApprovalController:rejectRequest', + messageParamsMock.metamaskId, + 'Cancel', + ); + }); + }); + + describe('cancelEncryptionPublicKey', () => { + it('rejects message using message manager', async () => { + encryptionPublicKeyController.cancelEncryptionPublicKey(messageIdMock); + + expect( + encryptionPublicKeyManagerMock.rejectMessage, + ).toHaveBeenCalledTimes(1); + expect(encryptionPublicKeyManagerMock.rejectMessage).toHaveBeenCalledWith( + messageParamsMock.metamaskId, + ); + }); + + it('rejects approval using approval controller', async () => { + encryptionPublicKeyController.cancelEncryptionPublicKey(messageIdMock); + + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( + 'ApprovalController:rejectRequest', + messageParamsMock.metamaskId, + 'Cancel', + ); + }); + }); + + describe('message manager events', () => { + it('bubbles update badge event from EncryptionPublicKeyManager', () => { + const mockListener = jest.fn(); + + encryptionPublicKeyController.hub.on('updateBadge', mockListener); + (encryptionPublicKeyManagerMock.hub.on as any).mock.calls[0][1](); + + expect(mockListener).toHaveBeenCalledTimes(1); + }); + + it('requires approval on unapproved message event from EncryptionPublicKeyManager', () => { + messengerMock.call.mockResolvedValueOnce({}); + + (encryptionPublicKeyManagerMock.hub.on as any).mock.calls[1][1]( + messageParamsMock, + ); + + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: messageIdMock, + origin: messageParamsMock.origin, + type: 'eth_getEncryptionPublicKey', + }, + true, + ); + }); + + it('updates state on EncryptionPublicKeyManager state change', async () => { + await encryptionPublicKeyManagerMock.subscribe.mock.calls[0][0]({ + unapprovedMessages: { [messageIdMock]: coreMessageMock as any }, + unapprovedMessagesCount: 3, + }); + + expect(encryptionPublicKeyController.state).toEqual({ + unapprovedEncryptionPublicKeyMsgs: { + [messageIdMock]: stateMessageMock as any, + }, + unapprovedEncryptionPublicKeyMsgCount: 3, + }); + }); + }); +}); diff --git a/app/scripts/controllers/encryption-public-key.ts b/app/scripts/controllers/encryption-public-key.ts new file mode 100644 index 000000000000..f4cb5e25ec3a --- /dev/null +++ b/app/scripts/controllers/encryption-public-key.ts @@ -0,0 +1,421 @@ +import EventEmitter from 'events'; +import log from 'loglevel'; +import { + EncryptionPublicKeyManager, + EncryptionPublicKeyParamsMetamask, +} from '@metamask/message-manager'; +import { KeyringController } from '@metamask/eth-keyring-controller'; +import { + AbstractMessageManager, + AbstractMessage, + MessageManagerState, + AbstractMessageParams, + AbstractMessageParamsMetamask, + OriginalRequest, +} from '@metamask/message-manager/dist/AbstractMessageManager'; +import { + BaseControllerV2, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { Patch } from 'immer'; +import { + AcceptRequest, + AddApprovalRequest, + RejectRequest, +} from '@metamask/approval-controller'; +import { MetaMetricsEventCategory } from '../../../shared/constants/metametrics'; +import { KeyringType } from '../../../shared/constants/keyring'; +import { ORIGIN_METAMASK } from '../../../shared/constants/app'; + +const controllerName = 'EncryptionPublicKeyController'; +const methodNameGetEncryptionPublicKey = 'eth_getEncryptionPublicKey'; + +const stateMetadata = { + unapprovedEncryptionPublicKeyMsgs: { persist: false, anonymous: false }, + unapprovedEncryptionPublicKeyMsgCount: { persist: false, anonymous: false }, +}; + +const getDefaultState = () => ({ + unapprovedEncryptionPublicKeyMsgs: {}, + unapprovedEncryptionPublicKeyMsgCount: 0, +}); + +export type CoreMessage = AbstractMessage & { + messageParams: AbstractMessageParams; +}; + +export type StateMessage = Required< + Omit +> & { + msgParams: string; +}; + +export type EncryptionPublicKeyControllerState = { + unapprovedEncryptionPublicKeyMsgs: Record; + unapprovedEncryptionPublicKeyMsgCount: number; +}; + +export type GetEncryptionPublicKeyState = { + type: `${typeof controllerName}:getState`; + handler: () => EncryptionPublicKeyControllerState; +}; + +export type EncryptionPublicKeyStateChange = { + type: `${typeof controllerName}:stateChange`; + payload: [EncryptionPublicKeyControllerState, Patch[]]; +}; + +export type EncryptionPublicKeyControllerActions = GetEncryptionPublicKeyState; + +export type EncryptionPublicKeyControllerEvents = + EncryptionPublicKeyStateChange; + +type AllowedActions = AddApprovalRequest | AcceptRequest | RejectRequest; + +export type EncryptionPublicKeyControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + EncryptionPublicKeyControllerActions | AllowedActions, + EncryptionPublicKeyControllerEvents, + AllowedActions['type'], + never + >; + +export type EncryptionPublicKeyControllerOptions = { + messenger: EncryptionPublicKeyControllerMessenger; + keyringController: KeyringController; + getState: () => any; + metricsEvent: (payload: any, options?: any) => void; +}; + +/** + * Controller for requesting encryption public key requests requiring user approval. + */ +export default class EncryptionPublicKeyController extends BaseControllerV2< + typeof controllerName, + EncryptionPublicKeyControllerState, + EncryptionPublicKeyControllerMessenger +> { + hub: EventEmitter; + + private _keyringController: KeyringController; + + private _getState: () => any; + + private _encryptionPublicKeyManager: EncryptionPublicKeyManager; + + private _metricsEvent: (payload: any, options?: any) => void; + + /** + * Construct a EncryptionPublicKey controller. + * + * @param options - The controller options. + * @param options.messenger - The restricted controller messenger for the EncryptionPublicKey controller. + * @param options.keyringController - An instance of a keyring controller used to extract the encryption public key. + * @param options.getState - Callback to retrieve all user state. + * @param options.metricsEvent - A function for emitting a metric event. + */ + constructor({ + messenger, + keyringController, + getState, + metricsEvent, + }: EncryptionPublicKeyControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: getDefaultState(), + }); + + this._keyringController = keyringController; + this._getState = getState; + this._metricsEvent = metricsEvent; + + this.hub = new EventEmitter(); + this._encryptionPublicKeyManager = new EncryptionPublicKeyManager( + undefined, + undefined, + undefined, + ['received'], + ); + + this._encryptionPublicKeyManager.hub.on('updateBadge', () => { + this.hub.emit('updateBadge'); + }); + + this._encryptionPublicKeyManager.hub.on( + 'unapprovedMessage', + (msgParams: AbstractMessageParamsMetamask) => { + this._requestApproval(msgParams, methodNameGetEncryptionPublicKey); + }, + ); + + this._subscribeToMessageState( + this._encryptionPublicKeyManager, + (state, newMessages, messageCount) => { + state.unapprovedEncryptionPublicKeyMsgs = newMessages; + state.unapprovedEncryptionPublicKeyMsgCount = messageCount; + }, + ); + } + + /** + * A getter for the number of 'unapproved' Messages in this.messages + * + * @returns The number of 'unapproved' Messages in this.messages + */ + get unapprovedMsgCount(): number { + return this._encryptionPublicKeyManager.getUnapprovedMessagesCount(); + } + + /** + * Reset the controller state to the initial state. + */ + resetState() { + this.update(() => getDefaultState()); + } + + /** + * Called when a Dapp uses the eth_getEncryptionPublicKey method, to request user approval. + * + * @param address - The address from the encryption public key will be extracted. + * @param [req] - The original request, containing the origin. + */ + async newRequestEncryptionPublicKey( + address: string, + req: OriginalRequest, + ): Promise { + const keyring = await this._keyringController.getKeyringForAccount(address); + + switch (keyring.type) { + case KeyringType.ledger: { + return new Promise((_, reject) => { + reject( + new Error('Ledger does not support eth_getEncryptionPublicKey.'), + ); + }); + } + + case KeyringType.trezor: { + return new Promise((_, reject) => { + reject( + new Error('Trezor does not support eth_getEncryptionPublicKey.'), + ); + }); + } + + case KeyringType.lattice: { + return new Promise((_, reject) => { + reject( + new Error('Lattice does not support eth_getEncryptionPublicKey.'), + ); + }); + } + + case KeyringType.qr: { + return Promise.reject( + new Error('QR hardware does not support eth_getEncryptionPublicKey.'), + ); + } + + default: { + return this._encryptionPublicKeyManager.addUnapprovedMessageAsync( + { from: address }, + req, + ); + } + } + } + + /** + * Signifies a user's approval to receiving encryption public key in queue. + * + * @param msgParams - The params of the message to receive & return to the Dapp. + * @returns A full state update. + */ + async encryptionPublicKey(msgParams: EncryptionPublicKeyParamsMetamask) { + log.info('MetaMaskController - encryptionPublicKey'); + const messageId = msgParams.metamaskId as string; + // sets the status op the message to 'approved' + // and removes the metamaskId for decryption + try { + const cleanMessageParams = + await this._encryptionPublicKeyManager.approveMessage(msgParams); + + // EncryptionPublicKey message + const publicKey = await this._keyringController.getEncryptionPublicKey( + cleanMessageParams.from, + ); + + // tells the listener that the message has been processed + // and can be returned to the dapp + this._encryptionPublicKeyManager.setMessageStatusAndResult( + messageId, + publicKey, + 'received', + ); + + this._acceptApproval(messageId); + + return this._getState(); + } catch (error) { + log.info( + 'MetaMaskController - eth_getEncryptionPublicKey failed.', + error, + ); + this._cancelAbstractMessage(this._encryptionPublicKeyManager, messageId); + throw error; + } + } + + /** + * Used to cancel a message submitted via eth_getEncryptionPublicKey. + * + * @param msgId - The id of the message to cancel. + */ + cancelEncryptionPublicKey(msgId: string) { + this._cancelAbstractMessage(this._encryptionPublicKeyManager, msgId); + } + + /** + * Reject all unapproved messages of any type. + * + * @param reason - A message to indicate why. + */ + rejectUnapproved(reason?: string) { + Object.keys( + this._encryptionPublicKeyManager.getUnapprovedMessages(), + ).forEach((messageId) => { + this._cancelAbstractMessage( + this._encryptionPublicKeyManager, + messageId, + reason, + ); + }); + } + + /** + * Clears all unapproved messages from memory. + */ + clearUnapproved() { + this._encryptionPublicKeyManager.update({ + unapprovedMessages: {}, + unapprovedMessagesCount: 0, + }); + } + + private _cancelAbstractMessage( + messageManager: AbstractMessageManager< + AbstractMessage, + AbstractMessageParams, + AbstractMessageParamsMetamask + >, + messageId: string, + reason?: string, + ) { + if (reason) { + this._metricsEvent({ + event: reason, + category: MetaMetricsEventCategory.Messages, + properties: { + action: 'Encryption public key Request', + }, + }); + } + + messageManager.rejectMessage(messageId); + this._rejectApproval(messageId); + + return this._getState(); + } + + private _subscribeToMessageState( + messageManager: AbstractMessageManager< + AbstractMessage, + AbstractMessageParams, + AbstractMessageParamsMetamask + >, + updateState: ( + state: EncryptionPublicKeyControllerState, + newMessages: Record, + messageCount: number, + ) => void, + ) { + messageManager.subscribe( + async (state: MessageManagerState) => { + const newMessages = await this._migrateMessages( + state.unapprovedMessages as any, + ); + this.update((draftState) => { + updateState(draftState, newMessages, state.unapprovedMessagesCount); + }); + }, + ); + } + + private async _migrateMessages( + coreMessages: Record, + ): Promise> { + const stateMessages: Record = {}; + + for (const messageId of Object.keys(coreMessages)) { + const coreMessage = coreMessages[messageId]; + const stateMessage = await this._migrateMessage(coreMessage); + + stateMessages[messageId] = stateMessage; + } + + return stateMessages; + } + + private async _migrateMessage( + coreMessage: CoreMessage, + ): Promise { + const { messageParams, ...coreMessageData } = coreMessage; + + // Core message managers use messageParams but frontend uses msgParams with lots of references + const stateMessage = { + ...coreMessageData, + rawSig: coreMessage.rawSig as string, + msgParams: messageParams.from, + origin: messageParams.origin, + }; + + return stateMessage; + } + + private _requestApproval( + msgParams: AbstractMessageParamsMetamask, + type: string, + ) { + const id = msgParams.metamaskId as string; + const origin = msgParams.origin || ORIGIN_METAMASK; + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id, + origin, + type, + }, + true, + ) + .catch(() => { + // Intentionally ignored as promise not currently used + }); + } + + private _acceptApproval(messageId: string) { + this.messagingSystem.call('ApprovalController:acceptRequest', messageId); + } + + private _rejectApproval(messageId: string) { + this.messagingSystem.call( + 'ApprovalController:rejectRequest', + messageId, + 'Cancel', + ); + } +} diff --git a/app/scripts/controllers/sign.ts b/app/scripts/controllers/sign.ts index e04d70c099a7..1712ed1ee1dd 100644 --- a/app/scripts/controllers/sign.ts +++ b/app/scripts/controllers/sign.ts @@ -104,7 +104,6 @@ export type SignControllerOptions = { messenger: SignControllerMessenger; keyringController: KeyringController; preferencesController: PreferencesController; - sendUpdate: () => void; getState: () => any; metricsEvent: (payload: any, options?: any) => void; securityProviderRequest: ( diff --git a/app/scripts/lib/encryption-public-key-manager.js b/app/scripts/lib/encryption-public-key-manager.js deleted file mode 100644 index 9791e0378e95..000000000000 --- a/app/scripts/lib/encryption-public-key-manager.js +++ /dev/null @@ -1,318 +0,0 @@ -import EventEmitter from 'events'; -import { ObservableStore } from '@metamask/obs-store'; -import { ethErrors } from 'eth-rpc-errors'; -import log from 'loglevel'; -import { MESSAGE_TYPE } from '../../../shared/constants/app'; -import { MetaMetricsEventCategory } from '../../../shared/constants/metametrics'; -import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; -import createId from '../../../shared/modules/random-id'; - -/** - * Represents, and contains data about, an 'eth_getEncryptionPublicKey' type request. These are created when - * an eth_getEncryptionPublicKey call is requested. - * - * @typedef {object} EncryptionPublicKey - * @property {number} id An id to track and identify the message object - * @property {object} msgParams The parameters to pass to the encryptionPublicKey method once the request is - * approved. - * @property {object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. - * @property {string} msgParams.data A hex string conversion of the raw buffer data of the request - * @property {number} time The epoch time at which the this message was created - * @property {string} status Indicates whether the request is 'unapproved', 'approved', 'received' or 'rejected' - * @property {string} type The json-prc method for which a request has been made. A 'Message' will - * always have a 'eth_getEncryptionPublicKey' type. - */ - -export default class EncryptionPublicKeyManager extends EventEmitter { - /** - * Controller in charge of managing - storing, adding, removing, updating - EncryptionPublicKey. - * - * @param {object} opts - Controller options - * @param {Function} opts.metricEvent - A function for emitting a metric event. - */ - constructor(opts) { - super(); - this.memStore = new ObservableStore({ - unapprovedEncryptionPublicKeyMsgs: {}, - unapprovedEncryptionPublicKeyMsgCount: 0, - }); - - this.resetState = () => { - this.memStore.updateState({ - unapprovedEncryptionPublicKeyMsgs: {}, - unapprovedEncryptionPublicKeyMsgCount: 0, - }); - }; - - this.messages = []; - this.metricsEvent = opts.metricsEvent; - } - - /** - * A getter for the number of 'unapproved' EncryptionPublicKeys in this.messages - * - * @returns {number} The number of 'unapproved' EncryptionPublicKeys in this.messages - */ - get unapprovedEncryptionPublicKeyMsgCount() { - return Object.keys(this.getUnapprovedMsgs()).length; - } - - /** - * A getter for the 'unapproved' EncryptionPublicKeys in this.messages - * - * @returns {object} An index of EncryptionPublicKey ids to EncryptionPublicKeys, for all 'unapproved' EncryptionPublicKeys in - * this.messages - */ - getUnapprovedMsgs() { - return this.messages - .filter((msg) => msg.status === 'unapproved') - .reduce((result, msg) => { - result[msg.id] = msg; - return result; - }, {}); - } - - /** - * Creates a new EncryptionPublicKey with an 'unapproved' status using the passed msgParams. this.addMsg is called to add - * the new EncryptionPublicKey to this.messages, and to save the unapproved EncryptionPublicKeys from that list to - * this.memStore. - * - * @param {object} address - The param for the eth_getEncryptionPublicKey call to be made after the message is approved. - * @param {object} [req] - The original request object possibly containing the origin - * @returns {Promise} The raw public key contents - */ - addUnapprovedMessageAsync(address, req) { - return new Promise((resolve, reject) => { - if (!address) { - reject(new Error('MetaMask Message: address field is required.')); - return; - } - const msgId = this.addUnapprovedMessage(address, req); - this.once(`${msgId}:finished`, (data) => { - switch (data.status) { - case 'received': - resolve(data.rawData); - return; - case 'rejected': - reject( - ethErrors.provider.userRejectedRequest( - 'MetaMask EncryptionPublicKey: User denied message EncryptionPublicKey.', - ), - ); - return; - default: - reject( - new Error( - `MetaMask EncryptionPublicKey: Unknown problem: ${JSON.stringify( - address, - )}`, - ), - ); - } - }); - }); - } - - /** - * Creates a new EncryptionPublicKey with an 'unapproved' status using the passed msgParams. this.addMsg is called to add - * the new EncryptionPublicKey to this.messages, and to save the unapproved EncryptionPublicKeys from that list to - * this.memStore. - * - * @param {object} address - The param for the eth_getEncryptionPublicKey call to be made after the message is approved. - * @param {object} [req] - The original request object possibly containing the origin - * @returns {number} The id of the newly created EncryptionPublicKey. - */ - addUnapprovedMessage(address, req) { - log.debug(`EncryptionPublicKeyManager addUnapprovedMessage: address`); - // create txData obj with parameters and meta data - const time = new Date().getTime(); - const msgId = createId(); - const msgData = { - id: msgId, - msgParams: address, - time, - status: 'unapproved', - type: MESSAGE_TYPE.ETH_GET_ENCRYPTION_PUBLIC_KEY, - }; - - if (req) { - msgData.origin = req.origin; - } - - this.addMsg(msgData); - - // signal update - this.emit('update'); - return msgId; - } - - /** - * Adds a passed EncryptionPublicKey to this.messages, and calls this._saveMsgList() to save the unapproved EncryptionPublicKeys from that - * list to this.memStore. - * - * @param {Message} msg - The EncryptionPublicKey to add to this.messages - */ - addMsg(msg) { - this.messages.push(msg); - this._saveMsgList(); - } - - /** - * Returns a specified EncryptionPublicKey. - * - * @param {number} msgId - The id of the EncryptionPublicKey to get - * @returns {EncryptionPublicKey|undefined} The EncryptionPublicKey with the id that matches the passed msgId, or undefined - * if no EncryptionPublicKey has that id. - */ - getMsg(msgId) { - return this.messages.find((msg) => msg.id === msgId); - } - - /** - * Approves a EncryptionPublicKey. Sets the message status via a call to this.setMsgStatusApproved, and returns a promise - * with any the message params modified for proper providing. - * - * @param {object} msgParams - The msgParams to be used when eth_getEncryptionPublicKey is called, plus data added by MetaMask. - * @param {object} msgParams.metamaskId - Added to msgParams for tracking and identification within MetaMask. - * @returns {Promise} Promises the msgParams object with metamaskId removed. - */ - approveMessage(msgParams) { - this.setMsgStatusApproved(msgParams.metamaskId); - return this.prepMsgForEncryptionPublicKey(msgParams); - } - - /** - * Sets a EncryptionPublicKey status to 'approved' via a call to this._setMsgStatus. - * - * @param {number} msgId - The id of the EncryptionPublicKey to approve. - */ - setMsgStatusApproved(msgId) { - this._setMsgStatus(msgId, 'approved'); - } - - /** - * Sets a EncryptionPublicKey status to 'received' via a call to this._setMsgStatus and updates that EncryptionPublicKey in - * this.messages by adding the raw data of request to the EncryptionPublicKey - * - * @param {number} msgId - The id of the EncryptionPublicKey. - * @param {buffer} rawData - The raw data of the message request - */ - setMsgStatusReceived(msgId, rawData) { - const msg = this.getMsg(msgId); - msg.rawData = rawData; - this._updateMsg(msg); - this._setMsgStatus(msgId, 'received'); - } - - /** - * Removes the metamaskId property from passed msgParams and returns a promise which resolves the updated msgParams - * - * @param {object} msgParams - The msgParams to modify - * @returns {Promise} Promises the msgParams with the metamaskId property removed - */ - async prepMsgForEncryptionPublicKey(msgParams) { - delete msgParams.metamaskId; - return msgParams; - } - - /** - * Sets a EncryptionPublicKey status to 'rejected' via a call to this._setMsgStatus. - * - * @param {number} msgId - The id of the EncryptionPublicKey to reject. - * @param reason - */ - rejectMsg(msgId, reason = undefined) { - if (reason) { - this.metricsEvent({ - event: reason, - category: MetaMetricsEventCategory.Messages, - properties: { - action: 'Encryption public key Request', - }, - }); - } - this._setMsgStatus(msgId, 'rejected'); - } - - /** - * Sets a TypedMessage status to 'errored' via a call to this._setMsgStatus. - * - * @param {number} msgId - The id of the TypedMessage to error - * @param error - */ - errorMessage(msgId, error) { - const msg = this.getMsg(msgId); - msg.error = error; - this._updateMsg(msg); - this._setMsgStatus(msgId, 'errored'); - } - - /** - * Clears all unapproved messages from memory. - */ - clearUnapproved() { - this.messages = this.messages.filter((msg) => msg.status !== 'unapproved'); - this._saveMsgList(); - } - - /** - * Updates the status of a EncryptionPublicKey in this.messages via a call to this._updateMsg - * - * @private - * @param {number} msgId - The id of the EncryptionPublicKey to update. - * @param {string} status - The new status of the EncryptionPublicKey. - * @throws A 'EncryptionPublicKeyManager - EncryptionPublicKey not found for id: "${msgId}".' if there is no EncryptionPublicKey - * in this.messages with an id equal to the passed msgId - * @fires An event with a name equal to `${msgId}:${status}`. The EncryptionPublicKey is also fired. - * @fires If status is 'rejected' or 'received', an event with a name equal to `${msgId}:finished` is fired along - * with the EncryptionPublicKey - */ - _setMsgStatus(msgId, status) { - const msg = this.getMsg(msgId); - if (!msg) { - throw new Error( - `EncryptionPublicKeyManager - Message not found for id: "${msgId}".`, - ); - } - msg.status = status; - this._updateMsg(msg); - this.emit(`${msgId}:${status}`, msg); - if (status === 'rejected' || status === 'received') { - this.emit(`${msgId}:finished`, msg); - } - } - - /** - * Sets a EncryptionPublicKey in this.messages to the passed EncryptionPublicKey if the ids are equal. Then saves the - * unapprovedEncryptionPublicKeyMsgs index to storage via this._saveMsgList - * - * @private - * @param {EncryptionPublicKey} msg - A EncryptionPublicKey that will replace an existing EncryptionPublicKey (with the same - * id) in this.messages - */ - _updateMsg(msg) { - const index = this.messages.findIndex((message) => message.id === msg.id); - if (index !== -1) { - this.messages[index] = msg; - } - this._saveMsgList(); - } - - /** - * Saves the unapproved EncryptionPublicKeys, and their count, to this.memStore - * - * @private - * @fires 'updateBadge' - */ - _saveMsgList() { - const unapprovedEncryptionPublicKeyMsgs = this.getUnapprovedMsgs(); - const unapprovedEncryptionPublicKeyMsgCount = Object.keys( - unapprovedEncryptionPublicKeyMsgs, - ).length; - this.memStore.updateState({ - unapprovedEncryptionPublicKeyMsgs, - unapprovedEncryptionPublicKeyMsgCount, - }); - this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); - } -} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 7315f301f574..133f5a877cd8 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -155,7 +155,6 @@ import OnboardingController from './controllers/onboarding'; import BackupController from './controllers/backup'; import IncomingTransactionsController from './controllers/incoming-transactions'; import DecryptMessageManager from './lib/decrypt-message-manager'; -import EncryptionPublicKeyManager from './lib/encryption-public-key-manager'; import TransactionController from './controllers/transactions'; import DetectTokensController from './controllers/detect-tokens'; import SwapsController from './controllers/swaps'; @@ -167,6 +166,7 @@ import createMetaRPCHandler from './lib/createMetaRPCHandler'; import { previousValueComparator } from './lib/util'; import createMetamaskMiddleware from './lib/createMetamaskMiddleware'; import SignController from './controllers/sign'; +import EncryptionPublicKeyController from './controllers/encryption-public-key'; import { CaveatMutatorFactories, @@ -1125,7 +1125,18 @@ export default class MetamaskController extends EventEmitter { this.metaMetricsController, ), }); - this.encryptionPublicKeyManager = new EncryptionPublicKeyManager({ + + this.encryptionPublicKeyController = new EncryptionPublicKeyController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'EncryptionPublicKeyController', + allowedActions: [ + `${this.approvalController.name}:addRequest`, + `${this.approvalController.name}:acceptRequest`, + `${this.approvalController.name}:rejectRequest`, + ], + }), + keyringController: this.keyringController, + getState: this.getState.bind(this), metricsEvent: this.metaMetricsController.trackEvent.bind( this.metaMetricsController, ), @@ -1207,7 +1218,7 @@ export default class MetamaskController extends EventEmitter { NetworkControllerEventType.NetworkWillChange, () => { this.txController.txStateManager.clearUnapprovedTxs(); - this.encryptionPublicKeyManager.clearUnapproved(); + this.encryptionPublicKeyController.clearUnapproved(); this.decryptMessageManager.clearUnapproved(); this.signController.clearUnapproved(); }, @@ -1274,7 +1285,10 @@ export default class MetamaskController extends EventEmitter { this.signController, ), processDecryptMessage: this.newRequestDecryptMessage.bind(this), - processEncryptionPublicKey: this.newRequestEncryptionPublicKey.bind(this), + processEncryptionPublicKey: + this.encryptionPublicKeyController.newRequestEncryptionPublicKey.bind( + this.encryptionPublicKeyController, + ), getPendingNonce: this.getPendingNonce.bind(this), getPendingTransactionByHash: (hash) => this.txController.getTransactions({ @@ -1297,7 +1311,7 @@ export default class MetamaskController extends EventEmitter { TxController: this.txController.memStore, TokenRatesController: this.tokenRatesController, DecryptMessageManager: this.decryptMessageManager.memStore, - EncryptionPublicKeyManager: this.encryptionPublicKeyManager.memStore, + EncryptionPublicKeyController: this.encryptionPublicKeyController, SignController: this.signController, SwapsController: this.swapsController.store, EnsController: this.ensController.store, @@ -1379,7 +1393,9 @@ export default class MetamaskController extends EventEmitter { this.accountTracker.resetState, this.txController.resetState, this.decryptMessageManager.resetState, - this.encryptionPublicKeyManager.resetState, + this.encryptionPublicKeyController.resetState.bind( + this.encryptionPublicKeyController, + ), this.signController.resetState.bind(this.signController), this.swapsController.resetState, this.ensController.resetState, @@ -2090,9 +2106,15 @@ export default class MetamaskController extends EventEmitter { decryptMessageInline: this.decryptMessageInline.bind(this), cancelDecryptMessage: this.cancelDecryptMessage.bind(this), - // EncryptionPublicKeyManager - encryptionPublicKey: this.encryptionPublicKey.bind(this), - cancelEncryptionPublicKey: this.cancelEncryptionPublicKey.bind(this), + // EncryptionPublicKeyController + encryptionPublicKey: + this.encryptionPublicKeyController.encryptionPublicKey.bind( + this.encryptionPublicKeyController, + ), + cancelEncryptionPublicKey: + this.encryptionPublicKeyController.cancelEncryptionPublicKey.bind( + this.encryptionPublicKeyController, + ), // onboarding controller setSeedPhraseBackedUp: @@ -3317,109 +3339,6 @@ export default class MetamaskController extends EventEmitter { return this.getState(); } - // eth_getEncryptionPublicKey methods - - /** - * Called when a dapp uses the eth_getEncryptionPublicKey method. - * - * @param {object} msgParams - The params of the message to sign & return to the Dapp. - * @param {object} req - (optional) the original request, containing the origin - * Passed back to the requesting Dapp. - */ - async newRequestEncryptionPublicKey(msgParams, req) { - const address = msgParams; - const keyring = await this.keyringController.getKeyringForAccount(address); - - switch (keyring.type) { - case KeyringType.ledger: { - return new Promise((_, reject) => { - reject( - new Error('Ledger does not support eth_getEncryptionPublicKey.'), - ); - }); - } - - case KeyringType.trezor: { - return new Promise((_, reject) => { - reject( - new Error('Trezor does not support eth_getEncryptionPublicKey.'), - ); - }); - } - - case KeyringType.lattice: { - return new Promise((_, reject) => { - reject( - new Error('Lattice does not support eth_getEncryptionPublicKey.'), - ); - }); - } - - case KeyringType.qr: { - return Promise.reject( - new Error('QR hardware does not support eth_getEncryptionPublicKey.'), - ); - } - - default: { - const promise = - this.encryptionPublicKeyManager.addUnapprovedMessageAsync( - msgParams, - req, - ); - this.sendUpdate(); - this.opts.showUserConfirmation(); - return promise; - } - } - } - - /** - * Signifies a user's approval to receiving encryption public key in queue. - * Triggers receiving, and the callback function from newUnsignedEncryptionPublicKey. - * - * @param {object} msgParams - The params of the message to receive & return to the Dapp. - * @returns {Promise} A full state update. - */ - async encryptionPublicKey(msgParams) { - log.info('MetaMaskController - encryptionPublicKey'); - const msgId = msgParams.metamaskId; - // sets the status op the message to 'approved' - // and removes the metamaskId for decryption - try { - const params = await this.encryptionPublicKeyManager.approveMessage( - msgParams, - ); - - // EncryptionPublicKey message - const publicKey = await this.keyringController.getEncryptionPublicKey( - params.data, - ); - - // tells the listener that the message has been processed - // and can be returned to the dapp - this.encryptionPublicKeyManager.setMsgStatusReceived(msgId, publicKey); - } catch (error) { - log.info( - 'MetaMaskController - eth_getEncryptionPublicKey failed.', - error, - ); - this.encryptionPublicKeyManager.errorMessage(msgId, error); - } - return this.getState(); - } - - /** - * Used to cancel a eth_getEncryptionPublicKey type message. - * - * @param {string} msgId - The ID of the message to cancel. - */ - cancelEncryptionPublicKey(msgId) { - const messageManager = this.encryptionPublicKeyManager; - messageManager.rejectMsg(msgId); - return this.getState(); - } - /** * @returns {boolean} true if the keyring type supports EIP-1559 */ diff --git a/package.json b/package.json index 3b6b5f057fe9..ac58eca27183 100644 --- a/package.json +++ b/package.json @@ -246,7 +246,7 @@ "@metamask/jazzicon": "^2.0.0", "@metamask/key-tree": "^7.0.0", "@metamask/logo": "^3.1.1", - "@metamask/message-manager": "^2.1.0", + "@metamask/message-manager": "^3.0.0", "@metamask/metamask-eth-abis": "^3.0.0", "@metamask/notification-controller": "^2.0.0", "@metamask/obs-store": "^8.1.0", diff --git a/types/eth-keyring-controller.d.ts b/types/eth-keyring-controller.d.ts index 86d8ffc6b07b..81145fa6053a 100644 --- a/types/eth-keyring-controller.d.ts +++ b/types/eth-keyring-controller.d.ts @@ -5,5 +5,11 @@ declare module '@metamask/eth-keyring-controller' { signPersonalMessage: (...any) => any; signTypedMessage: (...any) => any; + + getKeyringForAccount: (address: string) => Promise<{ + type: string; + }>; + + getEncryptionPublicKey: (address: string) => Promise; } } diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index b4e04c7f611f..1b48a1e849f8 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -483,15 +483,11 @@ export function getCurrentCurrency(state) { } export function getTotalUnapprovedCount(state) { - const { - unapprovedDecryptMsgCount = 0, - unapprovedEncryptionPublicKeyMsgCount = 0, - pendingApprovalCount = 0, - } = state.metamask; + const { unapprovedDecryptMsgCount = 0, pendingApprovalCount = 0 } = + state.metamask; return ( unapprovedDecryptMsgCount + - unapprovedEncryptionPublicKeyMsgCount + pendingApprovalCount + getSuggestedAssetCount(state) ); diff --git a/yarn.lock b/yarn.lock index 475744a264df..b2d8e4cee53e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4015,9 +4015,9 @@ __metadata: languageName: node linkType: hard -"@metamask/message-manager@npm:^2.1.0": - version: 2.1.0 - resolution: "@metamask/message-manager@npm:2.1.0" +"@metamask/message-manager@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/message-manager@npm:3.0.0" dependencies: "@metamask/base-controller": ^2.0.0 "@metamask/controller-utils": ^3.1.0 @@ -4026,7 +4026,7 @@ __metadata: ethereumjs-util: ^7.0.10 jsonschema: ^1.2.4 uuid: ^8.3.2 - checksum: f3a233a84aec73051f8f1183dab32c4d9a976edaa3c6461b118a8e6f20cf43f8757827ad6c877aed635ef850944ce054af03b34592f5b72f5d0667fa8b179dc9 + checksum: 14e0a4a398d95ce720e515bd1f35aee7b7b9f5f59367210a9125fe66fb561b630ae51b61f32048767f0bb30dd4a2e442e47c8d850de78f820feda7f72e4dc05e languageName: node linkType: hard @@ -24252,7 +24252,7 @@ __metadata: "@metamask/jazzicon": ^2.0.0 "@metamask/key-tree": ^7.0.0 "@metamask/logo": ^3.1.1 - "@metamask/message-manager": ^2.1.0 + "@metamask/message-manager": ^3.0.0 "@metamask/metamask-eth-abis": ^3.0.0 "@metamask/notification-controller": ^2.0.0 "@metamask/obs-store": ^8.1.0