Skip to content

fix mint quote lookup: auth bypass, unreachable endpoint, and spec conformance - #8

Merged
TheMhv merged 6 commits into
TheMhv:feat/mint_quote_lookupfrom
vnprc:fix/nutxx-lookup
Aug 7, 2026
Merged

fix mint quote lookup: auth bypass, unreachable endpoint, and spec conformance#8
TheMhv merged 6 commits into
TheMhv:feat/mint_quote_lookupfrom
vnprc:fix/nutxx-lookup

Conversation

@vnprc

@vnprc vnprc commented Aug 7, 2026

Copy link
Copy Markdown

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

pubkeys.len().ne(&signatures.len()).then(|| {
    tracing::error!("Signatures must be the same length of publickeys");
    Error::SignatureMissingOrInvalid 
});

bool::then returns Option<Error>, and the statement drops it. Nothing returns. The zip below then stops at the shorter iterator, so an empty pubkey_signatures array 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 Option isn't unused_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:

  • The route lived in create_custom_routers, which is only nested when custom_methods is non-empty. An ordinary bolt11/bolt12 mint returned 404.
  • Where it was mounted, {method} had been removed from the path but the handler still declared Path(method): Path<String>, so axum rejected every request before the body ran. Compiles fine; fails at runtime.

Dropping the path parameter also left verify_auth with nothing to key on, so NUT-21 gains a RoutePath::MintQuoteByPubkey variant.

And the query itself was broken: get_mint_quotes_by_pubkey dropped updated_at from its SELECT, so every call failed with MissingColumn(14, 13).

Spec conformance

  • Signature was verified over a double hash. PublicKey::verify hashes its argument, so passing it the digest checks SHA256(SHA256(preimage)) rejecting conformant wallets. Now passes the pre-image, as NUT-20 does.
  • Response shape. Was a bare array with each element in a {"Bolt11": …} envelope; the NUT specifies {"quotes": [<MintQuoteResponse>]}.
  • NUT-06 settings now advertise support, or wallets can't discover it.

Tests

The database test was defined but never added to mint_db_test!, so no backend ran it. Registering it is what surfaced the updated_at bug. 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_PUBKEYS is a hard constant. I've proposed max_pubkeys as a NUT-06 setting upstream.
  • Mint::new prefers stored mint info, so existing mints won't advertise support until something rewrites theirs. Might warrant a migration.
  • The path here is /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.

vnprc added 6 commits August 7, 2026 15:35
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.

@TheMhv TheMhv left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@TheMhv
TheMhv merged commit d7ad402 into TheMhv:feat/mint_quote_lookup Aug 7, 2026
@vnprc

vnprc commented Aug 10, 2026

Copy link
Copy Markdown
Author

Yeah my clanker loves to talk. This gets the functionality working, make any edits you want in your PR. Thanks for merging!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants