Skip to content

fix(crypto): align NUT-29 batch quote signatures with amended spec#674

Open
robwoodgate wants to merge 14 commits into
v4-devfrom
fix/nut2029-msg-separators
Open

fix(crypto): align NUT-29 batch quote signatures with amended spec#674
robwoodgate wants to merge 14 commits into
v4-devfrom
fix/nut2029-msg-separators

Conversation

@robwoodgate

@robwoodgate robwoodgate commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Implements: cashubtc/nuts#375 to 91abdbb5

Summary

The current head of cashubtc/nuts#375 hardens the mint-quote signature message for both NUT-20 (single) and NUT-29 (batch) minting, harmonizing them on one domain-separated, length-framed construction:

msg_to_sign = DST || len32(quote) || quote
              || for each output: len32(amount) || amount || len32(B_) || B_

where DST = "Cashu_MintQuoteSig_v1", len32(x) is the 4-byte big-endian length of x, amount is the canonical minimal big-endian encoding (0 → empty), and B_ is the raw compressed point. The signature is a BIP340 Schnorr signature over the SHA-256 of that byte string.

Since the spec defines one message for both NUTs, all mint-quote signing lives in src/crypto/NUT20.ts (no separate NUT29 module):

  • signMintQuote / verifyMintQuoteSignatureunchanged legacy bytes (LTS: the released v4 API keeps its wire format).
  • signMintQuoteAmended / verifyMintQuoteSignatureAmended — the amended (spec) format, used for both single and batch minting against current mints. Wallet-internal on v4 (excluded from the public barrel via selective export); v5 exports this pair as signMintQuote/verifyMintQuoteSignature. Pinned to the canonical vector in tests/29-tests.md (msg_hash = 03dc68d6…).

Schnorr plumbing reuses crypto/core.ts (schnorrSignDigest plus a new schnorrVerifyDigest counterpart). Public API delta on v4: gains schnorrVerifyDigest, loses the never-released signBatchMintQuote pair.

Motivation

This aligns cashu-ts with the amended cashubtc/nuts#375 message construction. The message commits to the full ordered output set (each output's amount and blinded point), and length-frames every field so boundaries are unambiguous across curves (33-byte secp256k1 / 48-byte BLS points) without relying on point self-delimitation. See cashubtc/nuts#375 for the rationale.

Compatibility: legacy-signature fallback

Quotes are not versioned, so a wallet cannot tell from the quote itself which message format a mint verifies. Rather than sniff the mint's advertised version, the wallet signs the amended message by default and carries a legacy signature over the same outputs as a fallback:

  • prepareMint / prepareBatchMint sign both messages. The amended signature (signMintQuoteAmended) goes on the wire; the legacy one (signMintQuote, which keeps v4's released bytes) is kept in the preview (legacySignature / legacySignatures, marked @deprecated as transitional).
  • completeMint / completeBatchMint send the amended signature, and if the mint rejects it with error 20008 ("Signature for mint request invalid"), resend the same outputs with the legacy signature and log a warning.

Redeeming a mint quote burns no inputs and is idempotent on the quote, so the extra attempt is safe. The retry is gated on the spec-defined 20008 code — verified consistent across the mints cashu-ts targets — so unrelated failures (unpaid, expired) still fail fast. On the mint endpoint a 20008 is unambiguously the quote signature, since a mint request carries no NUT-11 input witnesses.

The mint-quote signature message concatenated the quote id and blinded
messages with no delimiter (quote || B_0 || ... || B_(n-1)). Because the
B_ hex length varies by curve (66 chars for secp256k1, 96 for
BLS12-381), the field boundaries are ambiguous: distinct (quote, outputs)
tuples can serialize to the same signed bytes, so a signature is not
unambiguously bound to a single request.

Insert ":" (UTF-8 0x3A) between the quote id and each blinded message so
msg_to_sign = quote || ":" || B_0 || ":" || ... || ":" || B_(n-1). This
covers both NUT-20 single mint-quote and NUT-29 batch-mint signatures,
which share the construction. Mints must adopt the same separator to
verify.

Test vectors updated to the canonical ones in cashubtc/nuts#375.
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (v4-dev@58edb64). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff            @@
##             v4-dev     #674   +/-   ##
=========================================
  Coverage          ?   92.59%           
=========================================
  Files             ?       50           
  Lines             ?     4859           
  Branches          ?     1192           
=========================================
  Hits              ?     4499           
  Misses            ?      156           
  Partials          ?      204           
Flag Coverage Δ
integration 39.88% <72.72%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robwoodgate robwoodgate added v3 nut hold This PR is held pending the merge of a new or updated NUT labels May 23, 2026
Revert NUT-20 to its retro-compatible message; NUT-29 batch minting now uses its
own domain-separated, length-framed message committing to each output's amount and
point (cashubtc/nuts#375).
@robwoodgate robwoodgate changed the title fix(crypto): add colon separators to mint-quote signature message fix(crypto): align NUT-29 batch quote signatures with amended spec May 26, 2026
Harden the public signBatchMintQuote/verifyBatchMintQuoteSignature against raw
JSON number/string amounts; Amount instances pass through unchanged.
Quotes are not versioned, so prepareBatchMint picks the signature format from
the mint's advertised implementation/version: Nutshell and cdk-mintd below the
first release that verifies the amended message (cashubtc/nuts#375) get the
legacy NUT-20-style message; everything else gets the amended format. Threshold
versions are placeholders until the upstream releases are known.

Adds MintInfo.isImplementationBelow() to parse and compare the NUT-06 version
string.
The current head of cashubtc/nuts#375 hardens the NUT-20 message itself, so the
single-quote path now picks its signature format the same way the batch path
does. The version threshold table and gate move to wallet/mintCompat.ts as
requiresLegacyQuoteSignature(), one place to collect (and later delete)
version-gated shims. NUT20/NUT29 modules document the legacy/amended split.

(cherry picked from commit f184c98c96a1f447457edf1b11b7e402b377f976)
…ir internal

cashubtc/nuts#375 defines one mint-quote message for NUT-20 single and NUT-29
batch minting, so the NUT29 module dissolves into NUT20.ts. On v4 the released
signMintQuote/verifyMintQuoteSignature keep their legacy bytes and the amended
pair (signMintQuoteAmended/verifyMintQuoteSignatureAmended) stays out of the
public barrel; v5 exports the amended pair as signMintQuote directly. Schnorr
plumbing now reuses core.ts (new schnorrVerifyDigest counterpart).
Integration against cdk-mintd/0.16.0 confirmed the latest releases verify only
the legacy message, so at-current thresholds break locked-quote minting. Next
minors (nutshell 0.21.0, cdk-mintd 0.17.0) keep every deployable mint on the
legacy path until the amended releases are pinned.
Canonical amount encoding (zero and even-length hex), non-compressed pubkey
rejection in both verify functions, schnorrVerifyDigest hex-string digest and
throws paths, and version-segment zero-padding in isImplementationBelow.
A prerelease compares equal to its base version, so an rc of the threshold
release gets the amended format — during the rc window those are the only
deployments that verify it. Pin both rc styles (semver dash, PEP440) in tests.

(cherry picked from commit 816957a)
Add the canonical msg_to_sign hash and signature vector from
tests/20-test.md alongside the already-pinned NUT-29 batch vector.
constructMessage/constructLegacyMessage are evaluated as arguments,
outside schnorrVerifyDigest's try/catch, so a negative amount or bad-hex
B_ threw past the boolean contract. Wrap both verify paths.
@robwoodgate robwoodgate modified the milestones: 4.6.0, 4.7.0 Jun 20, 2026
parseVersionSegments returned null on any non-numeric dot segment (e.g.
0.20.5.dev3, 0.16.5.post1), so an old mint with such a version was treated
as current and signed with the amended quote-sig format, which it rejects.
Read leading numeric segments and stop at the first prerelease/build tag so
the version gates to legacy.
…allback

Sign the amended (nuts#375) mint-quote message by default and retry with the legacy
signature when a pre-amendment mint rejects it with error 20008, warning on fallback.
Removes the version-sniffing shim (mintCompat, MintInfo.isImplementationBelow), so
releasing no longer depends on pinning the first mint versions to verify the amendment.

On v4 the public signMintQuote keeps its legacy bytes for back-compat, so the wallet
signs via signMintQuoteAmended and falls back to signMintQuote.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nut hold This PR is held pending the merge of a new or updated NUT

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant