Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
10aefae
fix(crypto): add colon separators to mint-quote signature message
robwoodgate May 22, 2026
351b3d9
fix(crypto): align NUT-29 batch quote signatures with amended spec
robwoodgate May 26, 2026
53f6062
fix(crypto): normalize NUT-29 output amounts via Amount.from
robwoodgate May 26, 2026
dbbc8f2
feat(wallet): gate amended NUT-29 batch signature on mint version
robwoodgate Jun 12, 2026
f184c98
feat(wallet): gate single-quote signing and extract mintCompat
robwoodgate Jun 12, 2026
9b94722
refactor(crypto): consolidate mint-quote signing in NUT20, mark legacy
robwoodgate Jun 12, 2026
59d2ee1
fix(wallet): set placeholder thresholds above current mint releases
robwoodgate Jun 12, 2026
841d041
test: cover patch branches flagged by codecov
robwoodgate Jun 12, 2026
816957a
docs(wallet): make prerelease handling in the version gate explicit
robwoodgate Jun 12, 2026
eaaf556
test(crypto): pin NUT-20 single-mint spec vector from nuts#375
robwoodgate Jun 18, 2026
7fd8b75
fix(crypto): verify malformed mint-quote signatures as false, not throw
robwoodgate Jun 18, 2026
239e736
fix(mintinfo): tolerate dot-separated prerelease tags in version gate
robwoodgate Jun 23, 2026
ad8840c
refactor(wallet): replace NUT-20 version gate with legacy-signature f…
robwoodgate Jun 25, 2026
0ed88fe
feat(wallet): warn when falling back to the legacy quote signature
robwoodgate Jun 25, 2026
14657ff
refactor(crypto)!: keep legacy NUT-20 signing internal in v5
robwoodgate Jun 25, 2026
87d1365
docs(migration): tighten v5 NUT-20 amended-message note
robwoodgate Jun 25, 2026
a72c120
Merge branch 'main' into fix/nut2029-msg-separators-main
robwoodgate Jun 29, 2026
4d694ae
fix(wallet): tolerate look-alike MintOperationError in NUT-20 legacy …
robwoodgate Jul 6, 2026
57203e6
Merge branch 'main' into fix/nut2029-msg-separators-main
robwoodgate Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export interface AuthProvider {
// @public
export interface BatchMintPreview<TQuote extends Pick<MintQuoteBaseResponse, 'quote' | 'pubkey'> = MintQuoteBaseResponse> {
keysetId: string;
// @deprecated (undocumented)
legacySignatures?: Array<string | null>;
// (undocumented)
method: string;
outputData: OutputDataLike[];
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1220,6 +1225,8 @@ export class MintOperationError extends HttpResponseError {
// @public
export interface MintPreview<TQuote extends Pick<MintQuoteBaseResponse, 'quote'> = MintQuoteBaseResponse> {
keysetId: string;
// @deprecated (undocumented)
legacySignature?: string;
// (undocumented)
method: string;
outputData: OutputDataLike[];
Expand Down Expand Up @@ -1791,7 +1798,7 @@ export type RequestArgs = {
// @public
export type RequestFetch = typeof fetch;

// @public (undocumented)
// @public
export type RequestFn = <T = unknown>(args: RequestOptions) => Promise<T>;

// @public (undocumented)
Expand Down Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions migration-5.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<Proof, 'secret'>>` — only `secret` was required. v5 requires both `id` and `secret`: `Array<Pick<Proof, 'secret' | 'id'>>`.
Expand Down
101 changes: 87 additions & 14 deletions src/crypto/NUT20.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,73 @@
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(
privkey: string,
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(
Expand All @@ -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;
}
}
26 changes: 24 additions & 2 deletions src/crypto/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type {
export { type LogLevel, ConsoleLogger, type Logger } from './logger';
export {
CTSError,
isMintOperationError,
MintOperationError,
NetworkError,
HttpResponseError,
Expand Down
3 changes: 2 additions & 1 deletion src/mint/Mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/model/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
12 changes: 11 additions & 1 deletion src/transport/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(...)
/**
* 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 = <T = unknown>(args: RequestOptions) => Promise<T>;

/**
Expand Down
Loading
Loading