From 13fc13f934c9dd45671e0a10b8564d607359187b Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Mon, 15 Sep 2025 12:20:03 -0700 Subject: [PATCH] Add Ledger connection as a configurable setting for wallets. --- src/lib/wallet/config.ts | 32 +++++---- src/lib/wallet/index.ts | 15 +++-- src/lib/wallet/ledger.ts | 57 +++++++++++----- src/types.d.ts | 137 +++++++++------------------------------ test/test.ledger.mjs | 47 ++++++++++++-- 5 files changed, 145 insertions(+), 143 deletions(-) diff --git a/src/lib/wallet/config.ts b/src/lib/wallet/config.ts index 2a5c34e..e9b7d97 100644 --- a/src/lib/wallet/config.ts +++ b/src/lib/wallet/config.ts @@ -1,24 +1,32 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { WalletType } from '#types' import { Vault } from '../vault' -import { Wallet } from '../wallet' -export async function _config (wallet: Wallet, vault: Vault, settings: { timeout: number }): Promise -export async function _config (wallet: Wallet, vault: Vault, settings: unknown): Promise { +export async function _config (type: WalletType, vault: Vault, settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise +export async function _config (type: WalletType, vault: Vault, settings: unknown): Promise { try { if (settings == null || typeof settings !== 'object') { throw new TypeError('Invalid configuration settings') } - const { timeout } = settings as { [key: string]: unknown } - if (typeof timeout !== 'number') { - throw new TypeError('Timeout must be number', { cause: timeout }) - } - if (wallet.type === 'BIP-44' || wallet.type === 'BLAKE2b') { - await vault.request({ - action: 'config', - timeout - }) + const { connection, timeout } = settings as { [key: string]: unknown } + if (type === 'Ledger') { + const { Ledger } = await import('./ledger') + if (connection !== undefined && connection !== 'hid' && connection !== 'ble' && connection !== 'usb') { + throw new Error('Ledger connection must be hid, ble, or usb', { cause: connection }) + } + await Ledger.connect(connection) + } else { + if (typeof timeout !== 'number') { + throw new TypeError('Timeout must be number', { cause: timeout }) + } + if (type === 'BIP-44' || type === 'BLAKE2b') { + await vault.request({ + action: 'config', + timeout + }) + } } } catch (err) { throw new Error('Failed to lock wallet', { cause: err }) diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 0b6b45b..e567420 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -43,9 +43,9 @@ export class Wallet { } /** - * Creates a new Ledger wallet manager. + * Creates a new hardware wallet manager. * - * @param {string} type - Encrypts the wallet to lock and unlock it + * @param {string} type - Wallet manufacturer * @returns {Wallet} A newly instantiated Wallet */ static async create (type: 'Ledger'): Promise @@ -53,6 +53,7 @@ export class Wallet { * Creates a new HD wallet by using an entropy value generated using a * cryptographically strong pseudorandom number generator. * + * @param {string} type - Algorithm used to generate wallet and child accounts * @param {string} password - Encrypts the wallet to lock and unlock it * @param {string} [salt=''] - Used when generating the final seed * @returns {Wallet} A newly instantiated Wallet @@ -246,12 +247,18 @@ export class Wallet { return await _accounts(this.type, this.#accounts, this.#vault, from, to) } + /** + * Configures Ledger connection settings. + * @param {string} connection - Transport interface to use + */ + async config (settings: { connection: 'hid' | 'ble' | 'usb' }): Promise /** * Configures vault worker settings. * @param {number} timeout - Measured in seconds of inactivity before wallet automatically locks */ - async config (settings: { timeout: number }): Promise { - return await _config(this, this.#vault, settings) + async config (settings: { timeout: number }): Promise + async config (settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise { + return await _config(this.type, this.#vault, settings) } /** diff --git a/src/lib/wallet/ledger.ts b/src/lib/wallet/ledger.ts index bed221c..85cf0ff 100644 --- a/src/lib/wallet/ledger.ts +++ b/src/lib/wallet/ledger.ts @@ -5,7 +5,7 @@ import { ledgerUSBVendorId } from '@ledgerhq/devices' import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble' import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' -import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types' +import { LedgerStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types' import { Account } from '../account' import { Block } from '../block' import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants' @@ -24,7 +24,7 @@ import { Wallet } from '../wallet' export class Ledger { static #listenTimeout: 30000 = 30000 static #openTimeout: 3000 = 3000 - static #status: DeviceStatus = 'DISCONNECTED' + static #status: LedgerStatus = 'DISCONNECTED' static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB static #ADPU_CODES: { [key: string]: number } = Object.freeze({ class: 0xa1, @@ -62,7 +62,9 @@ export class Ledger { * transport type according to the following priorities: HID, Bluetooth, USB. */ static get isUnsupported (): boolean { - console.log('Checking browser Ledger support...') + if (this.#transport === undefined) { + console.log('Checking browser Ledger support...') + } if (typeof globalThis.navigator?.hid?.getDevices === 'function') { this.#transport ??= TransportHID return false @@ -83,7 +85,7 @@ export class Ledger { * * DISCONNECTED | BUSY | LOCKED | CONNECTED */ - static get status (): DeviceStatus { return this.#status } + static get status (): LedgerStatus { return this.#status } /** * Request an account at a specific BIP-44 index. @@ -123,28 +125,49 @@ export class Ledger { /** * Check if the Nano app is currently open and set device status accordingly. * + * @param {string} [api] Transport interface to use * @returns Device status as follows: * - DISCONNECTED: Failed to communicate properly with the app * - BUSY: Nano app is not currently open * - LOCKED: Nano app is open but the device locked after a timeout * - CONNECTED: Nano app is open and listening */ - static async connect (): Promise { - const version = await this.#version() - if (version.status !== 'OK') { - this.#status = 'DISCONNECTED' - } else if (version.name === 'Nano') { - const { status } = await this.account() - if (status === 'OK') { - this.#status = 'CONNECTED' - } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') { - this.#status = 'LOCKED' - } else { + static async connect (api?: 'hid' | 'ble' | 'usb'): Promise { + if (Ledger.isUnsupported) { + throw new Error('Browser is unsupported') + } + if (api !== undefined) { + if (api === 'hid' && Ledger.#transport !== TransportHID) { + Ledger.#transport = TransportHID + } + if (api === 'ble' && Ledger.#transport !== TransportBLE) { + Ledger.#transport = TransportBLE + } + if (api === 'usb' && Ledger.#transport !== TransportUSB) { + Ledger.#transport = TransportUSB + } + } + try { + const version = await this.#version() + if (version.status !== 'OK') { this.#status = 'DISCONNECTED' + } else if (version.name === 'Nano') { + const { status } = await this.account() + if (status === 'OK') { + this.#status = 'CONNECTED' + } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') { + this.#status = 'LOCKED' + } else { + this.#status = 'DISCONNECTED' + } + } else { + this.#status = 'BUSY' } - } else { - this.#status = 'BUSY' + } catch (err) { + console.error(err) + this.#status = 'DISCONNECTED' } + console.log(this.#status) return this.#status } diff --git a/src/types.d.ts b/src/types.d.ts index 8c1d490..7c411d6 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -450,6 +450,27 @@ export type KeyPair = { publicKey?: string | Uint8Array } +export type LedgerStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' + +interface LedgerResponse { + status: string +} + +interface LedgerVersionResponse extends LedgerResponse { + name: string | null, + version: string | null +} + +interface LedgerAccountResponse extends LedgerResponse { + publicKey: string | null, + address: string | null +} + +interface LedgerSignResponse extends LedgerResponse { + signature: string | null, + hash?: string +} + /** * Represents a basic address book of Nano accounts. Multiple addresses can be * saved under one nickname. @@ -611,9 +632,9 @@ export declare class Wallet { */ static backup (): Promise /** - * Creates a new Ledger wallet manager. + * Creates a new hardware wallet manager. * - * @param {string} type - Encrypts the wallet to lock and unlock it + * @param {string} type - Wallet manufacturer * @returns {Wallet} A newly instantiated Wallet */ static create (type: 'Ledger'): Promise @@ -621,6 +642,7 @@ export declare class Wallet { * Creates a new HD wallet by using an entropy value generated using a * cryptographically strong pseudorandom number generator. * + * @param {string} type - Algorithm used to generate wallet and child accounts * @param {string} password - Encrypts the wallet to lock and unlock it * @param {string} [salt=''] - Used when generating the final seed * @returns {Wallet} A newly instantiated Wallet @@ -741,6 +763,13 @@ export declare class Wallet { */ accounts (from?: number, to?: number): Promise> /** + * Configures Ledger connection settings. + * @param {string} connection - Transport interface to use + */ + config (settings: { + connection: 'hid' | 'ble' | 'usb' + }): Promise + /** * Configures vault worker settings. * @param {number} timeout - Measured in seconds of inactivity before wallet automatically locks */ @@ -845,107 +874,3 @@ export declare class Wallet { */ verify (mnemonic: string): Promise } - -type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' - -interface LedgerResponse { - status: string -} - -interface LedgerVersionResponse extends LedgerResponse { - name: string | null, - version: string | null -} - -interface LedgerAccountResponse extends LedgerResponse { - publicKey: string | null, - address: string | null -} - -interface LedgerSignResponse extends LedgerResponse { - signature: string | null, - hash?: string -} - -/** -* Ledger hardware wallet created by communicating with a Ledger device via ADPU -* calls. This wallet does not feature any seed nor mnemonic phrase as all -* 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. -* -* https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md -*/ -export declare class Ledger { - #private - static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID - static UsbVendorId: number - static SYMBOL: Symbol - /** - * Check which transport protocols are supported by the browser and return the - * transport type according to the following priorities: USB, Bluetooth, HID. - */ - static get isUnsupported (): boolean - /** - * Status of the Ledger device connection. - * - * DISCONNECTED | BUSY | LOCKED | CONNECTED - */ - static get status (): DeviceStatus - /** - * Request an account at a specific BIP-44 index. - * - * @returns Response object containing command status, public key, and address - */ - static account (index?: number, show?: boolean): Promise - /** - * Check if the Nano app is currently open and set device status accordingly. - * - * @returns Device status as follows: - * - DISCONNECTED: Failed to communicate properly with the app - * - BUSY: Nano app is not currently open - * - LOCKED: Nano app is open but the device locked after a timeout - * - CONNECTED: Nano app is open and listening - */ - static connect (): Promise - /** - * Sign a block with the Ledger device. - * - * @param {number} index - Account number - * @param {Block} block - Block data to sign - * @param {Block} [frontier] - Previous block data to cache in the device - */ - static sign (index: number, block: Block, frontier?: Block): Promise - /** - * Update cache from raw block data. Suitable for offline use. - * - * @param {number} index - Account number - * @param {object} block - JSON-formatted block data - */ - static updateCache (index: number, block: Block): Promise - /** - * Update cache from a block hash by calling out to a node. Suitable for online - * use only. - * - * @param {number} index - Account number - * @param {string} hash - Hexadecimal block hash - * @param {Rpc} rpc - Rpc class object with a node URL - */ - static updateCache (index: number, hash: string, rpc: Rpc): Promise - /** - * Checks whether a given seed matches the wallet seed. The wallet must be - * unlocked prior to verification. - * - * @param {string} seed - Hexadecimal seed to be matched against the wallet data - * @returns True if input matches wallet seed - */ - static 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. - * The wallet must be unlocked prior to verification. - * - * @param {string} mnemonic - Phrase to be matched against the wallet data - * @returns True if input matches wallet mnemonic - */ - static verify (mnemonic: string): Promise -} diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index 81488d8..d3437bb 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -54,31 +54,66 @@ await Promise.all([ } await test('request permissions', async () => { - await assert.rejects(wallet.unlock(), 'expect DISCONNECTED') + console.log('expect DISCONNECTED...') + await click( + 'Reset permissions, unlock device, quit Nano app, then click to continue', + async () => new Promise(r => setTimeout(r, 5000)) + ) + await assert.rejects(wallet.unlock()) assert.equal(wallet.isLocked, true) await assert.rejects(async () => { + console.log('expect BUSY...') await click( 'Reset permissions, unlock device, quit Nano app, then click to continue', async () => wallet.unlock() ) - }, 'expect BUSY') + }) assert.equal(wallet.isLocked, true) await assert.rejects(async () => { + console.log('expect LOCKED...') await click( 'Open Nano app on device, allow device to auto-lock, then click to continue', async () => wallet.unlock() ) - }, 'expect LOCKED') + }) assert.equal(wallet.isLocked, true) await assert.resolves(async () => { + console.log('expect CONNECTED...') await click( 'Unlock device, verify Nano app is open, then click to continue', async () => wallet.unlock() ) - }, 'expect CONNECTED') + }) + assert.equal(wallet.isLocked, false) + + await assert.resolves(async () => { + console.log('expect BUSY...') + await click( + 'Verify current interface is HID, switch to Bluetooth device, then click to continue', + async () => wallet.config({ connection: 'ble' }) + ) + }) + assert.equal(wallet.isLocked, false) + + await assert.resolves(async () => { + console.log('expect CONNECTED...') + await click( + 'Verify current interface is BLE, switch back to USB device, then click to continue', + async () => wallet.config({ connection: 'usb' }) + ) + }) + assert.equal(wallet.isLocked, false) + + await assert.resolves(async () => { + console.log('expect CONNECTED...') + await click( + 'Verify current interface is USB, then click to continue', + async () => wallet.config({ connection: 'hid' }) + ) + }) assert.equal(wallet.isLocked, false) }) @@ -216,6 +251,10 @@ await Promise.all([ await test('destroy wallet', async () => { await wallet.destroy() + await click( + 'Click to finish Ledger tests by destroying wallet', + async () => new Promise(r => setTimeout(r)) + ) await assert.rejects(wallet.unlock()) }) }) -- 2.47.3