NUT-XX: Mint Quote Lookup by Public Key - #1834
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1834 +/- ##
========================================
Coverage 78.67% 78.67%
========================================
Files 386 387 +1
Lines 103373 103569 +196
========================================
+ Hits 81330 81485 +155
- Misses 22043 22084 +41 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6869944 to
4797303
Compare
900c85a to
0a811de
Compare
bf90e64 to
c19a25f
Compare
c19a25f to
7e6b290
Compare
|
Thanks for driving this forward. I have an ESP32-S3 device with an on-device ecash wallet (using the nucula library) that I'd like to connect directly to a CDK mint supporting NUT-XX ("Get quotes by pubkeys", cashubtc/nuts#341). My current workaround routes tokens through a stratum proxy translator, but direct mint queries from the ESP32 would simplify the architecture significantly. I can offer testing against actual ESP32 hardware with my wallet integration. The secp256k1 Schnorr signing is already available on-device. I'm very much looking forward to integrating this — let me know if there's a branch I should target for integration testing. |
22f427a to
7454f41
Compare
cdk-bot
left a comment
There was a problem hiding this comment.
Verified findings approved for disclosure:
- Signature check bypass lets pubkey quote lookup disclose other users' quotes (medium) - An attacker can query the new endpoint with arbitrary victim pubkeys and an empty or truncated signature list to read private mint-quote metadata/invoices for those pubkeys without proving ownership.
cdk-bot
left a comment
There was a problem hiding this comment.
Verified findings approved for disclosure:
- Mint quote by pubkey response is serialized with the wrong root shape (medium) - Spec-compliant clients cannot deserialize the new quote lookup response because the mint returns a top-level array instead of the required object with
quotes. - Mint quote by pubkey request uses non-standard signature field name (medium) - Spec-compliant clients sending
pubkey_signaturescannot use the new endpoint because the mint expects the non-standardpubkeys_signaturesfield.
| .map_err(|e| { | ||
| tracing::error!("Invalid Signature: {}", e); | ||
| into_response(cdk::Error::SignatureMissingOrInvalid) | ||
| })?; |
There was a problem hiding this comment.
The response shape for this new endpoint does not match the NUT-29 draft linked from the code. The spec defines PostMintQuotesByPubkeyResponse as an object:
{ "quotes": <Array[MintQuoteResponse]> }But this handler returns Json(response) directly, and response is the Vec<MintQuoteResponse<QuoteId>> returned by get_mint_quote_by_pubkey, so clients receive a top-level JSON array. Spec-compliant wallets deserializing the response object will fail. Please wrap the vector in a response struct with a quotes field before serializing.
7454f41 to
95ed28d
Compare
|
35cb21d to
cdf494c
Compare
00f0dc8 to
e1d5623
Compare
88aad43 to
5d9a509
Compare
|
@cdk-bot review |
cdk-bot
left a comment
There was a problem hiding this comment.
Verified findings approved for disclosure:
- get_mint_quotes_by_pubkey SELECT omits last_checked, so every matching row fails to decode (high) - On SQLite and Postgres backends (the only SQL implementors), any NUT-XX quote-by-pubkey lookup that matches at least one quote returns an internal error (ConversionError::MissingColumn(15, 14)) instead of the quotes. Only empty lookups succeed. The endpoint's success path is broken.
- Custom payment method named "pubkey" is silently shadowed by the new NUT-XX route (low) - A mint configuring a custom payment method literally named "pubkey" would have its POST /v1/mint/quote/pubkey (create quote) endpoint silently routed to the NUT-XX lookup handler instead of post_mint_custom_quote. Additionally, NUT-21 RoutePath::MintQuote("pubkey") no longer round-trips through FromStr. Narrow impact — only affects the unlikely configuration of a method named "pubkey".
Additional locations included in summary:- crates/cashu/src/nuts/auth/nut21.rs:177
Unanchored locations included in summary: - crates/cdk-axum/src/custom_router.rs:70
- crates/cashu/src/nuts/auth/nut21.rs:177
23d1a77 to
1ac0049
Compare
|
@cdk-bot review |
cdk-bot
left a comment
There was a problem hiding this comment.
Verified findings approved for disclosure:
- NUT-XX lookup route only mounted when custom payment methods are configured, yet nutxx support is advertised unconditionally () - Mints built without custom payment methods advertise NUT-XX support in their /v1/info response but return HTTP 404 for the lookup endpoint
POST /v1/mint/quote/{method}/pubkey. Spec-conformant wallets that read the advertisement will attempt the lookup and fail. In standard cdk-mintd deployments that include "bolt11" in custom_methods the route is available, but programmatic/embedded mints usingcreate_mint_router(mint, vec![])are affected.
Additional locations included in summary:- crates/cdk/src/mint/builder.rs:107
Unanchored locations included in summary: - crates/cdk-axum/src/lib.rs:126
- crates/cdk/src/mint/builder.rs:107
- New route tests POST to a path that no longer exists; happy-path assertions are shadowed by the quote-creation route (method = "pubkey") () - All three new route tests POST to the wrong path (/v1/mint/quote/pubkey instead of /v1/mint/quote/{method}/pubkey). Two of the three tests would fail in CI; the third passes as a false positive (404 instead of the intended 401/403). No test actually exercises the post_mint_quote_by_pubkey handler, so the route is untested.
Additional locations included in summary:- crates/cdk-axum/src/custom_router.rs:50
dfb55b6 to
0ee8f20
Compare
|
@cdk-bot review |
cdk-bot
left a comment
There was a problem hiding this comment.
Verified findings approved for disclosure:
- NUT-XX route registered at /v1/{method}/pubkey instead of /v1/mint/quote/{method}/pubkey — spec URL is never served (high) - see inline comment
- MintQuoteByPubkey missing from RoutePath::match_specificity — case-sensitive fallback allows NUT-21/22 auth bypass with mixed-case method segment (medium) - see inline comment
Unanchored locations included in summary:- crates/cashu/src/nuts/auth/nut21.rs:234
- get_mint_quotes_by_pubkey: unbounded row count plus 2N hydration queries on an anonymously reachable endpoint (medium) - see inline comment
Additional locations included in summary:- crates/cdk/src/mint/issue/mod.rs:455
| Ok(mint_quotes) | ||
| } | ||
|
|
||
| async fn get_mint_quotes_by_pubkey( |
There was a problem hiding this comment.
Anonymous endpoint fans out into 1 + 2N SQL queries with no row cap
The new SQL implementation:
// crates/cdk-sql-common/src/mint/quotes.rs
SELECT ... FROM mint_quote WHERE pubkey IN (:pubkeys) // no LIMIT
...
for quote in mint_quotes.as_mut_slice() {
let payments = get_mint_quote_payments(&*conn, "e.id).await?; // +1 query per quote
let issuance = get_mint_quote_issuance(&*conn, "e.id).await?; // +1 query per quote
...
}MAX_LOOKUP_PUBKEYS = 50 caps the number of pubkeys (and thus signature verifications) per request, but nothing caps how many quote rows those pubkeys resolve to. Since mint-quote creation is unauthenticated on a default mint and accepts an arbitrary NUT-20 pubkey, an attacker can:
- Over time, create thousands of quotes locked to their own key(s) (each quote creation may also cost the LN backend an invoice).
- Call
POST /v1/mint/quote/{method}/pubkeyanonymously with a valid signature and trigger1 + 2Ndatabase queries per request, repeatedly.
There is also no rate limiting on this endpoint — the tower dependency added to cdk-axum is a dev-dependency used only by the new tests. The per-quote hydration mirrors get_mint_quotes, but that trait method is only used by internal/management paths, not by an anonymously reachable HTTP handler, so this PR newly exposes the pattern to untrusted input.
Fix options (any combination):
- Cap total returned rows (
LIMIT/ rejection when a pubkey set resolves to more than a bounded number of quotes). - Batch the hydration: one
IN (:quote_ids)query each for payments and issuance instead of two queries per quote. - Add a rate limit (e.g. tower
ServiceBuilder::rate_limit, keyed per-IP) on this unauthenticated route.
There was a problem hiding this comment.
@thesimplekid @crodas I don't know how we can solve this things without adding a rate limit or quote result limit.
There was a problem hiding this comment.
Yeah brought this up on the spec a bit ago. cashubtc/nuts#341 (comment)
d72bb6b to
66077ef
Compare
|
Can we clean up the commit history here. |
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.
66077ef to
1c5cf35
Compare
Description
The CDK implementation of NUT-XX: Mint Quote Lookup by Public Key
Closes: #1746
Notes to the reviewers
/mint/quote/{method}/pubkeyget_mint_quotes_by_pubkeydatabase function and add your testSuggested CHANGELOG Updates
CHANGED
ADDED
nuts/nutxx.rsv1/mint/quote/{method}/pubkeyREMOVED
FIXED
Checklist
just final-checkbefore committing