fix(crypto): align NUT-29 batch quote signatures with amended spec#674
Open
robwoodgate wants to merge 14 commits into
Open
fix(crypto): align NUT-29 batch quote signatures with amended spec#674robwoodgate wants to merge 14 commits into
robwoodgate wants to merge 14 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✅ All modified and coverable lines are covered by tests. 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
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. (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)
3 tasks
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.
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.
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— unchanged 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 assignMintQuote/verifyMintQuoteSignature. Pinned to the canonical vector intests/29-tests.md(msg_hash = 03dc68d6…).Schnorr plumbing reuses
crypto/core.ts(schnorrSignDigestplus a newschnorrVerifyDigestcounterpart). Public API delta on v4: gainsschnorrVerifyDigest, loses the never-releasedsignBatchMintQuotepair.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 (signMintQuoteAmended) goes on the wire; the legacy one (signMintQuote, which keeps v4's released bytes) 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 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.