From: Chris Duncan Date: Fri, 19 Sep 2025 08:39:39 +0000 (-0700) Subject: Split up interface swap tests from main Ledger permission tests. Add busy flag. Ensur... X-Git-Tag: v0.10.5~12^2~38 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=b9edcfcb6b2709cb0282831870adbc7bb7f66148;p=libnemo.git Split up interface swap tests from main Ledger permission tests. Add busy flag. Ensure transport closes with finally calls. Override USB with HID if it's supported. Could use some cleanup but almost there I think. --- diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index 123a429..869c452 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -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 { - 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 { - 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 { - 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 { 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 { - 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): Promise { - 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(() => { }) } } diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index c32f290..e3a9188 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -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' }) ) })