From c8f8a4e6cb3f053001b161d17626fdda802944eb Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero <bernardo.chapero@consensys.net> Date: Thu, 15 Jun 2023 11:18:27 +0100 Subject: [PATCH] Refactor keyring to split bridge logic (#156) --- jest.config.js | 8 +- src/index.ts | 4 +- src/ledger-bridge.ts | 54 ++ src/ledger-iframe-bridge.test.ts | 482 ++++++++++++++++ src/ledger-iframe-bridge.ts | 312 +++++++++++ ...keyring.test.ts => ledger-keyring.test.ts} | 345 +++++------- ...er-bridge-keyring.ts => ledger-keyring.ts} | 514 +++++------------- 7 files changed, 1114 insertions(+), 605 deletions(-) create mode 100644 src/ledger-bridge.ts create mode 100644 src/ledger-iframe-bridge.test.ts create mode 100644 src/ledger-iframe-bridge.ts rename src/{ledger-bridge-keyring.test.ts => ledger-keyring.test.ts} (73%) rename src/{ledger-bridge-keyring.ts => ledger-keyring.ts} (58%) diff --git a/jest.config.js b/jest.config.js index d79019c5..178632c7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -41,10 +41,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 59.25, - functions: 81.94, - lines: 78.54, - statements: 78.49, + branches: 65.42, + functions: 88.57, + lines: 81.57, + statements: 81.49, }, }, diff --git a/src/index.ts b/src/index.ts index c75413da..b08ff492 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ -export * from './ledger-bridge-keyring'; +export * from './ledger-keyring'; +export * from './ledger-iframe-bridge'; +export * from './ledger-bridge'; diff --git a/src/ledger-bridge.ts b/src/ledger-bridge.ts new file mode 100644 index 00000000..bcee7afe --- /dev/null +++ b/src/ledger-bridge.ts @@ -0,0 +1,54 @@ +import type LedgerHwAppEth from '@ledgerhq/hw-app-eth'; + +export type GetPublicKeyParams = { hdPath: string }; +export type GetPublicKeyResponse = Awaited< + ReturnType<LedgerHwAppEth['getAddress']> +> & { + chainCode: string; +}; + +export type LedgerSignTransactionParams = { hdPath: string; tx: string }; +export type LedgerSignTransactionResponse = Awaited< + ReturnType<LedgerHwAppEth['signTransaction']> +>; + +export type LedgerSignMessageParams = { hdPath: string; message: string }; +export type LedgerSignMessageResponse = Awaited< + ReturnType<LedgerHwAppEth['signPersonalMessage']> +>; + +export type LedgerSignTypedDataParams = { + hdPath: string; + domainSeparatorHex: string; + hashStructMessageHex: string; +}; +export type LedgerSignTypedDataResponse = Awaited< + ReturnType<LedgerHwAppEth['signEIP712HashedMessage']> +>; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface LedgerBridge { + isDeviceConnected: boolean; + + init(bridgeUrl: string): Promise<void>; + + destroy(): Promise<void>; + + attemptMakeApp(): Promise<boolean>; + + updateTransportMethod(transportType: string): Promise<boolean>; + + getPublicKey(params: GetPublicKeyParams): Promise<GetPublicKeyResponse>; + + deviceSignTransaction( + params: LedgerSignTransactionParams, + ): Promise<LedgerSignTransactionResponse>; + + deviceSignMessage( + params: LedgerSignMessageParams, + ): Promise<LedgerSignMessageResponse>; + + deviceSignTypedData( + params: LedgerSignTypedDataParams, + ): Promise<LedgerSignTypedDataResponse>; +} diff --git a/src/ledger-iframe-bridge.test.ts b/src/ledger-iframe-bridge.test.ts new file mode 100644 index 00000000..161eaf69 --- /dev/null +++ b/src/ledger-iframe-bridge.test.ts @@ -0,0 +1,482 @@ +import { hasProperty } from '@metamask/utils'; + +import { + IFrameMessageAction, + LedgerIframeBridge, +} from './ledger-iframe-bridge'; +import documentShim from '../test/document.shim'; +import windowShim from '../test/window.shim'; + +global.document = documentShim; +global.window = windowShim; + +// eslint-disable-next-line no-restricted-globals +type HTMLIFrameElementShim = HTMLIFrameElement; +// eslint-disable-next-line no-restricted-globals +type WindowShim = Window; + +/** + * Checks if the iframe provided has a valid contentWindow + * and onload function. + * + * @param iframe - The iframe to check. + * @returns Returns true if the iframe is valid, false otherwise. + */ +function isIFrameValid( + iframe?: HTMLIFrameElementShim, +): iframe is HTMLIFrameElementShim & { contentWindow: WindowShim } & { + onload: () => any; +} { + return ( + iframe !== undefined && + hasProperty(iframe, 'contentWindow') && + typeof iframe.onload === 'function' && + hasProperty(iframe.contentWindow as WindowShim, 'postMessage') + ); +} + +/** + * Simulates the loading of an iframe by calling the onload function. + * + * @param iframe - The iframe to simulate the loading of. + * @returns Returns a promise that resolves when the onload function is called. + */ +async function simulateIFrameLoad(iframe?: HTMLIFrameElementShim) { + if (!isIFrameValid(iframe)) { + throw new Error('the iframe is not valid'); + } + // we call manually the onload event to simulate the iframe loading + return await iframe.onload(); +} + +const LEDGER_IFRAME_ID = 'LEDGER-IFRAME'; + +describe('LedgerIframeBridge', function () { + let bridge: LedgerIframeBridge; + + /** + * Stubs the postMessage function of the keyring iframe. + * + * @param bridgeInstance - The bridge instance to stub. + * @param fn - The function to call when the postMessage function is called. + */ + function stubKeyringIFramePostMessage( + bridgeInstance: LedgerIframeBridge, + fn: (message: any) => void, + ) { + if (!isIFrameValid(bridgeInstance.iframe)) { + throw new Error('the iframe is not valid'); + } + + jest + .spyOn(bridgeInstance.iframe.contentWindow, 'postMessage') + .mockImplementation(fn); + } + + beforeEach(async function () { + bridge = new LedgerIframeBridge(); + await bridge.init('bridgeUrl'); + await simulateIFrameLoad(bridge.iframe); + }); + + afterEach(function () { + jest.clearAllMocks(); + }); + + describe('init', function () { + it('sets up the listener and iframe', async function () { + bridge = new LedgerIframeBridge(); + + const addEventListenerSpy = jest.spyOn(global.window, 'addEventListener'); + + await bridge.init('bridgeUrl'); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(bridge.iframeLoaded).toBe(false); + + await simulateIFrameLoad(bridge.iframe); + expect(bridge.iframeLoaded).toBe(true); + }); + }); + + describe('destroy', function () { + it('removes the message event listener', async function () { + const removeEventListenerSpy = jest.spyOn( + global.window, + 'removeEventListener', + ); + + await bridge.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('attemptMakeApp', function () { + it('sends and processes a successful ledger-make-app message', async function () { + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerMakeApp, + messageId: 1, + target: LEDGER_IFRAME_ID, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerMakeApp, + messageId: 1, + success: true, + }); + }); + + const result = await bridge.attemptMakeApp(); + + expect(result).toBe(true); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-make-app message is not successful', async function () { + const errorMessage = 'Ledger Error'; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerMakeApp, + messageId: 1, + target: LEDGER_IFRAME_ID, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerMakeApp, + messageId: 1, + success: false, + error: new Error(errorMessage), + }); + }); + + await expect(bridge.attemptMakeApp()).rejects.toThrow(errorMessage); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); + + describe('updateTransportMethod', function () { + it('sends and processes a successful ledger-update-transport message', async function () { + bridge.iframeLoaded = true; + + const transportType = 'u2f'; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerUpdateTransport, + messageId: 1, + target: LEDGER_IFRAME_ID, + params: { transportType }, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerUpdateTransport, + messageId: 1, + success: true, + }); + }); + + const result = await bridge.updateTransportMethod(transportType); + + expect(result).toBe(true); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-update-transport message is not successful', async function () { + bridge.iframeLoaded = true; + + const transportType = 'u2f'; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: 'ledger-update-transport', + messageId: 1, + params: { transportType }, + target: LEDGER_IFRAME_ID, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerUpdateTransport, + messageId: 1, + success: false, + }); + }); + + await expect(bridge.updateTransportMethod(transportType)).rejects.toThrow( + 'Ledger transport could not be updated', + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); + + describe('getPublicKey', function () { + it('sends and processes a successful ledger-unlock message', async function () { + const payload = { + publicKey: '', + address: '', + chainCode: '', + }; + const params = { + hdPath: "m/44'/60'/0'/0", + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerUnlock, + params, + messageId: 1, + target: LEDGER_IFRAME_ID, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerUnlock, + messageId: 1, + success: true, + payload, + }); + }); + + const result = await bridge.getPublicKey(params); + + expect(result).toBe(payload); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-unlock message is not successful', async function () { + const errorMessage = 'Ledger Error'; + const params = { + hdPath: "m/44'/60'/0'/0", + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerUnlock, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerUnlock, + messageId: 1, + success: false, + payload: { error: new Error(errorMessage) }, + }); + }); + + await expect(bridge.getPublicKey(params)).rejects.toThrow(errorMessage); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); + + describe('deviceSignTransaction', function () { + it('sends and processes a successful ledger-sign-transaction message', async function () { + const payload = { + v: '', + r: '', + s: '', + }; + const params = { + hdPath: "m/44'/60'/0'/0", + tx: '', + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignTransaction, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignTransaction, + messageId: 1, + success: true, + payload, + }); + }); + + const result = await bridge.deviceSignTransaction(params); + + expect(result).toBe(payload); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-sign-transaction message is not successful', async function () { + const errorMessage = 'Ledger Error'; + const params = { hdPath: "m/44'/60'/0'/0", tx: '' }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignTransaction, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignTransaction, + messageId: 1, + success: false, + payload: { error: new Error(errorMessage) }, + }); + }); + + await expect(bridge.deviceSignTransaction(params)).rejects.toThrow( + errorMessage, + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); + + describe('deviceSignMessage', function () { + it('sends and processes a successful ledger-sign-personal-message message', async function () { + const payload = { + v: 0, + r: '', + s: '', + }; + const params = { hdPath: "m/44'/60'/0'/0", message: '' }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignPersonalMessage, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignPersonalMessage, + messageId: 1, + success: true, + payload, + }); + }); + + const result = await bridge.deviceSignMessage(params); + + expect(result).toBe(payload); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-sign-personal-message message is not successful', async function () { + const errorMessage = 'Ledger Error'; + const params = { hdPath: "m/44'/60'/0'/0", message: '' }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignPersonalMessage, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignPersonalMessage, + messageId: 1, + success: false, + payload: { error: new Error(errorMessage) }, + }); + }); + + await expect(bridge.deviceSignMessage(params)).rejects.toThrow( + errorMessage, + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); + + describe('deviceSignTypedData', function () { + it('sends and processes a successful ledger-sign-typed-data message', async function () { + const payload = { + v: 0, + r: '', + s: '', + }; + const params = { + hdPath: "m/44'/60'/0'/0", + domainSeparatorHex: '', + hashStructMessageHex: '', + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignTypedData, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignTypedData, + messageId: 1, + success: true, + payload, + }); + }); + + const result = await bridge.deviceSignTypedData(params); + + expect(result).toBe(payload); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-sign-typed-data message is not successful', async function () { + const errorMessage = 'Ledger Error'; + const params = { + hdPath: "m/44'/60'/0'/0", + domainSeparatorHex: '', + hashStructMessageHex: '', + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerSignTypedData, + messageId: 1, + target: LEDGER_IFRAME_ID, + params, + }); + + bridge.messageCallbacks[message.messageId]?.({ + action: IFrameMessageAction.LedgerSignTypedData, + messageId: 1, + success: false, + payload: { error: new Error(errorMessage) }, + }); + }); + + await expect(bridge.deviceSignTypedData(params)).rejects.toThrow( + errorMessage, + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ledger-iframe-bridge.ts b/src/ledger-iframe-bridge.ts new file mode 100644 index 00000000..a5ffe5f8 --- /dev/null +++ b/src/ledger-iframe-bridge.ts @@ -0,0 +1,312 @@ +import { + GetPublicKeyParams, + GetPublicKeyResponse, + LedgerBridge, + LedgerSignMessageParams, + LedgerSignMessageResponse, + LedgerSignTransactionParams, + LedgerSignTransactionResponse, + LedgerSignTypedDataParams, + LedgerSignTypedDataResponse, +} from './ledger-bridge'; + +const LEDGER_IFRAME_ID = 'LEDGER-IFRAME'; + +export enum IFrameMessageAction { + LedgerConnectionChange = 'ledger-connection-change', + LedgerUnlock = 'ledger-unlock', + LedgerMakeApp = 'ledger-make-app', + LedgerUpdateTransport = 'ledger-update-transport', + LedgerSignTransaction = 'ledger-sign-transaction', + LedgerSignPersonalMessage = 'ledger-sign-personal-message', + LedgerSignTypedData = 'ledger-sign-typed-data', +} + +type IFrameMessageResponse<TAction extends IFrameMessageAction> = { + action: TAction; + messageId: number; +} & ( + | { + action: IFrameMessageAction.LedgerConnectionChange; + payload: { connected: boolean }; + } + | ({ + action: IFrameMessageAction.LedgerMakeApp; + } & ({ success: true } | { success: false; error?: unknown })) + | { + action: IFrameMessageAction.LedgerUpdateTransport; + success: boolean; + } + | ({ + action: IFrameMessageAction.LedgerUnlock; + } & ( + | { success: true; payload: GetPublicKeyResponse } + | { success: false; payload: { error: Error } } + )) + | ({ + action: IFrameMessageAction.LedgerSignTransaction; + } & ( + | { success: true; payload: LedgerSignTransactionResponse } + | { success: false; payload: { error: Error } } + )) + | ({ + action: + | IFrameMessageAction.LedgerSignPersonalMessage + | IFrameMessageAction.LedgerSignTypedData; + } & ( + | { + success: true; + payload: LedgerSignMessageResponse | LedgerSignTypedDataResponse; + } + | { success: false; payload: { error: Error } } + )) +); + +type IFrameMessage<TAction extends IFrameMessageAction> = { + action: TAction; + params?: Readonly<Record<string, unknown>>; +}; + +type IFramePostMessage<TAction extends IFrameMessageAction> = + IFrameMessage<TAction> & { + messageId: number; + target: typeof LEDGER_IFRAME_ID; + }; + +export class LedgerIframeBridge implements LedgerBridge { + iframe?: HTMLIFrameElement; + + iframeLoaded = false; + + eventListener?: (eventMessage: { + origin: string; + data: IFrameMessageResponse<IFrameMessageAction>; + }) => void; + + isDeviceConnected = false; + + currentMessageId = 0; + + messageCallbacks: Record< + number, + (response: IFrameMessageResponse<IFrameMessageAction>) => void + > = {}; + + delayedPromise?: { + resolve: (value: boolean) => void; + reject: (error: unknown) => void; + transportType: string; + }; + + async init(bridgeUrl: string) { + this.#setupIframe(bridgeUrl); + + this.eventListener = this.#eventListener.bind(this, bridgeUrl); + + window.addEventListener('message', this.eventListener); + } + + async destroy() { + if (this.eventListener) { + window.removeEventListener('message', this.eventListener); + } + } + + async attemptMakeApp(): Promise<boolean> { + return new Promise((resolve, reject) => { + this.#sendMessage( + { + action: IFrameMessageAction.LedgerMakeApp, + }, + (response) => { + if (response.success) { + resolve(true); + } else { + reject(response.error); + } + }, + ); + }); + } + + async updateTransportMethod(transportType: string): Promise<boolean> { + return new Promise((resolve, reject) => { + // If the iframe isn't loaded yet, let's store the desired transportType value and + // optimistically return a successful promise + if (!this.iframeLoaded) { + this.delayedPromise = { + resolve, + reject, + transportType, + }; + return; + } + + this.#sendMessage( + { + action: IFrameMessageAction.LedgerUpdateTransport, + params: { transportType }, + }, + ({ success }) => { + if (success) { + return resolve(true); + } + return reject(new Error('Ledger transport could not be updated')); + }, + ); + }); + } + + async getPublicKey( + params: GetPublicKeyParams, + ): Promise<GetPublicKeyResponse> { + return this.#deviceActionMessage(IFrameMessageAction.LedgerUnlock, params); + } + + async deviceSignTransaction( + params: LedgerSignTransactionParams, + ): Promise<LedgerSignTransactionResponse> { + return this.#deviceActionMessage( + IFrameMessageAction.LedgerSignTransaction, + params, + ); + } + + async deviceSignMessage( + params: LedgerSignMessageParams, + ): Promise<LedgerSignMessageResponse> { + return this.#deviceActionMessage( + IFrameMessageAction.LedgerSignPersonalMessage, + params, + ); + } + + async deviceSignTypedData( + params: LedgerSignTypedDataParams, + ): Promise<LedgerSignTypedDataResponse> { + return this.#deviceActionMessage( + IFrameMessageAction.LedgerSignTypedData, + params, + ); + } + + async #deviceActionMessage( + action: IFrameMessageAction.LedgerUnlock, + params: GetPublicKeyParams, + ): Promise<GetPublicKeyResponse>; + + async #deviceActionMessage( + action: IFrameMessageAction.LedgerSignTransaction, + params: LedgerSignTransactionParams, + ): Promise<LedgerSignTransactionResponse>; + + async #deviceActionMessage( + action: IFrameMessageAction.LedgerSignPersonalMessage, + params: LedgerSignMessageParams, + ): Promise<LedgerSignMessageResponse>; + + async #deviceActionMessage( + action: IFrameMessageAction.LedgerSignTypedData, + params: LedgerSignTypedDataParams, + ): Promise<LedgerSignTypedDataResponse>; + + async #deviceActionMessage( + ...[action, params]: + | [IFrameMessageAction.LedgerUnlock, GetPublicKeyParams] + | [IFrameMessageAction.LedgerSignTransaction, LedgerSignTransactionParams] + | [IFrameMessageAction.LedgerSignPersonalMessage, LedgerSignMessageParams] + | [IFrameMessageAction.LedgerSignTypedData, LedgerSignTypedDataParams] + ) { + return new Promise((resolve, reject) => { + this.#sendMessage( + { + action, + params, + }, + ({ success, payload }) => { + if (success) { + return resolve(payload); + } + return reject(payload.error); + }, + ); + }); + } + + #setupIframe(bridgeUrl: string) { + this.iframe = document.createElement('iframe'); + this.iframe.src = bridgeUrl; + this.iframe.allow = `hid 'src'`; + this.iframe.onload = async () => { + // If the ledger live preference was set before the iframe is loaded, + // set it after the iframe has loaded + this.iframeLoaded = true; + if (this.delayedPromise) { + try { + const result = await this.updateTransportMethod( + this.delayedPromise.transportType, + ); + this.delayedPromise.resolve(result); + } catch (error) { + this.delayedPromise.reject(error); + } finally { + delete this.delayedPromise; + } + } + }; + document.head.appendChild(this.iframe); + } + + #getOrigin(bridgeUrl: string) { + const tmp = bridgeUrl.split('/'); + tmp.splice(-1, 1); + return tmp.join('/'); + } + + #eventListener( + bridgeUrl: string, + eventMessage: { + origin: string; + data: IFrameMessageResponse<IFrameMessageAction>; + }, + ) { + if (eventMessage.origin !== this.#getOrigin(bridgeUrl)) { + return; + } + + if (eventMessage.data) { + const messageCallback = + this.messageCallbacks[eventMessage.data.messageId]; + if (messageCallback) { + messageCallback(eventMessage.data); + } else if ( + eventMessage.data.action === IFrameMessageAction.LedgerConnectionChange + ) { + this.isDeviceConnected = eventMessage.data.payload.connected; + } + } + } + + #sendMessage<TAction extends IFrameMessageAction>( + message: IFrameMessage<TAction>, + callback: (response: IFrameMessageResponse<TAction>) => void, + ) { + this.currentMessageId += 1; + + const postMsg: IFramePostMessage<TAction> = { + ...message, + messageId: this.currentMessageId, + target: LEDGER_IFRAME_ID, + }; + + this.messageCallbacks[this.currentMessageId] = callback as ( + response: IFrameMessageResponse<IFrameMessageAction>, + ) => void; + + if (!this.iframeLoaded || !this.iframe || !this.iframe.contentWindow) { + throw new Error('The iframe is not loaded yet'); + } + + this.iframe.contentWindow.postMessage(postMsg, '*'); + } +} diff --git a/src/ledger-bridge-keyring.test.ts b/src/ledger-keyring.test.ts similarity index 73% rename from src/ledger-bridge-keyring.test.ts rename to src/ledger-keyring.test.ts index 9fc07b53..4c15cd8f 100644 --- a/src/ledger-bridge-keyring.test.ts +++ b/src/ledger-keyring.test.ts @@ -1,22 +1,13 @@ import { Common, Chain, Hardfork } from '@ethereumjs/common'; import { TransactionFactory } from '@ethereumjs/tx'; -import { hasProperty } from '@metamask/utils'; import sigUtil from 'eth-sig-util'; import EthereumTx from 'ethereumjs-tx'; import * as ethUtil from 'ethereumjs-util'; import HDKey from 'hdkey'; -import { AccountDetails, LedgerBridgeKeyring } from './ledger-bridge-keyring'; -import documentShim from '../test/document.shim'; -import windowShim from '../test/window.shim'; - -global.document = documentShim; -global.window = windowShim; - -// eslint-disable-next-line no-restricted-globals -type HTMLIFrameElementShim = HTMLIFrameElement; -// eslint-disable-next-line no-restricted-globals -type WindowShim = Window; +import { LedgerBridge } from './ledger-bridge'; +import { LedgerIframeBridge } from './ledger-iframe-bridge'; +import { AccountDetails, LedgerKeyring } from './ledger-keyring'; const fakeAccounts = [ '0xF30952A1c534CDE7bC471380065726fa8686dfB3', @@ -83,42 +74,9 @@ const fakeTypeTwoTx = TransactionFactory.fromTxData( { common: commonEIP1559, freeze: false }, ); -/** - * Checks if the iframe provided has a valid contentWindow - * and onload function. - * - * @param iframe - The iframe to check. - * @returns Returns true if the iframe is valid, false otherwise. - */ -function isIFrameValid( - iframe?: HTMLIFrameElementShim, -): iframe is HTMLIFrameElementShim & { contentWindow: WindowShim } & { - onload: () => any; -} { - return ( - iframe !== undefined && - hasProperty(iframe, 'contentWindow') && - typeof iframe.onload === 'function' && - hasProperty(iframe.contentWindow as WindowShim, 'postMessage') - ); -} - -/** - * Simulates the loading of an iframe by calling the onload function. - * - * @param iframe - The iframe to simulate the loading of. - * @returns Returns a promise that resolves when the onload function is called. - */ -async function simulateIFrameLoad(iframe?: HTMLIFrameElementShim) { - if (!isIFrameValid(iframe)) { - throw new Error('the iframe is not valid'); - } - // we call manually the onload event to simulate the iframe loading - return await iframe.onload(); -} - -describe('LedgerBridgeKeyring', function () { - let keyring: LedgerBridgeKeyring; +describe('LedgerKeyring', function () { + let keyring: LedgerKeyring; + let bridge: LedgerBridge; /** * Sets up the keyring to unlock one account. @@ -134,30 +92,11 @@ describe('LedgerBridgeKeyring', function () { .mockResolvedValue(fakeAccounts[accountIndex] as string); } - /** - * Stubs the postMessage function of the keyring iframe. - * - * @param keyringInstance - The keyring instance to stub. - * @param fn - The function to call when the postMessage function is called. - */ - function stubKeyringIFramePostMessage( - keyringInstance: LedgerBridgeKeyring, - fn: (message: any) => void, - ) { - if (!isIFrameValid(keyringInstance.iframe)) { - throw new Error('the iframe is not valid'); - } - - jest - .spyOn(keyringInstance.iframe.contentWindow, 'postMessage') - .mockImplementation(fn); - } - beforeEach(async function () { - keyring = new LedgerBridgeKeyring(); + bridge = new LedgerIframeBridge(); + keyring = new LedgerKeyring({ bridge }); keyring.hdk = fakeHdKey; - - await simulateIFrameLoad(keyring.iframe); + await keyring.deserialize(); }); afterEach(function () { @@ -166,25 +105,47 @@ describe('LedgerBridgeKeyring', function () { describe('Keyring.type', function () { it('is a class property that returns the type string.', function () { - const { type } = LedgerBridgeKeyring; + const { type } = LedgerKeyring; expect(typeof type).toBe('string'); }); it('returns the correct value', function () { const { type } = keyring; - const correct = LedgerBridgeKeyring.type; + const correct = LedgerKeyring.type; expect(type).toBe(correct); }); }); describe('constructor', function () { it('constructs', async function () { - const ledgerKeyring = new LedgerBridgeKeyring({ hdPath: `m/44'/60'/0'` }); + const ledgerKeyring = new LedgerKeyring({ + bridge: new LedgerIframeBridge(), + }); expect(typeof ledgerKeyring).toBe('object'); const accounts = await ledgerKeyring.getAccounts(); expect(Array.isArray(accounts)).toBe(true); }); + + it('throws if a bridge is not provided', function () { + expect( + () => + new LedgerKeyring({ + bridge: undefined as unknown as LedgerBridge, + }), + ).toThrow('Bridge is a required dependency for the keyring'); + }); + }); + + describe('init', function () { + it('should call bridge init', async function () { + jest.spyOn(bridge, 'init').mockResolvedValue(undefined); + + await keyring.init(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.init).toHaveBeenCalledTimes(1); + }); }); describe('serialize', function () { @@ -277,53 +238,35 @@ describe('LedgerBridgeKeyring', function () { }); it('should update hdk.publicKey if updateHdk is true', async function () { - const ledgerKeyring = new LedgerBridgeKeyring(); // @ts-expect-error we want to bypass the set publicKey property set method - ledgerKeyring.hdk = { publicKey: 'ABC' }; - await simulateIFrameLoad(ledgerKeyring.iframe); - - stubKeyringIFramePostMessage(ledgerKeyring, (message) => { - ledgerKeyring.messageCallbacks[message.messageId]?.({ - action: message.action, - messageId: message.messageId, - success: true, - payload: { - publicKey: - '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a', - chainCode: - 'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b', - address: fakeAccounts[1], - }, - }); + keyring.hdk = { publicKey: 'ABC' }; + + jest.spyOn(bridge, 'getPublicKey').mockResolvedValue({ + publicKey: + '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a', + chainCode: + 'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b', + address: fakeAccounts[1], }); - await ledgerKeyring.unlock(`m/44'/60'/0'/1`); - expect(ledgerKeyring.hdk.publicKey).not.toBe('ABC'); + await keyring.unlock(`m/44'/60'/0'/1`); + expect(keyring.hdk.publicKey).not.toBe('ABC'); }); it('should not update hdk.publicKey if updateHdk is false', async function () { - const ledgerKeyring = new LedgerBridgeKeyring(); // @ts-expect-error we want to bypass the publicKey property set method - ledgerKeyring.hdk = { publicKey: 'ABC' }; - await simulateIFrameLoad(ledgerKeyring.iframe); - - stubKeyringIFramePostMessage(ledgerKeyring, (message) => { - ledgerKeyring.messageCallbacks[message.messageId]?.({ - action: message.action, - messageId: message.messageId, - success: true, - payload: { - publicKey: - '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a', - chainCode: - 'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b', - address: fakeAccounts[1], - }, - }); + keyring.hdk = { publicKey: 'ABC' }; + + jest.spyOn(bridge, 'getPublicKey').mockResolvedValue({ + publicKey: + '04197ced33b63059074b90ddecb9400c45cbc86210a20317b539b8cae84e573342149c3384ae45f27db68e75823323e97e03504b73ecbc47f5922b9b8144345e5a', + chainCode: + 'ba0fb16e01c463d1635ec36f5adeb93a838adcd1526656c55f828f1e34002a8b', + address: fakeAccounts[1], }); - await ledgerKeyring.unlock(`m/44'/60'/0'/1`, false); - expect(ledgerKeyring.hdk.publicKey).toBe('ABC'); + await keyring.unlock(`m/44'/60'/0'/1`, false); + expect(keyring.hdk.publicKey).toBe('ABC'); }); }); @@ -536,29 +479,25 @@ describe('LedgerBridgeKeyring', function () { describe('using old versions of ethereumjs/tx', function () { it('should pass serialized transaction to ledger and return signed tx', async function () { await basicSetupToUnlockOneAccount(); - stubKeyringIFramePostMessage(keyring, (message) => { - expect(message.params).toStrictEqual({ - hdPath: "m/44'/60'/0'/0", - tx: fakeTx.serialize().toString('hex'), + jest + .spyOn(keyring.bridge, 'deviceSignTransaction') + .mockImplementation(async (params) => { + expect(params).toStrictEqual({ + hdPath: "m/44'/60'/0'/0", + tx: fakeTx.serialize().toString('hex'), + }); + return { v: '0x1', r: '0x0', s: '0x0' }; }); - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: { v: '0x1', r: '0x0', s: '0x0' }, - }); - }); - jest.spyOn(fakeTx, 'verifySignature').mockReturnValue(true); const returnedTx = await keyring.signTransaction( fakeAccounts[0], fakeTx, ); - expect( - // eslint-disable-next-line @typescript-eslint/unbound-method - keyring.iframe?.contentWindow?.postMessage, - ).toHaveBeenCalled(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled(); expect(returnedTx).toHaveProperty('v'); expect(returnedTx).toHaveProperty('r'); expect(returnedTx).toHaveProperty('s'); @@ -592,20 +531,17 @@ describe('LedgerBridgeKeyring', function () { .spyOn(signedNewFakeTx, 'verifySignature') .mockImplementation(() => true); - stubKeyringIFramePostMessage(keyring, (message) => { - expect(message.params).toStrictEqual({ - hdPath: "m/44'/60'/0'/0", - tx: ethUtil.rlp - .encode(newFakeTx.getMessageToSign(false)) - .toString('hex'), - }); - - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: expectedRSV, + jest + .spyOn(keyring.bridge, 'deviceSignTransaction') + .mockImplementation(async (params) => { + expect(params).toStrictEqual({ + hdPath: "m/44'/60'/0'/0", + tx: ethUtil.rlp + .encode(newFakeTx.getMessageToSign(false)) + .toString('hex'), + }); + return expectedRSV; }); - }); const returnedTx = await keyring.signTransaction( fakeAccounts[0], @@ -613,7 +549,7 @@ describe('LedgerBridgeKeyring', function () { ); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled(); expect(returnedTx.toJSON()).toStrictEqual(signedNewFakeTx.toJSON()); }); @@ -643,30 +579,23 @@ describe('LedgerBridgeKeyring', function () { .mockReturnValue(true); jest.spyOn(fakeTypeTwoTx, 'verifySignature').mockReturnValue(true); - - stubKeyringIFramePostMessage(keyring, (message) => { - expect(message.params).toStrictEqual({ - hdPath: "m/44'/60'/0'/0", - tx: fakeTypeTwoTx.getMessageToSign(false).toString('hex'), - }); - - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: expectedRSV, + jest + .spyOn(keyring.bridge, 'deviceSignTransaction') + .mockImplementation(async (params) => { + expect(params).toStrictEqual({ + hdPath: "m/44'/60'/0'/0", + tx: fakeTypeTwoTx.getMessageToSign(false).toString('hex'), + }); + return expectedRSV; }); - }); const returnedTx = await keyring.signTransaction( fakeAccounts[0], fakeTypeTwoTx, ); - expect( - // eslint-disable-next-line @typescript-eslint/unbound-method - keyring.iframe?.contentWindow?.postMessage, - ).toHaveBeenCalled(); - + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(keyring.bridge.deviceSignTransaction).toHaveBeenCalled(); expect(returnedTx.toJSON()).toStrictEqual(signedFakeTypeTwoTx.toJSON()); }); }); @@ -675,51 +604,48 @@ describe('LedgerBridgeKeyring', function () { describe('signPersonalMessage', function () { it('should call create a listener waiting for the iframe response', async function () { await basicSetupToUnlockOneAccount(); - - stubKeyringIFramePostMessage(keyring, (message) => { - expect(message.params).toStrictEqual({ - hdPath: "m/44'/60'/0'/0", - message: 'some message', - }); - - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: { v: 1, r: '0x0', s: '0x0' }, + jest + .spyOn(keyring.bridge, 'deviceSignMessage') + .mockImplementation(async (params) => { + expect(params).toStrictEqual({ + hdPath: "m/44'/60'/0'/0", + message: 'some message', + }); + return { v: 1, r: '0x0', s: '0x0' }; }); - }); jest .spyOn(sigUtil, 'recoverPersonalSignature') .mockReturnValue(fakeAccounts[0]); + await keyring.signPersonalMessage(fakeAccounts[0], 'some message'); + // eslint-disable-next-line @typescript-eslint/unbound-method - expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + expect(keyring.bridge.deviceSignMessage).toHaveBeenCalled(); }); }); describe('signMessage', function () { it('should call create a listener waiting for the iframe response', async function () { await basicSetupToUnlockOneAccount(); - stubKeyringIFramePostMessage(keyring, (message) => { - expect(message.params).toStrictEqual({ - hdPath: "m/44'/60'/0'/0", - message: 'some message', - }); - - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: { v: 1, r: '0x0', s: '0x0' }, + jest + .spyOn(keyring.bridge, 'deviceSignMessage') + .mockImplementation(async (params) => { + expect(params).toStrictEqual({ + hdPath: "m/44'/60'/0'/0", + message: 'some message', + }); + return { v: 1, r: '0x0', s: '0x0' }; }); - }); jest .spyOn(sigUtil, 'recoverPersonalSignature') .mockReturnValue(fakeAccounts[0]); + await keyring.signMessage(fakeAccounts[0], 'some message'); + // eslint-disable-next-line @typescript-eslint/unbound-method - expect(keyring.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + expect(keyring.bridge.deviceSignMessage).toHaveBeenCalled(); }); }); @@ -808,17 +734,13 @@ describe('LedgerBridgeKeyring', function () { }); it('should resolve properly when called', async function () { - stubKeyringIFramePostMessage(keyring, (message) => { - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: { - v: 27, - r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9', - s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32', - }, - }); - }); + jest + .spyOn(keyring.bridge, 'deviceSignTypedData') + .mockImplementation(async () => ({ + v: 27, + r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9', + s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32', + })); const result = await keyring.signTypedData( fakeAccounts[15], @@ -831,17 +753,14 @@ describe('LedgerBridgeKeyring', function () { }); it('should error when address does not match', async function () { - stubKeyringIFramePostMessage(keyring, (message) => { - keyring.messageCallbacks[message.messageId]?.({ - ...message, - success: true, - payload: { - v: 28, - r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9', - s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32', - }, - }); - }); + jest + .spyOn(keyring.bridge, 'deviceSignTypedData') + // Changing v to 28 should cause a validation error + .mockImplementation(async () => ({ + v: 28, + r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9', + s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32', + })); await expect( keyring.signTypedData(fakeAccounts[15], fixtureData, options), @@ -850,17 +769,13 @@ describe('LedgerBridgeKeyring', function () { }); describe('destroy', function () { - it('should remove the message event listener', function () { - jest - .spyOn(global.window, 'removeEventListener') - .mockImplementation((type, listener) => { - expect(type).toBe('message'); - expect(typeof listener).toBe('function'); - return true; - }); - keyring.destroy(); - // eslint-disable-next-line no-restricted-globals - expect(global.window.removeEventListener).toHaveBeenCalled(); + it('should call the destroy bridge method', async function () { + jest.spyOn(keyring.bridge, 'destroy').mockResolvedValue(undefined); + + await keyring.destroy(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.destroy).toHaveBeenCalled(); }); }); }); diff --git a/src/ledger-bridge-keyring.ts b/src/ledger-keyring.ts similarity index 58% rename from src/ledger-bridge-keyring.ts rename to src/ledger-keyring.ts index 2ed6eb43..bef198d3 100644 --- a/src/ledger-bridge-keyring.ts +++ b/src/ledger-keyring.ts @@ -1,6 +1,4 @@ import { TransactionFactory, TxData, TypedTransaction } from '@ethereumjs/tx'; -import type LedgerHwAppEth from '@ledgerhq/hw-app-eth'; -import { hasProperty } from '@metamask/utils'; // eslint-disable-next-line import/no-nodejs-modules import { Buffer } from 'buffer'; import * as sigUtil from 'eth-sig-util'; @@ -10,6 +8,8 @@ import * as ethUtil from 'ethereumjs-util'; import { EventEmitter } from 'events'; import HDKey from 'hdkey'; +import { LedgerBridge } from './ledger-bridge'; + const pathBase = 'm'; const hdPathString = `${pathBase}/44'/60'/0'`; const keyringType = 'Ledger Hardware'; @@ -18,8 +18,6 @@ const BRIDGE_URL = 'https://metamask.github.io/eth-ledger-bridge-keyring'; const MAX_INDEX = 1000; -const LEDGER_IFRAME_ID = 'LEDGER-IFRAME'; - enum NetworkApiUrls { Ropsten = 'http://api-ropsten.etherscan.io', Kovan = 'http://api-kovan.etherscan.io', @@ -27,57 +25,10 @@ enum NetworkApiUrls { Mainnet = 'https://api.etherscan.io', } -enum IFrameMessageAction { - LedgerConnectionChange = 'ledger-connection-change', - LedgerUnlock = 'ledger-unlock', - LedgerMakeApp = 'ledger-make-app', - LedgerUpdateTransport = 'ledger-update-transport', - LedgerSignTransaction = 'ledger-sign-transaction', - LedgerSignPersonalMessage = 'ledger-sign-personal-message', - LedgerSignTypedData = 'ledger-sign-typed-data', -} - -type GetAddressPayload = Awaited<ReturnType<LedgerHwAppEth['getAddress']>> & { - chainCode: string; -}; - -type SignMessagePayload = Awaited< - ReturnType<LedgerHwAppEth['signEIP712HashedMessage']> ->; - type SignTransactionPayload = Awaited< - ReturnType<LedgerHwAppEth['signTransaction']> + ReturnType<LedgerBridge['deviceSignTransaction']> >; -type ConnectionChangedPayload = { - connected: boolean; -}; - -type IFrameMessage = { - action: IFrameMessageAction; - params?: Readonly<Record<string, unknown>>; -}; - -type IFramePostMessage = IFrameMessage & { - messageId: number; - target: typeof LEDGER_IFRAME_ID; -}; - -type IFrameMessageResponsePayload = { error?: Error } & ( - | GetAddressPayload - | SignTransactionPayload - | SignMessagePayload - | ConnectionChangedPayload -); - -export type IFrameMessageResponse = { - success: boolean; - action: IFrameMessageAction; - messageId: number; - payload: IFrameMessageResponsePayload; - error?: unknown; -}; - export type AccountDetails = { index?: number; bip44?: boolean; @@ -112,59 +63,7 @@ function isOldStyleEthereumjsTx( return 'getChainId' in tx && typeof tx.getChainId === 'function'; } -/** - * Check if the given payload is a SignTransactionPayload. - * - * @param payload - IFrame message response payload to check. - * @returns Returns `true` if payload is a SignTransactionPayload. - */ -function isSignTransactionResponse( - payload: IFrameMessageResponsePayload, -): payload is SignTransactionPayload { - return hasProperty(payload, 'v') && typeof payload.v === 'string'; -} - -/** - * Check if the given payload is a SignMessagePayload. - * - * @param payload - IFrame message response payload to check. - * @returns Returns `true` if payload is a SignMessagePayload. - */ -function isSignMessageResponse( - payload: IFrameMessageResponsePayload, -): payload is SignMessagePayload { - return hasProperty(payload, 'v') && typeof payload.v === 'number'; -} - -/** - * Check if the given payload is a GetAddressPayload. - * - * @param payload - IFrame message response payload to check. - * @returns Returns `true` if payload is a GetAddressPayload. - */ -function isGetAddressMessageResponse( - payload: IFrameMessageResponsePayload, -): payload is GetAddressPayload { - return ( - hasProperty(payload, 'publicKey') && typeof payload.publicKey === 'string' - ); -} - -/** - * Check if the given payload is a ConnectionChangedPayload. - * - * @param payload - IFrame message response payload to check. - * @returns Returns `true` if payload is a ConnectionChangedPayload. - */ -function isConnectionChangedResponse( - payload: IFrameMessageResponsePayload, -): payload is ConnectionChangedPayload { - return ( - hasProperty(payload, 'connected') && typeof payload.connected === 'boolean' - ); -} - -export class LedgerBridgeKeyring extends EventEmitter { +export class LedgerKeyring extends EventEmitter { static type: string = keyringType; readonly type: string = keyringType; @@ -189,34 +88,26 @@ export class LedgerBridgeKeyring extends EventEmitter { implementFullBIP44 = false; - iframeLoaded = false; - - isDeviceConnected = false; - - currentMessageId = 0; - - messageCallbacks: Record<number, (response: IFrameMessageResponse) => void> = - {}; - bridgeUrl: string = BRIDGE_URL; - iframe?: HTMLIFrameElement; + bridge: LedgerBridge; - delayedPromise?: { - resolve: (value: unknown) => void; - reject: (error: unknown) => void; - transportType: string; - }; - - constructor(opts: Partial<LedgerBridgeKeyringOptions> = {}) { + constructor({ bridge }: { bridge: LedgerBridge }) { super(); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.deserialize(opts); + if (!bridge) { + throw new Error('Bridge is a required dependency for the keyring'); + } - this.#setupIframe(); + this.bridge = bridge; + } + + async init() { + return this.bridge.init(this.bridgeUrl); + } - this.#setupListener(); + async destroy() { + return this.bridge.destroy(); } async serialize() { @@ -287,7 +178,7 @@ export class LedgerBridgeKeyring extends EventEmitter { } isConnected() { - return this.isDeviceConnected; + return this.bridge.isDeviceConnected; } setAccountToUnlock(index: number | string) { @@ -308,27 +199,22 @@ export class LedgerBridgeKeyring extends EventEmitter { return 'already unlocked'; } const path = hdPath ? this.#toLedgerPath(hdPath) : this.hdPath; - return new Promise((resolve, reject) => { - this.#sendMessage( - { - action: IFrameMessageAction.LedgerUnlock, - params: { - hdPath: path, - }, - }, - ({ success, payload }) => { - if (success && isGetAddressMessageResponse(payload)) { - if (updateHdk) { - this.hdk.publicKey = Buffer.from(payload.publicKey, 'hex'); - this.hdk.chainCode = Buffer.from(payload.chainCode, 'hex'); - } - resolve(payload.address); - } else { - reject(payload.error ?? new Error('Unknown error')); - } - }, - ); - }); + + let payload; + try { + payload = await this.bridge.getPublicKey({ + hdPath: path, + }); + } catch (error) { + throw error instanceof Error ? error : new Error('Unknown error'); + } + + if (updateHdk && payload.chainCode) { + this.hdk.publicKey = Buffer.from(payload.publicKey, 'hex'); + this.hdk.chainCode = Buffer.from(payload.chainCode, 'hex'); + } + + return payload.address; } async addAccounts(amount = 1): Promise<string[]> { @@ -395,49 +281,11 @@ export class LedgerBridgeKeyring extends EventEmitter { } async attemptMakeApp() { - return new Promise((resolve, reject) => { - this.#sendMessage( - { - action: IFrameMessageAction.LedgerMakeApp, - }, - ({ success, error }) => { - if (success) { - resolve(true); - } else { - reject(error); - } - }, - ); - }); + return this.bridge.attemptMakeApp(); } async updateTransportMethod(transportType: string) { - return new Promise((resolve, reject) => { - // If the iframe isn't loaded yet, let's store the desired transportType value and - // optimistically return a successful promise - if (!this.iframeLoaded) { - this.delayedPromise = { - resolve, - reject, - transportType, - }; - return; - } - - this.#sendMessage( - { - action: IFrameMessageAction.LedgerUpdateTransport, - params: { transportType }, - }, - ({ success }) => { - if (success) { - resolve(true); - } else { - reject(new Error('Ledger transport could not be updated')); - } - }, - ); - }); + return this.bridge.updateTransportMethod(transportType); } // tx is an instance of the ethereumjs-transaction class. @@ -514,41 +362,30 @@ export class LedgerBridgeKeyring extends EventEmitter { payload: SignTransactionPayload, ) => TypedTransaction | OldEthJsTransaction, ): Promise<TypedTransaction | OldEthJsTransaction> { - return new Promise((resolve, reject) => { - this.unlockAccountByAddress(address) - .then((hdPath) => { - this.#sendMessage( - { - action: IFrameMessageAction.LedgerSignTransaction, - params: { - tx: rawTxHex, - hdPath, - }, - }, - ({ success, payload }) => { - if (success && isSignTransactionResponse(payload)) { - const newOrMutatedTx = handleSigning(payload); - const valid = newOrMutatedTx.verifySignature(); - if (valid) { - resolve(newOrMutatedTx); - } else { - reject( - new Error('Ledger: The transaction signature is not valid'), - ); - } - } else { - reject( - payload.error ?? - new Error( - 'Ledger: Unknown error while signing transaction', - ), - ); - } - }, - ); - }) - .catch(reject); - }); + const hdPath = await this.unlockAccountByAddress(address); + + if (!hdPath) { + throw new Error('Ledger: Unknown error while signing transaction'); + } + + let payload; + try { + payload = await this.bridge.deviceSignTransaction({ + tx: rawTxHex, + hdPath, + }); + } catch (error) { + throw error instanceof Error + ? error + : new Error('Ledger: Unknown error while signing transaction'); + } + + const newOrMutatedTx = handleSigning(payload); + const valid = newOrMutatedTx.verifySignature(); + if (valid) { + return newOrMutatedTx; + } + throw new Error('Ledger: The transaction signature is not valid'); } async signMessage(withAccount: string, data: string) { @@ -557,51 +394,41 @@ export class LedgerBridgeKeyring extends EventEmitter { // For personal_sign, we need to prefix the message: async signPersonalMessage(withAccount: string, message: string) { - return new Promise((resolve, reject) => { - this.unlockAccountByAddress(withAccount) - .then((hdPath) => { - this.#sendMessage( - { - action: IFrameMessageAction.LedgerSignPersonalMessage, - params: { - hdPath, - message: ethUtil.stripHexPrefix(message), - }, - }, - ({ success, payload }) => { - if (success && isSignMessageResponse(payload)) { - let recoveryId = parseInt(String(payload.v), 10).toString(16); - if (recoveryId.length < 2) { - recoveryId = `0${recoveryId}`; - } - const signature = `0x${payload.r}${payload.s}${recoveryId}`; - const addressSignedWith = sigUtil.recoverPersonalSignature({ - data: message, - // eslint-disable-next-line id-denylist - sig: signature, - }); - if ( - ethUtil.toChecksumAddress(addressSignedWith) !== - ethUtil.toChecksumAddress(withAccount) - ) { - reject( - new Error( - 'Ledger: The signature doesnt match the right address', - ), - ); - } - resolve(signature); - } else { - reject( - payload.error ?? - new Error('Ledger: Unknown error while signing message'), - ); - } - }, - ); - }) - .catch(reject); + const hdPath = await this.unlockAccountByAddress(withAccount); + + if (!hdPath) { + throw new Error('Ledger: Unknown error while signing message'); + } + + let payload; + try { + payload = await this.bridge.deviceSignMessage({ + hdPath, + message: ethUtil.stripHexPrefix(message), + }); + } catch (error) { + throw error instanceof Error + ? error + : new Error('Ledger: Unknown error while signing message'); + } + + let recoveryId = parseInt(String(payload.v), 10).toString(16); + if (recoveryId.length < 2) { + recoveryId = `0${recoveryId}`; + } + const signature = `0x${payload.r}${payload.s}${recoveryId}`; + const addressSignedWith = sigUtil.recoverPersonalSignature({ + data: message, + // eslint-disable-next-line id-denylist + sig: signature, }); + if ( + ethUtil.toChecksumAddress(addressSignedWith) !== + ethUtil.toChecksumAddress(withAccount) + ) { + throw new Error('Ledger: The signature doesnt match the right address'); + } + return signature; } async unlockAccountByAddress(address: string) { @@ -657,47 +484,44 @@ export class LedgerBridgeKeyring extends EventEmitter { ).toString('hex'); const hdPath = await this.unlockAccountByAddress(withAccount); - const { success, payload }: IFrameMessageResponse = await new Promise( - (resolve) => { - this.#sendMessage( - { - action: IFrameMessageAction.LedgerSignTypedData, - params: { - hdPath, - domainSeparatorHex, - hashStructMessageHex, - }, - }, - (result) => resolve(result), - ); - }, - ); - if (success && isSignMessageResponse(payload)) { - let recoveryId = parseInt(String(payload.v), 10).toString(16); - if (recoveryId.length < 2) { - recoveryId = `0${recoveryId}`; - } - const signature = `0x${payload.r}${payload.s}${recoveryId}`; - // @ts-expect-error recoverTypedSignature_v4 is missing from - // @types/eth-sig-util. - // See: https://github.com/MetaMask/eth-sig-util/blob/v2.5.4/index.js#L464 - const addressSignedWith = sigUtil.recoverTypedSignature_v4({ - data, - // eslint-disable-next-line id-denylist - sig: signature, + if (!hdPath) { + throw new Error('Ledger: Unknown error while signing message'); + } + + let payload; + try { + payload = await this.bridge.deviceSignTypedData({ + hdPath, + domainSeparatorHex, + hashStructMessageHex, }); - if ( - ethUtil.toChecksumAddress(addressSignedWith) !== - ethUtil.toChecksumAddress(withAccount) - ) { - throw new Error('Ledger: The signature doesnt match the right address'); - } - return signature; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Ledger: Unknown error while signing message'); } - throw ( - payload.error ?? new Error('Ledger: Unknown error while signing message') - ); + + let recoveryId = parseInt(String(payload.v), 10).toString(16); + if (recoveryId.length < 2) { + recoveryId = `0${recoveryId}`; + } + const signature = `0x${payload.r}${payload.s}${recoveryId}`; + // @ts-expect-error recoverTypedSignature_v4 is missing from + // @types/eth-sig-util. + // See: https://github.com/MetaMask/eth-sig-util/blob/v2.5.4/index.js#L464 + const addressSignedWith = sigUtil.recoverTypedSignature_v4({ + data, + // eslint-disable-next-line id-denylist + sig: signature, + }); + if ( + ethUtil.toChecksumAddress(addressSignedWith) !== + ethUtil.toChecksumAddress(withAccount) + ) { + throw new Error('Ledger: The signature doesnt match the right address'); + } + return signature; } exportAccount() { @@ -714,86 +538,6 @@ export class LedgerBridgeKeyring extends EventEmitter { } /* PRIVATE METHODS */ - - #setupIframe() { - this.iframe = document.createElement('iframe'); - this.iframe.src = this.bridgeUrl; - this.iframe.allow = `hid 'src'`; - this.iframe.onload = async () => { - // If the ledger live preference was set before the iframe is loaded, - // set it after the iframe has loaded - this.iframeLoaded = true; - if (this.delayedPromise) { - try { - const result = await this.updateTransportMethod( - this.delayedPromise.transportType, - ); - this.delayedPromise.resolve(result); - } catch (error) { - this.delayedPromise.reject(error); - } finally { - delete this.delayedPromise; - } - } - }; - document.head.appendChild(this.iframe); - } - - #getOrigin() { - const tmp = this.bridgeUrl.split('/'); - tmp.splice(-1, 1); - return tmp.join('/'); - } - - #eventListener(params: { origin: string; data: IFrameMessageResponse }) { - if (params.origin !== this.#getOrigin()) { - return false; - } - - if (params.data) { - const messageCallback = this.messageCallbacks[params.data.messageId]; - if (messageCallback) { - messageCallback(params.data); - } else if ( - params.data.action === IFrameMessageAction.LedgerConnectionChange && - isConnectionChangedResponse(params.data.payload) - ) { - this.isDeviceConnected = params.data.payload.connected; - } - } - - return undefined; - } - - #sendMessage( - message: IFrameMessage, - callback: (response: IFrameMessageResponse) => void, - ) { - this.currentMessageId += 1; - - const postMsg: IFramePostMessage = { - ...message, - messageId: this.currentMessageId, - target: LEDGER_IFRAME_ID, - }; - - this.messageCallbacks[this.currentMessageId] = callback; - - if (!this.iframeLoaded || !this.iframe || !this.iframe.contentWindow) { - throw new Error('The iframe is not loaded yet'); - } - - this.iframe.contentWindow.postMessage(postMsg, '*'); - } - - #setupListener() { - window.addEventListener('message', this.#eventListener.bind(this)); - } - - destroy() { - window.removeEventListener('message', this.#eventListener.bind(this)); - } - async #getPage(increment: number) { this.page += increment;