diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index dc81de6b8..f202302b4 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -197,6 +197,8 @@ export interface AuthProvider { // @public export interface BatchMintPreview = MintQuoteBaseResponse> { keysetId: string; + // @deprecated (undocumented) + legacySignatures?: Array; // (undocumented) method: string; outputData: OutputDataLike[]; @@ -681,6 +683,9 @@ export function isBlsKeyset(keysetId: string): boolean; // @public export function isHTLCSpendAuthorised(proof: Proof, logger?: Logger, message?: string): boolean; +// @public +export function isMintOperationError(e: unknown): e is MintOperationError; + // @public export function isP2PKSpendAuthorised(proof: Proof, logger?: Logger, message?: string): boolean; @@ -1220,6 +1225,8 @@ export class MintOperationError extends HttpResponseError { // @public export interface MintPreview = MintQuoteBaseResponse> { keysetId: string; + // @deprecated (undocumented) + legacySignature?: string; // (undocumented) method: string; outputData: OutputDataLike[]; @@ -1791,7 +1798,7 @@ export type RequestArgs = { // @public export type RequestFetch = typeof fetch; -// @public (undocumented) +// @public export type RequestFn = (args: RequestOptions) => Promise; // @public (undocumented) @@ -1825,6 +1832,9 @@ export const schnorrSignDigest: (digest: DigestInput, privateKey: PrivKey) => st // @public export const schnorrSignMessage: (message: string, privateKey: PrivKey) => string; +// @public +export const schnorrVerifyDigest: (signature: string, digest: DigestInput, pubkey: string, throws?: boolean) => boolean; + // @public export const schnorrVerifyMessage: (signature: string, message: string, pubkey: string, throws?: boolean) => boolean; diff --git a/migration-5.0.0.md b/migration-5.0.0.md index dba3a0ded..a0eeb8862 100644 --- a/migration-5.0.0.md +++ b/migration-5.0.0.md @@ -76,6 +76,18 @@ If you were relying on a symbol that is not exported by the package entry point --- +## `signMintQuote` / `verifyMintQuoteSignature` now use the amended NUT-20 message + +These functions now produce and verify the hardened mint-quote signature message introduced by cashubtc/nuts#375. Legacy signing is supported as an internal transitional fallback and is not exported. + +For most consumers this is transparent: `Wallet` signs the amended message by default and automatically retries with the legacy message if a legacy mint rejects it (NUT error 20008), so wallet-level minting needs no changes. + +### Migration + +If you call `signMintQuote`/`verifyMintQuoteSignature` only to mint against a mint, no change is required — current mints verify the amended message and the wallet's fallback covers older ones. If you depended on the **old** byte format directly, it is no longer reachable from the package entry point: rely on the wallet's built-in fallback rather than calling the primitive, or pin v4 if you must emit the legacy bytes yourself. + +--- + ## `checkProofsStates` now requires `id` on every proof `Wallet.checkProofsStates` previously accepted `Array>` — only `secret` was required. v5 requires both `id` and `secret`: `Array>`. diff --git a/src/crypto/NUT20.ts b/src/crypto/NUT20.ts index d9adaa6a7..351e02cad 100644 --- a/src/crypto/NUT20.ts +++ b/src/crypto/NUT20.ts @@ -1,16 +1,65 @@ -import { schnorr } from '@noble/curves/secp256k1.js'; +import { numberToBytesBE } from '@noble/curves/utils.js'; import { sha256 } from '@noble/hashes/sha2.js'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; +import { Amount } from '../model/Amount'; import { type SerializedBlindedMessage } from '../model/types'; +import { schnorrSignDigest, schnorrVerifyDigest } from './core'; + +// Domain-separation tag. +const MINT_QUOTE_SIG_DST = utf8ToBytes('Cashu_MintQuoteSig_v1'); + +// Canonical minimal BE bytes of a non-negative amount (0 → empty, 1 → 0x01, 256 → 0x0100). +// Amount.from defensively normalizes a raw JSON number/string (Amount passes through). +function amountToMinimalBytes(blindedMessage: SerializedBlindedMessage): Uint8Array { + const value = Amount.from(blindedMessage.amount).toBigInt(); + if (value === 0n) return new Uint8Array(0); + const hex = value.toString(16); + return hexToBytes(hex.length % 2 === 1 ? '0' + hex : hex); +} + +/** + * Mint-quote signature message per the amended NUT-20 (cashubtc/nuts#375): domain-separated and + * length-framed, shared by NUT-20 single and NUT-29 batch minting. + */ function constructMessage(quote: string, blindedMessages: SerializedBlindedMessage[]): Uint8Array { + // Stream into the digest rather than concat-then-hash: spreading the per-output chunks into + // concatBytes(...) would hit V8's argument-count limit on large batches. + const transcript = sha256.create(); + transcript.update(MINT_QUOTE_SIG_DST); + const quoteBytes = utf8ToBytes(quote); + transcript.update(numberToBytesBE(quoteBytes.length, 4)); + transcript.update(quoteBytes); + for (const blindedMessage of blindedMessages) { + const amountBytes = amountToMinimalBytes(blindedMessage); + transcript.update(numberToBytesBE(amountBytes.length, 4)); + transcript.update(amountBytes); + const pointBytes = hexToBytes(blindedMessage.B_); + transcript.update(numberToBytesBE(pointBytes.length, 4)); + transcript.update(pointBytes); + } + return transcript.digest(); +} + +/** + * Legacy mint-quote signature message: `quote || B_0 || … || B_(n-1)`, hex strings concatenated as + * UTF-8. Kept for mints that predate cashubtc/nuts#375 TODO: Remove legacy message support. + */ +function constructLegacyMessage( + quote: string, + blindedMessages: SerializedBlindedMessage[], +): Uint8Array { let message = quote; for (const blindedMessage of blindedMessages) { message += blindedMessage.B_; } - const msgbytes = new TextEncoder().encode(message); - return sha256(msgbytes); + return sha256(utf8ToBytes(message)); +} + +// NUT-20 quote pubkeys are compressed 33-byte SEC1 (66 hex chars). +function isCompressedPubkey(pubkey: string): boolean { + return pubkey.length === 66; } export function signMintQuote( @@ -18,10 +67,7 @@ export function signMintQuote( quote: string, blindedMessages: SerializedBlindedMessage[], ): string { - const message = constructMessage(quote, blindedMessages); - const privkeyBytes = hexToBytes(privkey); - const signature = schnorr.sign(message, privkeyBytes); - return bytesToHex(signature); + return schnorrSignDigest(constructMessage(quote, blindedMessages), privkey); } export function verifyMintQuoteSignature( @@ -30,10 +76,37 @@ export function verifyMintQuoteSignature( blindedMessages: SerializedBlindedMessage[], signature: string, ): boolean { - const sigbytes = hexToBytes(signature); - let pubkeyBytes = hexToBytes(pubkey); - if (pubkeyBytes.length !== 33) return false; - pubkeyBytes = pubkeyBytes.slice(1); - const message = constructMessage(quote, blindedMessages); - return schnorr.verify(sigbytes, message, pubkeyBytes); + if (!isCompressedPubkey(pubkey)) return false; + // Malformed outputs (negative amount, bad hex B_) must verify as false, not throw: these + // functions take untrusted input and the boolean contract mirrors schnorrVerifyDigest's + // own error swallowing. + try { + return schnorrVerifyDigest(signature, constructMessage(quote, blindedMessages), pubkey); + } catch { + return false; + } +} + +export function signMintQuoteLegacy( + privkey: string, + quote: string, + blindedMessages: SerializedBlindedMessage[], +): string { + return schnorrSignDigest(constructLegacyMessage(quote, blindedMessages), privkey); +} + +export function verifyMintQuoteSignatureLegacy( + pubkey: string, + quote: string, + blindedMessages: SerializedBlindedMessage[], + signature: string, +): boolean { + if (!isCompressedPubkey(pubkey)) return false; + // See verifyMintQuoteSignature: malformed input must verify as false rather than throw + // past schnorrVerifyDigest's try/catch. + try { + return schnorrVerifyDigest(signature, constructLegacyMessage(quote, blindedMessages), pubkey); + } catch { + return false; + } } diff --git a/src/crypto/core.ts b/src/crypto/core.ts index 61e20ccab..e0106a5f9 100644 --- a/src/crypto/core.ts +++ b/src/crypto/core.ts @@ -102,12 +102,34 @@ export const schnorrVerifyMessage = ( message: string, pubkey: string, throws: boolean = false, +): boolean => { + return schnorrVerifyDigest(signature, computeMessageDigest(message), pubkey, throws); +}; + +/** + * Verifies a Schnorr signature on a message digest. + * + * @remarks + * This function swallows Schnorr verification errors (eg invalid signature / pubkey format) and + * treats them as false. If you want to throw such errors, use the throws param. + * @param signature - The Schnorr signature (hex-encoded). + * @param digest - The SHA-256 digest to verify (hex string or Uint8Array). + * @param pubkey - The public key (hex-encoded, X-only or with 02/03 prefix). + * @param throws - True: throws on error, False: swallows errors and returns false. + * @returns True if the signature is valid, false otherwise. + * @throws If throws param is true and error is encountered. + */ +export const schnorrVerifyDigest = ( + signature: string, + digest: DigestInput, + pubkey: string, + throws: boolean = false, ): boolean => { try { - const msghash = computeMessageDigest(message); + const digestBytes = typeof digest === 'string' ? hexToBytes(digest) : digest; // Use X-only pubkey: strip 02/03 prefix if pubkey is 66 hex chars (33 bytes) const pubkeyX = pubkey.length === 66 ? pubkey.slice(2) : pubkey; - return schnorr.verify(hexToBytes(signature), msghash, hexToBytes(pubkeyX)); + return schnorr.verify(hexToBytes(signature), digestBytes, hexToBytes(pubkeyX)); } catch (e) { if (throws) { throw e; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 7103e0649..16e528a81 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -8,6 +8,6 @@ export * from './NUT11'; export * from './NUT12'; export * from './NUT13'; export * from './NUT14'; -export * from './NUT20'; +export { signMintQuote, verifyMintQuoteSignature } from './NUT20'; export * from './NUT27'; export * from './NUT28'; diff --git a/src/index.ts b/src/index.ts index 0fb372fb0..168b2273c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,7 @@ export type { export { type LogLevel, ConsoleLogger, type Logger } from './logger'; export { CTSError, + isMintOperationError, MintOperationError, NetworkError, HttpResponseError, diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index c613a4f98..54d91c45e 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -83,7 +83,8 @@ class Mint { /** * @param mintUrl Requires mint URL to create this object. - * @param customRequest Optional, for custom network communication with the mint. + * @param customRequest Optional, for custom network communication with the mint. Implementations + * must follow the {@link RequestFn} error contract. * @param requestFetch Optional fetch-compatible transport for the default mint request pipeline. * Ignored when `customRequest` is supplied. * @param authTokenGetter Optional. Function to obtain a NUT-22 BlindedAuthToken (e.g. from a diff --git a/src/model/Errors.ts b/src/model/Errors.ts index 6a71736f1..a9a4ed63f 100644 --- a/src/model/Errors.ts +++ b/src/model/Errors.ts @@ -73,3 +73,17 @@ export class MintOperationError extends HttpResponseError { Object.setPrototypeOf(this, MintOperationError.prototype); } } + +/** + * True when `e` is a {@link MintOperationError}, including look-alikes by name + code. + * + * @remarks + * Prefer this over `instanceof`, which misses errors thrown by another copy of this package (eg: + * npm dedupe failure) or by a custom request layer declaring its own class. + */ +export function isMintOperationError(e: unknown): e is MintOperationError { + return ( + e instanceof MintOperationError || + (e instanceof Error && e.name === 'MintOperationError' && 'code' in e) + ); +} diff --git a/src/transport/request.ts b/src/transport/request.ts index dc1edb7a0..2ccb1a056 100644 --- a/src/transport/request.ts +++ b/src/transport/request.ts @@ -9,7 +9,17 @@ import { import { type Nut19Policy } from '../model/types'; import { JSONInt } from '../utils/JSONInt'; -// Generic request function type so callers can do requestInstance(...) +/** + * Pluggable request function used for all mint HTTP calls. + * + * @remarks + * Error contract: on a mint protocol error (JSON body with `code`/`detail`), implementations must + * throw an error `isMintOperationError` accepts, preferably this package's + * {@link MintOperationError}, with the NUT error code preserved. Wallet behavior that branches on + * mint error codes (eg the NUT-20 legacy signature retry) will not engage otherwise. If you only + * need a custom transport, prefer the `requestFetch` option ({@link RequestFetch}): the default + * pipeline then keeps this contract for you. + */ export type RequestFn = (args: RequestOptions) => Promise; /** diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 560a48252..6234ade66 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -19,10 +19,12 @@ import { buildLegacyP2PKSigAllMessage, parseSecret, } from '../crypto'; +// Internal transitional fallback — not part of crypto/index.ts +import { signMintQuoteLegacy } from '../crypto/NUT20'; import { type Logger, NULL_LOGGER, fail, failIf, failIfNullish, safeCallback } from '../logger'; import { Mint } from '../mint'; import { Amount, type AmountLike } from '../model/Amount'; -import { CTSError } from '../model/Errors'; +import { CTSError, isMintOperationError } from '../model/Errors'; import { MintInfo } from '../model/MintInfo'; import { OutputData, type OutputDataLike } from '../model/OutputData'; import { DefaultOutputDataCreator, type OutputDataCreator } from '../model/OutputDataCreator'; @@ -35,6 +37,7 @@ import type { MeltQuoteBolt12Response, MeltQuoteOnchainResponse, MintRequest, + MintResponse, MintQuoteBaseResponse, MintQuoteBolt11Response, MintQuoteBolt12Response, @@ -99,6 +102,9 @@ import { WalletOps } from './WalletOps'; const PENDING_KEYSET_ID = '__PENDING__'; +// NUT-20 "Signature for mint request invalid" +const MINT_QUOTE_SIGNATURE_INVALID_CODE = 20008; + /** * Class that represents a Cashu wallet. * @@ -2196,6 +2202,7 @@ class Wallet { } // Sign whenever a privkey is provided — quote.pubkey may be absent if only the // quote ID was stored, but the caller still needs to produce a NUT-20 signature + let legacySignature: string | undefined; if (privkey) { const quotePubkey = 'pubkey' in quote ? (quote.pubkey as string | undefined) : undefined; this.failIf( @@ -2208,7 +2215,10 @@ class Wallet { ? privkey[0] : privkey; this.failIf(!signingKey, 'prepareMint: privkey is empty or correct privkey not provided'); + // Sign the amended (nuts#375) message by default and keep a legacy signature over the same + // outputs as a fallback for not-yet-upgraded mints — see completeMint(). mintPayload.signature = signMintQuote(signingKey, quote.quote, blindedMessages); + legacySignature = signMintQuoteLegacy(signingKey, quote.quote, blindedMessages); } return { @@ -2217,9 +2227,37 @@ class Wallet { outputData: outputs, keysetId: keyset.id, quote, + legacySignature, }; } + /** + * Send a mint request, retrying with the legacy NUT-20 signature for legacy mints. + * + * TODO: Remove this compat shim when legacy message support is dropped. + */ + private async withLegacyQuoteSigFallback( + hasLegacySignature: boolean, + attempt: () => Promise, + retryWithLegacySignature: () => Promise, + ): Promise { + try { + return await attempt(); + } catch (e) { + if ( + hasLegacySignature && + isMintOperationError(e) && + e.code === MINT_QUOTE_SIGNATURE_INVALID_CODE + ) { + this._logger.warn( + 'Mint rejected the amended NUT-20 quote signature (20008); retrying with the legacy message.', + ); + return retryWithLegacySignature(); + } + throw e; + } + } + /** * Complete a prepared mint transaction. * @@ -2232,8 +2270,13 @@ class Wallet { async completeMint( mintPreview: MintPreview>, ): Promise { - const { payload, outputData, keysetId, method } = mintPreview; - const { signatures } = await this.mint.mint(method, payload); + const { payload, outputData, keysetId, method, legacySignature } = mintPreview; + // TODO: Remove legacy message support + const { signatures } = await this.withLegacyQuoteSigFallback( + legacySignature !== undefined, + () => this.mint.mint(method, payload), + () => this.mint.mint(method, { ...payload, signature: legacySignature }), + ); this.failIf( signatures.length !== outputData.length, `Mint returned ${signatures.length} signatures, expected ${outputData.length}. The mint quote may already be marked issued; if the wallet is seeded, try restoring (NUT-09) to recover.`, @@ -2350,12 +2393,14 @@ class Wallet { // Unlocked quotes get null. If no quotes are locked, omit signatures entirely. // Each locked quote's pubkey is matched against the provided privkey(s). const signatures: Array = []; + const legacySignatures: Array = []; // Temporary legacy message support let hasSignatures = false; for (const entry of entries) { const quotePubkey = 'pubkey' in entry.quote ? entry.quote.pubkey : undefined; if (quotePubkey && privkey) { const signingKey = findSigningKey(quotePubkey, privkey); signatures.push(signMintQuote(signingKey, entry.quote.quote, blindedMessages)); + legacySignatures.push(signMintQuoteLegacy(signingKey, entry.quote.quote, blindedMessages)); hasSignatures = true; } else { if (privkey && !quotePubkey) { @@ -2364,6 +2409,7 @@ class Wallet { ); } signatures.push(null); + legacySignatures.push(null); } } @@ -2380,6 +2426,7 @@ class Wallet { outputData: outputs, keysetId: keyset.id, quotes: entries.map((e) => e.quote), + ...(hasSignatures ? { legacySignatures } : {}), }; } @@ -2395,9 +2442,13 @@ class Wallet { async completeBatchMint( batchPreview: BatchMintPreview>, ): Promise { - const { method, payload, outputData, keysetId } = batchPreview; - - const { signatures: sigs } = await this.mint.mintBatch(method, payload); + const { method, payload, outputData, keysetId, legacySignatures } = batchPreview; + // TODO: Remove legacy message support + const { signatures: sigs } = await this.withLegacyQuoteSigFallback( + legacySignatures !== undefined, + () => this.mint.mintBatch(method, payload), + () => this.mint.mintBatch(method, { ...payload, signatures: legacySignatures! }), + ); this.failIf( sigs.length !== outputData.length, `Mint returned ${sigs.length} signatures, expected ${outputData.length}. The mint quote may already be marked issued; if the wallet is seeded, try restoring (NUT-09) to recover.`, diff --git a/src/wallet/types/payloads.ts b/src/wallet/types/payloads.ts index 57d29792e..ededcbb42 100644 --- a/src/wallet/types/payloads.ts +++ b/src/wallet/types/payloads.ts @@ -35,6 +35,10 @@ export interface MintPreview< * Mint Quote object. */ quote: TQuote; + /** + * @deprecated Temporary compatibility for legacy mints. + */ + legacySignature?: string; } /** @@ -63,6 +67,10 @@ export interface BatchMintPreview< * Mint Quote objects included in this batch. */ quotes: TQuote[]; + /** + * @deprecated Temporary compatibility for legacy mints. + */ + legacySignatures?: Array; } /** diff --git a/test/crypto/NUT20.test.ts b/test/crypto/NUT20.test.ts index 4a85862e1..3a57aeaed 100644 --- a/test/crypto/NUT20.test.ts +++ b/test/crypto/NUT20.test.ts @@ -1,11 +1,152 @@ import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { test, describe, expect } from 'vitest'; import { Amount, type MintRequest } from '../../src'; import { signMintQuote, verifyMintQuoteSignature } from '../../src/crypto'; +import { signMintQuoteLegacy, verifyMintQuoteSignatureLegacy } from '../../src/crypto/NUT20'; -describe('mint quote signatures', () => { +/** + * Amended mint-quote signature message (cashubtc/nuts#375), shared by NUT-20 single and NUT-29 + * batch minting. Test vector from nuts/tests/29-tests.md (sk = 1). + */ +describe('mint quote signatures (amended message)', () => { + const pubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const privkey = '0000000000000000000000000000000000000000000000000000000000000001'; + const keysetId = '010000000000000000000000000000000000000000000000000000000000000000'; + + const allOutputs = [ + { + amount: Amount.from(1), + id: keysetId, + B_: '036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2', + }, + { + amount: Amount.from(1), + id: keysetId, + B_: '021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59', + }, + ]; + + // Canonical results from the spec test vector. + const expectedMsgToSign = + '43617368755f4d696e7451756f74655369675f76310000000c6c6f636b65642d71756f7465' + + '000000010100000021036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2' + + '000000010100000021021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59'; + const expectedMsgHash = '03dc68d6617bba502d8648efd0965bf393841082cf04fd03e5de4bcb5777cdfc'; + const expectedSignature = + 'a913e48177027d87e0e38c6f2021763c46997ff4866a4b63ebca800b0776b28519eab37377cf9bc1869e489d7b25747b7a998eaa1c33c2cac7fa168449d8267a'; + + test('canonical msg_to_sign hashes to the test vector', () => { + expect(bytesToHex(sha256(hexToBytes(expectedMsgToSign)))).toBe(expectedMsgHash); + }); + + test('test vector signature verifies correctly', () => { + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, expectedSignature)).toBe( + true, + ); + }); + + test('signMintQuote over all outputs produces a valid signature', () => { + const signature = signMintQuote(privkey, 'locked-quote', allOutputs); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, signature)).toBe(true); + }); + + test('signature over per-quote subset is invalid against full output set', () => { + const perQuoteSig = signMintQuote(privkey, 'locked-quote', [allOutputs[0]]); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, perQuoteSig)).toBe(false); + }); + + test('signature is bound to output amounts', () => { + const signature = signMintQuote(privkey, 'locked-quote', allOutputs); + const reValued = [{ ...allOutputs[0], amount: Amount.from(2) }, allOutputs[1]]; + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', reValued, signature)).toBe(false); + }); + + test('normalizes a raw JSON number amount (Amount instances pass through)', () => { + // A server may pass outputs straight from JSON.parse, where amount is a primitive number. + const numberOutputs = allOutputs.map((o) => ({ ...o, amount: o.amount.toNumber() })); + const cast = numberOutputs as unknown as typeof allOutputs; + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', cast, expectedSignature)).toBe(true); + const sig = signMintQuote(privkey, 'locked-quote', cast); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, sig)).toBe(true); + }); + + test('signature is bound to output order', () => { + const signature = signMintQuote(privkey, 'locked-quote', allOutputs); + const reordered = [allOutputs[1], allOutputs[0]]; + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', reordered, signature)).toBe(false); + }); + + test('encodes amounts canonically (0 -> empty, even-length hex unpadded)', () => { + const outputs = [ + { ...allOutputs[0], amount: Amount.from(0) }, + { ...allOutputs[1], amount: Amount.from(16) }, + ]; + const signature = signMintQuote(privkey, 'locked-quote', outputs); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', outputs, signature)).toBe(true); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, signature)).toBe(false); + }); + + test('rejects pubkeys that are not 33-byte compressed', () => { + const xOnly = pubkey.slice(2); + expect(verifyMintQuoteSignature(xOnly, 'locked-quote', allOutputs, expectedSignature)).toBe( + false, + ); + const legacySig = signMintQuoteLegacy(privkey, 'locked-quote', allOutputs); + expect(verifyMintQuoteSignatureLegacy(xOnly, 'locked-quote', allOutputs, legacySig)).toBe( + false, + ); + }); + + test('each quote in a batch signs over the same output set, bound to its quote ID', () => { + const sigQuote1 = signMintQuote(privkey, 'quote-1', allOutputs); + const sigQuote2 = signMintQuote(privkey, 'quote-2', allOutputs); + + expect(verifyMintQuoteSignature(pubkey, 'quote-1', allOutputs, sigQuote1)).toBe(true); + expect(verifyMintQuoteSignature(pubkey, 'quote-2', allOutputs, sigQuote2)).toBe(true); + + // Each signature is bound to its quote ID + expect(verifyMintQuoteSignature(pubkey, 'quote-1', allOutputs, sigQuote2)).toBe(false); + expect(verifyMintQuoteSignature(pubkey, 'quote-2', allOutputs, sigQuote1)).toBe(false); + }); + + // Canonical NUT-20 single-mint vector from nuts/tests/20-test.md (sk = 1, UUIDv7 quote id). + describe('NUT-20 single-mint spec vector', () => { + const quote = '0192d3c0-7e8a-7c3d-8e9f-1a2b3c4d5e6f'; + const outputs = [ + { + amount: Amount.from(1), + id: '009a1f293253e41e', + B_: '036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2', + }, + { + amount: Amount.from(1), + id: '009a1f293253e41e', + B_: '021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59', + }, + ]; + const expectedMsgToSign = + '43617368755f4d696e7451756f74655369675f7631000000243031393264336330' + + '2d376538612d376333642d386539662d316132623363346435653666' + + '000000010100000021036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2' + + '000000010100000021021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59'; + const expectedMsgHash = 'c164fd384879f74ab6ea2e7cf13d90ed42e6df9d5de607eeb5c9cc7d36fb1c21'; + const expectedSignature = + '4881093a332ff7c79f3e598ce5b249d64978b47165a0b19c18adf0ced0246228e61e702f0abaf1bf27b92be4336bdbabacfbe4c914076386b3c66fdcd0b3480e'; + + test('canonical msg_to_sign hashes to the test vector', () => { + expect(bytesToHex(sha256(hexToBytes(expectedMsgToSign)))).toBe(expectedMsgHash); + }); + + test('test vector signature verifies correctly', () => { + expect(verifyMintQuoteSignature(pubkey, quote, outputs, expectedSignature)).toBe(true); + }); + }); +}); + +describe('mint quote signatures (legacy message)', () => { test('valid signature verification', () => { const mintRequest = { quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', @@ -43,7 +184,9 @@ describe('mint quote signatures', () => { const quote = mintRequest.quote; const pubkey = '03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; const blindedMessages = mintRequest.outputs; - expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, sig)).toBe(true); + expect(verifyMintQuoteSignatureLegacy(pubkey, quote, blindedMessages, sig)).toBe(true); + // The legacy vector does not verify against the amended message. + expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, sig)).toBe(false); }); test('invalid signature verification', () => { const mintRequest = { @@ -82,7 +225,7 @@ describe('mint quote signatures', () => { const quote = mintRequest.quote; const pubkey = '03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; const blindedMessages = mintRequest.outputs; - expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, sig)).toBe(false); + expect(verifyMintQuoteSignatureLegacy(pubkey, quote, blindedMessages, sig)).toBe(false); }); test('signature creation', () => { const mintRequest = { @@ -119,7 +262,35 @@ describe('mint quote signatures', () => { const privkey = 'd56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; const pubkey = bytesToHex(secp256k1.getPublicKey(hexToBytes(privkey))); const blindedMessages = mintRequest.outputs; - const signature = signMintQuote(privkey, quote, blindedMessages); - expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, signature)).toBe(true); + const signature = signMintQuoteLegacy(privkey, quote, blindedMessages); + expect(verifyMintQuoteSignatureLegacy(pubkey, quote, blindedMessages, signature)).toBe(true); + }); +}); + +describe('mint quote signature verification rejects malformed input (no throw)', () => { + const pubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const keysetId = '010000000000000000000000000000000000000000000000000000000000000000'; + const sig = + 'a913e48177027d87e0e38c6f2021763c46997ff4866a4b63ebca800b0776b28519eab37377cf9bc1869e489d7b25747b7a998eaa1c33c2cac7fa168449d8267a'; + const goodB_ = '036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2'; + + // A server may pass outputs straight from JSON.parse; an attacker controls amount and B_. + const withOutputs = (outputs: unknown) => + outputs as Array<{ amount: Amount; id: string; B_: string }>; + + test('amended: negative amount verifies as false', () => { + const outputs = withOutputs([{ amount: -1, id: keysetId, B_: goodB_ }]); + expect(verifyMintQuoteSignature(pubkey, 'q', outputs, sig)).toBe(false); + }); + + test('amended: invalid hex B_ verifies as false', () => { + const outputs = withOutputs([{ amount: 1, id: keysetId, B_: 'invalidhex' }]); + expect(verifyMintQuoteSignature(pubkey, 'q', outputs, sig)).toBe(false); + }); + + test('legacy: non-string quote with no outputs verifies as false', () => { + // message stays a non-string and utf8ToBytes would throw without the guard. + const quote = 256 as unknown as string; + expect(verifyMintQuoteSignatureLegacy(pubkey, quote, [], sig)).toBe(false); }); }); diff --git a/test/crypto/NUT29.test.ts b/test/crypto/NUT29.test.ts deleted file mode 100644 index 27485de1a..000000000 --- a/test/crypto/NUT29.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { sha256 } from '@noble/hashes/sha2.js'; -import { bytesToHex } from '@noble/hashes/utils.js'; -import { test, describe, expect } from 'vitest'; - -import { Amount } from '../../src'; -import { signMintQuote, verifyMintQuoteSignature } from '../../src/crypto'; - -/** - * NUT-29 test vectors for batch mint signatures. - * - * Signatures follow the NUT-20 message aggregation pattern: the message is constructed by - * concatenating the quote ID and all B_ hex strings as UTF-8, then SHA-256 hashing the result. The - * signature covers ALL outputs in the batch, not just outputs belonging to a single quote. - * - * Test vector parameters (from nuts/tests/29-tests.md, corrected for NUT-20 message format): sk = 1 - * pubkey = 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 quote = - * "locked-quote" B_0 = 036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2 B_1 = - * 021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59. - */ -describe('NUT-29 batch mint signatures', () => { - const pubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; - const privkey = '0000000000000000000000000000000000000000000000000000000000000001'; - const keysetId = '010000000000000000000000000000000000000000000000000000000000000000'; - - const allOutputs = [ - { - amount: Amount.from(1), - id: keysetId, - B_: '036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2', - }, - { - amount: Amount.from(1), - id: keysetId, - B_: '021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59', - }, - ]; - - const expectedMsgHash = 'a62f12934711693d6045ed2843ae4c5b33fd156df029fb9337dea3175c438263'; - const expectedSignature = - 'bd4d55f3fda33109fe3694c041aa9358c8e6e581236245ee310e7e225dfb075d9a2799b9672e646cb7e9fad9887f5b42a04d307a238d219783a4790b323194c0'; - - test('message hash matches test vector', () => { - const message = 'locked-quote' + allOutputs[0].B_ + allOutputs[1].B_; - const hash = bytesToHex(sha256(new TextEncoder().encode(message))); - expect(hash).toBe(expectedMsgHash); - }); - - test('test vector signature verifies correctly', () => { - expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, expectedSignature)).toBe( - true, - ); - }); - - test('signMintQuote over all outputs produces a valid signature', () => { - const signature = signMintQuote(privkey, 'locked-quote', allOutputs); - expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, signature)).toBe(true); - }); - - test('signature over per-quote subset is invalid against full output set', () => { - const perQuoteSig = signMintQuote(privkey, 'locked-quote', [allOutputs[0]]); - expect(verifyMintQuoteSignature(pubkey, 'locked-quote', allOutputs, perQuoteSig)).toBe(false); - }); - - test('each quote in a batch must sign over the same complete output set', () => { - const sigQuote1 = signMintQuote(privkey, 'quote-1', allOutputs); - const sigQuote2 = signMintQuote(privkey, 'quote-2', allOutputs); - - expect(verifyMintQuoteSignature(pubkey, 'quote-1', allOutputs, sigQuote1)).toBe(true); - expect(verifyMintQuoteSignature(pubkey, 'quote-2', allOutputs, sigQuote2)).toBe(true); - - // Each signature is bound to its quote ID - expect(verifyMintQuoteSignature(pubkey, 'quote-1', allOutputs, sigQuote2)).toBe(false); - expect(verifyMintQuoteSignature(pubkey, 'quote-2', allOutputs, sigQuote1)).toBe(false); - }); -}); diff --git a/test/crypto/core.test.ts b/test/crypto/core.test.ts index 88487fd95..8a9f48664 100644 --- a/test/crypto/core.test.ts +++ b/test/crypto/core.test.ts @@ -20,6 +20,8 @@ import { hash_e, isBlsKeyset, pointFromBytes, + schnorrSignDigest, + schnorrVerifyDigest, } from '../../src/crypto'; import { verifyUnblindedSignature } from '../../src/crypto/NUT01'; import { Bytes } from '../../src/utils'; @@ -227,3 +229,19 @@ describe('getKeysetIdInt', () => { expect(getKeysetIdInt(b64Id)).toBe(expected); }); }); + +describe('schnorrVerifyDigest', () => { + const privkey = '0000000000000000000000000000000000000000000000000000000000000001'; + const pubkey = bytesToHex(secp256k1.getPublicKey(hexToBytes(privkey), true)); + const digest = sha256(new TextEncoder().encode('msg')); + const signature = schnorrSignDigest(digest, privkey); + + test('accepts a hex-string digest', () => { + expect(schnorrVerifyDigest(signature, bytesToHex(digest), pubkey)).toBe(true); + }); + + test('swallows malformed input by default and throws when asked', () => { + expect(schnorrVerifyDigest('not-hex', digest, pubkey)).toBe(false); + expect(() => schnorrVerifyDigest('not-hex', digest, pubkey, true)).toThrow(); + }); +}); diff --git a/test/model/Errors.test.ts b/test/model/Errors.test.ts index 3272ff2bc..521433831 100644 --- a/test/model/Errors.test.ts +++ b/test/model/Errors.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { CTSError, HttpResponseError, + isMintOperationError, MintOperationError, NetworkError, RateLimitError, @@ -112,3 +113,24 @@ describe('MintOperationError', () => { expect(err.message).toBe('Unknown mint operation error'); }); }); + +describe('isMintOperationError', () => { + test('accepts a real MintOperationError', () => { + expect(isMintOperationError(new MintOperationError(20008, 'invalid signature'))).toBe(true); + }); + + test('accepts a look-alike by name + code', () => { + const lookAlike = Object.assign(new Error('invalid signature'), { + name: 'MintOperationError', + code: 20008, + }); + expect(isMintOperationError(lookAlike)).toBe(true); + }); + + test('rejects other errors and non-errors', () => { + expect(isMintOperationError(new NetworkError('offline'))).toBe(false); + expect(isMintOperationError(new Error('MintOperationError'))).toBe(false); + expect(isMintOperationError({ name: 'MintOperationError', code: 20008 })).toBe(false); + expect(isMintOperationError(undefined)).toBe(false); + }); +}); diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index 0b5143f73..f6c5931ad 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -16,7 +16,13 @@ import { type MintQuoteBolt11Response, Amount, type AmountLike, + Mint, + MintOperationError, + type RequestFn, } from '../../src'; +import { verifyMintQuoteSignature } from '../../src/crypto'; +import { verifyMintQuoteSignatureLegacy } from '../../src/crypto/NUT20'; +import request from '../../src/transport'; import { Bytes, sumProofs } from '../../src/utils'; import { useTestServer, mint, mintUrl, unit, logger, mintInfoResp } from './_setup'; @@ -616,6 +622,261 @@ describe('requestTokens', () => { }); }); +describe('mint quote signature legacy fallback', () => { + const privkey = '0000000000000000000000000000000000000000000000000000000000000001'; + const pubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + function spyLogger() { + return { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + log: vi.fn(), + }; + } + + async function makeWallet(walletLogger?: ReturnType) { + const wallet = new Wallet(mint, { unit, logger: walletLogger }); + await wallet.loadMint(); + return wallet; + } + + function makeLockedQuote(amount = 3): MintQuoteBolt11Response { + return { + quote: 'locked-quote', + request: 'lnbc...', + amount: Amount.from(amount), + unit: 'sat', + state: MintQuoteState.PAID, + expiry: null, + pubkey, + }; + } + + describe('prepare signs the amended message and keeps a legacy fallback', () => { + test('prepareMint', async () => { + const wallet = await makeWallet(); + const preview = await wallet.prepareMint('bolt11', 3, makeLockedQuote(), { privkey }); + const { outputs, signature } = preview.payload; + const legacy = preview.legacySignature!; + + // Wire signature is the amended message; the fallback is the legacy message. Each verifies + // only against its own message — the DST makes them non-interchangeable. + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', outputs, signature!)).toBe(true); + expect(verifyMintQuoteSignatureLegacy(pubkey, 'locked-quote', outputs, signature!)).toBe( + false, + ); + expect(verifyMintQuoteSignatureLegacy(pubkey, 'locked-quote', outputs, legacy)).toBe(true); + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', outputs, legacy)).toBe(false); + }); + + test('prepareBatchMint', async () => { + const wallet = await makeWallet(); + const preview = await wallet.prepareBatchMint( + 'bolt11', + [{ amount: 3, quote: makeLockedQuote() }], + { privkey }, + ); + const { outputs, signatures } = preview.payload; + const legacy = preview.legacySignatures![0]!; + + expect(verifyMintQuoteSignature(pubkey, 'locked-quote', outputs, signatures![0]!)).toBe(true); + expect(verifyMintQuoteSignatureLegacy(pubkey, 'locked-quote', outputs, legacy)).toBe(true); + }); + + test('omits the legacy fallback for an unsigned (unlocked) quote', async () => { + const wallet = await makeWallet(); + const preview = await wallet.prepareMint('bolt11', 3, { quote: 'unlocked' }); + expect(preview.payload.signature).toBeUndefined(); + expect(preview.legacySignature).toBeUndefined(); + }); + }); + + describe('completeMint retries with the legacy signature on rejection', () => { + const blindSig = { + id: '00bd033559de27d0', + amount: 1, + C_: '0361a2725cfd88f60ded718378e8049a4a6cee32e214a9870b44c3ffea2dc9e625', + }; + + type SeenRequest = { + signature: string; + outputs: Parameters[2]; + }; + + // Mint that verifies only the legacy message: rejects the amended signature with a NUT-20 + // protocol error, accepts the legacy one. Records each request it is offered. + function legacyOnlyMint(seen: SeenRequest[]) { + server.use( + http.post(mintUrl + '/v1/mint/bolt11', async ({ request }) => { + const body = (await request.json()) as SeenRequest; + seen.push(body); + const amended = verifyMintQuoteSignature( + pubkey, + 'locked-quote', + body.outputs, + body.signature ?? '', + ); + if (amended) { + return HttpResponse.json({ code: 20008, detail: 'invalid signature' }, { status: 400 }); + } + return HttpResponse.json({ signatures: [blindSig] }); + }), + ); + } + + test('resends the same outputs with the legacy signature and warns', async () => { + const seen: SeenRequest[] = []; + legacyOnlyMint(seen); + const logger = spyLogger(); + const wallet = await makeWallet(logger); + + const proofs = await wallet.mintProofsBolt11(1, makeLockedQuote(1), { privkey }); + + expect(proofs).toHaveLength(1); + expect(seen).toHaveLength(2); + // Amended attempt first, legacy on the retry — both over the same outputs. + expect( + verifyMintQuoteSignature(pubkey, 'locked-quote', seen[0].outputs, seen[0].signature), + ).toBe(true); + expect( + verifyMintQuoteSignatureLegacy(pubkey, 'locked-quote', seen[1].outputs, seen[1].signature), + ).toBe(true); + expect(seen[1].outputs).toEqual(seen[0].outputs); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('20008')); + }); + + test('does not retry when the mint accepts the amended signature', async () => { + let calls = 0; + server.use( + http.post(mintUrl + '/v1/mint/bolt11', () => { + calls += 1; + return HttpResponse.json({ signatures: [blindSig] }); + }), + ); + const wallet = await makeWallet(); + + await wallet.mintProofsBolt11(1, makeLockedQuote(1), { privkey }); + + expect(calls).toBe(1); + }); + + test('does not retry on a non-protocol failure', async () => { + let calls = 0; + server.use( + http.post(mintUrl + '/v1/mint/bolt11', () => { + calls += 1; + return HttpResponse.json({ error: 'boom' }, { status: 500 }); + }), + ); + const wallet = await makeWallet(); + + await expect(wallet.mintProofsBolt11(1, makeLockedQuote(1), { privkey })).rejects.toThrow(); + expect(calls).toBe(1); + }); + + test('retries when a custom transport throws a look-alike MintOperationError', async () => { + const seen: SeenRequest[] = []; + legacyOnlyMint(seen); + // Transport that surfaces protocol errors as its own class (same name and shape, different + // constructor identity), as a consumer with a custom request layer might. + class LookAlikeError extends Error { + code: number; + constructor(code: number, detail: string) { + super(detail); + this.name = 'MintOperationError'; + this.code = code; + } + } + const customRequest: RequestFn = async (args) => { + try { + return await request(args); + } catch (e) { + if (e instanceof MintOperationError) throw new LookAlikeError(e.code, e.message); + throw e; + } + }; + const wallet = new Wallet(new Mint(mintUrl, { customRequest }), { unit }); + await wallet.loadMint(); + + const proofs = await wallet.mintProofsBolt11(1, makeLockedQuote(1), { privkey }); + + expect(proofs).toHaveLength(1); + expect(seen).toHaveLength(2); + expect( + verifyMintQuoteSignatureLegacy(pubkey, 'locked-quote', seen[1].outputs, seen[1].signature), + ).toBe(true); + }); + + test('completeBatchMint retries with the legacy signatures on rejection', async () => { + type SeenBatchRequest = { + signatures?: string[]; + outputs: SeenRequest['outputs']; + }; + const seen: SeenBatchRequest[] = []; + // Batch counterpart of legacyOnlyMint: rejects the amended signature with 20008. + server.use( + http.post(mintUrl + '/v1/mint/bolt11/batch', async ({ request }) => { + const body = (await request.json()) as SeenBatchRequest; + seen.push(body); + const amended = verifyMintQuoteSignature( + pubkey, + 'locked-quote', + body.outputs, + body.signatures?.[0] ?? '', + ); + if (amended) { + return HttpResponse.json({ code: 20008, detail: 'invalid signature' }, { status: 400 }); + } + return HttpResponse.json({ + signatures: body.outputs.map((o) => ({ ...blindSig, amount: o.amount })), + }); + }), + ); + const wallet = await makeWallet(); + + const preview = await wallet.prepareBatchMint( + 'bolt11', + [{ amount: 1, quote: makeLockedQuote(1) }], + { privkey }, + ); + const proofs = await wallet.completeBatchMint(preview); + + expect(proofs).toHaveLength(1); + expect(seen).toHaveLength(2); + // Amended attempt first, legacy on the retry, both over the same outputs. + expect( + verifyMintQuoteSignatureLegacy( + pubkey, + 'locked-quote', + seen[1].outputs, + seen[1].signatures![0], + ), + ).toBe(true); + expect(seen[1].outputs).toEqual(seen[0].outputs); + }); + + test('does not retry on an unrelated protocol error (only 20008 falls back)', async () => { + let calls = 0; + server.use( + http.post(mintUrl + '/v1/mint/bolt11', () => { + calls += 1; + // 20001 = quote not paid — a real failure the legacy signature cannot fix. + return HttpResponse.json({ code: 20001, detail: 'quote not paid' }, { status: 400 }); + }), + ); + const wallet = await makeWallet(); + + await expect(wallet.mintProofsBolt11(1, makeLockedQuote(1), { privkey })).rejects.toThrow( + /quote not paid/, + ); + expect(calls).toBe(1); + }); + }); +}); + describe('NUT-29 max_batch_size enforcement', () => { function makeBatchHandler() { server.use(