From: Chris Duncan Date: Wed, 20 Aug 2025 20:30:11 +0000 (-0700) Subject: Fix bad commit for Ledger version and method organizing. X-Git-Tag: v0.10.5~41^2~31 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=e802c927d93fb8c1ef71d0e36ec4ce5620517ac7;p=libnemo.git Fix bad commit for Ledger version and method organizing. --- diff --git a/src/lib/wallet/ledger.ts b/src/lib/wallet/ledger.ts index 5f766e9..21b9459 100644 --- a/src/lib/wallet/ledger.ts +++ b/src/lib/wallet/ledger.ts @@ -1,485 +1,485 @@ -//! SPDX-FileCopyrightText: 2025 Chris Duncan -//! SPDX-License-Identifier: GPL-3.0-or-later - -import { ledgerUSBVendorId } from '@ledgerhq/devices' -import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble' -import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' -import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' -import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types' -import { Wallet } from '#wallet' -import { Account } from '../account' -import { Block } from '../block' -import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants' -import { bytes, dec, hex } from '../convert' -import { Rpc } from '../rpc' - -/** -* 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 #listenTimeout: 30000 = 30000 - static #openTimeout: 3000 = 3000 - static #status: DeviceStatus = 'DISCONNECTED' - static #ADPU_CODES: { [key: string]: number } = Object.freeze({ - class: 0xa1, - bip32DerivationLevel: 0x03, - version: 0x01, - account: 0x02, - cacheBlock: 0x03, - signBlock: 0x04, - signNonce: 0x05, - paramUnused: 0x00 - }) - static #DERIVATION_PATH: Uint8Array = new Uint8Array([ - Ledger.#ADPU_CODES.bip32DerivationLevel, - ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4), - ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4) - ]) - static #STATUS_CODES: { [key: number]: string } = Object.freeze({ - 0x6700: 'INCORRECT_LENGTH', - 0x670a: 'NO_APPLICATION_SPECIFIED', - 0x6807: 'APPLICATION_NOT_INSTALLED', - 0x6d00: 'APPLICATION_ALREADY_LAUNCHED', - 0x6982: 'SECURITY_STATUS_NOT_SATISFIED', - 0x6985: 'CONDITIONS_OF_USE_NOT_SATISFIED', - 0x6a81: 'INVALID_SIGNATURE', - 0x6a82: 'CACHE_MISS', - 0x6b00: 'INCORRECT_PARAMETER', - 0x6e01: 'TRANSPORT_STATUS_ERROR', - 0x9000: 'OK' - }) - - static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID - static UsbVendorId = ledgerUSBVendorId - static SYMBOL: Symbol = Symbol('Ledger') - - /** - * 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 { - console.log('Checking browser Ledger support...') - if (typeof globalThis.navigator?.usb?.getDevices === 'function') { - this.DynamicTransport = TransportUSB - return false - } - if (typeof globalThis.navigator?.bluetooth?.getDevices === 'function') { - this.DynamicTransport = TransportBLE - return false - } - if (typeof globalThis.navigator?.hid?.getDevices === 'function') { - this.DynamicTransport = TransportHID - return false - } - return true - } - - /** - * Status of the Ledger device connection. - * - * DISCONNECTED | BUSY | LOCKED | CONNECTED - */ - static get status (): DeviceStatus { return this.#status } - - /** - * 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') - } - const account = dec.toBytes(index + HARDENED_OFFSET, 4) - const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account]) - - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, publicKey: null, address: null } - } - - try { - const publicKey = bytes.toHex(response.slice(0, 32)) - const addressLength = response[32] - const address = response.slice(33, 33 + addressLength).toString() - - return { status, publicKey, address } - } catch (err) { - return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } - } - } - - /** - * 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 async connect (): Promise { - const version = await this.#version() - if (version.status !== 'OK') { - this.#status = 'DISCONNECTED' - } else if (version.name === 'Nano') { - const { status } = await Ledger.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' - } - return this.#status - } - - /** - * 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 { - try { - if (typeof index !== 'number') { - throw new TypeError('Index must be a number', { cause: index }) - } - if (index < 0 || index >= HARDENED_OFFSET) { - throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index }) - } - if (frontier != null) { - const { status } = await Ledger.#cacheBlock(index, frontier) - if (status !== 'OK') { - throw new Error('Failed to cache frontier block in ledger', { cause: status }) - } - } - console.log('Waiting for signature confirmation on Ledger device...') - const { status, signature, hash } = await Ledger.#signBlock(index, block) - if (status !== 'OK') { - throw new Error('Signing with ledger failed', { cause: status }) - } - if (hash !== block.hash) { - throw new Error('Hash from ledger does not match hash from block', { cause: `${hash} | ${block.hash}` }) - } - if (signature == null) { - throw new Error('Ledger silently failed to return signature') - } - return signature - } catch (err) { - console.error(err) - throw new Error('Failed to sign block with Ledger', { cause: err }) - } - } - - /** - * Update cache from raw block data. Suitable for offline use. - * - * @param {number} index - Account number - * @param {object} block - JSON-formatted block data - */ - static async 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 async updateCache (index: number, hash: string, rpc: Rpc): Promise - static async updateCache (index: number, input: any, node?: Rpc): Promise { - if (typeof input === 'string' && node instanceof Rpc) { - const data = { - 'json_block': 'true', - 'hash': input - } - const res = await node.call('block_info', data) - if (!res || !res.ok || res.error) { - throw new Error(`Unable to fetch block info`, res) - } - input = res.contents - } - const { status } = await this.#cacheBlock(index, input) - if (status !== 'OK') { - throw new Error('failed to cache frontier block in ledger', { cause: status }) - } - return { status } - } - - /** - * Get the version of the current process. If a specific app is running, get - * the app version. Otherwise, get the Ledger BOLOS version instead. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information - * - * @returns Status, process name, and version - */ - static async version (): Promise { - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, name: null, version: null } - } - - const nameLength = response[1] - const name = response.slice(2, 2 + nameLength).toString() - const versionLength = response[2 + nameLength] - const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() - - return { status, name, version } - } - - /** - * 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 - */ - static async verify (mnemonic: string): Promise - static async verify (secret: string): Promise { - const testWallet = await Wallet.load('BIP-44', '', secret) - await testWallet.unlock('') - const testAccount = await testWallet.account(0) - const testOpenBlock = await new Block(testAccount.address, '0', testAccount.publicKey, testAccount.address) - .receive(testAccount.publicKey, 0) - .sign(testWallet, 0) - const testSendBlock = new Block(testAccount.address, '0', testOpenBlock.hash, testAccount.address) - .send(testAccount.address, 0) - await testWallet.sign(0, testOpenBlock) - try { - await Ledger.sign(0, testSendBlock, testOpenBlock) - return testSendBlock.signature === testOpenBlock.signature - } catch (err) { - throw new Error('Failed to verify wallet', { cause: err }) - } - } - - /** - * 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 - */ - async #close (): Promise { - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(0xb0, 0xa7, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) - .then(res => bytes.toDec(res)) - .catch(err => err.statusCode) as number - return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] })) - } - - /** - * Cache frontier block in device memory. - * - * @param {number} index - Account number - * @param {any} block - Block data to cache - * @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([Ledger.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature]) - - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.cacheBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) - .then(res => bytes.toDec(res)) - .catch(err => err.statusCode) as number - await transport.close() - - return { status: Ledger.#STATUS_CODES[response] } - } - - static #onConnectUsb = async (e: USBConnectionEvent): Promise => { - console.log(e) - if (e.device?.vendorId === ledgerUSBVendorId) { - console.log('Ledger connected') - const { usb } = globalThis.navigator - usb.addEventListener('disconnect', this.#onDisconnectUsb) - usb.removeEventListener('connect', this.#onConnectUsb) - } - } - - static #onDisconnectUsb = async (e: USBConnectionEvent): Promise => { - console.log(e) - if (e.device?.vendorId === ledgerUSBVendorId) { - console.log('Ledger disconnected') - const { usb } = globalThis.navigator - usb.addEventListener('connect', this.#onConnectUsb) - usb.removeEventListener('disconnect', this.#onDisconnectUsb) - this.#status = 'DISCONNECTED' - } - } - - /** - * 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 - */ - async #open (): Promise { - const name = new TextEncoder().encode('Nano') - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(0xe0, 0xd8, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, name as Buffer) - .then(res => bytes.toDec(res)) - .catch(err => err.statusCode) as number - return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] })) - } - - /** - * Sign a block with the Ledger device. - * - * @param {number} index - Account number - * @param {object} block - Block data to sign - * @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([...Ledger.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) - - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - - if (response.byteLength === 2) { - return { status, signature: null } - } - if (response.byteLength === 98) { - const hash = bytes.toHex(response.slice(0, 32)) - const signature = bytes.toHex(response.slice(32, 96)) - return { status, signature, hash } - } - - throw new Error('Unexpected byte length from device signature', { cause: response }) - } - - /** - * Sign a nonce with the Ledger device. - * - * nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14 - * - * @param {number} index - Account number - * @param {Uint8Array} nonce - 128-bit value to sign - * @returns {Promise} Status and signature - */ - 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([...Ledger.#DERIVATION_PATH, ...derivationAccount, ...nonce]) - - const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) - const response = await transport - .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signNonce, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - - if (response.byteLength === 2) { - return { status, signature: null } - } - if (response.byteLength === 66) { - const signature = bytes.toHex(response.slice(0, 64)) - return { status, signature } - } - - throw new Error('Unexpected byte length from device signature', { cause: response }) - } -} +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +import { ledgerUSBVendorId } from '@ledgerhq/devices' +import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble' +import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' +import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' +import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types' +import { Wallet } from '#wallet' +import { Account } from '../account' +import { Block } from '../block' +import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants' +import { bytes, dec, hex } from '../convert' +import { Rpc } from '../rpc' + +/** +* 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 #listenTimeout: 30000 = 30000 + static #openTimeout: 3000 = 3000 + static #status: DeviceStatus = 'DISCONNECTED' + static #ADPU_CODES: { [key: string]: number } = Object.freeze({ + class: 0xa1, + bip32DerivationLevel: 0x03, + version: 0x01, + account: 0x02, + cacheBlock: 0x03, + signBlock: 0x04, + signNonce: 0x05, + paramUnused: 0x00 + }) + static #DERIVATION_PATH: Uint8Array = new Uint8Array([ + Ledger.#ADPU_CODES.bip32DerivationLevel, + ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4), + ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4) + ]) + static #STATUS_CODES: { [key: number]: string } = Object.freeze({ + 0x6700: 'INCORRECT_LENGTH', + 0x670a: 'NO_APPLICATION_SPECIFIED', + 0x6807: 'APPLICATION_NOT_INSTALLED', + 0x6d00: 'APPLICATION_ALREADY_LAUNCHED', + 0x6982: 'SECURITY_STATUS_NOT_SATISFIED', + 0x6985: 'CONDITIONS_OF_USE_NOT_SATISFIED', + 0x6a81: 'INVALID_SIGNATURE', + 0x6a82: 'CACHE_MISS', + 0x6b00: 'INCORRECT_PARAMETER', + 0x6e01: 'TRANSPORT_STATUS_ERROR', + 0x9000: 'OK' + }) + + static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID + static UsbVendorId = ledgerUSBVendorId + static SYMBOL: Symbol = Symbol('Ledger') + + /** + * 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 { + console.log('Checking browser Ledger support...') + if (typeof globalThis.navigator?.usb?.getDevices === 'function') { + this.DynamicTransport = TransportUSB + return false + } + if (typeof globalThis.navigator?.bluetooth?.getDevices === 'function') { + this.DynamicTransport = TransportBLE + return false + } + if (typeof globalThis.navigator?.hid?.getDevices === 'function') { + this.DynamicTransport = TransportHID + return false + } + return true + } + + /** + * Status of the Ledger device connection. + * + * DISCONNECTED | BUSY | LOCKED | CONNECTED + */ + static get status (): DeviceStatus { return this.#status } + + /** + * 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') + } + const account = dec.toBytes(index + HARDENED_OFFSET, 4) + const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account]) + + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, publicKey: null, address: null } + } + + try { + const publicKey = bytes.toHex(response.slice(0, 32)) + const addressLength = response[32] + const address = response.slice(33, 33 + addressLength).toString() + + return { status, publicKey, address } + } catch (err) { + return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } + } + } + + /** + * 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 async connect (): Promise { + const version = await this.#version() + if (version.status !== 'OK') { + this.#status = 'DISCONNECTED' + } else if (version.name === 'Nano') { + const { status } = await Ledger.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' + } + return this.#status + } + + /** + * 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 { + try { + if (typeof index !== 'number') { + throw new TypeError('Index must be a number', { cause: index }) + } + if (index < 0 || index >= HARDENED_OFFSET) { + throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index }) + } + if (frontier != null) { + const { status } = await Ledger.#cacheBlock(index, frontier) + if (status !== 'OK') { + throw new Error('Failed to cache frontier block in ledger', { cause: status }) + } + } + console.log('Waiting for signature confirmation on Ledger device...') + const { status, signature, hash } = await Ledger.#signBlock(index, block) + if (status !== 'OK') { + throw new Error('Signing with ledger failed', { cause: status }) + } + if (hash !== block.hash) { + throw new Error('Hash from ledger does not match hash from block', { cause: `${hash} | ${block.hash}` }) + } + if (signature == null) { + throw new Error('Ledger silently failed to return signature') + } + return signature + } catch (err) { + console.error(err) + throw new Error('Failed to sign block with Ledger', { cause: err }) + } + } + + /** + * Update cache from raw block data. Suitable for offline use. + * + * @param {number} index - Account number + * @param {object} block - JSON-formatted block data + */ + static async 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 async updateCache (index: number, hash: string, rpc: Rpc): Promise + static async updateCache (index: number, input: any, node?: Rpc): Promise { + if (typeof input === 'string' && node instanceof Rpc) { + const data = { + 'json_block': 'true', + 'hash': input + } + const res = await node.call('block_info', data) + if (!res || !res.ok || res.error) { + throw new Error(`Unable to fetch block info`, res) + } + input = res.contents + } + const { status } = await this.#cacheBlock(index, input) + if (status !== 'OK') { + throw new Error('failed to cache frontier block in ledger', { cause: status }) + } + return { status } + } + + /** + * 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 + */ + static async verify (mnemonic: string): Promise + static async verify (secret: string): Promise { + const testWallet = await Wallet.load('BIP-44', '', secret) + await testWallet.unlock('') + const testAccount = await testWallet.account(0) + const testOpenBlock = await new Block(testAccount.address, '0', testAccount.publicKey, testAccount.address) + .receive(testAccount.publicKey, 0) + .sign(testWallet, 0) + const testSendBlock = new Block(testAccount.address, '0', testOpenBlock.hash, testAccount.address) + .send(testAccount.address, 0) + await testWallet.sign(0, testOpenBlock) + try { + await Ledger.sign(0, testSendBlock, testOpenBlock) + return testSendBlock.signature === testOpenBlock.signature + } catch (err) { + throw new Error('Failed to verify wallet', { cause: err }) + } + } + + /** + * Cache frontier block in device memory. + * + * @param {number} index - Account number + * @param {any} block - Block data to cache + * @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([Ledger.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature]) + + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.cacheBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) + .then(res => bytes.toDec(res)) + .catch(err => err.statusCode) as number + await transport.close() + + return { status: Ledger.#STATUS_CODES[response] } + } + + /** + * 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 { + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(0xb0, 0xa7, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) + .then(res => bytes.toDec(res)) + .catch(err => err.statusCode) as number + return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] })) + } + + static #onConnectUsb = async (e: USBConnectionEvent): Promise => { + console.log(e) + if (e.device?.vendorId === ledgerUSBVendorId) { + console.log('Ledger connected') + const { usb } = globalThis.navigator + usb.addEventListener('disconnect', this.#onDisconnectUsb) + usb.removeEventListener('connect', this.#onConnectUsb) + } + } + + static #onDisconnectUsb = async (e: USBConnectionEvent): Promise => { + console.log(e) + if (e.device?.vendorId === ledgerUSBVendorId) { + console.log('Ledger disconnected') + const { usb } = globalThis.navigator + usb.addEventListener('connect', this.#onConnectUsb) + usb.removeEventListener('disconnect', this.#onDisconnectUsb) + this.#status = 'DISCONNECTED' + } + } + + /** + * 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 { + const name = new TextEncoder().encode('Nano') + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(0xe0, 0xd8, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, name as Buffer) + .then(res => bytes.toDec(res)) + .catch(err => err.statusCode) as number + return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] })) + } + + /** + * Sign a block with the Ledger device. + * + * @param {number} index - Account number + * @param {object} block - Block data to sign + * @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([...Ledger.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) + + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + + if (response.byteLength === 2) { + return { status, signature: null } + } + if (response.byteLength === 98) { + const hash = bytes.toHex(response.slice(0, 32)) + const signature = bytes.toHex(response.slice(32, 96)) + return { status, signature, hash } + } + + throw new Error('Unexpected byte length from device signature', { cause: response }) + } + + /** + * Sign a nonce with the Ledger device. + * + * nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14 + * + * @param {number} index - Account number + * @param {Uint8Array} nonce - 128-bit value to sign + * @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([...Ledger.#DERIVATION_PATH, ...derivationAccount, ...nonce]) + + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signNonce, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + + if (response.byteLength === 2) { + return { status, signature: null } + } + if (response.byteLength === 66) { + const signature = bytes.toHex(response.slice(0, 64)) + return { status, signature } + } + + throw new Error('Unexpected byte length from device signature', { cause: response }) + } + + /** + * Get the version of the current process. If a specific app is running, get + * the app version. Otherwise, get the Ledger BOLOS version instead. + * + * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information + * + * @returns Status, process name, and version + */ + static async #version (): Promise { + const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout) + const response = await transport + .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, name: null, version: null } + } + + const nameLength = response[1] + const name = response.slice(2, 2 + nameLength).toString() + const versionLength = response[2 + nameLength] + const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() + + return { status, name, version } + } +}