]> git.codecow.com Git - libnemo.git/commitdiff
Restore Ledger ability to sign nonces and add test cases.
authorChris Duncan <chris@zoso.dev>
Tue, 21 Oct 2025 05:35:29 +0000 (22:35 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 21 Oct 2025 05:35:29 +0000 (22:35 -0700)
src/lib/ledger.ts
src/lib/wallet/index.ts
src/lib/wallet/sign.ts
test/test.ledger.mjs

index 86ce95e50b44a41924572e43806119d7a2fa0a0b..b93ef4be33261fc925506b44305d7553b720a22f 100644 (file)
@@ -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<string>
        /**
        * 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<string> {
+       static async sign (index: number, block: Block, frontier?: Block): Promise<string>
+       static async sign (index: number, data: string | Block, frontier?: Block): Promise<string> {
                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')
index 0c287deb3b2aa523fc2edd7c3f55a7e860e87b6c..d9a0cbfe4f0bc6d4b5b76c003a24b9b46cca4af5 100644 (file)
@@ -333,11 +333,22 @@ export class Wallet {
        * For compatibility with other Nano tools, the data is first hashed to a\r
        * 32-byte value using BLAKE2b. The wallet must be unlocked prior to signing.\r
        *\r
+       * Special note: This method can be used to sign a 16-byte nonce with a Ledger\r
+       * device. The actual messaage signed is a string which can be expressed as the\r
+       * following template literal:\r
+       *\r
+       * `Nano Signed Nonce:\n ${nonceBytes}`\r
+       *\r
+       * IMPORTANT: The current version of the Nano app for Ledger devices will NOT\r
+       * prompt users to confirm the signature. If valid, the nonce will immediately\r
+       * be signed and the signature returned without user interaction, similar to\r
+       * how receive blocks are automatically signed when auto-receive is configured.\r
+       * Plan for this eventuality if you implement this method to sign nonces.\r
+       *\r
        * @param {number} index - Account to use for signing\r
        * @param {(string|string[])} data - Arbitrary data to be hashed and signed\r
        */\r
        async sign (index: number, data: string | string[]): Promise<string>\r
-\r
        /**\r
        * Signs a block using the private key of the account at the wallet index\r
        * specified. The signature is appended to the signature field of the block\r
index eb1d0979070474558fc8ca3c036ae16b335c4508..92cdaf025f9a887f1e12bd85d6476cce5281270f 100644 (file)
@@ -11,9 +11,6 @@ import { Wallet } from '../wallet'
 export async function _signData (wallet: Wallet, vault: Vault, index: number, data: string | string[]): Promise<string>
 export async function _signData (wallet: Wallet, vault: Vault, index: unknown, data: unknown): Promise<string> {
        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<ArrayBuffer>({
index e07bf8d48698086b42bcc95f712bb17afb370e2f..dd3848a3975800fb738918c680829a9d57992b4d 100644 (file)
@@ -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'))
                })