From 15ed80c3c45badab575fb81405ce1af9526a08d4 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Mon, 18 May 2026 07:55:47 -0700 Subject: [PATCH] Refactor Ledger signing internals. --- src/lib/ledger/index.ts | 23 +++----- src/lib/ledger/sign.ts | 127 ++++++++++++++++++---------------------- 2 files changed, 65 insertions(+), 85 deletions(-) diff --git a/src/lib/ledger/index.ts b/src/lib/ledger/index.ts index 7e8a8ad..3524adc 100644 --- a/src/lib/ledger/index.ts +++ b/src/lib/ledger/index.ts @@ -31,20 +31,15 @@ export interface LedgerAccountResponse extends LedgerResponse { address: string | null } -export interface LedgerSignResponse extends LedgerResponse { - signature: string | null, - hash?: string -} - -export const APDU_CODES: Record = Object.freeze({ - class: 0xa1, - bip32DerivationLevel: 0x03, - version: 0x01, - account: 0x02, - cacheBlock: 0x03, - signBlock: 0x04, - signNonce: 0x05, - paramUnused: 0x00 +export const APDU_CODES = Object.freeze({ + class: 0xa1 as 0xa1, + bip32DerivationLevel: 0x03 as 0x03, + version: 0x01 as 0x01, + account: 0x02 as 0x02, + cacheBlock: 0x03 as 0x03, + signBlock: 0x04 as 0x04, + signNonce: 0x05 as 0x05, + paramUnused: 0x00 as 0x00 }) export const DERIVATION_PATH: Uint8Array = new Uint8Array([ diff --git a/src/lib/ledger/sign.ts b/src/lib/ledger/sign.ts index db1d98c..e2263e5 100644 --- a/src/lib/ledger/sign.ts +++ b/src/lib/ledger/sign.ts @@ -1,7 +1,7 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { APDU_CODES, DERIVATION_PATH, LedgerSignResponse, LedgerTransport, LISTEN_TIMEOUT, OPEN_TIMEOUT, STATUS_CODES } from '.' +import { APDU_CODES, DERIVATION_PATH, LedgerTransport, LISTEN_TIMEOUT, OPEN_TIMEOUT, STATUS_CODES } from '.' import { Account } from '../account' import { Block } from '../block' import { HARDENED_OFFSET } from '../constants' @@ -27,19 +27,9 @@ export async function _sign (transport: LedgerTransport, index: number, data: st } } console.log('Waiting for signature confirmation on Ledger device...') - const { status, signature, hash } = data instanceof Block + return data instanceof Block ? await signBlock(transport, index, data) : await signNonce(transport, index, utf8.toBytes(data)) - if (status !== 'OK') { - throw new Error('Signing with ledger failed', { cause: status }) - } - 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') - } - return signature } catch (err) { console.error('Ledger.sign()', err) throw new Error('Failed to sign block with Ledger', { cause: err }) @@ -53,50 +43,29 @@ export async function _sign (transport: LedgerTransport, index: number, data: st * @param {object} block - Block data to sign * @returns {Promise} Status, signature, and block hash */ -async function signBlock (transport: LedgerTransport, index: number, block: Block): Promise { +async function signBlock (transport: LedgerTransport, index: number, block: Block): Promise { if (block.signature !== undefined) { throw new TypeError('Block signature already exists', { cause: block.signature }) } - return queue(async () => { - try { - 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([...DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) - - const t = await transport.create(OPEN_TIMEOUT, LISTEN_TIMEOUT) - const response = await t - .send(APDU_CODES.class, APDU_CODES.signBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer) - .catch((err: any) => dec.toBytes(err.statusCode)) - .finally(async () => await t.close()) as Uint8Array + if (!(block.link instanceof Uint8Array)) { + throw new TypeError('Invalid block link') + } + if (!(block.representative instanceof Account)) { + throw new TypeError('Invalid block representative') + } - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + 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([...DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) - 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 } - } else { - throw new Error('Unexpected byte length from device signature', { cause: response }) - } - } catch (err: any) { - console.error('Ledger.#signBlock()', err) - return { status: err.message, signature: null } - } - }) + const res = await queue(async () => req(transport, APDU_CODES.signBlock, data as Buffer)) + if (res.hash !== block.hash) { + throw new Error('Hash from Ledger does not match hash from block', { cause: `${res.hash} | ${block.hash}` }) + } + return res.signature } /** @@ -106,32 +75,48 @@ async function signBlock (transport: LedgerTransport, index: number, block: Bloc * @param {Uint8Array} nonce - 128-bit value to sign * @returns {Promise} Status and signature */ -async function signNonce (transport: LedgerTransport, index: number, nonce: Uint8Array): Promise { - return queue(async () => { - if (nonce.byteLength !== 16) { - throw new RangeError('Nonce must be 16-byte string') - } +async function signNonce (transport: LedgerTransport, index: number, nonce: Uint8Array): Promise { + 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([...DERIVATION_PATH, ...derivationAccount, ...nonce]) + const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4) + const data = new Uint8Array([...DERIVATION_PATH, ...derivationAccount, ...nonce]) + const res = await queue(async () => req(transport, APDU_CODES.signNonce, data as Buffer)) + return res.signature +} - const t = await transport.create(OPEN_TIMEOUT, LISTEN_TIMEOUT) - const response = await t - .send(APDU_CODES.class, APDU_CODES.signNonce, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer) - .catch((err: any) => dec.toBytes(err.statusCode)) - .finally(async () => await t.close()) as Uint8Array +async function req (transport: LedgerTransport, command: typeof APDU_CODES.signBlock, data: Buffer): Promise<{ status: string, signature: string, hash: string }> +async function req (transport: LedgerTransport, command: typeof APDU_CODES.signNonce, data: Buffer): Promise<{ status: string, signature: string }> +async function req (transport: LedgerTransport, command: typeof APDU_CODES.signBlock | typeof APDU_CODES.signNonce, data: Buffer): Promise<{ status: string, signature: string, hash?: string }> { + const t = await transport.create(OPEN_TIMEOUT, LISTEN_TIMEOUT) + const response = await t + .send(APDU_CODES.class, command, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data) + .catch((err: any) => dec.toBytes(err.statusCode)) + .finally(async () => await t.close()) as Uint8Array - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (response.byteLength === 2) { - return { status, signature: null } - } - if (response.byteLength === 66) { + if (status !== 'OK') { + throw new Error('Signing with ledger failed', { cause: status }) + } + + switch (response.byteLength) { + case (66): { const signature = bytes.toHex(response.slice(0, 64)) return { status, signature } - } else { + } + case (98): { + const hash = bytes.toHex(response.slice(0, 32)) + const signature = bytes.toHex(response.slice(32, 96)) + return { status, signature, hash } + } + case (2): { + throw new Error('Ledger silently failed to return signature', { cause: status }) + } + default: { throw new Error('Unexpected byte length from device signature', { cause: response }) } - }) + } } -- 2.47.3