From 7f971deb3e98ec516b5e05d480daeacff629952b Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Mon, 20 Oct 2025 22:35:29 -0700 Subject: [PATCH] Restore Ledger ability to sign nonces and add test cases. --- src/lib/ledger.ts | 32 +++++++++++++++++++++++++++----- src/lib/wallet/index.ts | 13 ++++++++++++- src/lib/wallet/sign.ts | 6 +++--- test/test.ledger.mjs | 17 +++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index 86ce95e..b93ef4b 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -7,7 +7,7 @@ import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' 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 { bytes, dec, hex, utf8 } from './convert' import { Rpc } from './rpc' import { Wallet } from './wallet' @@ -230,6 +230,22 @@ export class Ledger { }) } + /** + * Sign a 16-byte nonce with the Ledger device. The actual messaage signed is a + * string which can be expressed as the following template literal: + * + * `Nano Signed Nonce:\n ${nonceBytes}` + * + * 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. * @@ -237,7 +253,8 @@ export class Ledger { * @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, block: Block, frontier?: Block): Promise + static async sign (index: number, data: string | Block, frontier?: Block): Promise { try { if (typeof index !== 'number') { throw new TypeError('Index must be a number', { cause: index }) @@ -245,6 +262,9 @@ export class Ledger { if (index < 0 || index >= HARDENED_OFFSET) { throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index }) } + if (typeof data !== 'string' && !(data instanceof Block)) { + throw new TypeError('Data to be signed must be a string nonce or a Block', { cause: data }) + } if (frontier != null) { const { status } = await this.#cacheBlock(index, frontier) if (status !== 'OK') { @@ -252,12 +272,14 @@ export class Ledger { } } console.log('Waiting for signature confirmation on Ledger device...') - const { status, signature, hash } = await this.#signBlock(index, block) + const { status, signature, hash } = data instanceof Block + ? await this.#signBlock(index, data) + : await this.#signNonce(index, utf8.toBytes(data)) 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 (data instanceof Block && hash !== data.hash) { + throw new Error('Hash from ledger does not match hash from block', { cause: `${hash} | ${data.hash}` }) } if (signature == null) { throw new Error('Ledger silently failed to return signature') diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 0c287de..d9a0cbf 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -333,11 +333,22 @@ export class Wallet { * For compatibility with other Nano tools, the data is first hashed to a * 32-byte value using BLAKE2b. The wallet must be unlocked prior to signing. * + * Special note: This method can be used to sign a 16-byte nonce with a Ledger + * device. The actual messaage signed is a string which can be expressed as the + * following template literal: + * + * `Nano Signed Nonce:\n ${nonceBytes}` + * + * 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 to use for signing * @param {(string|string[])} data - Arbitrary data to be hashed and signed */ async sign (index: number, data: string | string[]): Promise - /** * Signs a block using the private key of the account at the wallet index * specified. The signature is appended to the signature field of the block diff --git a/src/lib/wallet/sign.ts b/src/lib/wallet/sign.ts index eb1d097..92cdaf0 100644 --- a/src/lib/wallet/sign.ts +++ b/src/lib/wallet/sign.ts @@ -11,9 +11,6 @@ import { Wallet } from '../wallet' export async function _signData (wallet: Wallet, vault: Vault, index: number, data: string | string[]): Promise export async function _signData (wallet: Wallet, vault: Vault, index: unknown, data: unknown): Promise { try { - if (wallet.type === 'Ledger') { - throw new TypeError('Ledger wallet cannot sign arbitrary data') - } if (typeof index !== 'number') { throw new TypeError('Index must be a number', { cause: index }) } @@ -21,6 +18,9 @@ export async function _signData (wallet: Wallet, vault: Vault, index: unknown, d if (message.some(s => typeof s !== 'string')) { throw new TypeError('Data to sign must be strings', { cause: data }) } + if (wallet.type === 'Ledger') { + return await Ledger.sign(index, message[0]) + } const hash = new Blake2b(32) message.forEach(s => hash.update(utf8.toBytes(s))) const { signature } = await vault.request({ diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index e07bf8d..dd3848a 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -315,6 +315,23 @@ await Promise.all([ assert.ok(/^[A-F0-9]{128}$/i.test(sendBlock.signature ?? '')) }) + await test('sign a 16-byte nonce', async () => { + const nonce = 'hello nano world' + + const signature = await wallet.sign(0, nonce) + + assert.exists(signature) + assert.ok(/^[A-F0-9]{128}$/i.test(signature)) + }) + + await test('fail to sign invalid length nonces', async () => { + const nonceShort = 'hello world' + const nonceLong = 'hello world foobar' + + await assert.rejects(wallet.sign(0, nonceShort)) + await assert.rejects(wallet.sign(0, nonceLong)) + }) + await test('fail when using new', async () => { assert.throws(() => new Wallet('Ledger')) }) -- 2.47.3