From: Chris Duncan Date: Wed, 3 Sep 2025 19:07:45 +0000 (-0700) Subject: Add ability for wallet to sign arbitrary strings. X-Git-Tag: v0.10.5~33^2 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=f2807c5745e53cd7eccace003d3a442b32192cb0;p=libnemo.git Add ability for wallet to sign arbitrary strings. --- diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 4497d46..b036571 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -17,7 +17,7 @@ import { _load } from './load' import { _lock } from './lock' import { _refresh } from './refresh' import { _restore } from './restore' -import { _sign } from './sign' +import { _signBlock, _signData } from './sign' import { _unlock } from './unlock' import { _unopened } from './unopened' import { _update } from './update' @@ -271,6 +271,17 @@ export class Wallet { return await _refresh(this, rpc, from, to) } + /** + * 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 + */ + async sign (index: number, data: string | string[]): Promise + /** * 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 @@ -290,8 +301,10 @@ export class Wallet { * @param {Block} [frontier] - Previous block data to be cached by Ledger wallet */ async sign (index: number, block: Block, frontier?: Block): Promise - async sign (index: number, block: Block, frontier?: Block): Promise { - await _sign(this, this.#vault, index, block, frontier) + async sign (index: number, data: string | string[] | Block, frontier?: Block): Promise { + return data instanceof Block + ? await _signBlock(this, this.#vault, index, data, frontier) + : await _signData(this, this.#vault, index, data) } /** diff --git a/src/lib/wallet/sign.ts b/src/lib/wallet/sign.ts index 234d850..3078172 100644 --- a/src/lib/wallet/sign.ts +++ b/src/lib/wallet/sign.ts @@ -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 -export async function _sign (wallet: Wallet, vault: Vault, index: unknown, block: unknown, frontier?: unknown): Promise { +export async function _signData (wallet: Wallet, vault: Vault, index: number, data: string | string[]): Promise +export async function _signData (wallet: Wallet, vault: Vault, index: unknown, data: unknown): Promise { + 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({ + 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 +export async function _signBlock (wallet: Wallet, vault: Vault, index: unknown, block: unknown, frontier?: unknown): Promise { try { if (typeof index !== 'number') { throw new TypeError('Index must be a number', { cause: index }) diff --git a/src/types.d.ts b/src/types.d.ts index cb41fc1..50c9daa 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -730,6 +730,16 @@ export declare class Wallet { */ refresh (rpc: Rpc | string | URL, from?: number, to?: number): Promise /** + * 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 + /** * 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. diff --git a/test/main.test.mjs b/test/main.test.mjs index ada0c28..d59be6e 100644 --- a/test/main.test.mjs +++ b/test/main.test.mjs @@ -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) diff --git a/test/test.tools.mjs b/test/test.tools.mjs index 20ae465..d802209 100644 --- a/test/test.tools.mjs +++ b/test/test.tools.mjs @@ -136,41 +136,6 @@ await Promise.all([ assert.equal(result3, false) }) - await test('should verify a block using the public key', async () => { - const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) - await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) - const account = await wallet.account() - if (account.index == null) { - throw new Error('Account index missing') - } - const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou') - .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000') - .sign(wallet, account.index) - - assert.ok(await sendBlock.verify(account.publicKey)) - await assert.resolves(wallet.destroy()) - }) - - await test('should reject a block using the wrong public key', async () => { - const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) - await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) - const account = await wallet.account() - if (account.index == null) { - throw new Error('Account index missing') - } - assert.equal(account.index, 0) - - const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou') - .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000') - .sign(wallet, account.index) - assert.ok(await sendBlock.verify(account.publicKey)) - - const wrongAccount = Account.load('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p') - assert.equal(await sendBlock.verify(wrongAccount.publicKey), false) - - await assert.resolves(wallet.destroy()) - }) - await test('sweeper throws without required parameters', async () => { //@ts-expect-error await assert.rejects(Tools.sweep(), diff --git a/test/test.wallet-sign.mjs b/test/test.wallet-sign.mjs new file mode 100644 index 0000000..7a74744 --- /dev/null +++ b/test/test.wallet-sign.mjs @@ -0,0 +1,84 @@ +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +'use strict' + +import { assert, env, isNode, suite, test } from './GLOBALS.mjs' +import { MAX_RAW, MAX_SUPPLY, NANO_TEST_VECTORS } from './VECTORS.mjs' + +/** +* @type {typeof import('../dist/types').Account} +*/ +let Account +/** +* @type {typeof import('../dist/types').Block} +*/ +let Block +/** +* @type {typeof import('../dist/types').Tools} +*/ +let Tools +/** +* @type {typeof import('../dist/types').Wallet} +*/ +let Wallet +if (isNode) { + ({ Account, Block, Tools, Wallet } = await import('../dist/nodejs.min.js')) +} else { + ({ Account, Block, Tools, Wallet } = await import('../dist/browser.min.js')) +} + +await Promise.all([ + + suite('sign with Wallet', async () => { + + await test('sign an arbitrary string', async () => { + const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) + await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) + const account = await wallet.account() + if (account.index == null) { + throw new Error('Account index missing') + } + const data = crypto.randomUUID() + const signature = await wallet.sign(account.index, data) + + assert.ok(await Tools.verify(account.publicKey, signature, data)) + await assert.resolves(wallet.destroy()) + }) + + await test('verify Block signature using the correct public key', async () => { + const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) + await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) + const account = await wallet.account() + if (account.index == null) { + throw new Error('Account index missing') + } + const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou') + .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000') + .sign(wallet, account.index) + + assert.ok(await sendBlock.verify(account.publicKey)) + await assert.resolves(wallet.destroy()) + }) + + await test('reject Block signature using the wrong public key', async () => { + const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) + await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) + const account = await wallet.account() + if (account.index == null) { + throw new Error('Account index missing') + } + assert.equal(account.index, 0) + + const sendBlock = await new Block(account.address, '5618869000000000000000000000000', '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou') + .send('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p', '2000000000000000000000000000000') + .sign(wallet, account.index) + assert.ok(await sendBlock.verify(account.publicKey)) + + const wrongAccount = Account.load('nano_1q3hqecaw15cjt7thbtxu3pbzr1eihtzzpzxguoc37bj1wc5ffoh7w74gi6p') + assert.equal(await sendBlock.verify(wrongAccount.publicKey), false) + + await assert.resolves(wallet.destroy()) + }) + }) +])