From: Chris Duncan Date: Thu, 24 Jul 2025 21:23:56 +0000 (-0700) Subject: Add wallet method to sign blocks by account index. Remove nonce signing from Ledger... X-Git-Tag: v0.10.5~52^2~1 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=44f6f14f17266d82e1955ce766f33e01f9724677;p=libnemo.git Add wallet method to sign blocks by account index. Remove nonce signing from Ledger wallet since the underlying device app functionality is broken. Add tests and types accordingly. --- diff --git a/src/lib/block.ts b/src/lib/block.ts index aff883f..a56d2a1 100644 --- a/src/lib/block.ts +++ b/src/lib/block.ts @@ -141,27 +141,27 @@ abstract class Block { * property. * * @param {number} index - Account index between 0x0 and 0x7fffffff - * @param {object} [block] - JSON of previous block for offline signing + * @param {object} [frontier] - JSON of frontier block for offline signing */ - async sign (index?: number, block?: { [key: string]: string }): Promise - async sign (input?: number | string, block?: { [key: string]: string }): Promise { + async sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise + async sign (input?: number | string, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise { if (typeof input === 'number') { const index = input const { LedgerWallet } = await import('./wallets') const ledger = await LedgerWallet.create() await ledger.connect() - if (block) { + if (frontier) { try { - await ledger.updateCache(index, block) + await ledger.updateCache(index, frontier) } catch (err) { console.warn('Error updating Ledger cache of previous block, attempting signature anyway', err) } } - const result = await ledger.sign(index, this as SendBlock | ReceiveBlock | ChangeBlock) - if (result.status !== 'OK' || result.signature == null) { - throw new Error(result.status) + try { + this.signature = await ledger.sign(index, this as SendBlock | ReceiveBlock | ChangeBlock) + } catch (err) { + throw new Error('failed to sign with ledger', { cause: err }) } - this.signature = result.signature } else if (typeof input === 'string') { try { const account = await Account.import({ index: 0, privateKey: input }, '') diff --git a/src/lib/wallets/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index c2b059f..a813183 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -11,28 +11,7 @@ import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LED import { bytes, dec, hex } from '#src/lib/convert.js' import { Entropy } from '#src/lib/entropy.js' import { Rpc } from '#src/lib/rpc.js' -import { KeyPair } from '#types' - -type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' - -interface LedgerResponse { - status: string -} - -interface LedgerVersionResponse extends LedgerResponse { - name: string | null, - version: string | null -} - -interface LedgerAccountResponse extends LedgerResponse { - publicKey: string | null, - address: string | null -} - -interface LedgerSignResponse extends LedgerResponse { - signature: string | null, - hash?: string -} +import { DeviceStatus, KeyPair, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types' /** * Ledger hardware wallet created by communicating with a Ledger device via ADPU @@ -221,29 +200,31 @@ export class LedgerWallet extends Wallet { * * @param {number} index - Account number * @param {object} block - Block data to sign - * @returns {Promise} Status, signature, and block hash + * @returns {Promise} Signature */ - async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise - /** - * Sign a nonce with the Ledger device. - * - * @param {number} index - Account number - * @param {Uint8Array} nonce - 128-bit value to sign - * @returns {Promise} Status and signature - */ - async sign (index: number, nonce: Uint8Array): Promise - async sign (index: number = 0, input: Uint8Array | SendBlock | ReceiveBlock | ChangeBlock): Promise { + async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock, frontier?: SendBlock | ReceiveBlock | ChangeBlock): Promise { if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { throw new TypeError('Invalid account index') } + if (frontier != null) { + const { status } = await this.#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...') - if (input instanceof Uint8Array) { - // input is a nonce - return await this.#signNonce(index, input) - } else { - // input is a block - return await this.#signBlock(index, input) + const { status, signature, hash } = await this.#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 failed to return signature') } + block.signature = signature + return signature } /** @@ -264,7 +245,7 @@ export class LedgerWallet extends Wallet { * @param {number} index - Account number * @param {object} block - JSON-formatted block data */ - async updateCache (index: number, block: { [key: string]: string }): Promise + async updateCache (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise /** * Update cache from a block hash by calling out to a node. Suitable for online * use only. @@ -288,7 +269,7 @@ export class LedgerWallet extends Wallet { } const { status } = await this.#cacheBlock(index, input) if (status !== 'OK') { - throw new Error(status) + throw new Error('failed to cache frontier block in ledger', { cause: status }) } return { status } } @@ -392,11 +373,11 @@ export class LedgerWallet extends Wallet { * @param {any} block - Block data to cache * @returns Status of command */ - async #cacheBlock (index: number = 0, block: SendBlock | ReceiveBlock | ChangeBlock): Promise { + async #cacheBlock (index: number = 0, block: ChangeBlock | ReceiveBlock | SendBlock): Promise { if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { throw new TypeError('Invalid account index') } - if (!(block instanceof SendBlock) && !(block instanceof ReceiveBlock) && !(block instanceof ChangeBlock)) { + if (!(block instanceof ChangeBlock) && !(block instanceof ReceiveBlock) && !(block instanceof SendBlock)) { throw new TypeError('Invalid block format') } if (!block.signature) { diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts index 7a8153f..fc5d924 100644 --- a/src/lib/wallets/wallet.ts +++ b/src/lib/wallets/wallet.ts @@ -2,6 +2,7 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { Account, AccountList } from '#src/lib/account.js' +import { ChangeBlock, ReceiveBlock, SendBlock } from '#src/lib/block.js' import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' import { ADDRESS_GAP } from '#src/lib/constants.js' import { bytes, hex, utf8 } from '#src/lib/convert.js' @@ -234,6 +235,26 @@ export abstract class Wallet { return accounts } + /** + * 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 + * before being returned. The wallet must be unlocked prior to signing. + * + * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed + * @param {number} index - Account to use for signing + * @returns {Promise} Hexadecimal-formatted 64-byte signature + */ + async sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise { + if (this.#locked) throw new Error('wallet must be unlocked to sign') + if (this.#s == null) throw new Error('wallet seed not found') + try { + const account = this.account(index) + return (await account).sign(block, new Uint8Array(this.#s)) + } catch (err) { + throw new Error(`failed to sign block`, { cause: err }) + } + } + /** * Unlocks the wallet using the same password as used prior to lock it. * diff --git a/src/types.d.ts b/src/types.d.ts index f980098..6180d2f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -318,11 +318,9 @@ declare abstract class Block { * property. * * @param {number} index - Account index between 0x0 and 0x7fffffff - * @param {object} [block] - JSON of previous block for offline signing + * @param {object} [frontier] - JSON of frontier block for offline signing */ - sign (index?: number, block?: { - [key: string]: string - }): Promise + sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise /** * Verifies the signature of the block. If a key is not provided, the public * key of the block's account will be used if it exists. @@ -666,6 +664,16 @@ export declare abstract class Wallet { */ refresh (rpc: Rpc | string | URL, from?: number, to?: number): 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 + * before being returned. The wallet must be unlocked prior to signing. + * + * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed + * @param {number} index - Account to use for signing + * @returns {Promise} Hexadecimal-formatted 64-byte signature + */ + sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise + /** * Unlocks the wallet using the same password as used prior to lock it. * * @param {Key} password Used previously to lock the wallet @@ -887,17 +895,26 @@ export declare class Blake2bWallet extends Wallet { } type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' + interface LedgerResponse { status: string } + interface LedgerVersionResponse extends LedgerResponse { - name: string | null + name: string | null, version: string | null } + +interface LedgerAccountResponse extends LedgerResponse { + publicKey: string | null, + address: string | null +} + interface LedgerSignResponse extends LedgerResponse { - signature: string | null + signature: string | null, hash?: string } + /** * 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 @@ -971,17 +988,9 @@ export declare class LedgerWallet extends Wallet { * * @param {number} index - Account number * @param {object} block - Block data to sign - * @returns {Promise} Status, signature, and block hash - */ - sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise - /** - * Sign a nonce with the Ledger device. - * - * @param {number} index - Account number - * @param {Uint8Array} nonce - 128-bit value to sign - * @returns {Promise} Status and signature + * @returns {Promise} Signature */ - sign (index: number, nonce: Uint8Array): Promise + sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock, frontier?: SendBlock | ReceiveBlock | ChangeBlock): Promise /** * Attempts to connect to the Ledger device. * @@ -997,9 +1006,7 @@ export declare class LedgerWallet extends Wallet { * @param {number} index - Account number * @param {object} block - JSON-formatted block data */ - updateCache (index: number, block: { - [key: string]: string - }): Promise + updateCache (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise /** * Update cache from a block hash by calling out to a node. Suitable for online * use only. diff --git a/test/test.blocks.mjs b/test/test.blocks.mjs index bc1804a..6869879 100644 --- a/test/test.blocks.mjs +++ b/test/test.blocks.mjs @@ -6,6 +6,10 @@ import { assert, isNode, suite, test } from './GLOBALS.mjs' import { NANO_TEST_VECTORS } from './VECTORS.mjs' +/** +* @type {typeof import('../dist/types.d.ts').Bip44Wallet} +*/ +let Bip44Wallet /** * @type {typeof import('../dist/types.d.ts').ChangeBlock} */ @@ -19,9 +23,9 @@ let ReceiveBlock */ let SendBlock if (isNode) { - ({ ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/nodejs.min.js')) + ({ Bip44Wallet, ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/nodejs.min.js')) } else { - ({ ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/browser.min.js')) + ({ Bip44Wallet, ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/browser.min.js')) } await Promise.all([ @@ -80,7 +84,39 @@ await Promise.all([ suite('Block signing using official test vectors', async () => { - await test('sign open block', async () => { + await test('sign open block with wallet', async () => { + const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) + await assert.resolves(wallet.unlock(NANO_TEST_VECTORS.PASSWORD)) + + const block = new ReceiveBlock( + NANO_TEST_VECTORS.ADDRESS_0, + '0', + NANO_TEST_VECTORS.OPEN_BLOCK.link, + NANO_TEST_VECTORS.OPEN_BLOCK.balance, + NANO_TEST_VECTORS.OPEN_BLOCK.representative, + NANO_TEST_VECTORS.OPEN_BLOCK.previous, + NANO_TEST_VECTORS.OPEN_BLOCK.work + ) + const signature = await wallet.sign(0, block) + assert.equal(block.signature, signature) + }) + + await test('sign open block with account', async () => { + const block = new ReceiveBlock( + NANO_TEST_VECTORS.OPEN_BLOCK.account, + '0', + NANO_TEST_VECTORS.OPEN_BLOCK.link, + NANO_TEST_VECTORS.OPEN_BLOCK.balance, + NANO_TEST_VECTORS.OPEN_BLOCK.representative, + NANO_TEST_VECTORS.OPEN_BLOCK.previous, + NANO_TEST_VECTORS.OPEN_BLOCK.work + ) + await block.sign(NANO_TEST_VECTORS.OPEN_BLOCK.key) + assert.equal(block.hash, NANO_TEST_VECTORS.OPEN_BLOCK.hash) + assert.equal(block.signature, NANO_TEST_VECTORS.OPEN_BLOCK.signature) + }) + + await test('sign open block with private key', async () => { const block = new ReceiveBlock( NANO_TEST_VECTORS.OPEN_BLOCK.account, '0', diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index 8dd2571..384c3d4 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -41,7 +41,7 @@ const rpc = new Rpc(env.NODE_URL ?? '', env.API_KEY_NAME) */ await Promise.all([ /* node:coverage disable */ - suite('Ledger hardware wallet', { skip: true || isNode }, async () => { + suite('Ledger hardware wallet', { skip: false || isNode }, async () => { let wallet, account, openBlock, sendBlock, receiveBlock @@ -130,10 +130,8 @@ await Promise.all([ assert.nullish(openBlock.signature) assert.equal(openBlock.account.publicKey, account.publicKey) - const { status, hash, signature } = await wallet.sign(0, openBlock) + const signature = await wallet.sign(0, openBlock) - assert.equal(status, 'OK') - assert.ok(/^[A-Fa-f0-9]{64}$/.test(hash)) assert.ok(/^[A-Fa-f0-9]{128}$/.test(signature)) await openBlock.sign(0) @@ -163,12 +161,10 @@ await Promise.all([ assert.nullish(sendBlock.signature) assert.equal(sendBlock.account.publicKey, account.publicKey) - const { status, hash, signature } = await wallet.sign(0, sendBlock) + const signature = await wallet.sign(0, sendBlock) - assert.equal(status, 'OK') - assert.ok(/^[A-Fa-f0-9]{64}$/.test(hash)) assert.ok(/^[A-Fa-f0-9]{128}$/.test(signature)) - sendBlock.signature = signature + assert.equal(signature, sendBlock.signature) }) await test('sign a receive block from block object which can accept previous block for cache', async () => { @@ -191,13 +187,25 @@ await Promise.all([ assert.ok(/^[A-Fa-f0-9]{128}$/.test(receiveBlock.signature ?? '')) }) - // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14 - await test('sign a nonce', { skip: true }, async () => { - const nonce = new TextEncoder().encode('0123456789abcdef') - const { status, signature } = await click('Click to sign nonce', wallet.sign(0, nonce)) + await test('sign a receive block from wallet including frontier block for cache', async () => { + sendBlock = new ReceiveBlock( + account, + receiveBlock.balance, + receiveBlock.hash, + '0', + NANO_TEST_VECTORS.RECEIVE_BLOCK.representative, + receiveBlock.hash + ) + + assert.ok(/^[A-Fa-f0-9]{64}$/.test(receiveBlock.hash)) + assert.nullish(sendBlock.signature) + assert.equal(sendBlock.account.publicKey, account.publicKey) + + const signature = await wallet.sign(0, sendBlock, receiveBlock) - assert.equal(status, 'OK') - assert.OK(/^[A-Fa-f0-9]{128}$/.test(signature)) + assert.exists(sendBlock.signature) + assert.ok(/^[A-Fa-f0-9]{128}$/.test(sendBlock.signature ?? '')) + assert.equal(signature, sendBlock.signature) }) await test('destroy wallet', async () => {