]> git.codecow.com Git - libnemo.git/commitdiff
Add Ledger connection as a configurable setting for wallets.
authorChris Duncan <chris@zoso.dev>
Mon, 15 Sep 2025 19:20:03 +0000 (12:20 -0700)
committerChris Duncan <chris@zoso.dev>
Mon, 15 Sep 2025 19:20:03 +0000 (12:20 -0700)
src/lib/wallet/config.ts
src/lib/wallet/index.ts
src/lib/wallet/ledger.ts
src/types.d.ts
test/test.ledger.mjs

index 2a5c34ed28f22b0514bab50d61afa65c126decd1..e9b7d979a6195ad80691aa49de2bba276a97b12d 100644 (file)
@@ -1,24 +1,32 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! 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<void>
-export async function _config (wallet: Wallet, vault: Vault, settings: unknown): Promise<void> {
+export async function _config (type: WalletType, vault: Vault, settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise<void>
+export async function _config (type: WalletType, vault: Vault, settings: unknown): Promise<void> {
        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 })
index 0b6b45b92dea1f0799b76c9ff0d2c2fb9294a34f..e567420a92fb7bf505fd175c2e24afae8a98efda 100644 (file)
@@ -43,9 +43,9 @@ export class Wallet {
        }\r
 \r
        /**\r
-       * Creates a new Ledger wallet manager.\r
+       * Creates a new hardware wallet manager.\r
        *\r
-       * @param {string} type - Encrypts the wallet to lock and unlock it\r
+       * @param {string} type - Wallet manufacturer\r
        * @returns {Wallet} A newly instantiated Wallet\r
        */\r
        static async create (type: 'Ledger'): Promise<Wallet>\r
@@ -53,6 +53,7 @@ export class Wallet {
        * Creates a new HD wallet by using an entropy value generated using a\r
        * cryptographically strong pseudorandom number generator.\r
        *\r
+       * @param {string} type - Algorithm used to generate wallet and child accounts\r
        * @param {string} password - Encrypts the wallet to lock and unlock it\r
        * @param {string} [salt=''] - Used when generating the final seed\r
        * @returns {Wallet} A newly instantiated Wallet\r
@@ -246,12 +247,18 @@ export class Wallet {
                return await _accounts(this.type, this.#accounts, this.#vault, from, to)\r
        }\r
 \r
+       /**\r
+       * Configures Ledger connection settings.\r
+       * @param {string} connection - Transport interface to use\r
+       */\r
+       async config (settings: { connection: 'hid' | 'ble' | 'usb' }): Promise<void>\r
        /**\r
        * Configures vault worker settings.\r
        * @param {number} timeout - Measured in seconds of inactivity before wallet automatically locks\r
        */\r
-       async config (settings: { timeout: number }): Promise<void> {\r
-               return await _config(this, this.#vault, settings)\r
+       async config (settings: { timeout: number }): Promise<void>\r
+       async config (settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise<void> {\r
+               return await _config(this.type, this.#vault, settings)\r
        }\r
 \r
        /**\r
index bed221c98a04e71d354b5d2c976284fd98840055..85cf0ff2db1950bf839eb99c83d64f611e5fb1ab 100644 (file)
@@ -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<DeviceStatus> {
-               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<LedgerStatus> {
+               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
        }
 
index 8c1d490bdddb39d31fd2481b9c533efcec7b3a27..7c411d6fa529be8016c352a0e8c358495f6e02ac 100644 (file)
@@ -450,6 +450,27 @@ export type KeyPair = {
        publicKey?: string | Uint8Array<ArrayBuffer>
 }
 
+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<NamedData[]>
        /**
-       * 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<Wallet>
@@ -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<Map<number, Account>>
        /**
+       * Configures Ledger connection settings.
+       * @param {string} connection - Transport interface to use
+       */
+       config (settings: {
+               connection: 'hid' | 'ble' | 'usb'
+       }): Promise<void>
+       /**
        * 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<boolean>
 }
-
-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<LedgerAccountResponse>
-       /**
-       * 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<DeviceStatus>
-       /**
-       * 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<string>
-       /**
-       * 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<LedgerResponse>
-       /**
-       * 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<LedgerResponse>
-       /**
-       * 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<boolean>
-       /**
-       * 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<boolean>
-}
index 81488d832537bbc076e039b7e69b4386783b6dd2..d3437bbc56d1d8e6ee6eed1663133550d12f9562 100644 (file)
@@ -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())
                })
        })