]> git.codecow.com Git - libnemo.git/commitdiff
Reorder and refactor Ledger device wallet functions. Update ledger device tests.
authorChris Duncan <chris@zoso.dev>
Thu, 10 Jul 2025 13:13:09 +0000 (06:13 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 10 Jul 2025 13:13:09 +0000 (06:13 -0700)
src/lib/block.ts
src/lib/wallets/ledger-wallet.ts
test/test.ledger.mjs

index 3ab8c99788fe9ccad0e4f6678222bf6768a2149a..cf99ed37cb9f32affb2340d2228ebb440b561250 100644 (file)
@@ -128,7 +128,7 @@ abstract class Block {
                        const index = input
                        const { LedgerWallet } = await import('./wallets')
                        const ledger = await LedgerWallet.create()
-                       await ledger.open()
+                       await ledger.connect()
                        if (block) {
                                try {
                                        await ledger.updateCache(index, block)
index 6cce19888a53e0d48133f716e1d6a52c0660acdb..d62402ce60bb2035dea2807b11ea838c34319099 100644 (file)
@@ -1,19 +1,19 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import Transport from '@ledgerhq/hw-transport'\r
+import { ledgerUSBVendorId } from '@ledgerhq/devices'\r
 import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble'\r
 import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb'\r
 import { default as TransportHID } from '@ledgerhq/hw-transport-webhid'\r
 import { Account } from '#src/lib/account.js'\r
 import { ChangeBlock, ReceiveBlock, SendBlock } from '#src/lib/block.js'\r
 import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LEDGER_STATUS_CODES } from '#src/lib/constants.js'\r
-import { bytes, dec, hex, utf8 } from '#src/lib/convert.js'\r
+import { bytes, dec, hex } from '#src/lib/convert.js'\r
 import { Entropy } from '#src/lib/entropy.js'\r
 import { Rpc } from '#src/lib/rpc.js'\r
 import { KeyPair, Wallet } from './wallet'\r
 \r
-type DeviceStatus = 'DISCONNECTED' | 'PAIRED' | 'LOCKED' | 'BUSY' | 'CONNECTED'\r
+type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'\r
 \r
 interface LedgerResponse {\r
        status: string\r
@@ -47,6 +47,11 @@ interface LedgerSignResponse extends LedgerResponse {
 */\r
 export class LedgerWallet extends Wallet {\r
        static #isInternal: boolean = false\r
+       static #derivationPath: Uint8Array = new Uint8Array([\r
+               LEDGER_ADPU_CODES.bip32DerivationLevel,\r
+               ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),\r
+               ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)\r
+       ])\r
 \r
        #status: DeviceStatus = 'DISCONNECTED'\r
 \r
@@ -64,6 +69,81 @@ export class LedgerWallet extends Wallet {
                super(id)\r
        }\r
 \r
+       /**\r
+       * Since the Ledger device can only return one account per request, this\r
+       * overrides the default behavior of calling `accounts()` to instead\r
+       * directly derive and return a single account at the specified index.\r
+       *\r
+       * @returns Account\r
+       */\r
+       async account (index: number = 0, show: boolean = false): Promise<Account> {\r
+               const { status, publicKey } = await this.#account(index, show)\r
+               if (publicKey == null) {\r
+                       throw new Error('Failed to get account from device', { cause: status })\r
+               }\r
+               const account = await Account.fromPublicKey(publicKey)\r
+               return account\r
+       }\r
+\r
+       /**\r
+       * Since the Ledger device can only return one account per request, this\r
+       * overrides the default behavior of returning multiple accounts to instead\r
+       * return a single account at the specified index.\r
+       *\r
+       * @returns Account\r
+       */\r
+       async accounts (): Promise<never> {\r
+               throw new Error(`Ledger device only supports 'account()' calls`)\r
+       }\r
+\r
+       /**\r
+       * Check which transport protocols are supported by the browser and set the\r
+       * transport type according to the following priorities: Bluetooth, USB, HID.\r
+       */\r
+       async checkBrowserSupport (): Promise<typeof TransportBLE | typeof TransportUSB | typeof TransportHID> {\r
+               console.log('Checking browser Ledger support...')\r
+               try {\r
+                       if (await TransportBLE.isSupported()) {\r
+                               return TransportBLE\r
+                       }\r
+                       if (await TransportUSB.isSupported()) {\r
+                               return TransportUSB\r
+                       }\r
+                       if (await TransportHID.isSupported()) {\r
+                               return TransportHID\r
+                       }\r
+               } catch { }\r
+               throw new Error('Unsupported browser')\r
+       }\r
+\r
+       /**\r
+       * Check if the Nano app is currently open and set device status accordingly.\r
+       *\r
+       * @returns Device status as follows:\r
+       * - DISCONNECTED: Failed to communicate properly with the app\r
+       * - BUSY: Nano app is not currently open\r
+       * - LOCKED: Nano app is open but the device locked after a timeout\r
+       * - CONNECTED: Nano app is open and listening\r
+       */\r
+       async connect (): Promise<DeviceStatus> {\r
+               const version = await this.#version()\r
+               if (version.status !== 'OK') {\r
+                       this.#status = 'DISCONNECTED'\r
+               } else if (version.name === 'Nano') {\r
+                       const { status } = await this.#account()\r
+                       if (status === 'OK') {\r
+                               this.#status = 'CONNECTED'\r
+                       } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {\r
+                               this.#status = 'LOCKED'\r
+                       } else {\r
+                               this.#status = 'DISCONNECTED'\r
+                       }\r
+               } else {\r
+                       this.#status = 'BUSY'\r
+               }\r
+               return this.status\r
+       }\r
+\r
        /**\r
        * Creates a new Ledger hardware wallet communication layer by dynamically\r
        * importing the ledger.js service.\r
@@ -84,9 +164,81 @@ export class LedgerWallet extends Wallet {
        */\r
        async destroy (): Promise<void> {\r
                await super.destroy()\r
-               const { status } = await this.close()\r
+               const { status } = await this.#close()\r
                if (status !== 'OK') {\r
-                       throw new Error('Failed to close wallet', { cause: status })\r
+                       throw new Error('Failed to lock Ledger wallet', { cause: status })\r
+               }\r
+       }\r
+\r
+       async init (): Promise<void> {\r
+               try {\r
+                       this.DynamicTransport = await this.checkBrowserSupport()\r
+                       // await this.connect()\r
+               } catch (err) {\r
+                       throw new Error('Failed to initialize Ledger wallet', { cause: err })\r
+               }\r
+       }\r
+\r
+       /**\r
+       * Attempts to close the current process on the Ledger device.\r
+       *\r
+       * Overrides the default wallet `lock()` method since as a hardware wallet it\r
+       * does not need to be encrypted by software.\r
+       *\r
+       * @returns True if successfully locked\r
+       */\r
+       async lock (): Promise<boolean> {\r
+               const { status } = await this.#close()\r
+               return status === 'OK'\r
+       }\r
+\r
+       onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+               console.log(e)\r
+               if (e.device?.vendorId === ledgerUSBVendorId) {\r
+                       console.log('Ledger connected')\r
+                       const { usb } = globalThis.navigator\r
+                       usb.addEventListener('disconnect', this.onDisconnectUsb)\r
+                       usb.removeEventListener('connect', this.onConnectUsb)\r
+               }\r
+       }\r
+\r
+       onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+               console.log(e)\r
+               if (e.device?.vendorId === ledgerUSBVendorId) {\r
+                       console.log('Ledger disconnected')\r
+                       const { usb } = globalThis.navigator\r
+                       usb.addEventListener('connect', this.onConnectUsb)\r
+                       usb.removeEventListener('disconnect', this.onDisconnectUsb)\r
+                       this.#status = 'DISCONNECTED'\r
+               }\r
+       }\r
+\r
+       /**\r
+       * Sign a block with the Ledger device.\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {object} block - Block data to sign\r
+       * @returns {Promise} Status, signature, and block hash\r
+       */\r
+       async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>\r
+       /**\r
+       * Sign a nonce with the Ledger device.\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {Uint8Array} nonce - 128-bit value to sign\r
+       * @returns {Promise} Status and signature\r
+       */\r
+       async sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>\r
+       async sign (index: number = 0, input: Uint8Array<ArrayBuffer> | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
+               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+                       throw new TypeError('Invalid account index')\r
+               }\r
+               if (input instanceof Uint8Array) {\r
+                       // input is a nonce\r
+                       return await this.#signNonce(index, input)\r
+               } else {\r
+                       // input is a block\r
+                       return await this.#signBlock(index, input)\r
                }\r
        }\r
 \r
@@ -106,19 +258,6 @@ export class LedgerWallet extends Wallet {
                return wallet\r
        }\r
 \r
-       /**\r
-       * Attempts to close the current process on the Ledger device.\r
-       *\r
-       * Overrides the default wallet `lock()` method since as a hardware wallet it\r
-       * does not need to be encrypted by software.\r
-       *\r
-       * @returns True if successfully locked\r
-       */\r
-       async lock (): Promise<boolean> {\r
-               const { status } = await this.close()\r
-               return status === 'OK'\r
-       }\r
-\r
        /**\r
        * Attempts to connect to the Ledger device.\r
        *\r
@@ -131,72 +270,51 @@ export class LedgerWallet extends Wallet {
                return await this.connect() === 'CONNECTED'\r
        }\r
 \r
-       async init (): Promise<void> {\r
-               await this.checkBrowserSupport()\r
-               const { usb } = globalThis.navigator\r
-               if (usb) {\r
-                       usb.addEventListener('connect', console.log.bind(console))\r
-                       usb.addEventListener('disconnect', console.log.bind(console))\r
-               }\r
-       }\r
-\r
        /**\r
-       * Check which transport protocols are supported by the browser and set the\r
-       * transport type according to the following priorities: Bluetooth, USB, HID.\r
+       * Update cache from raw block data. Suitable for offline use.\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {object} block - JSON-formatted block data\r
        */\r
-       async checkBrowserSupport (): Promise<typeof TransportBLE | typeof TransportUSB | typeof TransportHID> {\r
-               console.log('Checking browser Ledger support...')\r
-               try {\r
-                       if (await TransportBLE.isSupported()) {\r
-                               return TransportBLE\r
-                       }\r
-                       if (await TransportUSB.isSupported()) {\r
-                               return TransportUSB\r
+       async updateCache (index: number, block: { [key: string]: string }): Promise<LedgerResponse>\r
+       /**\r
+       * Update cache from a block hash by calling out to a node. Suitable for online\r
+       * use only.\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {string} hash - Hexadecimal block hash\r
+       * @param {Rpc} rpc - Rpc class object with a node URL\r
+       */\r
+       async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
+       async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
+               if (typeof input === 'string' && node instanceof Rpc) {\r
+                       const data = {\r
+                               'json_block': 'true',\r
+                               'hash': input\r
                        }\r
-                       if (await TransportHID.isSupported()) {\r
-                               return TransportHID\r
+                       const res = await node.call('block_info', data)\r
+                       if (!res || res.ok === false) {\r
+                               throw new Error(`Unable to fetch block info`, res)\r
                        }\r
-               } catch { }\r
-               throw new Error('Unsupported browser')\r
-       }\r
-\r
-       async connect (): Promise<LedgerResponse> {\r
-               const { usb } = globalThis.navigator\r
-               if (usb) {\r
-                       usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this))\r
-                       usb.addEventListener('disconnect', this.onDisconnectUsb.bind(this))\r
+                       input = res.contents\r
                }\r
-               const version = await this.version()\r
-               if (version.status === 'OK') {\r
-                       if (version.name === 'Nano') {\r
-                               const { status } = await this.#account()\r
-                               if (status === 'OK') {\r
-                                       this.#status = 'CONNECTED'\r
-                               } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {\r
-                                       this.#status = 'LOCKED'\r
-                               } else {\r
-                                       this.#status = 'DISCONNECTED'\r
-                               }\r
-                       } else if (version.name === 'BOLOS') {\r
-                               const open = await this.open()\r
-                               this.#status = (open.status === 'OK')\r
-                                       ? 'CONNECTED'\r
-                                       : 'DISCONNECTED'\r
-                       } else {\r
-                               this.#status = 'BUSY'\r
-                       }\r
-               } else {\r
-                       this.#status = 'DISCONNECTED'\r
+               const { status } = await this.#cacheBlock(index, input)\r
+               if (status !== 'OK') {\r
+                       throw new Error(status)\r
                }\r
-               return { status: this.status }\r
+               return { status }\r
        }\r
 \r
-       async onDisconnectUsb (e: USBConnectionEvent): Promise<void> {\r
-               if (e.device?.manufacturerName === 'Ledger') {\r
-                       const { usb } = globalThis.navigator\r
-                       usb.removeEventListener('disconnect', this.onDisconnectUsb)\r
-                       this.#status = 'DISCONNECTED'\r
-               }\r
+       /**\r
+       * Get the version of the current process. If a specific app is running, get\r
+       * the app version. Otherwise, get the Ledger BOLOS version instead.\r
+       *\r
+       * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
+       *\r
+       * @returns Status, process name, and version\r
+       */\r
+       async version (): Promise<LedgerVersionResponse> {\r
+               return await this.#version()\r
        }\r
 \r
        /**\r
@@ -212,13 +330,14 @@ export class LedgerWallet extends Wallet {
        *\r
        * @returns Status of command\r
        */\r
-       async open (): Promise<LedgerResponse> {\r
+       async #open (): Promise<LedgerResponse> {\r
                const name = new TextEncoder().encode('Nano')\r
                const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
-               const response = await transport.send(0xe0, 0xd8, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, name as Buffer)\r
+               const response = await transport\r
+                       .send(0xe0, 0xd8, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, name as Buffer)\r
                        .then(res => bytes.toDec(res))\r
                        .catch(err => err.statusCode) as number\r
-               return new Promise(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
+               return new Promise(r => setTimeout(r, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
        }\r
 \r
        /**\r
@@ -234,12 +353,13 @@ export class LedgerWallet extends Wallet {
        *\r
        * @returns Status of command\r
        */\r
-       async close (): Promise<LedgerResponse> {\r
+       async #close (): Promise<LedgerResponse> {\r
                const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
-               const response = await transport.send(0xb0, 0xa7, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused)\r
+               const response = await transport\r
+                       .send(0xb0, 0xa7, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused)\r
                        .then(res => bytes.toDec(res))\r
                        .catch(err => err.statusCode) as number\r
-               return new Promise(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
+               return new Promise(r => setTimeout(r, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
        }\r
 \r
        /**\r
@@ -250,7 +370,7 @@ export class LedgerWallet extends Wallet {
        *\r
        * @returns Status, process name, and version\r
        */\r
-       async version (): Promise<LedgerVersionResponse> {\r
+       async #version (): Promise<LedgerVersionResponse> {\r
                const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
                const response = await transport\r
                        .send(0xb0, LEDGER_ADPU_CODES.version, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused)\r
@@ -271,136 +391,6 @@ export class LedgerWallet extends Wallet {
                return { status, name, version }\r
        }\r
 \r
-       /**\r
-       * Since the Ledger device can only return one account per request, this\r
-       * overrides the default behavior of calling `accounts()` to instead\r
-       * directly derive and return a single account at the specified index.\r
-       *\r
-       * @returns Account\r
-       */\r
-       async account (index: number = 0, show: boolean = false): Promise<Account> {\r
-               const { status, publicKey } = await this.#account(index, show)\r
-               if (publicKey == null) {\r
-                       throw new Error('Failed to get account from device', { cause: status })\r
-               }\r
-               const account = await Account.fromPublicKey(publicKey)\r
-               return account\r
-       }\r
-\r
-       /**\r
-       * Since the Ledger device can only return one account per request, this\r
-       * overrides the default behavior of returning multiple accounts to instead\r
-       * return a single account at the specified index.\r
-       *\r
-       * @returns Account\r
-       */\r
-       async accounts (): Promise<never> {\r
-               throw new Error(`Ledger device only supports 'account()' calls`)\r
-       }\r
-\r
-       /**\r
-       * Sign a block with the Ledger device.\r
-       *\r
-       * @param {number} index - Account number\r
-       * @param {object} block - Block data to sign\r
-       * @returns {Promise} Status, signature, and block hash\r
-       */\r
-       async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>\r
-       /**\r
-       * Sign a nonce with the Ledger device.\r
-       *\r
-       * @param {number} index - Account number\r
-       * @param {Uint8Array} nonce - 128-bit value to sign\r
-       * @returns {Promise} Status and signature\r
-       */\r
-       async sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>\r
-       async sign (index: number = 0, input: Uint8Array<ArrayBuffer> | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
-                       throw new TypeError('Invalid account index')\r
-               }\r
-               const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4)\r
-               const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)\r
-               const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
-\r
-               let instruction: number\r
-               let data: Uint8Array\r
-\r
-               if (input instanceof Uint8Array) {\r
-                       // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
-                       // input is a nonce\r
-                       instruction = LEDGER_ADPU_CODES.signNonce\r
-                       if (input.byteLength !== 16) {\r
-                               throw new RangeError('Nonce must be 16-byte string')\r
-                       }\r
-                       data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...input])\r
-               } else {\r
-                       // input is a block\r
-                       instruction = LEDGER_ADPU_CODES.signBlock\r
-                       const previous = hex.toBytes(input.previous, 32)\r
-                       const link = hex.toBytes(input.link, 32)\r
-                       const representative = hex.toBytes(input.representative.publicKey, 32)\r
-                       const balance = hex.toBytes(BigInt(input.balance).toString(16), 16)\r
-                       data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance])\r
-               }\r
-               const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
-               const response = await transport.send(LEDGER_ADPU_CODES.class, instruction, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, data as Buffer)\r
-                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
-               await transport.close()\r
-\r
-               const statusCode = bytes.toDec(response.slice(-2)) as number\r
-               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-\r
-               if (response.byteLength === 2) {\r
-                       return { status, signature: null }\r
-               }\r
-               if (response.byteLength === 66) {\r
-                       const signature = bytes.toHex(response.slice(0, 64))\r
-                       return { status, signature }\r
-               }\r
-               if (response.byteLength === 98) {\r
-                       const hash = bytes.toHex(response.slice(0, 32))\r
-                       const signature = bytes.toHex(response.slice(32, 96))\r
-                       return { status, signature, hash }\r
-               }\r
-\r
-               throw new Error('Unexpected byte length from device signature', { cause: response })\r
-       }\r
-\r
-       /**\r
-       * Update cache from raw block data. Suitable for offline use.\r
-       *\r
-       * @param {number} index - Account number\r
-       * @param {object} block - JSON-formatted block data\r
-       */\r
-       async updateCache (index: number, block: { [key: string]: string }): Promise<LedgerResponse>\r
-       /**\r
-       * Update cache from a block hash by calling out to a node. Suitable for online\r
-       * use only.\r
-       *\r
-       * @param {number} index - Account number\r
-       * @param {string} hash - Hexadecimal block hash\r
-       * @param {Rpc} rpc - Rpc class object with a node URL\r
-       */\r
-       async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
-       async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
-               if (typeof input === 'string' && node instanceof Rpc) {\r
-                       const data = {\r
-                               'json_block': 'true',\r
-                               'hash': input\r
-                       }\r
-                       const res = await node.call('block_info', data)\r
-                       if (!res || res.ok === false) {\r
-                               throw new Error(`Unable to fetch block info`, res)\r
-                       }\r
-                       input = res.contents\r
-               }\r
-               const { status } = await this.#cacheBlock(index, input)\r
-               if (status !== 'OK') {\r
-                       throw new Error(status)\r
-               }\r
-               return { status }\r
-       }\r
-\r
        /**\r
        * Request an account at a specific BIP-44 index.\r
        *\r
@@ -476,6 +466,85 @@ export class LedgerWallet extends Wallet {
                return { status: LEDGER_STATUS_CODES[response] }\r
        }\r
 \r
+       /**\r
+       * Sign a block with the Ledger device.\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {object} block - Block data to sign\r
+       * @returns {Promise} Status, signature, and block hash\r
+       */\r
+       async #signBlock (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
+               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+                       throw new TypeError('Invalid account index')\r
+               }\r
+\r
+               const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
+               const previous = hex.toBytes(block.previous, 32)\r
+               const link = hex.toBytes(block.link, 32)\r
+               const representative = hex.toBytes(block.representative.publicKey, 32)\r
+               const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)\r
+               const data = new Uint8Array([...LedgerWallet.#derivationPath, ...account, ...previous, ...link, ...representative, ...balance])\r
+\r
+               const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
+               const response = await transport.send(LEDGER_ADPU_CODES.class, LEDGER_ADPU_CODES.signBlock, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, data as Buffer)\r
+                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+               await transport.close()\r
+\r
+               const statusCode = bytes.toDec(response.slice(-2)) as number\r
+               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+\r
+               if (response.byteLength === 2) {\r
+                       return { status, signature: null }\r
+               }\r
+               if (response.byteLength === 98) {\r
+                       const hash = bytes.toHex(response.slice(0, 32))\r
+                       const signature = bytes.toHex(response.slice(32, 96))\r
+                       return { status, signature, hash }\r
+               }\r
+\r
+               throw new Error('Unexpected byte length from device signature', { cause: response })\r
+       }\r
+\r
+       /**\r
+       * Sign a nonce with the Ledger device.\r
+       *\r
+       * nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
+       *\r
+       * @param {number} index - Account number\r
+       * @param {Uint8Array} nonce - 128-bit value to sign\r
+       * @returns {Promise} Status and signature\r
+       */\r
+       async #signNonce (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse> {\r
+               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+                       throw new TypeError('Invalid account index')\r
+               }\r
+               if (nonce.byteLength !== 16) {\r
+                       throw new RangeError('Nonce must be 16-byte string')\r
+               }\r
+\r
+               const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)\r
+               const data = new Uint8Array([...LedgerWallet.#derivationPath, ...derivationAccount, ...nonce])\r
+\r
+               const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
+               const response = await transport\r
+                       .send(LEDGER_ADPU_CODES.class, LEDGER_ADPU_CODES.signNonce, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, data as Buffer)\r
+                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+               await transport.close()\r
+\r
+               const statusCode = bytes.toDec(response.slice(-2)) as number\r
+               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+\r
+               if (response.byteLength === 2) {\r
+                       return { status, signature: null }\r
+               }\r
+               if (response.byteLength === 66) {\r
+                       const signature = bytes.toHex(response.slice(0, 64))\r
+                       return { status, signature }\r
+               }\r
+\r
+               throw new Error('Unexpected byte length from device signature', { cause: response })\r
+       }\r
+\r
        /**\r
        * Gets the public key for an account from the Ledger device.\r
        *\r
index 67e451900576a0d42bf1f67aa36ed141d9ba116c..62eb225b7e4bcf2c5745d751d67c5b4fb743e2ec 100644 (file)
@@ -13,22 +13,27 @@ import { Account, LedgerWallet, ReceiveBlock, SendBlock } from '../dist/main.min
 */
 await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
 
-       let wallet, account, openBlock, sendBlock, receiveBlock
+       let wallet = await LedgerWallet.create(), account, openBlock, sendBlock, receiveBlock
 
-       await test('connect to the device', async () => {
-               wallet = await LedgerWallet.create()
-               const { status } = await click(
-                       'Unlock, then click to connect',
+       await test('request permissions', async () => {
+               let status = wallet.status
+               status = await click(
+                       'Reset permissions, unlock device, quit Nano app, then click to continue',
+                       async () => wallet.connect()
+               )
+               assert.equals(status, 'BUSY')
+               assert.equals(status, wallet.status)
+
+               status = await click(
+                       'Open Nano app on device, then click to continue',
                        async () => wallet.connect()
                )
                assert.equals(status, 'CONNECTED')
+               assert.equals(status, wallet.status)
        })
 
        await test('get version', async () => {
-               const { status, name, version } = await click(
-                       'Click to get version',
-                       async () => wallet.version()
-               )
+               const { status, name, version } = await wallet.version()
 
                assert.equals(status, 'OK')
                assert.equals(name, 'Nano')
@@ -36,10 +41,7 @@ await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
        })
 
        await test('get first account', async () => {
-               account = await click(
-                       'Click to get account',
-                       async () => wallet.account()
-               )
+               account = await wallet.account()
 
                assert.exists(account)
                assert.ok(account instanceof Account)
@@ -61,29 +63,20 @@ await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
                assert.nullish(openBlock.signature)
                assert.equals(openBlock.account.publicKey, account.publicKey)
 
-               const { status, hash, signature } = await click(
-                       'Click to sign opening ReceiveBlock from wallet',
-                       async () => wallet.sign(0, openBlock)
-               )
+               const { status, hash, signature } = await wallet.sign(0, openBlock)
 
                assert.equals(status, 'OK')
                assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
                assert.ok(/[A-Fa-f0-9]{128}/.test(signature))
 
-               await click(
-                       'Click to sign open ReceiveBlock from block and compare signatures',
-                       async () => openBlock.sign(0)
-               )
+               await openBlock.sign(0)
 
                assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))
                assert.equals(signature, openBlock.signature)
        })
 
        await test('cache open block', async () => {
-               const { status } = await click(
-                       'Click to cache open block',
-                       async () => wallet.updateCache(0, openBlock)
-               )
+               const { status } = await wallet.updateCache(0, openBlock)
 
                assert.equals(status, 'OK')
        })
@@ -102,10 +95,7 @@ await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
                assert.nullish(sendBlock.signature)
                assert.equals(sendBlock.account.publicKey, account.publicKey)
 
-               const { status, hash, signature } = await click(
-                       'Click to sign SendBlock from wallet',
-                       async () => wallet.sign(0, sendBlock)
-               )
+               const { status, hash, signature } = await wallet.sign(0, sendBlock)
 
                assert.equals(status, 'OK')
                assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
@@ -127,19 +117,14 @@ await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
                assert.nullish(receiveBlock.signature)
                assert.equals(receiveBlock.account.publicKey, account.publicKey)
 
-               await click(
-                       'Click to sign SendBlock from block',
-                       async () => receiveBlock.sign(0, sendBlock)
-               )
+               await receiveBlock.sign(0, sendBlock)
 
                assert.ok(/[A-Fa-f0-9]{128}/.test(receiveBlock.signature))
        })
 
        await test('destroy wallet', async () => {
-               const { status } = await click(
-                       'Click to close',
-                       async () => wallet.close()
-               )
+               const { status } = await wallet.close()
+
                assert.equals(status, 'OK')
                await wallet.destroy()
        })