]> git.codecow.com Git - libnemo.git/commitdiff
Fix Ledger block hashing and caching.
authorChris Duncan <chris@zoso.dev>
Tue, 8 Jul 2025 17:27:21 +0000 (10:27 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 8 Jul 2025 17:27:21 +0000 (10:27 -0700)
Ledger app requires open blocks to have zeroed frontier bytes instead of account public key, diverging from spec, so zero out "parent" block like Ledger expects if public key is the frontier.
Ensure block data is correct length before hashing.
Merge shared functionality of signing blocks and nonces, and document bug with Ledger nano app that is preventing nonces from being signed.
Skip online tests for now.

src/lib/block.ts
src/lib/constants.ts
src/lib/wallets/ledger-wallet.ts
test/test.create-wallet.mjs
test/test.derive-accounts.mjs
test/test.refresh-accounts.mjs
test/test.sign-blocks.mjs

index 63e4c4bde6effc9a9374cffff949f3bb371e207b..13a3e6d33343a181878e15232d01be08f1dc426c 100644 (file)
@@ -50,10 +50,10 @@ abstract class Block {
                const data = [
                        PREAMBLE,
                        this.account.publicKey,
-                       this.previous,
+                       this.previous.padStart(64, '0'),
                        this.representative.publicKey,
                        dec.toHex(this.balance, 32),
-                       this.link
+                       this.link.padStart(64, '0')
                ]
                const hash = new Blake2b(32)
                data.forEach(str => hash.update(hex.toBytes(str)))
index 937b84f1c3a9f01092e2fa64cc75ce1c90e8f3b9..e49b25b140b797dd2156a622e25d97e3f5a8f76c 100644 (file)
@@ -44,7 +44,6 @@ export const LEDGER_ADPU_CODES: { [key: string]: number } = Object.freeze({
        signBlock: 0x04,
        signNonce: 0x05,
        paramUnused: 0x00
-
 })
 
 export const UNITS: { [key: string]: number } = Object.freeze({
index 7fe56c5cfa8e59abc2f9ebb5f33cbd1aafca14a1..720e00978cf5613a821431fc8adf2643b1266cfa 100644 (file)
@@ -74,6 +74,15 @@ export class LedgerWallet extends Wallet {
                return wallet\r
        }\r
 \r
+       /**\r
+       * Removes encrypted secrets in storage and releases variable references to\r
+       * allow garbage collection.\r
+       */\r
+       async destroy (): Promise<void> {\r
+               await super.destroy()\r
+               await this.close()\r
+       }\r
+\r
        /**\r
        * Retrieves an existing Ledger wallet from session storage using its ID.\r
        *\r
@@ -299,11 +308,11 @@ export class LedgerWallet extends Wallet {
        * Sign a nonce with the Ledger device.\r
        *\r
        * @param {number} index - Account number\r
-       * @param {string} nonce - 128-bit string to sign\r
+       * @param {Uint8Array} nonce - 128-bit value to sign\r
        * @returns {Promise} Status and signature\r
        */\r
-       async sign (index: number, nonce: string): Promise<LedgerSignResponse>\r
-       async sign (index: number = 0, input: string | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\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
@@ -311,51 +320,46 @@ export class LedgerWallet extends Wallet {
                const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)\r
                const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
 \r
-               const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
-               if (typeof input === 'string') {\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
-                       const nonce = utf8.toBytes(input)\r
-                       if (nonce.length !== 16) {\r
+                       instruction = LEDGER_ADPU_CODES.signNonce\r
+                       if (input.byteLength !== 16) {\r
                                throw new RangeError('Nonce must be 16-byte string')\r
                        }\r
-                       const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...nonce])\r
-                       const response = await transport.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
-                       if (response.length === 2) {\r
-                               const statusCode = bytes.toDec(response) as number\r
-                               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-                               return { status, signature: null }\r
-                       }\r
-\r
-                       const signature = bytes.toHex(response.slice(0, 64))\r
-                       const statusCode = bytes.toDec(response.slice(-2)) as number\r
-                       const status = LEDGER_STATUS_CODES[statusCode]\r
-                       return { status, signature }\r
+                       data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...input])\r
                } else {\r
                        // input is a block\r
-                       const previous = hex.toBytes(input.previous)\r
-                       const link = hex.toBytes(input.link)\r
-                       const representative = hex.toBytes(input.representative.publicKey)\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
-                       const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance])\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
-                       if (response.length === 2) {\r
-                               const statusCode = bytes.toDec(response) as number\r
-                               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-                               return { status, signature: null }\r
-                       }\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 hash = bytes.toHex(response.slice(0, 32))\r
-                       const signature = bytes.toHex(response.slice(32, 96))\r
-                       const statusCode = bytes.toDec(response.slice(-2)) as number\r
-                       const status = LEDGER_STATUS_CODES[statusCode]\r
-                       return { status, signature, hash }\r
+               const statusCode = bytes.toDec(response.slice(-2)) as number\r
+               const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+\r
+               if (status !== 'OK') {\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
+               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
 \r
        /**\r
@@ -386,7 +390,11 @@ export class LedgerWallet extends Wallet {
                        }\r
                        input = res.contents\r
                }\r
-               return this.#cacheBlock(index, input)\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
@@ -447,11 +455,11 @@ export class LedgerWallet extends Wallet {
                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
-               const previous = hex.toBytes(block.previous)\r
-               const link = hex.toBytes(block.link)\r
-               const representative = hex.toBytes(block.representative.publicKey)\r
-               const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)\r
-               const signature = hex.toBytes(block.signature)\r
+               const previous = hex.toBytes(block.previous === block.account.publicKey ? '0' : 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(block.balance.toString(16), 16)\r
+               const signature = hex.toBytes(block.signature, 64)\r
                const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])\r
 \r
                const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
index 385d78a13d3b364642dd02d472ec387d7cf2fcd8..4899787c2540b80d120b877f4643eba1dc8c2700 100644 (file)
@@ -71,9 +71,6 @@ await suite('Create wallets', async () => {
                })\r
 \r
                assert.equals(status, 'CONNECTED')\r
-\r
-               status = (await wallet.close()).status\r
-\r
-               assert.equals(status, 'OK')\r
+               assert.equals((await wallet.close()).status, 'OK')\r
        })\r
 })\r
index 762697619cabbb48c9428aa00060b4afb86df253..72371761c6de9bada180ff5984f828077cb2c605 100644 (file)
@@ -7,7 +7,7 @@ import { assert, isNode, suite, test } from './GLOBALS.mjs'
 import { NANO_TEST_VECTORS } from './VECTORS.js'\r
 import { Bip44Wallet, Blake2bWallet, LedgerWallet } from '../dist/main.min.js'\r
 \r
-await suite('Account derivation', async () => {\r
+await suite('BIP-44 account derivation', async () => {\r
        await test('should derive the first account from the given BIP-44 seed', async () => {\r
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
@@ -58,7 +58,9 @@ await suite('Account derivation', async () => {
 \r
                await wallet.destroy()\r
        })\r
+})\r
 \r
+await suite('BLAKE2b account derivation', async () => {\r
        await test('should derive accounts for a BLAKE2b wallet', async () => {\r
                const wallet = await Blake2bWallet.create(NANO_TEST_VECTORS.PASSWORD)\r
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
@@ -91,7 +93,7 @@ await suite('Account derivation', async () => {
 /**\r
 * This suite requires a connected unlocked Ledger device to execute tests.\r
 */\r
-await suite('should derive accounts for a Ledger device wallet', { skip: false || isNode }, async () => {\r
+await suite('Ledger device account derivation', { skip: false || isNode }, async () => {\r
        const wallet = await LedgerWallet.create()\r
        await wallet.connect()\r
 \r
index 54063f4307467f89cd57e57b7885d8bc3ec128e3..b54b6d57cd55ddced0fb542dc1b244574fff23e5 100644 (file)
@@ -9,7 +9,7 @@ import { Account, Bip44Wallet, Rpc } from '../dist/main.min.js'
 
 const rpc = new Rpc(process.env.NODE_URL ?? '', process.env.API_KEY_NAME)
 
-await suite('refreshing account info', { skip: false }, async () => {
+await suite('refreshing account info', { skip: true }, async () => {
 
        await test('fetch balance, frontier, and representative', async () => {
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)
@@ -70,7 +70,7 @@ await suite('refreshing account info', { skip: false }, async () => {
        })
 })
 
-await suite('Fetch next unopened account', { skip: false }, async () => {
+await suite('Fetch next unopened account', { skip: true }, async () => {
 
        await test('return correct account from test vector', async () => {
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)
@@ -135,7 +135,7 @@ await suite('Fetch next unopened account', { skip: false }, async () => {
        })
 })
 
-await suite('Refreshing wallet accounts', { skip: false }, async () => {
+await suite('Refreshing wallet accounts', { skip: true }, async () => {
 
        await test('should get balance, frontier, and representative for one account', async () => {
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)
index 6997be33468a1f02cda52a5dd99da6f149b29975..90e62e9cc536cb47a5f8305dedd39a1c00ae8182 100644 (file)
@@ -5,7 +5,7 @@
 \r
 import { assert, suite, test } from './GLOBALS.mjs'\r
 import { NANO_TEST_VECTORS } from './VECTORS.js'\r
-import { SendBlock, ReceiveBlock, ChangeBlock } from '../dist/main.min.js'\r
+import { SendBlock, ReceiveBlock, ChangeBlock, LedgerWallet } from '../dist/main.min.js'\r
 \r
 await suite('Valid blocks', async () => {\r
 \r
@@ -163,3 +163,95 @@ await suite('Block signing tests using official test vectors', async () => {
                assert.equals(block.work, '')\r
        })\r
 })\r
+\r
+await suite('Ledger device signing tests', { skip: false }, async () => {\r
+       const wallet = await LedgerWallet.create()\r
+       const account = await new Promise(resolve => {\r
+               const button = document.createElement('button')\r
+               button.innerText = 'Unlock Ledger, then click to continue'\r
+               button.addEventListener('click', async (event) => {\r
+                       await wallet.connect()\r
+                       resolve(await wallet.account())\r
+                       document.body.removeChild(button)\r
+               })\r
+               document.body.appendChild(button)\r
+       })\r
+\r
+       // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
+       await test('should sign a nonce with a Ledger device', { skip: true }, async () => {\r
+               let status = await new Promise(resolve => {\r
+                       const button = document.createElement('button')\r
+                       button.innerText = 'Unlock Ledger, then click to continue'\r
+                       button.addEventListener('click', async (event) => {\r
+                               await wallet.connect()\r
+                               const result = await wallet.sign(0, new TextEncoder().encode('0123456789abcdef'))\r
+\r
+                               assert.equals(result.status, 'OK')\r
+                               assert.equals(result.signature.toUpperCase(), '2BD2F905E74B5BEE3E2277CED1D1E3F7535E5286B6E22F7B08A814AA9E5C4E1FEA69B61D60B435ADC2CE756E6EE5F5BE7EC691FE87E024A0B22A3D980CA5B305')\r
+\r
+                               document.body.removeChild(button)\r
+                               resolve(result)\r
+                       })\r
+                       document.body.appendChild(button)\r
+               })\r
+       })\r
+\r
+       await test('should sign an open block and send block with a Ledger device', async () => {\r
+               const openBlock = new ReceiveBlock(\r
+                       account,\r
+                       '0',\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.link,\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.balance,\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,\r
+                       '0'\r
+               )\r
+               await new Promise(resolve => {\r
+                       const button = document.createElement('button')\r
+                       button.innerText = 'Unlock Ledger, then click to test signing open ReceiveBlock'\r
+                       button.addEventListener('click', async (event) => {\r
+                               assert.nullish(openBlock.signature)\r
+                               await openBlock.sign(0)\r
+                               assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))\r
+                               resolve(document.body.removeChild(button))\r
+                       })\r
+                       document.body.appendChild(button)\r
+               })\r
+               assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))\r
+               assert.ok(/[A-Fa-f0-9]{64}/.test(openBlock.hash))\r
+\r
+               const sendBlock = new SendBlock(\r
+                       account,\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.balance,\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.account,\r
+                       '0',\r
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,\r
+                       openBlock.hash\r
+               )\r
+               await new Promise(resolve => {\r
+                       const button = document.createElement('button')\r
+                       button.innerText = 'Unlock Ledger, then click to test signing SendBlock'\r
+                       button.addEventListener('click', async (event) => {\r
+                               assert.nullish(sendBlock.signature)\r
+                               await sendBlock.sign(0, openBlock)\r
+                               assert.ok(/[A-Fa-f0-9]{128}/.test(sendBlock.signature))\r
+                               resolve(document.body.removeChild(button))\r
+                       })\r
+                       document.body.appendChild(button)\r
+               })\r
+       })\r
+\r
+       await test('should fail sign a block with a Ledger device without caching frontier', async () => {\r
+               const block = new SendBlock(\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.account,\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.balance,\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.link,\r
+                       '0',\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.representative,\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.previous,\r
+                       NANO_TEST_VECTORS.SEND_BLOCK.work\r
+               )\r
+               assert.rejects(block.sign(0))\r
+       })\r
+\r
+       await wallet.destroy()\r
+})\r