]> git.codecow.com Git - libnemo.git/commitdiff
Add ability for wallet to sign arbitrary strings.
authorChris Duncan <chris@zoso.dev>
Wed, 3 Sep 2025 19:07:45 +0000 (12:07 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 3 Sep 2025 19:07:45 +0000 (12:07 -0700)
src/lib/wallet/index.ts
src/lib/wallet/sign.ts
src/types.d.ts
test/main.test.mjs
test/test.tools.mjs
test/test.wallet-sign.mjs [new file with mode: 0644]

index 4497d460ec9fb323bd068e412c64f965448e4c1b..b0365713b0719426f4d59960d41d32140a6d340e 100644 (file)
@@ -17,7 +17,7 @@ import { _load } from './load'
 import { _lock } from './lock'\r
 import { _refresh } from './refresh'\r
 import { _restore } from './restore'\r
-import { _sign } from './sign'\r
+import { _signBlock, _signData } from './sign'\r
 import { _unlock } from './unlock'\r
 import { _unopened } from './unopened'\r
 import { _update } from './update'\r
@@ -271,6 +271,17 @@ export class Wallet {
                return await _refresh(this, rpc, from, to)\r
        }\r
 \r
+       /**\r
+       * Signs arbitrary strings using the private key of the account at the wallet\r
+       * index specified and returns a detached signature as a hexadecimal string.\r
+       * 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
+       * @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
@@ -290,8 +301,10 @@ export class Wallet {
        * @param {Block} [frontier] - Previous block data to be cached by Ledger wallet\r
        */\r
        async sign (index: number, block: Block, frontier?: Block): Promise<void>\r
-       async sign (index: number, block: Block, frontier?: Block): Promise<void> {\r
-               await _sign(this, this.#vault, index, block, frontier)\r
+       async sign (index: number, data: string | string[] | Block, frontier?: Block): Promise<void | string> {\r
+               return data instanceof Block\r
+                       ? await _signBlock(this, this.#vault, index, data, frontier)\r
+                       : await _signData(this, this.#vault, index, data)\r
        }\r
 \r
        /**\r
index 234d850888be275cb673f86053a70a3854904a7c..3078172900f3c2c665327d9afc4d924403778be3 100644 (file)
@@ -2,12 +2,40 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
 import { Block } from '../block'
-import { bytes, hex } from '../convert'
+import { bytes, hex, utf8 } from '../convert'
+import { Blake2b } from '../crypto'
 import { Vault } from '../vault'
 import { Wallet } from '../wallet'
 
-export async function _sign (wallet: Wallet, vault: Vault, index: number, block: Block, frontier?: Block): Promise<void>
-export async function _sign (wallet: Wallet, vault: Vault, index: unknown, block: unknown, frontier?: unknown): Promise<void> {
+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 })
+               }
+               const message = Array.isArray(data) ? data : [data]
+               if (message.some(s => typeof s !== 'string')) {
+                       throw new TypeError('Data to sign must be strings', { cause: data })
+               }
+               const hash = new Blake2b(32)
+               message.forEach(s => hash.update(utf8.toBytes(s)))
+               const { signature } = await vault.request<ArrayBuffer>({
+                       action: 'sign',
+                       index,
+                       message: hash.digest().buffer
+               })
+               return bytes.toHex(new Uint8Array(signature))
+       } catch (err) {
+               throw new Error('Failed to sign data', { cause: err })
+       }
+}
+
+
+export async function _signBlock (wallet: Wallet, vault: Vault, index: number, block: Block, frontier?: Block): Promise<void>
+export async function _signBlock (wallet: Wallet, vault: Vault, index: unknown, block: unknown, frontier?: unknown): Promise<void> {
        try {
                if (typeof index !== 'number') {
                        throw new TypeError('Index must be a number', { cause: index })
index cb41fc1c4a73b7d5a9ad2f4db802556d69755b00..50c9daabe21a5d688aacd21750e32afe2e9efc1d 100644 (file)
@@ -730,6 +730,16 @@ export declare class Wallet {
        */
        refresh (rpc: Rpc | string | URL, from?: number, to?: number): Promise<AccountList>
        /**
+       * Signs arbitrary strings using the private key of the account at the wallet
+       * index specified and returns a detached signature as a hexadecimal string.
+       * For compatibility with other Nano tools, the data is first hashed to a
+       * 32-byte value using BLAKE2b. The wallet must be unlocked prior to signing.
+       *
+       * @param {number} index - Account to use for signing
+       * @param {(string|string[])} data - Arbitrary data to be hashed and signed
+       */
+       sign (index: number, data: string | string[]): Promise<string>
+       /**
        * 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.
index ada0c28a6a82c56c51ec92ec5f2b578718caf2c9..d59be6e1485df56baba7090c75a1f26bace3380e 100644 (file)
@@ -15,6 +15,7 @@ import './test.lock-unlock.mjs'
 import './test.manage-rolodex.mjs'
 import './test.refresh-accounts.mjs'
 import './test.tools.mjs'
+import './test.wallet-sign.mjs'
 
 console.log('%cTESTING COMPLETE', 'color:orange;font-weight:bold')
 console.log('%cPASS: ', 'color:green;font-weight:bold', passes.length)
index 20ae465eff8e89e04303635e58180586db7e8878..d802209e36aac2f87eb638bcaea61d764acd53d1 100644 (file)
@@ -136,41 +136,6 @@ await Promise.all([
                        assert.equal(result3, false)\r
                })\r
 \r
-               await test('should verify a block using the public key', async () => {\r
-                       const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
-                       await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
-                       const account = await wallet.account()\r
-                       if (account.index == null) {\r
-                               throw new Error('Account index missing')\r
-                       }\r
-                       const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou')\r
-                               .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000')\r
-                               .sign(wallet, account.index)\r
-\r
-                       assert.ok(await sendBlock.verify(account.publicKey))\r
-                       await assert.resolves(wallet.destroy())\r
-               })\r
-\r
-               await test('should reject a block using the wrong public key', async () => {\r
-                       const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
-                       await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
-                       const account = await wallet.account()\r
-                       if (account.index == null) {\r
-                               throw new Error('Account index missing')\r
-                       }\r
-                       assert.equal(account.index, 0)\r
-\r
-                       const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou')\r
-                               .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000')\r
-                               .sign(wallet, account.index)\r
-                       assert.ok(await sendBlock.verify(account.publicKey))\r
-\r
-                       const wrongAccount = Account.load('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p')\r
-                       assert.equal(await sendBlock.verify(wrongAccount.publicKey), false)\r
-\r
-                       await assert.resolves(wallet.destroy())\r
-               })\r
-\r
                await test('sweeper throws without required parameters', async () => {\r
                        //@ts-expect-error\r
                        await assert.rejects(Tools.sweep(),\r
diff --git a/test/test.wallet-sign.mjs b/test/test.wallet-sign.mjs
new file mode 100644 (file)
index 0000000..7a74744
--- /dev/null
@@ -0,0 +1,84 @@
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
+//! SPDX-License-Identifier: GPL-3.0-or-later\r
+\r
+'use strict'\r
+\r
+import { assert, env, isNode, suite, test } from './GLOBALS.mjs'\r
+import { MAX_RAW, MAX_SUPPLY, NANO_TEST_VECTORS } from './VECTORS.mjs'\r
+\r
+/**\r
+* @type {typeof import('../dist/types').Account}\r
+*/\r
+let Account\r
+/**\r
+* @type {typeof import('../dist/types').Block}\r
+*/\r
+let Block\r
+/**\r
+* @type {typeof import('../dist/types').Tools}\r
+*/\r
+let Tools\r
+/**\r
+* @type {typeof import('../dist/types').Wallet}\r
+*/\r
+let Wallet\r
+if (isNode) {\r
+       ({ Account, Block, Tools, Wallet } = await import('../dist/nodejs.min.js'))\r
+} else {\r
+       ({ Account, Block, Tools, Wallet } = await import('../dist/browser.min.js'))\r
+}\r
+\r
+await Promise.all([\r
+\r
+       suite('sign with Wallet', async () => {\r
+\r
+               await test('sign an arbitrary string', async () => {\r
+                       const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
+                       await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
+                       const account = await wallet.account()\r
+                       if (account.index == null) {\r
+                               throw new Error('Account index missing')\r
+                       }\r
+                       const data = crypto.randomUUID()\r
+                       const signature = await wallet.sign(account.index, data)\r
+\r
+                       assert.ok(await Tools.verify(account.publicKey, signature, data))\r
+                       await assert.resolves(wallet.destroy())\r
+               })\r
+\r
+               await test('verify Block signature using the correct public key', async () => {\r
+                       const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
+                       await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
+                       const account = await wallet.account()\r
+                       if (account.index == null) {\r
+                               throw new Error('Account index missing')\r
+                       }\r
+                       const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou')\r
+                               .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000')\r
+                               .sign(wallet, account.index)\r
+\r
+                       assert.ok(await sendBlock.verify(account.publicKey))\r
+                       await assert.resolves(wallet.destroy())\r
+               })\r
+\r
+               await test('reject Block signature using the wrong public key', async () => {\r
+                       const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
+                       await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
+                       const account = await wallet.account()\r
+                       if (account.index == null) {\r
+                               throw new Error('Account index missing')\r
+                       }\r
+                       assert.equal(account.index, 0)\r
+\r
+                       const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou')\r
+                               .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000')\r
+                               .sign(wallet, account.index)\r
+                       assert.ok(await sendBlock.verify(account.publicKey))\r
+\r
+                       const wrongAccount = Account.load('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p')\r
+                       assert.equal(await sendBlock.verify(wrongAccount.publicKey), false)\r
+\r
+                       await assert.resolves(wallet.destroy())\r
+               })\r
+       })\r
+])\r