From: Chris Duncan Date: Tue, 19 Aug 2025 21:38:34 +0000 (-0700) Subject: Start merging Ledger functionality into main Wallet class and call out to static... X-Git-Tag: v0.10.5~41^2~45 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=279bf27aa6c6b77b86c85930fd42d7856d2be236;p=libnemo.git Start merging Ledger functionality into main Wallet class and call out to static methods as necessary. --- diff --git a/src/lib/block.ts b/src/lib/block.ts index 7381ef1..ea3a512 100644 --- a/src/lib/block.ts +++ b/src/lib/block.ts @@ -427,18 +427,18 @@ export class Block { const wallet = input await wallet.sign(param, this) } else if (typeof input === 'number') { + const { Ledger } = await import('./wallet/ledger') const index = input - const { Ledger } = await import('#wallet') - const ledger = await Ledger.create() - await ledger.connect() + const wallet = await Wallet.create('Ledger') + await wallet.unlock() if (param && param instanceof Block) { try { - await ledger.updateCache(index, param) + await Ledger.updateCache(index, param) } catch (err) { console.warn('Error updating Ledger cache of previous block, attempting signature anyway', err) } } - await ledger.sign(index, this) + await Ledger.sign(index, this) } else { throw new TypeError('Invalid key for block signature', { cause: input }) } diff --git a/src/lib/wallet/accounts.ts b/src/lib/wallet/accounts.ts index 3aa3f96..a9a716b 100644 --- a/src/lib/wallet/accounts.ts +++ b/src/lib/wallet/accounts.ts @@ -1,12 +1,13 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair } from '#types' +import { KeyPair, WalletType } from '#types' import { Vault } from '#vault' import { Account, AccountList } from '../account' +import { Ledger } from './ledger' -export async function _accounts (accounts: AccountList, vault: Vault, from: number, to: number): Promise -export async function _accounts (accounts: AccountList, vault: Vault, from: unknown, to: unknown): Promise { +export async function _accounts (type: WalletType, accounts: AccountList, vault: Vault, from: number, to: number): Promise +export async function _accounts (type: WalletType, accounts: AccountList, vault: Vault, from: unknown, to: unknown): Promise { if (typeof from !== 'number' || typeof to !== 'number') { throw new TypeError('Invalid account range', { cause: `${from}-${to}` }) } @@ -21,22 +22,33 @@ export async function _accounts (accounts: AccountList, vault: Vault, from: unkn } } if (indexes.length > 0) { - const promises = [] - for (const index of indexes) { - promises.push(vault.request({ - action: 'derive', - index - })) - } - const publicKeys: KeyPair[] = await Promise.all(promises) - if (publicKeys.length > 0) { - const publicAccounts = Account.load(publicKeys) - for (const a of publicAccounts) { - if (a.index == null) { - throw new RangeError('Index missing for Account') + const publicAccounts = [] + if (type === 'Ledger') { + for (const index of indexes) { + const { status, publicKey } = await Ledger.account(index) + if (status !== 'OK' || publicKey == null) { + throw new Error(`Error getting Ledger account: ${status}`) } - output[a.index] = accounts[a.index] = a + publicAccounts.push(Account.load({ index, publicKey })) + } + } else { + const promises = [] + for (const index of indexes) { + promises.push(vault.request({ + action: 'derive', + index + })) + } + const publicKeys: KeyPair[] = await Promise.all(promises) + if (publicKeys.length > 0) { + publicAccounts.push(...Account.load(publicKeys)) + } + } + for (const a of publicAccounts) { + if (a.index == null) { + throw new RangeError('Index missing for Account') } + output[a.index] = accounts[a.index] = a } } return output diff --git a/src/lib/wallet/create.ts b/src/lib/wallet/create.ts index 8b83af8..63dc9e1 100644 --- a/src/lib/wallet/create.ts +++ b/src/lib/wallet/create.ts @@ -6,34 +6,48 @@ import { Vault } from '#vault' import { Wallet } from '#wallet' import { utf8 } from '../convert' import { Database } from '../database' +import { Ledger } from './ledger' import { _load } from './load' -export async function _create (wallet: Wallet, vault: Vault, password: string, mnemonicSalt?: string): Promise> +export async function _create (wallet: Wallet, vault: Vault, password?: string, mnemonicSalt?: string): Promise> export async function _create (wallet: Wallet, vault: Vault, password: unknown, mnemonicSalt?: unknown): Promise> { try { - if (typeof password !== 'string') { - throw new TypeError('Invalid password', { cause: typeof password }) - } - if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') { - throw new TypeError('Mnemonic salt must be a string') - } - const { iv, salt, encrypted, seed, mnemonic } = await vault.request({ - action: 'create', - type: wallet.type, - password: utf8.toBuffer(password), - mnemonicSalt: mnemonicSalt ?? '' - }) - password = undefined - mnemonicSalt = undefined - const record = { + const result: NamedData = {} + const record: NamedData = { id: wallet.id, - type: wallet.type, - iv, - salt, - encrypted + type: wallet.type + } + if (wallet.type === 'Ledger') { + try { + if (Ledger.isUnsupported) { + throw new Error('Browser is unsupported') + } + } catch (err) { + throw new Error('Failed to initialize Ledger wallet', { cause: err }) + } + } else { + if (typeof password !== 'string') { + throw new TypeError('Invalid password', { cause: typeof password }) + } + if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') { + throw new TypeError('Mnemonic salt must be a string') + } + const response = await vault.request({ + action: 'create', + type: wallet.type, + password: utf8.toBuffer(password), + mnemonicSalt: mnemonicSalt ?? '' + }) + password = undefined + mnemonicSalt = undefined + record.iv = response.iv + record.salt = response.salt + record.encrypted = response.encrypted + result.mnemonic = response.mnemonic + result.seed = response.seed } await Database.add({ [wallet.id]: record }, Wallet.DB_NAME) - return { mnemonic, seed } + return result } catch (err) { await wallet.destroy() throw new Error('Error creating new Wallet', { cause: err }) diff --git a/src/lib/wallet/destroy.ts b/src/lib/wallet/destroy.ts index 133294a..57387d9 100644 --- a/src/lib/wallet/destroy.ts +++ b/src/lib/wallet/destroy.ts @@ -8,6 +8,9 @@ import { Database } from '../database' export async function _destroy (wallet: Wallet, vault: Vault) { try { vault.terminate() + if (wallet.type === 'Ledger') { + wallet.lock() + } const isDeleted = await Database.delete(wallet.id, Wallet.DB_NAME) if (!isDeleted) { throw new Error('Failed to delete wallet from database') diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 3a32e9f..6af7806 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -22,8 +22,6 @@ import { _unlock } from './unlock' import { _unopened } from './unopened' import { _verify } from './verify' -export { Ledger } from './ledger' - /** * Represents a wallet containing numerous Nano accounts derived from a single * source, the form of which can vary based on the type of wallet. Currently, @@ -41,6 +39,13 @@ export class Wallet { return _backup() } + /** + * Creates a new Ledger wallet manager. + * + * @param {string} type - Encrypts the wallet to lock and unlock it + * @returns {Wallet} A newly instantiated Wallet + */ + static async create (type: 'Ledger'): Promise /** * Creates a new HD wallet by using an entropy value generated using a * cryptographically strong pseudorandom number generator. @@ -49,7 +54,8 @@ export class Wallet { * @param {string} [salt=''] - Used when generating the final seed * @returns {Wallet} A newly instantiated Wallet */ - static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise { + static async create (type: 'BIP-44' | 'BLAKE2b', password?: string, mnemonicSalt?: string): Promise + static async create (type: WalletType, password?: string, mnemonicSalt?: string): Promise { Wallet.#isInternal = true const self = new this(type) { ({ mnemonic: self.#mnemonic, seed: self.#seed } = await _create(self, self.#vault, password, mnemonicSalt)) } @@ -109,7 +115,7 @@ export class Wallet { constructor (type: WalletType, id?: string) constructor (type: unknown, id?: string) { - if (!Wallet.#isInternal && type !== 'Ledger') { + if (!Wallet.#isInternal) { throw new Error(`Wallet cannot be instantiated directly. Use 'await Wallet.create()' instead.`) } if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Ledger') { @@ -227,7 +233,7 @@ export class Wallet { * @returns {AccountList} Promise for a list of Accounts at the specified indexes */ async accounts (from: number = 0, to: number = from): Promise { - return await _accounts(this.#accounts, this.#vault, from, to) + return await _accounts(this.type, this.#accounts, this.#vault, from, to) } /** @@ -239,10 +245,15 @@ export class Wallet { } /** - * Clears the seed and mnemonic from the vault. + * For BIP-44 and BLAKE2b wallets, clears the seed and mnemonic from the vault. + * + * For Ledger hardware wallets, revokes permission granted by the user to + * access the Ledger device. The 'quit app' ADPU command is uncooperative, so + * this is currently the only way to ensure the connection is severed. + * `setTimeout` is used to expire any lingering transient user activation. */ lock (): void { - _lock(this.#vault) + _lock(this, this.#vault) } /** @@ -270,12 +281,17 @@ export class Wallet { await _sign(this.#vault, index, block) } + /** + * Attempts to connect to the Ledger device. + */ + async unlock (): Promise /** * Unlocks the wallet using the same password as used prior to lock it. * * @param {string} password Used previously to lock the wallet */ - async unlock (password: string): Promise { + async unlock (password: string): Promise + async unlock (password?: string): Promise { await _unlock(this, this.#vault, password) } @@ -310,7 +326,7 @@ export class Wallet { */ async verify (mnemonic: string): Promise async verify (secret: string): Promise { - return await _verify(this.#vault, secret) + return await _verify(this.type, this.#vault, secret) } static #isInternal: boolean = false diff --git a/src/lib/wallet/ledger.ts b/src/lib/wallet/ledger.ts index 54a5a03..2899d38 100644 --- a/src/lib/wallet/ledger.ts +++ b/src/lib/wallet/ledger.ts @@ -20,8 +20,9 @@ import { Rpc } from '../rpc' * private keys are held in the secure chip of the device. As such, the user * is responsible for using Ledger technology to back up these pieces of data. */ -export class Ledger extends Wallet { +export class Ledger { static #isInternal: boolean = false + static #status: DeviceStatus = 'DISCONNECTED' static #ADPU_CODES: { [key: string]: number } = Object.freeze({ class: 0xa1, @@ -53,10 +54,12 @@ export class Ledger extends Wallet { }) static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID + static UsbVendorId = ledgerUSBVendorId static SYMBOL: Symbol = Symbol('Ledger') static get #listenTimeout (): 30000 { return 30000 } static get #openTimeout (): 3000 { return 3000 } + static get status (): DeviceStatus { return this.#status } /** * Check which transport protocols are supported by the browser and return the @@ -80,116 +83,38 @@ export class Ledger extends Wallet { } /** - * Creates a new Ledger hardware wallet communication layer by dynamically - * importing the ledger.js service. + * Request an account at a specific BIP-44 index. * - * @returns {Ledger} A wallet containing accounts and a Ledger device communication object + * @returns Response object containing command status, public key, and address */ - static async create (): Promise { - try { - if (this.isUnsupported) throw new Error('Browser is unsupported') - this.#isInternal = true - const self = new this() - await Database.add({ [self.id]: { id: self.id, type: 'Ledger' } }, Wallet.DB_NAME) - return self - } catch (err) { - throw new Error('Failed to initialize Ledger wallet', { cause: err }) + static async account (index: number = 0, show: boolean = false): Promise { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') } - } + const account = dec.toBytes(index + HARDENED_OFFSET, 4) + const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account]) - /** - * Overrides `import()` from the base Wallet class since Ledger secrets cannot - * be extracted from the device. - */ - static import (): Promise { - return Ledger.create() - } + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() - private constructor () { - if (!Ledger.#isInternal) { - throw new Error(`Ledger cannot be instantiated directly. Use 'await Ledger.create()' instead.`) + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, publicKey: null, address: null } } - Ledger.#isInternal = false - super('Ledger') - this.#accounts = new AccountList() - } - - get status (): DeviceStatus { return this.#status } - /** - * Gets the index and public key for an account from the Ledger device. - * - * @param {number} index - Wallet index of the account - * @returns Promise for the Account at the index specified - */ - async account (index: number): Promise { - const { status, publicKey } = await Ledger.#account(index) - if (status !== 'OK' || publicKey == null) { - throw new Error(`Error getting Ledger account: ${status}`) - } - return Account.load({ index, publicKey }) - } + try { + const publicKey = bytes.toHex(response.slice(0, 32)) + const addressLength = response[32] + const address = response.slice(33, 33 + addressLength).toString() - /** - * Retrieves accounts from a Ledger wallet using its internal secure software. - * Defaults to the first account at index 0. - * - * The returned object will have keys corresponding with the requested range - * of account indexes. The value of each key will be the Account derived for - * that index in the wallet. - * - * ``` - * const accounts = await wallet.accounts(0, 1)) - * // outputs the first and second account of the wallet - * console.log(accounts) - * // { - * // 0: { - * // address: <...>, - * // publicKey: <...>, - * // index: 0, - * // - * // }, - * // 1: { - * // address: <...>, - * // publicKey: <...>, - * // index: 1, - * // - * // } - * // } - * // individual accounts can be referenced using array index notation - * console.log(accounts[1]) - * // { address: <...>, publicKey: <...>, index: 1, } - * ``` - * - * If `from` is greater than `to`, their values will be swapped. - * @param {number} from - Start index of accounts. Default: 0 - * @param {number} to - End index of accounts. Default: `from` - * @returns {AccountList} Promise for a list of Accounts at the specified indexes - */ - async accounts (from: number = 0, to: number = from): Promise { - if (from > to) [from, to] = [to, from] - const output = new AccountList() - const indexes: number[] = [] - for (let i = from; i <= to; i++) { - if (this.#accounts[i] == null) { - indexes.push(i) - } else { - output[i] = this.#accounts[i] - } - } - if (indexes.length > 0) { - const publicAccounts = [] - for (const index of indexes) { - publicAccounts.push(await this.account(index)) - } - for (const a of publicAccounts) { - if (a.index == null) { - throw new RangeError('Index missing for Account') - } - output[a.index] = this.#accounts[a.index] = a - } + return { status, publicKey, address } + } catch (err) { + return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } } - return output } /** @@ -201,12 +126,12 @@ export class Ledger extends Wallet { * - LOCKED: Nano app is open but the device locked after a timeout * - CONNECTED: Nano app is open and listening */ - async connect (): Promise { - const version = await this.#version() + static async connect (): Promise { + const version = await this.version() if (version.status !== 'OK') { this.#status = 'DISCONNECTED' } else if (version.name === 'Nano') { - const { status } = await Ledger.#account() + const { status } = await Ledger.account() if (status === 'OK') { this.#status = 'CONNECTED' } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') { @@ -220,36 +145,6 @@ export class Ledger extends Wallet { return this.#status } - /** - * Removes encrypted secrets in storage and releases variable references to - * allow garbage collection. - */ - async destroy (): Promise { - await super.destroy() - this.lock() - } - - /** - * Revokes permission granted by the user to access the Ledger device. - * - * The 'quit app' ADPU command has not passed testing, so this is the only way - * to ensure the connection is severed at this time. `setTimeout` is used to - * expire any lingering transient user activation. - * - * Overrides the default wallet `lock()` method since as a hardware wallet it - * does not need to be encrypted by software. - */ - lock (): void { - setTimeout(async () => { - const devices = await globalThis.navigator.usb.getDevices() - for (const device of devices) { - if (device.vendorId === ledgerUSBVendorId) { - device.forget() - } - } - }) - } - /** * Sign a block with the Ledger device. * @@ -257,8 +152,8 @@ export class Ledger extends Wallet { * @param {Block} block - Block data to sign * @param {Block} [frontier] - Previous block data to cache in the device */ - async sign (index: number, block: Block, frontier?: Block): Promise - async sign (index: number, block: Block, frontier?: Block): Promise { + static async sign (index: number, block: Block, frontier?: Block): Promise + static async sign (index: number, block: Block, frontier?: Block): Promise { try { if (typeof index !== 'number') { throw new TypeError('Index must be a number', { cause: index }) @@ -267,13 +162,13 @@ export class Ledger extends Wallet { throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index }) } if (frontier != null) { - const { status } = await this.#cacheBlock(index, frontier) + const { status } = await Ledger.#cacheBlock(index, frontier) if (status !== 'OK') { throw new Error('Failed to cache frontier block in ledger', { cause: status }) } } console.log('Waiting for signature confirmation on Ledger device...') - const { status, signature, hash } = await this.#signBlock(index, block) + const { status, signature, hash } = await Ledger.#signBlock(index, block) if (status !== 'OK') { throw new Error('Signing with ledger failed', { cause: status }) } @@ -290,28 +185,13 @@ export class Ledger extends Wallet { } } - /** - * Attempts to connect to the Ledger device. - * - * Overrides the default wallet `unlock()` method since as a hardware wallet it - * does not need to be encrypted by software. - * - * @returns True if successfully unlocked - */ - async unlock (): Promise { - const status = await this.connect() - if (await status !== 'CONNECTED') { - throw new Error('Failed to unlock wallet', { cause: status }) - } - } - /** * Update cache from raw block data. Suitable for offline use. * * @param {number} index - Account number * @param {object} block - JSON-formatted block data */ - async updateCache (index: number, block: Block): Promise + static async updateCache (index: number, block: Block): Promise /** * Update cache from a block hash by calling out to a node. Suitable for online * use only. @@ -320,8 +200,8 @@ export class Ledger extends Wallet { * @param {string} hash - Hexadecimal block hash * @param {Rpc} rpc - Rpc class object with a node URL */ - async updateCache (index: number, hash: string, rpc: Rpc): Promise - async updateCache (index: number, input: any, node?: Rpc): Promise { + static async updateCache (index: number, hash: string, rpc: Rpc): Promise + static async updateCache (index: number, input: any, node?: Rpc): Promise { if (typeof input === 'string' && node instanceof Rpc) { const data = { 'json_block': 'true', @@ -340,6 +220,35 @@ export class Ledger extends Wallet { return { status } } + /** + * Get the version of the current process. If a specific app is running, get + * the app version. Otherwise, get the Ledger BOLOS version instead. + * + * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information + * + * @returns Status, process name, and version + */ + static async version (): Promise { + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, name: null, version: null } + } + + const nameLength = response[1] + const name = response.slice(2, 2 + nameLength).toString() + const versionLength = response[2 + nameLength] + const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() + + return { status, name, version } + } + /** * Checks whether a given seed matches the wallet seed. The wallet must be * unlocked prior to verification. @@ -347,7 +256,7 @@ export class Ledger extends Wallet { * @param {string} seed - Hexadecimal seed to be matched against the wallet data * @returns True if input matches wallet seed */ - async verify (seed: string): Promise + static async verify (seed: string): Promise /** * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a * personal salt was used when generating the mnemonic, it cannot be verified. @@ -356,8 +265,8 @@ export class Ledger extends Wallet { * @param {string} mnemonic - Phrase to be matched against the wallet data * @returns True if input matches wallet mnemonic */ - async verify (mnemonic: string): Promise - async verify (secret: string): Promise { + static async verify (mnemonic: string): Promise + static async verify (secret: string): Promise { const testWallet = await Wallet.load('BIP-44', '', secret) await testWallet.unlock('') const testAccount = await testWallet.account(0) @@ -368,28 +277,13 @@ export class Ledger extends Wallet { .send(testAccount.address, 0) await testWallet.sign(0, testOpenBlock) try { - await this.sign(0, testSendBlock, testOpenBlock) + await Ledger.sign(0, testSendBlock, testOpenBlock) return testSendBlock.signature === testOpenBlock.signature } catch (err) { throw new Error('Failed to verify wallet', { cause: err }) } } - /** - * Get the version of the current process. If a specific app is running, get - * the app version. Otherwise, get the Ledger BOLOS version instead. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information - * - * @returns Status, process name, and version - */ - async version (): Promise { - return await this.#version() - } - - #accounts: AccountList - #status: DeviceStatus = 'DISCONNECTED' - /** * Close the currently running app and return to the device dashboard. * @@ -412,41 +306,6 @@ export class Ledger extends Wallet { return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] })) } - /** - * Request an account at a specific BIP-44 index. - * - * @returns Response object containing command status, public key, and address - */ - static async #account (index: number = 0, show: boolean = false): Promise { - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - const account = dec.toBytes(index + HARDENED_OFFSET, 4) - const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account]) - - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, publicKey: null, address: null } - } - - try { - const publicKey = bytes.toHex(response.slice(0, 32)) - const addressLength = response[32] - const address = response.slice(33, 33 + addressLength).toString() - - return { status, publicKey, address } - } catch (err) { - return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } - } - } - /** * Cache frontier block in device memory. * @@ -454,7 +313,7 @@ export class Ledger extends Wallet { * @param {any} block - Block data to cache * @returns Status of command */ - async #cacheBlock (index: number = 0, block: Block): Promise { + static async #cacheBlock (index: number = 0, block: Block): Promise { if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { throw new TypeError('Invalid account index') } @@ -491,7 +350,7 @@ export class Ledger extends Wallet { return { status: Ledger.#STATUS_CODES[response] } } - #onConnectUsb = async (e: USBConnectionEvent): Promise => { + static #onConnectUsb = async (e: USBConnectionEvent): Promise => { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger connected') @@ -501,7 +360,7 @@ export class Ledger extends Wallet { } } - #onDisconnectUsb = async (e: USBConnectionEvent): Promise => { + static #onDisconnectUsb = async (e: USBConnectionEvent): Promise => { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger disconnected') @@ -542,7 +401,7 @@ export class Ledger extends Wallet { * @param {object} block - Block data to sign * @returns {Promise} Status, signature, and block hash */ - async #signBlock (index: number, block: Block): Promise { + static async #signBlock (index: number, block: Block): Promise { if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { throw new TypeError('Invalid account index') } @@ -620,33 +479,4 @@ export class Ledger extends Wallet { throw new Error('Unexpected byte length from device signature', { cause: response }) } - - /** - * Get the version of the current process. If a specific app is running, get - * the app version. Otherwise, get the Ledger BOLOS version instead. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information - * - * @returns Status, process name, and version - */ - async #version (): Promise { - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, name: null, version: null } - } - - const nameLength = response[1] - const name = response.slice(2, 2 + nameLength).toString() - const versionLength = response[2 + nameLength] - const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() - - return { status, name, version } - } } diff --git a/src/lib/wallet/load.ts b/src/lib/wallet/load.ts index d19558b..22b1233 100644 --- a/src/lib/wallet/load.ts +++ b/src/lib/wallet/load.ts @@ -7,43 +7,52 @@ import { Vault } from '#vault' import { Wallet } from '#wallet' import { hex, utf8 } from '../convert' import { Database } from '../database' +import { Ledger } from './ledger' export async function _load (wallet: Wallet, vault: Vault, password: string, secret: string, mnemonicSalt?: string): Promise export async function _load (wallet: Wallet, vault: Vault, password: unknown, secret: unknown, mnemonicSalt?: unknown): Promise { try { - if (typeof password !== 'string') { - throw new TypeError('Password must be a string') - } - if (typeof secret !== 'string') { - throw new TypeError('Wallet secret must be a string') - } - if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') { - throw new TypeError('Mnemonic salt must be a string') - } - const data: NamedData = { - action: 'load', - type: wallet.type, - password: utf8.toBuffer(password) + const record: NamedData = { + id: wallet.id, + type: wallet.type } - password = undefined - if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) { - data.seed = hex.toBuffer(secret) - } else if (await Bip39.validate(secret)) { - data.mnemonicPhrase = secret.toLowerCase() - if (mnemonicSalt != null) data.mnemonicSalt = mnemonicSalt + if (wallet.type === 'Ledger') { + if (Ledger.isUnsupported) { + throw new Error('Failed to initialize Ledger wallet', { cause: 'Browser is unsupported' }) + } } else { - throw new TypeError('Invalid wallet data') - } - secret = undefined - mnemonicSalt = undefined - const result = vault.request(data) - const { iv, salt, encrypted } = await result - const record = { - id: wallet.id, - type: wallet.type, - iv, - salt, - encrypted + if (wallet.type !== 'BIP-44' && wallet.type !== 'BLAKE2b') { + throw new TypeError('Invalid wallet type', { cause: wallet.type }) + } + if (typeof password !== 'string') { + throw new TypeError('Password must be a string') + } + if (typeof secret !== 'string') { + throw new TypeError('Wallet secret must be a string') + } + if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') { + throw new TypeError('Mnemonic salt must be a string') + } + const data: NamedData = { + action: 'load', + type: wallet.type, + password: utf8.toBuffer(password) + } + password = undefined + if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) { + data.seed = hex.toBuffer(secret) + } else if (await Bip39.validate(secret)) { + data.mnemonicPhrase = secret.toLowerCase() + if (mnemonicSalt != null) data.mnemonicSalt = mnemonicSalt + } else { + throw new TypeError('Invalid wallet data') + } + secret = undefined + mnemonicSalt = undefined + const response = await vault.request(data) + record.iv = response.iv + record.salt = response.salt + record.encrypted = response.encrypted } await Database.add({ [wallet.id]: record }, Wallet.DB_NAME) } catch (err) { diff --git a/src/lib/wallet/lock.ts b/src/lib/wallet/lock.ts index 75cccbb..863654b 100644 --- a/src/lib/wallet/lock.ts +++ b/src/lib/wallet/lock.ts @@ -2,15 +2,27 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { Vault } from '#vault' +import { Wallet } from '#wallet' +import { Ledger } from './ledger' -export async function _lock (vault: Vault): Promise -export async function _lock (vault: Vault): Promise { +export async function _lock (wallet: Wallet, vault: Vault): Promise { try { - const { isLocked } = await vault.request({ - action: 'lock' - }) - if (!isLocked) { - throw new Error('Lock request to Vault failed') + if (wallet.type === 'Ledger') { + setTimeout(async () => { + const devices = await globalThis.navigator.usb.getDevices() + for (const device of devices) { + if (device.vendorId === Ledger.UsbVendorId) { + device.forget() + } + } + }) + } else { + const { isLocked } = await vault.request({ + action: 'lock' + }) + if (!isLocked) { + throw new Error('Lock request to Vault failed') + } } } catch (err) { throw new Error('Failed to lock wallet', { cause: err }) diff --git a/src/lib/wallet/unlock.ts b/src/lib/wallet/unlock.ts index 90209ae..135a1c1 100644 --- a/src/lib/wallet/unlock.ts +++ b/src/lib/wallet/unlock.ts @@ -5,24 +5,32 @@ import { Vault } from '#vault' import { Wallet } from '#wallet' import { utf8 } from '../convert' import { _get } from './get' +import { Ledger } from './ledger' -export async function _unlock (wallet: Wallet, vault: Vault, password: string): Promise +export async function _unlock (wallet: Wallet, vault: Vault, password?: string): Promise export async function _unlock (wallet: Wallet, vault: Vault, password: unknown): Promise { try { - if (typeof password !== 'string') { - throw new TypeError('Password must be a string') - } - const { iv, salt, encrypted } = await _get(wallet.id) - const { isUnlocked } = await vault.request({ - action: 'unlock', - type: wallet.type, - password: utf8.toBuffer(password), - iv, - keySalt: salt, - encrypted - }) - if (!isUnlocked) { - throw new Error('Unlock request to Vault failed') + if (wallet.type === 'Ledger') { + const status = await Ledger.connect() + if (await status !== 'CONNECTED') { + throw new Error('Failed to unlock wallet', { cause: status }) + } + } else { + if (typeof password !== 'string') { + throw new TypeError('Password must be a string') + } + const { iv, salt, encrypted } = await _get(wallet.id) + const { isUnlocked } = await vault.request({ + action: 'unlock', + type: wallet.type, + password: utf8.toBuffer(password), + iv, + keySalt: salt, + encrypted + }) + if (!isUnlocked) { + throw new Error('Unlock request to Vault failed') + } } } catch (err) { throw new Error('Failed to unlock wallet', { cause: err }) diff --git a/src/lib/wallet/verify.ts b/src/lib/wallet/verify.ts index 9fcf267..1d530d1 100644 --- a/src/lib/wallet/verify.ts +++ b/src/lib/wallet/verify.ts @@ -1,29 +1,34 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { NamedData } from '#types' +import { NamedData, WalletType } from '#types' import { Vault } from '#vault' import { hex } from '../convert' +import { Ledger } from './ledger' -export async function _verify (vault: Vault, secret: string): Promise -export async function _verify (vault: Vault, secret: unknown): Promise { +export async function _verify (type: WalletType, vault: Vault, secret: string): Promise +export async function _verify (type: WalletType, vault: Vault, secret: unknown): Promise { try { if (typeof secret !== 'string') { throw new TypeError('Wallet secret must be a string', { cause: typeof secret }) } - const data: NamedData = { - action: 'verify' - } - if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) { - data.seed = hex.toBuffer(secret) - } else if (/^([a-z]{3,8} ){11,23}[a-z]{3,8}$/i.test(secret)) { - data.mnemonicPhrase = secret.toLowerCase() + if (type === 'Ledger') { + return await Ledger.verify(secret) } else { - throw new TypeError('Invalid format') + const data: NamedData = { + action: 'verify' + } + if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) { + data.seed = hex.toBuffer(secret) + } else if (/^([a-z]{3,8} ){11,23}[a-z]{3,8}$/i.test(secret)) { + data.mnemonicPhrase = secret.toLowerCase() + } else { + throw new TypeError('Invalid format') + } + const result = await vault.request(data) + const { isVerified } = result + return isVerified } - const result = await vault.request(data) - const { isVerified } = result - return isVerified } catch (err) { throw new Error('Failed to verify wallet', { cause: err }) } diff --git a/src/main.ts b/src/main.ts index e3746f2..dd9a3d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { Blake2b } from '#crypto' -import { Ledger, Wallet } from '#wallet' +import { Wallet } from '#wallet' import { Account } from './lib/account' import { Block } from './lib/block' import { Rolodex } from './lib/rolodex' @@ -13,7 +13,6 @@ export { Account, Blake2b, Block, - Ledger, Rolodex, Rpc, Tools,