UNPKG

@metamask/eth-ledger-bridge-keyring

Version:

A MetaMask compatible keyring, for ledger hardware wallets

549 lines 26.8 kB
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _LedgerKeyring_instances, _LedgerKeyring_migrateAccountDetails, _LedgerKeyring_signTransaction, _LedgerKeyring_getPage, _LedgerKeyring_getAccountsBIP44, _LedgerKeyring_getAccountsLegacy, _LedgerKeyring_addressFromIndex, _LedgerKeyring_pathFromAddress, _LedgerKeyring_getPathForIndex, _LedgerKeyring_isLedgerLiveHdPath, _LedgerKeyring_toLedgerPath, _LedgerKeyring_hasPreviousTransactions, _LedgerKeyring_getApiUrl, _LedgerKeyring_getChecksumHexAddress; function $importDefault(module) { if (module?.__esModule) { return module.default; } return module; } import { RLP } from "@ethereumjs/rlp"; import { TransactionFactory } from "@ethereumjs/tx"; import { publicToAddress } from "@ethereumjs/util"; import { TransportStatusError } from "@ledgerhq/hw-transport"; import { recoverPersonalSignature, recoverTypedSignature, SignTypedDataVersion, TypedDataUtils } from "@metamask/eth-sig-util"; import { add0x, bytesToHex, getChecksumAddress, remove0x } from "@metamask/utils"; import { Buffer } from "buffer"; import $HDKey from "hdkey"; const HDKey = $importDefault($HDKey); const pathBase = 'm'; const hdPathString = `${pathBase}/44'/60'/0'`; const keyringType = 'Ledger Hardware'; // This number causes one of our failing tests to run very slowly, as the for loop needs to iterate 1000 times. const MAX_INDEX = 1000; var NetworkApiUrls; (function (NetworkApiUrls) { NetworkApiUrls["Ropsten"] = "https://api-ropsten.etherscan.io"; NetworkApiUrls["Kovan"] = "https://api-kovan.etherscan.io"; NetworkApiUrls["Rinkeby"] = "https://api-rinkeby.etherscan.io"; NetworkApiUrls["Mainnet"] = "https://api.etherscan.io"; })(NetworkApiUrls || (NetworkApiUrls = {})); /** * Check if the given transaction is made with ethereumjs-tx or @ethereumjs/tx * * Transactions built with older versions of ethereumjs-tx have a * getChainId method that newer versions do not. * Older versions are mutable * while newer versions default to being immutable. * Expected shape and type * of data for v, r and s differ (Buffer (old) vs BN (new)). * * @param tx - Transaction to check, instance of either ethereumjs-tx or @ethereumjs/tx. * @returns Returns `true` if tx is an old-style ethereumjs-tx transaction. */ function isOldStyleEthereumjsTx(tx) { return 'getChainId' in tx && typeof tx.getChainId === 'function'; } export class LedgerKeyring { constructor({ bridge }) { _LedgerKeyring_instances.add(this); this.deviceId = ''; this.type = keyringType; this.page = 0; this.perPage = 5; this.unlockedAccount = 0; this.accounts = []; this.accountDetails = {}; this.hdk = new HDKey(); this.hdPath = hdPathString; this.paths = {}; this.network = NetworkApiUrls.Mainnet; this.implementFullBIP44 = false; if (!bridge) { throw new Error('Bridge is a required dependency for the keyring'); } this.bridge = bridge; } async init() { return this.bridge.init(); } async destroy() { return this.bridge.destroy(); } async serialize() { return { hdPath: this.hdPath, accounts: this.accounts.slice(), deviceId: this.deviceId, accountDetails: this.accountDetails, implementFullBIP44: false, }; } async deserialize(opts) { this.hdPath = opts.hdPath ?? hdPathString; this.accounts = opts.accounts ?? []; this.deviceId = opts.deviceId ?? ''; this.accountDetails = opts.accountDetails ?? {}; if (!opts.accountDetails) { __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_migrateAccountDetails).call(this, opts); } this.implementFullBIP44 = opts.implementFullBIP44 ?? false; const keys = new Set(Object.keys(this.accountDetails)); // Remove accounts that don't have corresponding account details this.accounts = this.accounts.filter((account) => keys.has(__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, account))); return Promise.resolve(); } setDeviceId(deviceId) { this.deviceId = deviceId; } getDeviceId() { return this.deviceId; } isUnlocked() { return Boolean(this.hdk.publicKey); } isConnected() { return this.bridge.isDeviceConnected; } setAccountToUnlock(index) { this.unlockedAccount = index; } setHdPath(hdPath) { // Reset HDKey if the path changes if (this.hdPath !== hdPath) { this.hdk = new HDKey(); } this.hdPath = hdPath; } async unlock(hdPath, updateHdk = true) { if (this.isUnlocked() && !hdPath) { // if the device is already unlocked and no path is provided, // we return the checksummed address of the public key stored in // `this.hdk`, which is the root address of the last unlocked path. return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, bytesToHex(publicToAddress(this.hdk.publicKey, true))); } const path = hdPath ? __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_toLedgerPath).call(this, hdPath) : this.hdPath; let payload; try { payload = await this.bridge.getPublicKey({ hdPath: path, }); } catch (error) { throw error instanceof Error ? error : new Error('Ledger Ethereum app closed. Open it to unlock.'); } if (updateHdk && payload.chainCode) { this.hdk.publicKey = Buffer.from(payload.publicKey, 'hex'); this.hdk.chainCode = Buffer.from(payload.chainCode, 'hex'); } return add0x(payload.address); } async addAccounts(amount) { return new Promise((resolve, reject) => { this.unlock() .then(async (_) => { const from = this.unlockedAccount; const to = from + amount; const newAccounts = []; for (let i = from; i < to; i++) { const path = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPathForIndex).call(this, i); let address; if (__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this)) { address = await this.unlock(path); } else { address = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_addressFromIndex).call(this, pathBase, i); } this.accountDetails[__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address)] = { // TODO: consider renaming this property, as the current name is misleading // It's currently used to represent whether an account uses the Ledger Live path. bip44: __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this), hdPath: path, }; if (!this.accounts.includes(address)) { this.accounts = [...this.accounts, address]; newAccounts.push(address); } this.page = 0; } resolve(newAccounts); }) .catch(reject); }); } getName() { return keyringType; } async getFirstPage() { this.page = 0; return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPage).call(this, 1); } async getNextPage() { return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPage).call(this, 1); } async getPreviousPage() { return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPage).call(this, -1); } async getAccounts() { return Promise.resolve(this.accounts.slice()); } removeAccount(address) { const filteredAccounts = this.accounts.filter((a) => a.toLowerCase() !== address.toLowerCase()); if (filteredAccounts.length === this.accounts.length) { throw new Error(`Address ${address} not found in this keyring`); } this.accounts = filteredAccounts; delete this.accountDetails[__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address)]; } async attemptMakeApp() { return this.bridge.attemptMakeApp(); } async updateTransportMethod(transportType) { return this.bridge.updateTransportMethod(transportType); } // tx is an instance of the ethereumjs-transaction class. async signTransaction(address, tx) { let rawTxHex; // transactions built with older versions of ethereumjs-tx have a // getChainId method that newer versions do not. Older versions are mutable // while newer versions default to being immutable. Expected shape and type // of data for v, r and s differ (Buffer (old) vs BN (new)) if (isOldStyleEthereumjsTx(tx)) { // In this version of ethereumjs-tx we must add the chainId in hex format // to the initial v value. The chainId must be included in the serialized // transaction which is only communicated to ethereumjs-tx in this // value. In newer versions the chainId is communicated via the 'Common' // object. tx.v = tx.getChainId(); // @ts-expect-error tx.r should be a Buffer, but we are assigning a string tx.r = '0x00'; // @ts-expect-error tx.s should be a Buffer, but we are assigning a string tx.s = '0x00'; rawTxHex = tx.serialize().toString('hex'); return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_signTransaction).call(this, address, rawTxHex, (payload) => { tx.v = Buffer.from(payload.v, 'hex'); tx.r = Buffer.from(payload.r, 'hex'); tx.s = Buffer.from(payload.s, 'hex'); return tx; }); } // The below `encode` call is only necessary for legacy transactions, as `getMessageToSign` // calls `rlp.encode` internally for non-legacy transactions. As per the "Transaction Execution" // section of the ethereum yellow paper, transactions need to be "well-formed RLP, with no additional // trailing bytes". // Note also that `getMessageToSign` will return valid RLP for all transaction types, whereas the // `serialize` method will not for any transaction type except legacy. This is because `serialize` includes // empty r, s and v values in the encoded rlp. This is why we use `getMessageToSign` here instead of `serialize`. const messageToSign = tx.getMessageToSign(); rawTxHex = Array.isArray(messageToSign) ? Buffer.from(RLP.encode(messageToSign)).toString('hex') : bytesToHex(messageToSign); return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_signTransaction).call(this, address, rawTxHex, (payload) => { // Because tx will be immutable, first get a plain javascript object that // represents the transaction. Using txData here as it aligns with the // nomenclature of ethereumjs/tx. const txData = tx.toJSON(); // The fromTxData utility expects a type to support transactions with a type other than 0 txData.type = tx.type; // The fromTxData utility expects v,r and s to be hex prefixed txData.v = add0x(payload.v); txData.r = add0x(payload.r); txData.s = add0x(payload.s); // Adopt the 'common' option from the original transaction and set the // returned object to be frozen if the original is frozen. return TransactionFactory.fromTxData(txData, { common: tx.common, freeze: Object.isFrozen(tx), }); }); } async signMessage(withAccount, data) { return this.signPersonalMessage(withAccount, data); } // For personal_sign, we need to prefix the message: async signPersonalMessage(withAccount, message) { 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: remove0x(message), }); } catch (error) { if (error instanceof TransportStatusError) { const transportError = error; /** * for user rejected the transaction error */ if (transportError.statusCode === 27013 && transportError.message.includes('(denied by the user?) (0x6985)')) { throw new Error('Ledger: User rejected the transaction'); } } throw error instanceof Error ? error : new Error('Ledger: Unknown error while signing message'); } let modifiedV = parseInt(String(payload.v), 10).toString(16); if (modifiedV.length < 2) { modifiedV = `0${modifiedV}`; } const signature = `0x${payload.r}${payload.s}${modifiedV}`; const addressSignedWith = recoverPersonalSignature({ data: message, signature, }); if (__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, addressSignedWith) !== __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, withAccount)) { throw new Error('Ledger: The signature doesnt match the right address'); } return signature; } async unlockAccountByAddress(address) { const checksummedAddress = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address); const accountDetails = this.accountDetails[checksummedAddress]; if (!accountDetails) { throw new Error(`Ledger: Account for address '${checksummedAddress}' not found`); } const { hdPath } = accountDetails; const unlockedAddress = await this.unlock(hdPath, false); // unlock resolves to the address for the given hdPath as reported by the ledger device // if that address is not the requested address, then this account belongs to a different device or seed if (unlockedAddress.toLowerCase() !== address.toLowerCase()) { throw new Error(`Ledger: Account ${address} does not belong to the connected device`); } return hdPath; } async signTypedData(withAccount, data, options) { const { version } = options ?? {}; const isV4 = version === 'V4'; if (!isV4) { throw new Error('Ledger: Only version 4 of typed data signing is supported'); } const { domain, types, primaryType, message } = TypedDataUtils.sanitizeData(data); const hdPath = await this.unlockAccountByAddress(withAccount); if (!hdPath) { throw new Error('Ledger: Unknown error while signing message'); } let payload; try { payload = await this.bridge.deviceSignTypedData({ hdPath, message: { domain: { name: domain.name, chainId: domain.chainId, version: domain.version, verifyingContract: domain.verifyingContract, salt: domain.salt instanceof ArrayBuffer ? Buffer.from(domain.salt).toString('hex') : domain.salt, }, types, primaryType: primaryType.toString(), message, }, }); } catch (error) { if (error instanceof TransportStatusError) { // cast error to TransportError const transportError = error; if (transportError.statusCode === 27013 && transportError.message.includes('(denied by the user?) (0x6985)')) { throw new Error('Ledger: User rejected the transaction'); } if (transportError.statusCode === 27264 && transportError.message.includes('Invalid data received (0x6a80)')) { throw new Error('Ledger: Blind signing must be enabled'); } } 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 = recoverTypedSignature({ data, signature, version: SignTypedDataVersion.V4, }); if (__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, addressSignedWith) !== __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, withAccount)) { throw new Error('Ledger: The signature doesnt match the right address'); } return signature; } forgetDevice() { this.deviceId = ''; this.accounts = []; this.page = 0; this.unlockedAccount = 0; this.paths = {}; this.accountDetails = {}; this.hdk = new HDKey(); } } _LedgerKeyring_instances = new WeakSet(), _LedgerKeyring_migrateAccountDetails = function _LedgerKeyring_migrateAccountDetails(opts) { if (__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this) && opts.accountIndexes) { for (const [account, index] of Object.entries(opts.accountIndexes)) { this.accountDetails[account] = { bip44: true, hdPath: __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPathForIndex).call(this, index), }; } } const keys = new Set(Object.keys(this.accountDetails)); // try to migrate non-LedgerLive accounts too if (!__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this)) { this.accounts.forEach((account) => { const key = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, account); if (!keys.has(key)) { this.accountDetails[key] = { bip44: false, hdPath: __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_pathFromAddress).call(this, account), }; } }); } }, _LedgerKeyring_signTransaction = async function _LedgerKeyring_signTransaction(address, rawTxHex, handleSigning) { const hdPath = await this.unlockAccountByAddress(address); if (!hdPath) { throw new Error('Ledger: hdPath is empty while signing transaction'); } let payload; try { payload = await this.bridge.deviceSignTransaction({ tx: remove0x(rawTxHex), hdPath, }); } catch (error) { // check whether error is TransportError if (error instanceof TransportStatusError) { // cast error to TransportError const transportError = error; if (transportError.statusCode === 27013 && transportError.message.includes('(denied by the user?) (0x6985)')) { throw new Error('Ledger: User rejected the transaction'); } if (transportError.statusCode === 27264 && transportError.message.includes('Invalid data received (0x6a80)')) { throw new Error('Ledger: Blind signing must be enabled'); } } 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'); }, _LedgerKeyring_getPage = /* PRIVATE METHODS */ async function _LedgerKeyring_getPage(increment) { this.page += increment; if (this.page <= 0) { this.page = 1; } const from = (this.page - 1) * this.perPage; const to = from + this.perPage; await this.unlock(); let accounts; if (__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this)) { accounts = await __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getAccountsBIP44).call(this, from, to); } else { accounts = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getAccountsLegacy).call(this, from, to); } return accounts; }, _LedgerKeyring_getAccountsBIP44 = async function _LedgerKeyring_getAccountsBIP44(from, to) { const accounts = []; for (let i = from; i < to; i++) { const path = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPathForIndex).call(this, i); const address = await this.unlock(path); const valid = this.implementFullBIP44 ? await __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_hasPreviousTransactions).call(this, address) : true; accounts.push({ address, balance: null, index: i, }); // PER BIP44 // "Software should prevent a creation of an account if // a previous account does not have a transaction history // (meaning none of its addresses have been used before)." if (!valid) { break; } } return accounts; }, _LedgerKeyring_getAccountsLegacy = function _LedgerKeyring_getAccountsLegacy(from, to) { const accounts = []; for (let i = from; i < to; i++) { const address = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_addressFromIndex).call(this, pathBase, i); accounts.push({ address, balance: null, index: i, }); this.paths[__classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address)] = i; } return accounts; }, _LedgerKeyring_addressFromIndex = function _LedgerKeyring_addressFromIndex(basePath, i) { const dkey = this.hdk.derive(`${basePath}/${i}`); const address = bytesToHex(publicToAddress(dkey.publicKey, true)); return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address); }, _LedgerKeyring_pathFromAddress = function _LedgerKeyring_pathFromAddress(address) { const checksummedAddress = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getChecksumHexAddress).call(this, address); let index = this.paths[checksummedAddress]; if (typeof index === 'undefined') { for (let i = 0; i < MAX_INDEX; i++) { if (checksummedAddress === __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_addressFromIndex).call(this, pathBase, i)) { index = i; break; } } } if (typeof index === 'undefined') { throw new Error('Unknown address'); } return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getPathForIndex).call(this, index); }, _LedgerKeyring_getPathForIndex = function _LedgerKeyring_getPathForIndex(index) { // Check if the path is BIP 44 (Ledger Live) return __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_isLedgerLiveHdPath).call(this) ? `m/44'/60'/${index}'/0/0` : `${this.hdPath}/${index}`; }, _LedgerKeyring_isLedgerLiveHdPath = function _LedgerKeyring_isLedgerLiveHdPath() { return this.hdPath === `m/44'/60'/0'/0/0`; }, _LedgerKeyring_toLedgerPath = function _LedgerKeyring_toLedgerPath(path) { return path.toString().replace('m/', ''); }, _LedgerKeyring_hasPreviousTransactions = async function _LedgerKeyring_hasPreviousTransactions(address) { const apiUrl = __classPrivateFieldGet(this, _LedgerKeyring_instances, "m", _LedgerKeyring_getApiUrl).call(this); const response = await window.fetch(`${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1&offset=1`); const parsedResponse = await response.json(); return parsedResponse.status !== '0' && parsedResponse.result.length > 0; }, _LedgerKeyring_getApiUrl = function _LedgerKeyring_getApiUrl() { return this.network; }, _LedgerKeyring_getChecksumHexAddress = function _LedgerKeyring_getChecksumHexAddress(address) { return getChecksumAddress(add0x(address)); }; LedgerKeyring.type = keyringType; //# sourceMappingURL=ledger-keyring.mjs.map