Skip to content

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

Open
robwoodgate wants to merge 17 commits into
mainfrom
fix/nut2029-msg-separators-main
Open

fix(crypto)!: align NUT-29 batch quote signatures with amended spec#675
robwoodgate wants to merge 17 commits into
mainfrom
fix/nut2029-msg-separators-main

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 / verifyMintQuoteSignature — the amended (spec) format, used for both single and batch minting against current mints. Pinned to the canonical vector in tests/29-tests.md (msg_hash = 03dc68d6…).
  • signMintQuoteLegacy / verifyMintQuoteSignatureLegacy — the pre-amendment concatenation (quote || B_0 || … || B_(n-1)), used internally as a transitional fallback for mints that predate the amendment. Not exported from the public barrel (the wallet imports them directly); the transition cleanup is pure deletion of the Legacy-suffixed pair.

Schnorr plumbing reuses crypto/core.ts (schnorrSignDigest plus a new schnorrVerifyDigest counterpart).

Note

Breaking (v5): signMintQuote/verifyMintQuoteSignature now produce/verify the amended message. The pre-amendment bytes are no longer exported — the Legacy pair remains only as an internal fallback driving the wallet's automatic legacy retry. Consumers needing the old bytes should pin v4.

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 goes on the wire; the legacy one 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.

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

❌ Patch coverage is 98.21429% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.70%. Comparing base (9546f23) to head (a72c120).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/wallet/Wallet.ts 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #675      +/-   ##
==========================================
+ Coverage   92.60%   92.70%   +0.09%     
==========================================
  Files          55       55              
  Lines        5060     5098      +38     
  Branches     1248     1256       +8     
==========================================
+ Hits         4686     4726      +40     
  Misses        160      160              
+ Partials      214      212       -2     
Flag Coverage Δ
integration 38.54% <66.07%> (+0.24%) ⬆️

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 the nut hold This PR is held pending the merge of a new or updated NUT label 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.
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: signMintQuote /
verifyMintQuoteSignature now produce the amended format, and the pre-amendment
concatenation moves to signMintQuoteLegacy / verifyMintQuoteSignatureLegacy.
The transition cleanup is then pure deletion of the Legacy-suffixed pair.
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.
@robwoodgate robwoodgate changed the title fix(crypto): align NUT-29 batch quote signatures with amended spec fix(crypto)!: align NUT-29 batch quote signatures with amended spec Jun 12, 2026
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.
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 and others added 6 commits June 23, 2026 23:38
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 a legacy
signature when a pre-amendment mint rejects it with error 20008. Removes the version-
sniffing compat shim (mintCompat, MintInfo.isImplementationBelow), so releasing no
longer depends on pinning the first mint versions that verify the amended message.
Log a warning before retrying a mint request with the legacy NUT-20 signature, so
operators can see when they are talking to a mint that predates the amended message.
Drop signMintQuoteLegacy/verifyMintQuoteSignatureLegacy from the public crypto barrel
(switch the NUT20 re-export to the amended pair only). The legacy message is a
transitional fallback used internally by Wallet.completeMint and deleted with it, so
v5 should not expose it as a fresh public symbol — mirroring how v4 keeps its amended
pair internal. Wallet and tests import the legacy pair directly from crypto/NUT20.

Documents the amended-message switch in migration-5.0.0.md.
@robwoodgate robwoodgate added the experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental label Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental 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