From 0afe69a230831a37a19ad50d94a33c3853c0227b Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Mon, 18 May 2026 06:30:20 -0700 Subject: [PATCH] Extract open and close functions. Adjust doc comments. --- src/lib/ledger/close.ts | 15 +++ src/lib/ledger/index.ts | 215 +++++++++++++++++++--------------------- src/lib/ledger/open.ts | 16 +++ 3 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 src/lib/ledger/close.ts create mode 100644 src/lib/ledger/open.ts diff --git a/src/lib/ledger/close.ts b/src/lib/ledger/close.ts new file mode 100644 index 0000000..18ffd7a --- /dev/null +++ b/src/lib/ledger/close.ts @@ -0,0 +1,15 @@ +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { LedgerResponse, LedgerTransport, openTimeout, listenTimeout, APDU_CODES, STATUS_CODES } from '.' +import { bytes } from '../convert' + +export async function _close (transport: LedgerTransport): Promise { + const t = await transport.create(openTimeout, listenTimeout) + const response = await t + .send(0xb0, 0xa7, APDU_CODES.paramUnused, APDU_CODES.paramUnused) + .then((res: Buffer) => bytes.toDec(res)) + .catch((err: any) => err.statusCode) + .finally(async () => await t.close()) as number + return new Promise(r => setTimeout(r, 1000, { status: STATUS_CODES[response] })) +} diff --git a/src/lib/ledger/index.ts b/src/lib/ledger/index.ts index 73a0805..50ae83b 100644 --- a/src/lib/ledger/index.ts +++ b/src/lib/ledger/index.ts @@ -7,7 +7,7 @@ import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' import { Block } from '../block' import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants' -import { bytes, dec, utf8 } from '../convert' +import { dec, utf8 } from '../convert' import { Rpc } from '../rpc' import { Wallet } from '../wallet' import { _account } from './account' @@ -15,6 +15,8 @@ import { _cache } from './cache' import { _connect } from './connect' import { queue } from './queue' import { signBlock, signNonce } from './sign' +import { _open } from './open' +import { _close } from './close' export type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' export type LedgerTransport = typeof TransportHID | typeof TransportBLE | typeof TransportUSB @@ -43,11 +45,13 @@ export const APDU_CODES: Record = Object.freeze({ signNonce: 0x05, paramUnused: 0x00 }) + export const DERIVATION_PATH: Uint8Array = new Uint8Array([ APDU_CODES.bip32DerivationLevel, ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4), ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4) ]) + export const STATUS_CODES: Readonly> = Object.freeze({ ...Object.fromEntries(Object.entries(StatusCodes).map(([k, v]) => [+v, k])), 0x6807: 'APPLICATION_NOT_INSTALLED', @@ -55,17 +59,19 @@ export const STATUS_CODES: Readonly> = Object.freeze({ 0x6a81: 'INVALID_SIGNATURE', 0x6a82: 'CACHE_MISS' }) + export const listenTimeout: 30000 = 30000 + export const openTimeout: 3000 = 3000 /** -* 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 -*/ + * 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 class Ledger { static #isPolling: boolean = false static #status: LedgerStatus = 'DISCONNECTED' @@ -78,9 +84,9 @@ export class Ledger { static removeEventListener = this.#eventTarget.removeEventListener.bind(this.#eventTarget) /** - * Check which transport protocols are supported by the browser and return the - * transport type according to the following priorities: HID, Bluetooth, USB. - */ + * Check which transport protocols are supported by the browser and return the + * transport type according to the following priorities: HID, Bluetooth, USB. + */ static get isUnsupported (): boolean { if (this.#status === 'UNSUPPORTED') { return true @@ -106,18 +112,18 @@ export class Ledger { } /** - * 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 - */ + * 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. - * - * UNSUPPORTED | DISCONNECTED | BUSY | LOCKED | CONNECTED - */ + * Status of the Ledger device connection. + * + * UNSUPPORTED | DISCONNECTED | BUSY | LOCKED | CONNECTED + */ static get status (): LedgerStatus { return this.isUnsupported ? 'UNSUPPORTED' : this.#status } @@ -168,9 +174,9 @@ export class Ledger { } /** - * Clears Ledger connections from HID and USB interfaces and stops polling for - * connection updates. - */ + * Clears Ledger connections from HID and USB interfaces and stops polling for + * connection updates. + */ static async disconnect (): Promise { queue(async () => { try { @@ -191,33 +197,33 @@ export class Ledger { } /** - * Sign a nonce with the Ledger device. The nonce must be a string that encodes - * to 16 bytes using UTF-8. For this reason, although any Unicode characters - * can be used, it is recommended to only pass printable ASCII which encodes to - * one byte per character. - * - * The actual message signed is a string which can be expressed as the - * following template literal: - * - * `Nano Signed Nonce:\n${nonce_bytes_as_hex}` - * - * IMPORTANT: The current version of the Nano app for Ledger devices will NOT - * prompt users to confirm the signature. If valid, the nonce will immediately - * be signed and the signature returned without user interaction, similar to - * how receive blocks are automatically signed when auto-receive is configured. - * Plan for this eventuality if you implement this method to sign nonces. - * - * @param {number} index - Account number - * @param {string} nonce - 128-bit value to sign - */ + * Sign a nonce with the Ledger device. The nonce must be a string that encodes + * to 16 bytes using UTF-8. For this reason, although any Unicode characters + * can be used, it is recommended to only pass printable ASCII which encodes to + * one byte per character. + * + * The actual message signed is a string which can be expressed as the + * following template literal: + * + * `Nano Signed Nonce:\n${nonce_bytes_as_hex}` + * + * IMPORTANT: The current version of the Nano app for Ledger devices will NOT + * prompt users to confirm the signature. If valid, the nonce will immediately + * be signed and the signature returned without user interaction, similar to + * how receive blocks are automatically signed when auto-receive is configured. + * Plan for this eventuality if you implement this method to sign nonces. + * + * @param {number} index - Account number + * @param {string} nonce - 128-bit value to sign + */ static async sign (index: number, nonce: string): 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 - */ + * 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 async sign (index: number, block: Block, frontier?: Block): Promise static async sign (index: number, data: string | Block, frontier?: Block): Promise { try { @@ -293,21 +299,21 @@ export class Ledger { } /** - * 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 - */ + * 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 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. - * 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 - */ + * 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 async verify (mnemonic: string): Promise static async verify (secret: string): Promise { const testWallet = await Wallet.load('BIP-44', '', secret) @@ -328,66 +334,49 @@ export class Ledger { } /** - * Close the currently running app and return to the device dashboard. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#quit-application - * - * This command resets the internal USB connection of the device which can - * cause subsequent commands to fail if called too quickly. A one-second delay - * is implemented in this method to mitigate the issue. - * - * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157 - * - * @returns Status of command - */ + * Close the currently running app and return to the device dashboard. + * + * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#quit-application + * + * This command resets the internal USB connection of the device which can + * cause subsequent commands to fail if called too quickly. A one-second delay + * is implemented in this method to mitigate the issue. + * + * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157 + * + * @returns Status of command + */ static async #close (): Promise { - return queue(async () => { - const transport = await this.#transport.create(openTimeout, listenTimeout) - const response = await transport - .send(0xb0, 0xa7, APDU_CODES.paramUnused, APDU_CODES.paramUnused) - .then((res: Buffer) => bytes.toDec(res)) - .catch((err: any) => err.statusCode) - .finally(async () => await transport.close()) as number - return new Promise(r => setTimeout(r, 1000, { status: STATUS_CODES[response] })) - }) + return queue(async () => _close(this.#transport)) } /** - * Open the Nano app by launching a user flow. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application - * - * This command resets the internal USB connection of the device which can - * cause subsequent commands to fail if called too quickly. A one-second delay - * is implemented in this method to mitigate the issue. - * - * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157 - * - * @returns Status of command - */ + * Open the Nano app by launching a user flow. + * + * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application + * + * This command resets the internal USB connection of the device which can + * cause subsequent commands to fail if called too quickly. A one-second delay + * is implemented in this method to mitigate the issue. + * + * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157 + * + * @returns Status of command + */ static async #open (): Promise { - return queue(async () => { - const name = new TextEncoder().encode('Nano') - const transport = await this.#transport.create(openTimeout, listenTimeout) - const response = await transport - .send(0xe0, 0xd8, APDU_CODES.paramUnused, APDU_CODES.paramUnused, name as Buffer) - .then((res: Buffer) => bytes.toDec(res)) - .catch((err: any) => err.statusCode) - .finally(async () => await transport.close()) as number - return new Promise(r => setTimeout(r, 1000, { status: STATUS_CODES[response] })) - }) + return queue(async () => _open(this.#transport)) } /** - * 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. - */ + * 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 isHidPaired = (await navigator.hid?.getDevices?.() ?? []) @@ -408,8 +397,8 @@ export class Ledger { } /** - * Sets the Ledger status and emits an event. - */ + * Sets the Ledger status and emits an event. + */ static #setStatus (value: LedgerStatus) { if (this.#status !== value) { this.#status = value diff --git a/src/lib/ledger/open.ts b/src/lib/ledger/open.ts new file mode 100644 index 0000000..5ffc2b6 --- /dev/null +++ b/src/lib/ledger/open.ts @@ -0,0 +1,16 @@ +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { LedgerResponse, LedgerTransport, openTimeout, listenTimeout, APDU_CODES, STATUS_CODES } from '.' +import { bytes } from '../convert' + +export async function _open (transport: LedgerTransport): Promise { + const name = new TextEncoder().encode('Nano') + const t = await transport.create(openTimeout, listenTimeout) + const response = await t + .send(0xe0, 0xd8, APDU_CODES.paramUnused, APDU_CODES.paramUnused, name as Buffer) + .then((res: Buffer) => bytes.toDec(res)) + .catch((err: any) => err.statusCode) + .finally(async () => await t.close()) as number + return new Promise(r => setTimeout(r, 1000, { status: STATUS_CODES[response] })) +} -- 2.47.3