fix(crypto)!: align NUT-29 batch quote signatures with amended spec#675
Open
robwoodgate wants to merge 17 commits into
Open
fix(crypto)!: align NUT-29 batch quote signatures with amended spec#675robwoodgate wants to merge 17 commits into
robwoodgate wants to merge 17 commits into
Conversation
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 Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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).
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.
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.
This was referenced Jun 12, 2026
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.
This was referenced Jun 18, 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 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
where
DST = "Cashu_MintQuoteSig_v1",len32(x)is the 4-byte big-endian length ofx,amountis the canonical minimal big-endian encoding (0→ empty), andB_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 intests/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 theLegacy-suffixed pair.Schnorr plumbing reuses
crypto/core.ts(schnorrSignDigestplus a newschnorrVerifyDigestcounterpart).Note
Breaking (v5):
signMintQuote/verifyMintQuoteSignaturenow produce/verify the amended message. The pre-amendment bytes are no longer exported — theLegacypair 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/prepareBatchMintsign both messages. The amended signature goes on the wire; the legacy one is kept in the preview (legacySignature/legacySignatures, marked@deprecatedas transitional).completeMint/completeBatchMintsend 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.