From 20cd2b54582138af358fcec8ee5674b6d1400505 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Wed, 9 Jul 2025 10:55:57 -0700 Subject: [PATCH] Move Ledger block signing tests into dedicated file. --- index.html | 3 - src/lib/wallets/ledger-wallet.ts | 35 ++++--- test/GLOBALS.mjs | 26 ++++- test/test.ledger.mjs | 169 +++++++++++++++++++++++++++---- test/test.sign-blocks.mjs | 96 +----------------- 5 files changed, 193 insertions(+), 136 deletions(-) diff --git a/index.html b/index.html index f193df5..b1a75c6 100644 --- a/index.html +++ b/index.html @@ -41,7 +41,6 @@ SPDX-License-Identifier: GPL-3.0-or-later a = a.replace('%c', '') output.innerHTML += `
${a}
` consoleGroup(...args) - window?.scrollTo(0, document.body.scrollHeight) } const consoleError = console.error console.error = (...args) => { @@ -49,7 +48,6 @@ SPDX-License-Identifier: GPL-3.0-or-later a = a.replace('%cFAIL ,color:red,', 'FAIL ') output.innerHTML += `
${a}
` consoleError(...args) - window?.scrollTo(0, document.body.scrollHeight) } const consoleLog = console.log console.log = (...args) => { @@ -60,7 +58,6 @@ SPDX-License-Identifier: GPL-3.0-or-later a = a.replace('%cTESTING COMPLETE,color:orange;font-weight:bold', 'TESTING COMPLETE') output.innerHTML += `
${a}
` consoleLog(...args) - window?.scrollTo(0, document.body.scrollHeight) } })() diff --git a/src/lib/wallets/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index 3bf2093..d33b42b 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -80,7 +80,10 @@ export class LedgerWallet extends Wallet { */ async destroy (): Promise { await super.destroy() - await this.close() + const { status } = await this.close() + if (status !== 'OK') { + throw new Error('Failed to close wallet', { cause: status }) + } } /** @@ -108,8 +111,8 @@ export class LedgerWallet extends Wallet { * @returns True if successfully locked */ async lock (): Promise { - const result = await this.close() - return result.status === 'OK' + const { status } = await this.close() + return status === 'OK' } /** @@ -121,8 +124,8 @@ export class LedgerWallet extends Wallet { * @returns True if successfully unlocked */ async unlock (): Promise { - const result = await this.connect() - return result === 'OK' + const { status } = await this.connect() + return status === 'OK' } async init (): Promise { @@ -157,7 +160,7 @@ export class LedgerWallet extends Wallet { } } - async connect (): Promise { + async connect (): Promise { const { usb } = globalThis.navigator if (usb) { usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this)) @@ -185,7 +188,7 @@ export class LedgerWallet extends Wallet { } else { this.#status = 'DISCONNECTED' } - return this.status + return { status: this.status } } async onDisconnectUsb (e: USBConnectionEvent): Promise { @@ -197,7 +200,7 @@ export class LedgerWallet extends Wallet { } /** - * Open Nano app by launching user flow. + * Open the Nano app by launching a user flow. * * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application * @@ -219,7 +222,7 @@ export class LedgerWallet extends Wallet { } /** - * Close the currently running app. + * 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 * @@ -347,18 +350,20 @@ export class LedgerWallet extends Wallet { const statusCode = bytes.toDec(response.slice(-2)) as number const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { + if (response.byteLength === 2) { return { status, signature: null } } - if (response.byteLength > 66) { + if (response.byteLength === 66) { const signature = bytes.toHex(response.slice(0, 64)) return { status, signature } } + if (response.byteLength === 98) { + const hash = bytes.toHex(response.slice(0, 32)) + const signature = bytes.toHex(response.slice(32, 96)) + return { status, signature, hash } + } - 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 }) } /** diff --git a/test/GLOBALS.mjs b/test/GLOBALS.mjs index 6277040..1af784a 100644 --- a/test/GLOBALS.mjs +++ b/test/GLOBALS.mjs @@ -24,6 +24,29 @@ export { process } export const isNode = process.versions?.node != null +export async function click (text, fn) { + return new Promise((resolve, rejects) => { + const button = document.createElement('button') + const hourglass = document.createTextNode('⏳') + button.innerText = text + button.addEventListener('click', async () => { + button.innerText = 'Waiting for device...' + button.after(hourglass) + try { + const result = await fn() + resolve(result) + } catch (err) { + rejects(err) + } finally { + hourglass.remove() + button.remove() + } + }) + document.body.appendChild(button) + window?.scrollTo(0, document.body.scrollHeight) + }) +} + export function stats (times) { if (times == null || times.length === 0) return null @@ -131,7 +154,7 @@ export function suite (name, opts, fn) { console.groupEnd() return } - if (fn.constructor.name === 'AsyncFunction') fn = fn() + if (fn?.constructor?.name === 'AsyncFunction') fn = fn() if (typeof fn === 'function') fn = new Promise(resolve => resolve(fn())) console.group(`%c${name}`, 'font-weight:bold') await fn @@ -171,6 +194,7 @@ export function test (name, opts, fn) { } else { fail(`${name}: test cannot execute on ${typeof fn} ${fn}`) } + window?.scrollTo(0, document.body.scrollHeight) } export const assert = { diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index 32ec01b..4b3ea0d 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -3,36 +3,161 @@ 'use strict' -import { assert, isNode, suite, test } from './GLOBALS.mjs' +import { assert, click, isNode, suite, test } from './GLOBALS.mjs' import { NANO_TEST_VECTORS } from './VECTORS.js' -import { LedgerWallet } from '../dist/main.min.js' +import { Account, LedgerWallet, ReceiveBlock, SendBlock } from '../dist/main.min.js' -async function click (text, fn) { - return new Promise(resolve => { - const button = document.createElement('button') - button.innerText = text - button.addEventListener('click', async (event) => { - const result = await fn - document.body.removeChild(button) - resolve(result) - }) - document.body.appendChild(button) +/** +* HID interactions require user gestures, so to reduce clicks, the variables +* shared among tests like wallet and account are declared at the top-level. +*/ +await suite('Ledger hardware wallet', { skip: false || isNode }, async () => { + + let wallet, account, openBlock, sendBlock, receiveBlock + + await test('connect to the device and then disconnect', async () => { + wallet = await LedgerWallet.create() + const { status } = await click( + 'Unlock, then click to connect', + async () => wallet.connect() + ) + assert.equals(status, 'CONNECTED') }) -} -await suite('Ledger hardware wallet', async () => { + // 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('fail when using new', async () => { - assert.throws(() => new LedgerWallet()) + // assert.equals(resultSignNonce.status, 'OK') + // assert.OK(/[A-Fa-f0-9]{128}/.test(resultSignNonce.signature)) + }) + + await test('get first account', async () => { + account = await click( + 'Click to get account', + async () => wallet.account() + ) + + assert.exists(account) + assert.ok(account instanceof Account) + assert.exists(account.publicKey) + assert.exists(account.address) + }) + + await test('sign open block from block', async () => { + openBlock = new ReceiveBlock( + account, + '0', + NANO_TEST_VECTORS.RECEIVE_BLOCK.link, + NANO_TEST_VECTORS.RECEIVE_BLOCK.balance, + NANO_TEST_VECTORS.RECEIVE_BLOCK.representative, + '0' + ) + + assert.ok(/[A-Fa-f0-9]{64}/.test(openBlock.hash)) + 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) + ) + + 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) + ) + + assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature)) + assert.equals(signature, openBlock.signature) }) - await test('connect to a Ledger device and then disconnect', { skip: false || isNode }, async () => { - const wallet = await LedgerWallet.create() - const openStatus = await click('Unlock, then click to connect', wallet.connect()) + await test('cache open block', async () => { + const { status } = await click( + 'Click to cache open block', + async () => wallet.updateCache(0, openBlock) + ) + + assert.equals(status, 'OK') + }) - assert.equals(openStatus, 'CONNECTED') + await test('sign send block from wallet which requires cache to be up-to-date', async () => { + sendBlock = new SendBlock( + account, + openBlock.balance, + account.address, + '0', + NANO_TEST_VECTORS.SEND_BLOCK.representative, + openBlock.hash + ) + + assert.ok(/[A-Fa-f0-9]{64}/.test(sendBlock.hash)) + 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) + ) + + assert.equals(status, 'OK') + assert.ok(/[A-Fa-f0-9]{64}/.test(hash)) + assert.ok(/[A-Fa-f0-9]{128}/.test(signature)) + sendBlock.signature = signature + }) + + await test('sign a receive block from block object which can accept previous block for cache', async () => { + receiveBlock = new ReceiveBlock( + account, + sendBlock.balance, + sendBlock.hash, + '0', + NANO_TEST_VECTORS.RECEIVE_BLOCK.representative, + sendBlock.hash + ) + + assert.ok(/[A-Fa-f0-9]{64}/.test(sendBlock.hash)) + assert.nullish(receiveBlock.signature) + assert.equals(receiveBlock.account.publicKey, account.publicKey) + + await click( + 'Click to sign SendBlock from block', + async () => 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() + ) + assert.equals(status, 'OK') + await wallet.destroy() + }) + + await test('fail when using new', async () => { + assert.throws(() => new LedgerWallet()) + }) - const closeStatus = await click('Click to close', wallet.close()) - assert.equals(closeStatus.status, 'OK') + await test('fail to sign a block without caching frontier', async () => { + sendBlock = new SendBlock( + account, + receiveBlock.balance, + account.address, + '0', + NANO_TEST_VECTORS.SEND_BLOCK.representative, + receiveBlock.previous + ) + await assert.rejects(click( + 'Fail to sign', + async () => sendBlock.sign(0) + )) }) }) diff --git a/test/test.sign-blocks.mjs b/test/test.sign-blocks.mjs index cc76c17..6997be3 100644 --- a/test/test.sign-blocks.mjs +++ b/test/test.sign-blocks.mjs @@ -5,7 +5,7 @@ import { assert, suite, test } from './GLOBALS.mjs' import { NANO_TEST_VECTORS } from './VECTORS.js' -import { SendBlock, ReceiveBlock, ChangeBlock, LedgerWallet } from '../dist/main.min.js' +import { SendBlock, ReceiveBlock, ChangeBlock } from '../dist/main.min.js' await suite('Valid blocks', async () => { @@ -163,97 +163,3 @@ await suite('Block signing tests using official test vectors', async () => { assert.equals(block.work, '') }) }) - -await suite('Ledger device signing tests', { skip: false }, async () => { - const wallet = await LedgerWallet.create() - const account = await new Promise(resolve => { - const button = document.createElement('button') - button.innerText = 'Unlock Ledger, then click to continue' - button.addEventListener('click', async (event) => { - await wallet.connect() - resolve(await wallet.account()) - document.body.removeChild(button) - }) - document.body.appendChild(button) - }) - - // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14 - await test('should sign a nonce with a Ledger device', { skip: true }, async () => { - let status = await new Promise(resolve => { - const button = document.createElement('button') - button.innerText = 'Unlock Ledger, then click to continue' - button.addEventListener('click', async (event) => { - await wallet.connect() - const result = await wallet.sign(0, new TextEncoder().encode('0123456789abcdef')) - - assert.equals(result.status, 'OK') - assert.equals(result.signature.toUpperCase(), '2BD2F905E74B5BEE3E2277CED1D1E3F7535E5286B6E22F7B08A814AA9E5C4E1FEA69B61D60B435ADC2CE756E6EE5F5BE7EC691FE87E024A0B22A3D980CA5B305') - - document.body.removeChild(button) - resolve(result) - }) - document.body.appendChild(button) - }) - }) - - await test('should sign an open block and send block with a Ledger device', async () => { - const openBlock = new ReceiveBlock( - account, - '0', - NANO_TEST_VECTORS.RECEIVE_BLOCK.link, - NANO_TEST_VECTORS.RECEIVE_BLOCK.balance, - NANO_TEST_VECTORS.RECEIVE_BLOCK.representative, - '0' - ) - await new Promise(resolve => { - const button = document.createElement('button') - button.innerText = 'Unlock Ledger, then click to test signing open ReceiveBlock' - button.addEventListener('click', async (event) => { - assert.nullish(openBlock.signature) - await openBlock.sign(0) - assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature)) - resolve(document.body.removeChild(button)) - }) - document.body.appendChild(button) - }) - assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature)) - assert.ok(/[A-Fa-f0-9]{64}/.test(openBlock.hash)) - - const sendBlock = new SendBlock( - account, - NANO_TEST_VECTORS.RECEIVE_BLOCK.balance, - NANO_TEST_VECTORS.RECEIVE_BLOCK.account, - '0', - NANO_TEST_VECTORS.RECEIVE_BLOCK.representative, - openBlock.hash - ) - await new Promise(resolve => { - const button = document.createElement('button') - button.innerText = 'Unlock Ledger, then click to test signing SendBlock' - button.addEventListener('click', async (event) => { - assert.nullish(sendBlock.signature) - await sendBlock.sign(0, openBlock) - assert.ok(/[A-Fa-f0-9]{128}/.test(sendBlock.signature)) - resolve(document.body.removeChild(button)) - }) - document.body.appendChild(button) - }) - }) - - await test('should fail sign a block with a Ledger device without caching frontier', async () => { - const block = new SendBlock( - NANO_TEST_VECTORS.SEND_BLOCK.account, - NANO_TEST_VECTORS.SEND_BLOCK.balance, - NANO_TEST_VECTORS.SEND_BLOCK.link, - '0', - NANO_TEST_VECTORS.SEND_BLOCK.representative, - NANO_TEST_VECTORS.SEND_BLOCK.previous, - NANO_TEST_VECTORS.SEND_BLOCK.work - ) - assert.rejects(block.sign(0)) - }) - - await test('destroy Ledger wallet', async () => { - await wallet.destroy() - }) -}) -- 2.47.3