fix mint quote lookup: auth bypass, unreachable endpoint, and spec conformance - #8
Merged
Merged
Conversation
The branch was rebased onto a main that already had 04cbe81 ("align mint quote accounting with NUT-04"), which added method/amount_paid/ amount_issued/updated_at to the quote response types and a 15th `updated_at` argument to `MintQuote::new`. Nothing conflicted, because the new code is all in new blocks, so three sites broke silently. - `get_mint_quote_by_pubkey` built the four response types by hand. Replaced with the existing `TryFrom<MintQuote> for MintQuoteResponse<QuoteId>` in cdk-common, which is the conversion every other quote path already uses and stays current on its own. Fixes four E0063s and removes 60 lines. - The axum handler read `pubkeys_signatures`; the struct declares `pubkey_signatures` (E0609). - The database fixture passed 14 of 15 arguments to `MintQuote::new` (E0061).
`get_mint_quotes_by_pubkey` copied its SELECT list from `get_mint_quotes`
forty lines above but dropped `updated_at`. `sql_row_to_mint_quote` reads
fourteen columns and got thirteen, so every call to the endpoint failed:
Database(Conversion(MissingColumn(14, 13)))
This is the second bug in the feature caused by copying something that then
moved; the column list is a candidate for a shared const.
NUT-XX: "The mint MUST reject the request unless every signature is valid."
Two problems stopped that from holding.
The length guard did nothing. `pubkeys.len().ne(&signatures.len()).then(..)`
builds an `Option<Error>` and drops it, so nothing enforced the equality.
`pubkeys.iter().zip(signatures.iter())` then stops at the shorter iterator,
which means an empty `pubkey_signatures` array skipped verification
entirely: anyone who knew a pubkey could read that user's quotes, and
pubkeys travel in mint quote requests. Now `ensure_cdk!`.
Also bound the request length. The endpoint is unauthenticated until the
signatures verify, so without a cap an anonymous caller can ask the mint for
an unbounded number of Schnorr verifications plus an `IN (...)` query.
`MAX_LOOKUP_PUBKEYS` is a plain constant; happy to make it a NUT-06 setting
like NUT-29's `max_batch_size` if that reads better.
The signature was verified over a double hash. The NUT signs
SHA256("Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey), but
`PublicKey::verify` already hashes its argument before BIP-340 verification
— which is why NUT-20 passes it the raw message. This hashed first and
passed the digest, so the mint checked SHA256(SHA256(preimage)) and rejected
conformant wallets while accepting only double-hashed ones.
Message construction now lives in `mint_quote_lookup_msg_to_sign` next to
the request type, with a byte-level vector so it cannot drift from the NUT
silently, and a test that the digest does not verify.
The endpoint could not be reached on any mint.
The route was registered inside `create_custom_routers`, which
`create_mint_router` only nests when `custom_methods` is non-empty. An
ordinary bolt11/bolt12 mint therefore answered 404. The lookup is
method-agnostic, so it belongs next to the other v1 routes rather than in
the per-method custom router.
Where the custom router *was* mounted, every request failed instead: the
last commit removed `{method}` from the path but the handler still declared
`Path(method): Path<String>`, so axum rejected before the body ran with
"Wrong number of path arguments for `Path`. Expected 1 but got 0."
Dropping the path parameter left `verify_auth` with nothing to key on, so
NUT-21 gains a `RoutePath::MintQuoteByPubkey` variant rather than borrowing
`MintQuote(method)`. Its `FromStr` arm has to precede the
`/v1/mint/quote/` prefix branch, which would otherwise read this path as a
payment method literally named "pubkey" — there is a round-trip assertion
in `test_route_path_serialization` so a reordering cannot pass silently.
Response. The NUT defines `PostMintQuotesByPubkeyResponse` as
`{"quotes": [<MintQuoteResponse>, ...]}` with the NUT-04 quote objects.
The handler returned `Json(Vec<MintQuoteResponse<QuoteId>>)`, and that enum
is externally tagged, so the body was a bare array whose elements were each
wrapped in a `{"Bolt11": ...}` envelope no NUT describes. The envelope type
now lives in nutxx.rs, and the handler flattens each quote to its inner
response the way `melt_quote_response_to_json` already does in this file.
Each quote carries its own `method` field, so nothing is lost by flattening.
Request. `pubkeys` and `pubkey_signatures` are now `Vec<PublicKey>` and
`Vec<Signature>`. Both already serialise as hex, so the wire format is
unchanged, but the two manual `.parse()` blocks in the handler go away and
malformed input is reported by serde instead of as
`Error::InvalidPaymentRequest`, which read oddly on a lookup endpoint.
Settings. A mint that serves the endpoint has to say so, or wallets cannot
discover it: `"XX": {"supported": <bool>}` in the NUT-06 info response, set
by `MintBuilder` alongside the other supported NUTs. Note this is keyed "XX"
until the NUT is assigned a number, and it reaches the FFI `Nuts` record as
`nutxx_supported` so the round trip stays lossless.
Caveat for existing deployments: `Mint::new` prefers stored mint info and
only merges pubkey/nut21/nut22 from code defaults, so a mint upgrading to
this build keeps its stored settings and will not advertise support until
something rewrites its mint info.
`get_mint_quote_by_public_key` was defined but never added to the
`mint_db_test!` list, so no backend ran it. That is how the missing
`updated_at` column reached this branch: registering the test makes it fail
immediately on the unfixed query. It now runs against both sqlite and
postgres, and also covers quotes owned by another pubkey, several pubkeys at
once, an unknown pubkey, and an empty request.
New coverage for the rest of the feature:
- crates/cdk/tests: signed lookup succeeds; a digest-signed signature is
refused while a preimage-signed one is accepted; missing or short
signature arrays are refused; another key's and another mint's signatures
are refused; oversized requests are refused; support is advertised in
NUT-06.
- crates/cdk-axum/tests: the route answers with and without custom payment
methods configured, the body is `{"quotes": [...]}` with flat NUT-04
quote objects, and an unsigned request does not return 200.
Index naming: `idx_pubkey` was schema-global in both SQLite and Postgres and
did not match the other indexes on this table (`idx_mint_quote_expiry`,
`idx_mint_quote_created_time`, ...). Renamed to `idx_mint_quote_pubkey`. The
migration was also re-dated, having sorted ahead of eleven migrations
already in the tree.
2 tasks
TheMhv
self-requested a review
August 7, 2026 20:50
TheMhv
approved these changes
Aug 7, 2026
TheMhv
left a comment
Owner
There was a problem hiding this comment.
Nice work! Thank you for review and fix some issues with spec.
Maybe we can remove some of the additional comments for maintain code standard.
Author
|
Yeah my clanker loves to talk. This gets the functionality working, make any edits you want in your PR. Thanks for merging! |
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.
Six fixes on top of
feat/mint_quote_lookup. Commit messages carry the full reasoning. This is the summary.The length guard is a no-op
bool::thenreturnsOption<Error>, and the statement drops it. Nothing returns. The zip below then stops at the shorter iterator, so an emptypubkey_signaturesarray means the loop body never runs and no signature is checked at all. Anyone who knows a pubkey can read that pubkey's quotes, and pubkeys travel in mint quote requests.Worse, the closure still fires, so the mint logs the rejection it didn't perform. rustc is silent on this (a discarded
Optionisn'tunused_must_use), which is why it reads as a guard.Verified against the unpatched branch: a request with zero signatures returns the victim's quote.
Fixed with
ensure_cdk!, plus a cap on request length. The endpoint is unauthenticated until signatures verify, so without one an anonymous caller can buy unbounded Schnorr verifications.The endpoint never answered
Two independent reasons, both fixed in 5e288d:
create_custom_routers, which is only nested whencustom_methodsis non-empty. An ordinary bolt11/bolt12 mint returned 404.{method}had been removed from the path but the handler still declaredPath(method): Path<String>, so axum rejected every request before the body ran. Compiles fine; fails at runtime.Dropping the path parameter also left
verify_authwith nothing to key on, so NUT-21 gains aRoutePath::MintQuoteByPubkeyvariant.And the query itself was broken:
get_mint_quotes_by_pubkeydroppedupdated_atfrom its SELECT, so every call failed withMissingColumn(14, 13).Spec conformance
PublicKey::verifyhashes its argument, so passing it the digest checksSHA256(SHA256(preimage))rejecting conformant wallets. Now passes the pre-image, as NUT-20 does.{"Bolt11": …}envelope; the NUT specifies{"quotes": [<MintQuoteResponse>]}.Tests
The database test was defined but never added to
mint_db_test!, so no backend ran it. Registering it is what surfaced theupdated_atbug. Added coverage for the bypass, cross-key and cross-mint signatures, the double-hash case, oversized requests, both wire formats, and the route with and without custom methods. Full suite green (62 suites); postgres tests need a running server.Decisions:
MAX_LOOKUP_PUBKEYSis a hard constant. I've proposedmax_pubkeysas a NUT-06 setting upstream.Mint::newprefers stored mint info, so existing mints won't advertise support until something rewrites theirs. Might warrant a migration./v1/mint/quote/pubkey, but the NUT still specifies/v1/mint/quote/{method}/pubkey. I think your removal was right. The lookup is method-agnostic, the signature doesn't cover the method, and every MintQuoteResponse already carries a method field so I've opened a spec PR arguing that rather than quietly diverging: Mint Quote Lookup by Public Key spec fixes cashubtc/nuts#419. If it's rejected, this should follow the spec.