]> git.codecow.com Git - libnemo.git/commitdiff
Split up interface swap tests from main Ledger permission tests. Add busy flag. Ensur...
authorChris Duncan <chris@zoso.dev>
Fri, 19 Sep 2025 08:39:39 +0000 (01:39 -0700)
committerChris Duncan <chris@zoso.dev>
Fri, 19 Sep 2025 08:39:39 +0000 (01:39 -0700)
src/lib/ledger.ts
test/test.ledger.mjs

index 123a42983db3470a00c368e13d67adc19e21805d..869c45204242ee11af0760b0349c3059bf6f9803 100644 (file)
@@ -103,6 +103,14 @@ export class Ledger {
                return true
        }
 
+       /**
+       * Vendor ID assigned to Ledger for HID and USB interfaces.
+       * https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/devices/src/index.ts#L164
+       */
+       static get ledgerVendorId (): 0x2c97 {
+               return 0x2c97
+       }
+
        /**
        * Status of the Ledger device connection.
        *
@@ -112,33 +120,28 @@ export class Ledger {
                return this.isUnsupported ? 'UNSUPPORTED' : this.#status
        }
 
-       /**
-       * Vendor ID assigned to Ledger for HID and USB interfaces.
-       * https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/devices/src/index.ts#L164
-       */
-       static get UsbVendorId (): 0x2c97 {
-               return 0x2c97
-       }
-
        /**
        * 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<LedgerAccountResponse> {
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
-                       throw new TypeError('Invalid account index')
+               if (this.#isBusy) {
+                       return await this.account(index, show)
                }
-               const account = dec.toBytes(index + HARDENED_OFFSET, 4)
-               const data = new Uint8Array([...this.#DERIVATION_PATH, ...account])
-
                try {
                        this.#isBusy = true
+                       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([...this.#DERIVATION_PATH, ...account])
+
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
                                .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer)
-                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-                       await transport.close()
+                               .catch((err: any) => dec.toBytes(err.statusCode))
+                               .finally(async () => await transport.close()) as Uint8Array
 
                        const statusCode = bytes.toDec(response.slice(-2)) as number
                        const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
@@ -155,6 +158,9 @@ export class Ledger {
                        } catch (err) {
                                return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }
                        }
+               } catch (err: any) {
+                       console.error('Ledger.account()', err)
+                       return { status: err.message, publicKey: null, address: null }
                } finally {
                        this.#isBusy = false
                }
@@ -172,19 +178,21 @@ export class Ledger {
        * - CONNECTED: Nano app is open and listening
        */
        static async connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
-               if (api !== undefined || this.#status !== 'UNSUPPORTED') {
-                       if (api === 'hid' && this.#transport !== TransportHID) {
-                               this.#transport = TransportHID
-                       }
-                       if (api === 'ble' && this.#transport !== TransportBLE) {
-                               this.#transport = TransportBLE
-                       }
-                       if (api === 'usb' && this.#transport !== TransportUSB) {
-                               this.#transport = TransportUSB
-                       }
-               }
                try {
                        this.#isBusy = true
+                       if (api !== undefined || this.#status !== 'UNSUPPORTED') {
+                               if (api === 'hid' && this.#transport !== TransportHID) {
+                                       this.#transport = TransportHID
+                               }
+                               if (api === 'ble' && this.#transport !== TransportBLE) {
+                                       this.#transport = TransportBLE
+                               }
+                               if (api === 'usb' && this.#transport !== TransportUSB) {
+                                       this.#transport = typeof navigator.hid?.getDevices === 'function'
+                                               ? TransportHID
+                                               : TransportUSB
+                               }
+                       }
                        const version = await this.#version()
                        if (version.status !== 'OK') {
                                this.#status = 'DISCONNECTED'
@@ -203,7 +211,7 @@ export class Ledger {
                                }
                        }
                } catch (err) {
-                       console.error(err)
+                       console.error('Ledger.connect()', err)
                        this.#status = 'DISCONNECTED'
                } finally {
                        this.#isBusy = false
@@ -219,7 +227,7 @@ export class Ledger {
                setTimeout(async () => {
                        const hidDevices = await globalThis.navigator?.hid?.getDevices?.() ?? []
                        for (const device of hidDevices) {
-                               if (device.vendorId === this.UsbVendorId) {
+                               if (device.vendorId === this.ledgerVendorId) {
                                        device.forget()
                                }
                        }
@@ -229,7 +237,7 @@ export class Ledger {
                        }
                        const usbDevices = await globalThis.navigator?.usb?.getDevices?.() ?? []
                        for (const device of usbDevices) {
-                               if (device.vendorId === this.UsbVendorId) {
+                               if (device.vendorId === this.ledgerVendorId) {
                                        device.forget()
                                }
                        }
@@ -271,7 +279,7 @@ export class Ledger {
                        }
                        return signature
                } catch (err) {
-                       console.error(err)
+                       console.error('Ledger.sign()', err)
                        throw new Error('Failed to sign block with Ledger', { cause: err })
                }
        }
@@ -355,42 +363,45 @@ export class Ledger {
        * @returns Status of command
        */
        static async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
-                       throw new TypeError('Invalid account index')
-               }
-               if (!(block instanceof Block)) {
-                       throw new TypeError('Invalid block format')
-               }
-               if (!(block.link instanceof Uint8Array)) {
-                       throw new TypeError('Invalid block link')
-               }
-               if (!(block.representative instanceof Account)) {
-                       throw new TypeError('Invalid block link')
-               }
-               if (!block.signature) {
-                       throw new ReferenceError('Cannot cache unsigned block')
-               }
-
-               const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4)
-               const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
-               const account = dec.toBytes(index + HARDENED_OFFSET, 4)
-               const previous = block.previous
-               const link = block.link
-               const representative = hex.toBytes(block.representative.publicKey, 32)
-               const balance = hex.toBytes(block.balance.toString(16), 16)
-               const signature = hex.toBytes(block.signature, 64)
-               const data = new Uint8Array([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
-
                try {
                        this.#isBusy = true
+                       if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
+                               throw new TypeError('Invalid account index')
+                       }
+                       if (!(block instanceof Block)) {
+                               throw new TypeError('Invalid block format')
+                       }
+                       if (!(block.link instanceof Uint8Array)) {
+                               throw new TypeError('Invalid block link')
+                       }
+                       if (!(block.representative instanceof Account)) {
+                               throw new TypeError('Invalid block link')
+                       }
+                       if (!block.signature) {
+                               throw new ReferenceError('Cannot cache unsigned block')
+                       }
+
+                       const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4)
+                       const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
+                       const account = dec.toBytes(index + HARDENED_OFFSET, 4)
+                       const previous = block.previous
+                       const link = block.link
+                       const representative = hex.toBytes(block.representative.publicKey, 32)
+                       const balance = hex.toBytes(block.balance.toString(16), 16)
+                       const signature = hex.toBytes(block.signature, 64)
+                       const data = new Uint8Array([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
+
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
                                .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
                                .then((res: Buffer) => bytes.toDec(res))
-                               .catch((err: any) => err.statusCode) as number
-                       await transport.close()
+                               .catch((err: any) => err.statusCode)
+                               .finally(async () => await transport.close()) as number
 
                        return { status: this.#STATUS_CODES[response] }
+               } catch (err: any) {
+                       console.error('Ledger.#cacheBlock()', err)
+                       return { status: err.message }
                } finally {
                        this.#isBusy = false
                }
@@ -416,7 +427,8 @@ export class Ledger {
                        const response = await transport
                                .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
                                .then((res: Buffer) => bytes.toDec(res))
-                               .catch((err: any) => err.statusCode) as number
+                               .catch((err: any) => err.statusCode)
+                               .finally(async () => await transport.close()) as number
                        return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
                } finally {
                        this.#isBusy = false
@@ -444,24 +456,39 @@ export class Ledger {
                        const response = await transport
                                .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer)
                                .then((res: Buffer) => bytes.toDec(res))
-                               .catch((err: any) => err.statusCode) as number
+                               .catch((err: any) => err.statusCode)
+                               .finally(async () => await transport.close()) as number
                        return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
                } finally {
                        this.#isBusy = false
                }
        }
 
+       /**
+       * Checks for connected HID and USB Ledger devices to which access has been
+       * previously granted in response to a `requestDevice()` call. It does not work
+       * for Bluetooth interfaces.
+       *
+       * If no devices have been granted access, or if none with access are currently
+       * connected, the poll will set the 'DISCONNECTED' status accordingly and
+       * return. Otherwise, it will attempt to determine the actual status of the
+       * device.
+       */
        static async #poll (): Promise<void> {
                try {
-                       const hidDevices = await navigator.hid?.getDevices() ?? []
-                       const usbDevices = await navigator.usb?.getDevices() ?? []
-                       const isPaired = [...hidDevices, ...usbDevices]
-                               .some(device => device.vendorId === this.UsbVendorId)
-                       if (!this.#isBusy && isPaired && (this.#transport === TransportHID || this.#transport === TransportUSB)) {
-                               await this.connect().catch(() => { })
-                       } else {
-                               console.log('No Ledger USB devices paired')
-                               this.#status = 'DISCONNECTED'
+                       if (!this.#isBusy) {
+                               const isHidPaired = (await navigator.hid?.getDevices())
+                                       .some(device => device.vendorId === this.ledgerVendorId)
+                               const isUsbPaired = (await navigator.usb?.getDevices())
+                                       .some(device => device.vendorId === this.ledgerVendorId)
+                               if (this.#transport === TransportHID && isHidPaired) {
+                                       await this.connect()
+                               } else if (this.#transport === TransportUSB && isUsbPaired) {
+                                       await this.connect()
+                               } else {
+                                       console.log('No Ledger devices paired on current interface')
+                                       this.#status = 'DISCONNECTED'
+                               }
                        }
                } catch {
                        console.warn('Error polling Ledger device')
@@ -479,30 +506,30 @@ export class Ledger {
        * @returns {Promise} Status, signature, and block hash
        */
        static async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
-                       throw new TypeError('Invalid account index')
-               }
-               if (!(block.link instanceof Uint8Array)) {
-                       throw new TypeError('Invalid block link')
-               }
-               if (!(block.representative instanceof Account)) {
-                       throw new TypeError('Invalid block representative')
-               }
-
-               const account = dec.toBytes(index + HARDENED_OFFSET, 4)
-               const previous = block.previous
-               const link = block.link
-               const representative = hex.toBytes(block.representative.publicKey, 32)
-               const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)
-               const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
-
                try {
                        this.#isBusy = true
+                       if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
+                               throw new TypeError('Invalid account index')
+                       }
+                       if (!(block.link instanceof Uint8Array)) {
+                               throw new TypeError('Invalid block link')
+                       }
+                       if (!(block.representative instanceof Account)) {
+                               throw new TypeError('Invalid block representative')
+                       }
+
+                       const account = dec.toBytes(index + HARDENED_OFFSET, 4)
+                       const previous = block.previous
+                       const link = block.link
+                       const representative = hex.toBytes(block.representative.publicKey, 32)
+                       const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)
+                       const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
+
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
                                .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
-                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-                       await transport.close()
+                               .catch((err: any) => dec.toBytes(err.statusCode))
+                               .finally(async () => await transport.close()) as Uint8Array
 
                        const statusCode = bytes.toDec(response.slice(-2)) as number
                        const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
@@ -517,6 +544,9 @@ export class Ledger {
                        } else {
                                throw new Error('Unexpected byte length from device signature', { cause: response })
                        }
+               } catch (err: any) {
+                       console.error('Ledger.#signBlock()', err)
+                       return { status: err.message, signature: null }
                } finally {
                        this.#isBusy = false
                }
@@ -532,23 +562,23 @@ export class Ledger {
        * @returns {Promise} Status and signature
        */
        static async #signNonce (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse> {
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
-                       throw new TypeError('Invalid account index')
-               }
-               if (nonce.byteLength !== 16) {
-                       throw new RangeError('Nonce must be 16-byte string')
-               }
-
-               const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)
-               const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce])
-
                try {
                        this.#isBusy = true
+                       if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
+                               throw new TypeError('Invalid account index')
+                       }
+                       if (nonce.byteLength !== 16) {
+                               throw new RangeError('Nonce must be 16-byte string')
+                       }
+
+                       const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)
+                       const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce])
+
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
                                .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
-                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-                       await transport.close()
+                               .catch((err: any) => dec.toBytes(err.statusCode))
+                               .finally(async () => await transport.close()) as Uint8Array
 
                        const statusCode = bytes.toDec(response.slice(-2)) as number
                        const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
@@ -581,11 +611,8 @@ export class Ledger {
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
                                .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
-                               .catch((err: any) => {
-                                       console.error(err)
-                                       dec.toBytes(err.statusCode)
-                               }) as Uint8Array
-                       await transport.close()
+                               .catch((err: any) => dec.toBytes(err.statusCode))
+                               .finally(async () => await transport.close()) as Uint8Array
 
                        const statusCode = bytes.toDec(response.slice(-2)) as number
                        const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
@@ -599,12 +626,15 @@ export class Ledger {
                        const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()
 
                        return { status, name, version }
+               } catch (err: any) {
+                       console.error('Ledger.#version()', err)
+                       return { status: err.message, name: null, version: null }
                } finally {
                        this.#isBusy = false
                }
        }
 
        static {
-               this.#poll().catch(() => { })
+               this.isUnsupported ? void 0 : this.#poll().catch(() => { })
        }
 }
index c32f2904b03a5c0a6f3dbb4b4e194fba8389f2b4..e3a918831e0731733b4747bb2ad5b78d8f7ca3b6 100644 (file)
@@ -64,7 +64,7 @@ await Promise.all([
                        return
                }
 
-               await test('request permissions', async () => {
+               await test('request permissions', { skip: true }, async () => {
                        await click(
                                'Reset permissions, then click to continue',
                                async () => new Promise(r => setTimeout(r, 5000))
@@ -137,21 +137,23 @@ await Promise.all([
                                        assert.equal(wallet.isLocked, true)
                                        assert.equal(Ledger.status, 'LOCKED')
                                        resolve(null)
-                               }, 900000)
+                               }, 90000)
                        })
+               })
 
+               await test('switch between interfaces', { skip: false }, async () => {
                        await assert.resolves(async () => {
                                await click(
-                                       'Verify current interface is HID, switch to Bluetooth device, then click to continue',
+                                       'Verify current interface is HID, switch to unlocked Bluetooth device, then click to continue',
                                        async () => wallet.config({ connection: 'ble' })
                                )
                        })
-                       assert.equal(wallet.isLocked, false)
+                       assert.equal(wallet.isLocked, true)
                        assert.equal(Ledger.status, 'BUSY')
 
                        await assert.resolves(async () => {
                                await click(
-                                       'Verify current interface is BLE, switch back to USB device, then click to continue',
+                                       'Verify current interface is BLE, switch back to unlocked USB device, then click to continue',
                                        async () => wallet.config({ connection: 'usb' })
                                )
                        })