diff --git a/Cargo.lock b/Cargo.lock index 69c98fd31..c19087733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,6 +1256,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tokio", + "tower", "tracing", "uuid", ] diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index 266fc50e4..51442d279 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -130,6 +130,8 @@ pub enum RoutePath { Wildcard(String), /// Mint Quote for a specific payment method MintQuote(String), + /// Mint Quote lookup by public key (NUT-XX), method-agnostic + MintQuoteByPubkey, /// Mint for a specific payment method Mint(String), /// Melt Quote for a specific payment method @@ -172,6 +174,9 @@ impl std::str::FromStr for RoutePath { "/v1/restore" => Ok(RoutePath::Restore), "/v1/auth/blind/mint" => Ok(RoutePath::MintBlindAuth), "/v1/ws" => Ok(RoutePath::Ws), + // Must precede the `/v1/mint/quote/` prefix branch below, which would otherwise + // read this as a payment method literally named "pubkey". + "/v1/mint/quote/pubkey" => Ok(RoutePath::MintQuoteByPubkey), _ => { // Try to parse as a payment method route if let Some(method) = s.strip_prefix("/v1/mint/quote/") { @@ -339,6 +344,7 @@ impl std::fmt::Display for RoutePath { match self { RoutePath::Wildcard(prefix) => write!(f, "{}*", prefix), RoutePath::MintQuote(method) => write!(f, "/v1/mint/quote/{}", method), + RoutePath::MintQuoteByPubkey => write!(f, "/v1/mint/quote/pubkey"), RoutePath::Mint(method) => write!(f, "/v1/mint/{}", method), RoutePath::MeltQuote(method) => write!(f, "/v1/melt/quote/{}", method), RoutePath::Melt(method) => write!(f, "/v1/melt/{}", method), @@ -692,6 +698,15 @@ mod tests { let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap(); assert_eq!(json, "\"/v1/mint/quote/paypal\""); + // NUT-XX lookup is a static path. It must not be read as a payment method named + // "pubkey" by the `/v1/mint/quote/` prefix branch in `FromStr`. + let json = serde_json::to_string(&RoutePath::MintQuoteByPubkey).unwrap(); + assert_eq!(json, "\"/v1/mint/quote/pubkey\""); + assert_eq!( + RoutePath::from_str("/v1/mint/quote/pubkey").unwrap(), + RoutePath::MintQuoteByPubkey + ); + // Test deserialization of payment method paths let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap(); assert_eq!( diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 8dc6f6d82..1d56d7457 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -337,6 +337,12 @@ pub struct Nuts { #[serde(rename = "29")] #[serde(skip_serializing_if = "nut29::Settings::is_empty")] pub nut29: nut29::Settings, + /// NUTXX Settings (mint quote lookup by pubkey) + /// + /// Keyed `XX` until the NUT is assigned a number. + #[serde(default)] + #[serde(rename = "XX")] + pub nutxx: SupportedSettings, } impl Nuts { @@ -462,6 +468,14 @@ impl Nuts { } } + /// NutXX settings (mint quote lookup by pubkey) + pub fn nutxx(self, supported: bool) -> Self { + Self { + nutxx: SupportedSettings { supported }, + ..self + } + } + /// Units where minting is supported pub fn supported_mint_units(&self) -> Vec<&CurrencyUnit> { self.nut04 diff --git a/crates/cashu/src/nuts/nutxx.rs b/crates/cashu/src/nuts/nutxx.rs index d389106b6..eb07efec3 100644 --- a/crates/cashu/src/nuts/nutxx.rs +++ b/crates/cashu/src/nuts/nutxx.rs @@ -2,13 +2,155 @@ //! //! +use bitcoin::secp256k1::schnorr::Signature; use serde::{Deserialize, Serialize}; +use super::PublicKey; + +/// Domain separator for mint quote lookup signatures [NUT-XX] +pub const MINT_QUOTE_LOOKUP_DOMAIN: &[u8] = b"Cashu_MintQuoteLookup_v1"; + +/// Maximum number of pubkeys accepted in a single lookup request. +/// +/// The endpoint is unauthenticated until the signatures are checked, so the request length +/// bounds how much signature verification an anonymous caller can ask the mint to perform. +pub const MAX_LOOKUP_PUBKEYS: usize = 50; + /// Mint quote by pubkey request [NUT-XX] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintQuoteByPubkeyRequest { - /// Pubkeys - pub pubkeys: Vec, - /// Signatures - pub pubkey_signatures: Vec, + /// NUT-20 public keys to look up quotes for + pub pubkeys: Vec, + /// Schnorr signatures, in the same order as `pubkeys` + pub pubkey_signatures: Vec, +} + +/// Mint quote by pubkey response [NUT-XX] +/// +/// Generic over the quote representation so the mint can answer with its own response type +/// without this crate depending on the unified `MintQuoteResponse` enum in `cdk-common`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MintQuoteByPubkeyResponse { + /// Quotes locked to the requested pubkeys, in [NUT-04] response format + pub quotes: Vec, +} + +/// Build the message a wallet signs to prove control of `pubkey`. +/// +/// Per [NUT-XX] the signature covers the SHA-256 hash of +/// `"Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey`, with the pubkeys concatenated as +/// their UTF-8 hex string representations. +/// +/// The returned value is the *pre-image*, not the digest — pass it straight to +/// [`PublicKey::verify`] / [`crate::SecretKey::sign`], both of which hash their argument. +pub fn mint_quote_lookup_msg_to_sign(mint_pubkey: &PublicKey, pubkey: &PublicKey) -> Vec { + let mint_pubkey = mint_pubkey.to_hex(); + let pubkey = pubkey.to_hex(); + + let mut msg = + Vec::with_capacity(MINT_QUOTE_LOOKUP_DOMAIN.len() + mint_pubkey.len() + pubkey.len()); + msg.extend_from_slice(MINT_QUOTE_LOOKUP_DOMAIN); + msg.extend_from_slice(mint_pubkey.as_bytes()); + msg.extend_from_slice(pubkey.as_bytes()); + msg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecretKey; + + /// Fixed keys so the vector below is reproducible. + const MINT_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const WALLET_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + fn fixed_keys() -> (PublicKey, PublicKey) { + ( + SecretKey::from_hex(MINT_SECRET).unwrap().public_key(), + SecretKey::from_hex(WALLET_SECRET).unwrap().public_key(), + ) + } + + /// Pins the pre-image so the construction cannot drift from the NUT without this failing. + #[test] + fn test_msg_to_sign_vector() { + let (mint_pubkey, pubkey) = fixed_keys(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + + assert_eq!( + String::from_utf8(msg).unwrap(), + "Cashu_MintQuoteLookup_v1\ + 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\ + 02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + ); + } + + /// A signature produced over the pre-image verifies, and one bound to a different mint + /// does not — this is what stops a signature being replayed at another mint. + #[test] + fn test_sign_and_verify_is_mint_bound() { + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = secret_key.sign(&msg).unwrap(); + assert!(pubkey.verify(&msg, &signature).is_ok()); + + let other_mint = SecretKey::generate().public_key(); + let other_msg = mint_quote_lookup_msg_to_sign(&other_mint, &pubkey); + assert!(pubkey.verify(&other_msg, &signature).is_err()); + } + + /// A signature is bound to the pubkey it authorises, so one pubkey's signature cannot be + /// used to read another pubkey's quotes. + #[test] + fn test_signature_is_bound_to_pubkey() { + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let victim = SecretKey::generate().public_key(); + + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key()); + let signature = secret_key.sign(&msg).unwrap(); + + let victim_msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &victim); + assert!(victim.verify(&victim_msg, &signature).is_err()); + } + + /// The wire format is hex strings, per the NUT. + #[test] + fn test_request_wire_format() { + let (mint_pubkey, _) = fixed_keys(); + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = secret_key.sign(&msg).unwrap(); + + let json = serde_json::to_string(&MintQuoteByPubkeyRequest { + pubkeys: vec![pubkey], + pubkey_signatures: vec![signature], + }) + .unwrap(); + + assert!(json.contains(&pubkey.to_hex())); + assert!(json.contains(&signature.to_string())); + + let request: MintQuoteByPubkeyRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(request.pubkeys, vec![pubkey]); + assert_eq!(request.pubkey_signatures, vec![signature]); + } + + /// The response envelope is an object with a `quotes` array, not a bare array. + #[test] + fn test_response_wire_format() { + let response = MintQuoteByPubkeyResponse { + quotes: vec![serde_json::json!({"quote": "abc", "method": "bolt11"})], + }; + + assert_eq!( + serde_json::to_value(&response).unwrap(), + serde_json::json!({"quotes": [{"quote": "abc", "method": "bolt11"}]}) + ); + } } diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 09d6f412d..556592887 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -49,6 +49,8 @@ cdk-fake-wallet = { workspace = true } cdk-sqlite = { workspace = true, features = ["mint"] } cdk-signatory = { workspace = true } bip39 = { workspace = true } +tower = { workspace = true, features = ["util"] } +tokio = { workspace = true, features = ["full"] } [lints] workspace = true diff --git a/crates/cdk-axum/src/custom_handlers.rs b/crates/cdk-axum/src/custom_handlers.rs index 83f04ce9a..888fd78fe 100644 --- a/crates/cdk-axum/src/custom_handlers.rs +++ b/crates/cdk-axum/src/custom_handlers.rs @@ -13,7 +13,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::mint::QuoteId; use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; -use cdk::nuts::nutxx::MintQuoteByPubkeyRequest; +use cdk::nuts::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse}; use cdk::nuts::{ BatchCheckMintQuoteRequest, BatchMintRequest, MeltOnchainRequest, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteCustomRequest, MeltQuoteOnchainRequest, @@ -21,6 +21,7 @@ use cdk::nuts::{ MintQuoteBolt12Response, MintQuoteCustomRequest, MintQuoteOnchainRequest, MintQuoteOnchainResponse, MintRequest, MintResponse, PaymentMethod, }; +use cdk::MintQuoteResponse; use cdk::{MeltQuoteCreateResponse, MeltQuoteResponse}; use serde_json::Value; use tracing::instrument; @@ -728,55 +729,56 @@ pub async fn cache_post_batch_mint( Ok(result) } -/// Generic handler for get mint quotes by pubkey -#[instrument(skip_all, fields(method = ?method))] +/// Flatten a `MintQuoteResponse` to the NUT-04 quote object that goes on the wire. +/// +/// `MintQuoteResponse` is an externally tagged enum, so serialising it directly would wrap +/// each quote in a `{"Bolt11": …}` envelope that no NUT describes. +fn mint_quote_response_to_value( + response: MintQuoteResponse, +) -> Result { + match response { + MintQuoteResponse::Bolt11(r) => serde_json::to_value(r), + MintQuoteResponse::Bolt12(r) => serde_json::to_value(r), + MintQuoteResponse::Onchain(r) => serde_json::to_value(r), + MintQuoteResponse::Custom { response, .. } => serde_json::to_value(response), + } +} + +/// Handler for mint quote lookup by public key (NUT-XX) +/// +/// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all +/// returned together, each in its own NUT-04 response format. +#[instrument(skip_all)] pub async fn post_mint_quote_by_pubkey( auth: AuthHeader, State(state): State, - Path(method): Path, - Json(payload): Json, + Json(request): Json, ) -> Result { state .mint .verify_auth( auth.into(), - &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteByPubkey), ) .await .map_err(into_response)?; - let request: MintQuoteByPubkeyRequest = serde_json::from_value(payload).map_err(|e| { - tracing::error!("Failed to parse request: {}", e); - into_response(cdk::Error::InvalidPaymentRequest) - })?; - - let pubkeys = request - .pubkeys - .iter() - .map(|s| s.parse()) - .collect::>() - .map_err(|e| { - tracing::error!("Invalid Public Key: {}", e); - into_response(cdk::Error::InvalidPaymentRequest) - })?; - - let signatures = request - .pubkeys_signatures - .iter() - .map(|s| s.parse()) - .collect::>() - .map_err(|e| { - tracing::error!("Invalid Signature: {}", e); - into_response(cdk::Error::SignatureMissingOrInvalid) - })?; - - let response = state + let quotes = state .mint - .get_mint_quote_by_pubkey(pubkeys, signatures) + .get_mint_quote_by_pubkey(request.pubkeys, request.pubkey_signatures) .await .map_err(into_response)?; - Ok(Json(response).into_response()) + let quotes = quotes + .into_iter() + .map(mint_quote_response_to_value) + .collect::, _>>() + .map_err(|e| { + tracing::error!("Failed to serialize mint quotes: {}", e); + into_response(cdk::Error::Internal) + })?; + + Ok(Json(MintQuoteByPubkeyResponse { quotes }).into_response()) } #[cfg(test)] diff --git a/crates/cdk-axum/src/custom_router.rs b/crates/cdk-axum/src/custom_router.rs index 5af78728e..7c827f2ec 100644 --- a/crates/cdk-axum/src/custom_router.rs +++ b/crates/cdk-axum/src/custom_router.rs @@ -10,7 +10,7 @@ use cdk::nuts::PaymentMethod; use crate::custom_handlers::{ cache_post_batch_mint, cache_post_melt_custom, cache_post_mint_custom, get_check_melt_custom_quote, get_check_mint_custom_quote, post_batch_check_mint_quote, - post_melt_custom_quote, post_mint_custom_quote, post_mint_quote_by_pubkey, + post_melt_custom_quote, post_mint_custom_quote, }; use crate::MintState; @@ -46,7 +46,6 @@ pub fn create_custom_routers(state: MintState, custom_methods: Vec) -> R "/mint/quote/{method}/check", post(post_batch_check_mint_quote), ) - .route("/mint/quote/pubkey", post(post_mint_quote_by_pubkey)) .route("/mint/{method}", post(cache_post_mint_custom)) .route("/mint/{method}/batch", post(cache_post_batch_mint)) .route("/melt/quote/{method}", post(post_melt_custom_quote)) diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 7f8409cdb..d6d14bfcf 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -107,7 +107,13 @@ pub async fn create_mint_router_with_custom_cache( .route("/ws", get(ws_handler)) .route("/checkstate", post(post_check)) .route("/info", get(get_mint_info)) - .route("/restore", post(post_restore)); + .route("/restore", post(post_restore)) + // NUT-XX quote lookup is method-agnostic, so it belongs here rather than in the + // per-method custom router, which is only mounted when custom methods are configured. + .route( + "/mint/quote/pubkey", + post(custom_handlers::post_mint_quote_by_pubkey), + ); let mut mint_router = Router::new().nest("/v1", v1_router); diff --git a/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs new file mode 100644 index 000000000..a9f524ff9 --- /dev/null +++ b/crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs @@ -0,0 +1,157 @@ +#![allow(clippy::unwrap_used)] + +//! NUT-XX route: `POST /v1/mint/quote/pubkey` +//! +//! The lookup is method-agnostic, so it must be served by the main v1 router rather than the +//! per-method custom router, which is only mounted when custom methods are configured. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use bip39::Mnemonic; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::nuts::nut00::KnownMethod; +use cdk::nuts::nutxx::mint_quote_lookup_msg_to_sign; +use cdk::nuts::{CurrencyUnit, MintQuoteBolt11Request, PaymentMethod, SecretKey}; +use cdk::types::{FeeReserve, QuoteTTL}; +use cdk::{Amount, MintQuoteRequest}; +use cdk_fake_wallet::FakeWallet; +use tower::ServiceExt; + +async fn test_mint() -> Arc { + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); + let mut builder = MintBuilder::new(db.clone()); + + let backend = FakeWallet::new( + FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); + + builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Known(KnownMethod::Bolt11), + MintMeltLimits::new(1, 10_000), + Arc::new(backend), + ) + .await + .unwrap(); + + let mnemonic = Mnemonic::generate(12).unwrap(); + builder = builder + .with_name("nutxx route test".to_string()) + .with_description("nutxx route test".to_string()) + .with_urls(vec!["https://test-mint".to_string()]); + + let mint = builder + .build_with_seed(db.clone(), &mnemonic.to_seed_normalized("")) + .await + .unwrap(); + mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)) + .await + .unwrap(); + Arc::new(mint) +} + +async fn signed_request_body(mint: &Mint, owner: &SecretKey) -> String { + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let pubkey = owner.public_key(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + let signature = owner.sign(&msg).unwrap(); + + format!( + r#"{{"pubkeys":["{}"],"pubkey_signatures":["{}"]}}"#, + pubkey.to_hex(), + signature + ) +} + +fn lookup_request(body: String) -> Request { + Request::builder() + .method("POST") + .uri("/v1/mint/quote/pubkey") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap() +} + +/// The endpoint answers on a plain bolt11 mint with no custom payment methods configured, +/// and the body matches `PostMintQuotesByPubkeyResponse`: an object with a `quotes` array +/// whose elements are flat NUT-04 quote objects. +#[tokio::test] +async fn lookup_route_is_served_without_custom_methods() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + + mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { + amount: Amount::new(100, CurrencyUnit::Sat).into(), + unit: CurrencyUnit::Sat, + description: None, + pubkey: Some(owner.public_key()), + })) + .await + .unwrap(); + + let body = signed_request_body(&mint, &owner).await; + let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + let quotes = json + .get("quotes") + .expect("response is an object with a `quotes` field") + .as_array() + .expect("`quotes` is an array"); + assert_eq!(quotes.len(), 1); + + // Flat NUT-04 quote object, not an externally tagged `{"Bolt11": …}` envelope. + assert!(quotes[0].get("Bolt11").is_none()); + assert!(quotes[0].get("quote").is_some()); + assert_eq!(quotes[0]["method"], "bolt11"); + assert_eq!(quotes[0]["pubkey"], owner.public_key().to_hex()); +} + +/// Configuring custom payment methods must not shadow or break the route. +#[tokio::test] +async fn lookup_route_is_served_with_custom_methods() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let body = signed_request_body(&mint, &owner).await; + + let router = cdk_axum::create_mint_router(mint, vec!["paypal".to_string()]) + .await + .unwrap(); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +/// A request with no signatures is refused at the HTTP layer. +#[tokio::test] +async fn unsigned_request_is_refused() { + let mint = test_mint().await; + let victim = SecretKey::generate().public_key(); + + let router = cdk_axum::create_mint_router(mint, vec![]).await.unwrap(); + let body = format!( + r#"{{"pubkeys":["{}"],"pubkey_signatures":[]}}"#, + victim.to_hex() + ); + + let response = router.oneshot(lookup_request(body)).await.unwrap(); + assert_ne!(response.status(), StatusCode::OK); +} diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs index 5488bec31..859296b3d 100644 --- a/crates/cdk-common/src/database/mint/test/mint.rs +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -1087,10 +1087,11 @@ where let secret_key = SecretKey::generate(); let pubkey = secret_key.public_key(); + let other_pubkey = SecretKey::generate().public_key(); let mint_quote = MintQuote::new( None, - "".to_owned(), + unique_string(), cashu::CurrencyUnit::Sat, None, 0, @@ -1100,22 +1101,63 @@ where Amount::new(0, cashu::CurrencyUnit::Sat), cashu::PaymentMethod::Known(KnownMethod::Bolt11), 0, + 0, vec![], vec![], None, ); - // Add quote + // A second quote locked to a different pubkey, which must never be returned below. + let other_quote = MintQuote::new( + None, + unique_string(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + Some(other_pubkey), + Amount::new(100, cashu::CurrencyUnit::Sat), + Amount::new(0, cashu::CurrencyUnit::Sat), + cashu::PaymentMethod::Known(KnownMethod::Bolt11), + 0, + 0, + vec![], + vec![], + None, + ); + + // Add quotes let mut tx = Database::begin_transaction(&db).await.unwrap(); tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.add_mint_quote(other_quote.clone()).await.unwrap(); tx.commit().await.unwrap(); let retrieved = db.get_mint_quotes_by_pubkey(&[pubkey]).await.unwrap(); - assert!(!retrieved.is_empty()); - let retrieved = retrieved.first().unwrap(); - assert_eq!(retrieved.id, mint_quote.id); - assert!(retrieved.pubkey.is_some()); - assert_eq!(retrieved.pubkey, Some(pubkey)); + assert_eq!(retrieved.len(), 1); + let quote = retrieved.first().unwrap(); + assert_eq!(quote.id, mint_quote.id); + assert_eq!(quote.pubkey, Some(pubkey)); + + // Only the requested pubkey's quotes come back. + assert!(!retrieved.iter().any(|q| q.id == other_quote.id)); + + // Both pubkeys at once returns both quotes. + let both = db + .get_mint_quotes_by_pubkey(&[pubkey, other_pubkey]) + .await + .unwrap(); + assert_eq!(both.len(), 2); + + // An unknown pubkey returns nothing rather than erroring. + let unknown = SecretKey::generate().public_key(); + assert!(db + .get_mint_quotes_by_pubkey(&[unknown]) + .await + .unwrap() + .is_empty()); + + // An empty request is not an error. + assert!(db.get_mint_quotes_by_pubkey(&[]).await.unwrap().is_empty()); } /// Test deleting blinded messages diff --git a/crates/cdk-common/src/database/mint/test/mod.rs b/crates/cdk-common/src/database/mint/test/mod.rs index 1e6c8da7e..116109d21 100644 --- a/crates/cdk-common/src/database/mint/test/mod.rs +++ b/crates/cdk-common/src/database/mint/test/mod.rs @@ -267,6 +267,7 @@ macro_rules! mint_db_test { get_mint_quotes_by_ids, get_melt_quotes_by_request_lookup_id, lock_melt_quote_and_related, + get_mint_quote_by_public_key, ); }; ($make_db_fn:ident, $($name:ident),+ $(,)?) => { diff --git a/crates/cdk-ffi/src/types/mint.rs b/crates/cdk-ffi/src/types/mint.rs index 33078276b..5d47a5929 100644 --- a/crates/cdk-ffi/src/types/mint.rs +++ b/crates/cdk-ffi/src/types/mint.rs @@ -511,6 +511,8 @@ pub struct Nuts { pub nut22: Option, /// NUT29 Settings - Batch minting pub nut29: Nut29Settings, + /// NUTXX Settings - Mint quote lookup by pubkey + pub nutxx_supported: bool, /// Supported currency units for minting pub mint_units: Vec, /// Supported currency units for melting @@ -544,6 +546,7 @@ impl From for Nuts { nut21: nuts.nut21.map(Into::into), nut22: nuts.nut22.map(Into::into), nut29: nuts.nut29.into(), + nutxx_supported: nuts.nutxx.supported, mint_units, melt_units, } @@ -587,6 +590,9 @@ impl TryFrom for cdk::nuts::Nuts { nut21: n.nut21.map(|s| s.try_into()).transpose()?, nut22: n.nut22.map(|s| s.try_into()).transpose()?, nut29: n.nut29.into(), + nutxx: cdk::nuts::nut06::SupportedSettings { + supported: n.nutxx_supported, + }, }) } } @@ -764,6 +770,7 @@ mod tests { )], }), nut29: Default::default(), + nutxx: Default::default(), } } @@ -904,6 +911,7 @@ mod tests { nut21: None, nut22: None, nut29: Default::default(), + nutxx: Default::default(), }; let ffi_nuts: Nuts = cdk_nuts.into(); @@ -936,6 +944,7 @@ mod tests { nut21: None, nut22: None, nut29: Default::default(), + nutxx_supported: false, mint_units: vec![], melt_units: vec![], }; @@ -1099,6 +1108,7 @@ mod tests { }], }), nut29: Nut29Settings::default(), + nutxx_supported: false, mint_units: vec![], melt_units: vec![], }, diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql deleted file mode 100644 index 4980b0e40..000000000 --- a/crates/cdk-sql-common/src/mint/migrations/postgres/20260404221137_add_pubkey_index_to_mint_quote.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..61a3df711 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20260723000000_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_mint_quote_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql deleted file mode 100644 index 4980b0e40..000000000 --- a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260404221137_add_pubkey_index_to_mint_quote.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql new file mode 100644 index 000000000..61a3df711 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20260723000000_add_pubkey_index_to_mint_quote.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_mint_quote_pubkey ON mint_quote(pubkey); diff --git a/crates/cdk-sql-common/src/mint/quotes.rs b/crates/cdk-sql-common/src/mint/quotes.rs index f913a5522..00b7fd62b 100644 --- a/crates/cdk-sql-common/src/mint/quotes.rs +++ b/crates/cdk-sql-common/src/mint/quotes.rs @@ -1322,6 +1322,7 @@ where created_time, amount_paid, amount_issued, + updated_at, payment_method, request_lookup_id_kind, extra_json diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index ba08f4147..12353c215 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -184,6 +184,8 @@ required-features = ["wallet"] [dev-dependencies] rand.workspace = true +bitcoin.workspace = true +serde_json.workspace = true cdk-sqlite.workspace = true cdk-fake-wallet.workspace = true bip39.workspace = true diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 94e47ade6..59792c6bd 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -99,6 +99,7 @@ impl MintBuilder { .nut12(true) .nut14(true) .nut20(true) + .nutxx(true) .nut29(cdk_common::nut29::Settings::default()), ..Default::default() }; diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 62027a5be..c0b9e4813 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,11 +1,9 @@ use std::sync::Arc; -use bitcoin::hashes::sha256::Hash as Sha256Hash; -use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; use cdk_common::database::mint::Acquired; use cdk_common::mint::{MintQuote, Operation}; -use cdk_common::nut00::KnownMethod; +use cdk_common::nutxx::{mint_quote_lookup_msg_to_sign, MAX_LOOKUP_PUBKEYS}; use cdk_common::payment::{ Bolt11IncomingPaymentOptions, Bolt12IncomingPaymentOptions, CustomIncomingPaymentOptions, IncomingPaymentOptions, OnchainIncomingPaymentOptions, WaitPaymentResponse, @@ -14,9 +12,9 @@ use cdk_common::quote_id::QuoteId; use cdk_common::util::unix_time; use cdk_common::{ database, ensure_cdk, Amount, BatchMintRequest, BlindedMessage, CurrencyUnit, Error, - MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, - MintQuoteOnchainResponse, MintQuoteRequest, MintQuoteResponse, MintQuoteState, MintRequest, - MintResponse, NotificationPayload, PaymentMethod, PublicKey, + MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteOnchainResponse, MintQuoteRequest, + MintQuoteResponse, MintQuoteState, MintRequest, MintResponse, NotificationPayload, + PaymentMethod, PublicKey, }; use tracing::instrument; @@ -419,11 +417,14 @@ impl Mint { result } - /// Retrieves mint quotes with pubkey from the database + /// Retrieves the mint quotes locked to a set of NUT-20 public keys [NUT-XX] + /// + /// Every pubkey must be accompanied by a signature proving control of the corresponding + /// private key; the request is rejected outright unless all of them verify. /// /// # Returns - /// * `Vec` - List of mint quotes filtered by pubkeys - /// * `Error` if database access fails + /// * `Vec>` - quotes locked to the requested pubkeys + /// * `Error` if any signature is missing or invalid, or database access fails #[instrument(skip_all)] pub async fn get_mint_quote_by_pubkey( &self, @@ -433,29 +434,30 @@ impl Mint { #[cfg(feature = "prometheus")] let metrics = super::MintMetricGuard::new("mint_quotes_by_pubkeys"); - pubkeys.len().ne(&signatures.len()).then(|| { - tracing::error!("Signatures must be the same length of publickeys"); + // Anonymous callers reach this before any signature is checked, so bound the work a + // single request can ask for. + ensure_cdk!( + pubkeys.len() <= MAX_LOOKUP_PUBKEYS, + Error::BatchSizeExceeded { + actual: pubkeys.len(), + max: MAX_LOOKUP_PUBKEYS, + } + ); + + // NUT-XX: "The mint MUST reject the request unless every signature is valid." Checking + // the lengths match is what makes the zip below cover every pubkey. + ensure_cdk!( + pubkeys.len() == signatures.len(), Error::SignatureMissingOrInvalid - }); + ); - let mint_pubkey = self - .mint_info() - .await? - .pubkey - .ok_or(Error::MissingPubkey)? - .to_hex(); + let mint_pubkey = self.mint_info().await?.pubkey.ok_or(Error::MissingPubkey)?; for (pubkey, signature) in pubkeys.iter().zip(signatures.iter()) { - let pubkey_hex = pubkey.to_hex(); + // `verify` hashes its argument, so it takes the pre-image rather than the digest. + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, pubkey); - let mut preimage = Vec::with_capacity(24 + mint_pubkey.len() + pubkey_hex.len()); - preimage.extend_from_slice(b"Cashu_MintQuoteLookup_v1"); - preimage.extend_from_slice(mint_pubkey.as_bytes()); - preimage.extend_from_slice(pubkey_hex.as_bytes()); - - let hash = Sha256Hash::hash(&preimage).to_byte_array(); - - pubkey.verify(&hash, signature).map_err(|e| { + pubkey.verify(&msg, signature).map_err(|e| { tracing::error!("Failed to validate signature: {}", e); Error::SignatureMissingOrInvalid })?; @@ -464,65 +466,13 @@ impl Mint { let result: Result>, Error> = async { let quotes = self.localstore.get_mint_quotes_by_pubkey(&pubkeys).await?; + // `TryFrom` is the shared conversion every other quote path uses; hand-rolling it + // here would drift the moment a response field is added. quotes - .iter() - .map(|q| match q.payment_method { - PaymentMethod::Known(KnownMethod::Bolt11) => { - Ok(MintQuoteResponse::Bolt11(MintQuoteBolt11Response::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: Some(q.unit.clone()), - state: q.state(), - expiry: Some(q.expiry), - pubkey: q.pubkey, - })) - } - PaymentMethod::Known(KnownMethod::Bolt12) => { - Ok(MintQuoteResponse::Bolt12(MintQuoteBolt12Response::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: q.unit.clone(), - expiry: Some(q.expiry), - pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - })) - } - PaymentMethod::Known(KnownMethod::Onchain) => { - Ok(MintQuoteResponse::Onchain(MintQuoteOnchainResponse::< - QuoteId, - > { - quote: q.id.clone(), - request: q.request.clone(), - unit: q.unit.clone(), - expiry: Some(q.expiry), - pubkey: q.pubkey.ok_or(Error::PubkeyRequired)?, - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - })) - } - _ => Ok(MintQuoteResponse::Custom { - method: q.payment_method.clone(), - response: MintQuoteCustomResponse { - quote: q.id.clone(), - request: q.request.clone(), - amount: q.amount.clone().map(|a| a.into()), - unit: Some(q.unit.clone()), - expiry: Some(q.expiry), - pubkey: q.pubkey, - extra: q.extra_json.clone().unwrap_or_default(), - amount_paid: q.amount_paid().into(), - amount_issued: q.amount_issued().into(), - }, - }), - }) - .collect() + .into_iter() + .map(MintQuoteResponse::try_from) + .collect::, _>>() + .map_err(Error::from) } .await; diff --git a/crates/cdk/tests/nutxx_mint_quote_lookup.rs b/crates/cdk/tests/nutxx_mint_quote_lookup.rs new file mode 100644 index 000000000..9106fa19c --- /dev/null +++ b/crates/cdk/tests/nutxx_mint_quote_lookup.rs @@ -0,0 +1,226 @@ +#![allow(clippy::unwrap_used)] + +//! NUT-XX: mint quote lookup by public key +//! +//! + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use bip39::Mnemonic; +use bitcoin::hashes::sha256::Hash as Sha256Hash; +use bitcoin::hashes::Hash; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::nuts::nut00::KnownMethod; +use cdk::nuts::nutxx::{mint_quote_lookup_msg_to_sign, MAX_LOOKUP_PUBKEYS}; +use cdk::nuts::{CurrencyUnit, MintQuoteBolt11Request, PaymentMethod, PublicKey, SecretKey}; +use cdk::types::{FeeReserve, QuoteTTL}; +use cdk::{Amount, Error, MintQuoteRequest}; +use cdk_fake_wallet::FakeWallet; + +async fn test_mint() -> Mint { + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); + let mut builder = MintBuilder::new(db.clone()); + + let backend = FakeWallet::new( + FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); + + builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Known(KnownMethod::Bolt11), + MintMeltLimits::new(1, 10_000), + Arc::new(backend), + ) + .await + .unwrap(); + + let mnemonic = Mnemonic::generate(12).unwrap(); + builder = builder + .with_name("nutxx test mint".to_string()) + .with_description("nutxx test mint".to_string()) + .with_urls(vec!["https://test-mint".to_string()]); + + let mint = builder + .build_with_seed(db.clone(), &mnemonic.to_seed_normalized("")) + .await + .unwrap(); + mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)) + .await + .unwrap(); + mint +} + +/// Create a NUT-20 locked bolt11 mint quote owned by `pubkey`. +async fn locked_quote(mint: &Mint, pubkey: PublicKey) { + mint.get_mint_quote(MintQuoteRequest::Bolt11(MintQuoteBolt11Request { + amount: Amount::new(100, CurrencyUnit::Sat).into(), + unit: CurrencyUnit::Sat, + description: None, + pubkey: Some(pubkey), + })) + .await + .unwrap(); +} + +/// Sign the lookup message the way a spec-conformant wallet does. +async fn sign_lookup( + mint: &Mint, + secret_key: &SecretKey, +) -> bitcoin::secp256k1::schnorr::Signature { + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key()); + secret_key.sign(&msg).unwrap() +} + +/// A mint that serves the endpoint must say so in its NUT-06 info response, otherwise wallets +/// have no way to discover it. +#[tokio::test] +async fn support_is_advertised_in_mint_info() { + let mint = test_mint().await; + let mint_info = mint.mint_info().await.unwrap(); + + assert!(mint_info.nuts.nutxx.supported); + + let json = serde_json::to_value(&mint_info).unwrap(); + assert_eq!(json["nuts"]["XX"]["supported"], true); +} + +/// A valid signature returns the quotes locked to that pubkey. +#[tokio::test] +async fn signed_lookup_returns_own_quotes() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let signature = sign_lookup(&mint, &owner).await; + let quotes = mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await + .unwrap(); + + assert_eq!(quotes.len(), 1); + assert_eq!( + quotes[0].method(), + PaymentMethod::Known(KnownMethod::Bolt11) + ); +} + +/// The signature covers SHA256(preimage), matching the NUT. `PublicKey::verify` hashes its +/// argument, so passing a digest instead of the preimage would verify a double hash and reject +/// conformant wallets. +#[tokio::test] +async fn signature_is_over_a_single_hash_of_the_preimage() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let mint_pubkey = mint.mint_info().await.unwrap().pubkey.unwrap(); + let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey); + + // Signing the preimage is accepted... + let signature = owner.sign(&msg).unwrap(); + assert!(mint + .get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await + .is_ok()); + + // ...and signing the digest of the preimage is not. + let digest = Sha256Hash::hash(&msg).to_byte_array(); + let double_hashed = owner.sign(&digest).unwrap(); + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![pubkey], vec![double_hashed]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// "The mint MUST reject the request unless every signature is valid" — an empty or short +/// signature array must not silently skip verification. +#[tokio::test] +async fn missing_signatures_are_rejected() { + let mint = test_mint().await; + let victim = SecretKey::generate(); + let victim_pubkey = victim.public_key(); + locked_quote(&mint, victim_pubkey).await; + + // No signature at all. + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); + + // Fewer signatures than pubkeys: one valid signature must not authorise a second pubkey. + let attacker = SecretKey::generate(); + let attacker_signature = sign_lookup(&mint, &attacker).await; + assert!(matches!( + mint.get_mint_quote_by_pubkey( + vec![attacker.public_key(), victim_pubkey], + vec![attacker_signature] + ) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// A signature from one key must not unlock a different key's quotes. +#[tokio::test] +async fn signature_from_another_key_is_rejected() { + let mint = test_mint().await; + let victim_pubkey = SecretKey::generate().public_key(); + locked_quote(&mint, victim_pubkey).await; + + let attacker = SecretKey::generate(); + let attacker_signature = sign_lookup(&mint, &attacker).await; + + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![victim_pubkey], vec![attacker_signature]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// A signature bound to a different mint must not be replayable here. +#[tokio::test] +async fn signature_for_another_mint_is_rejected() { + let mint = test_mint().await; + let owner = SecretKey::generate(); + let pubkey = owner.public_key(); + locked_quote(&mint, pubkey).await; + + let other_mint_pubkey = SecretKey::generate().public_key(); + let msg = mint_quote_lookup_msg_to_sign(&other_mint_pubkey, &pubkey); + let signature = owner.sign(&msg).unwrap(); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(vec![pubkey], vec![signature]) + .await, + Err(Error::SignatureMissingOrInvalid) + )); +} + +/// An anonymous caller cannot ask the mint for unbounded signature verification. +#[tokio::test] +async fn oversized_request_is_rejected() { + let mint = test_mint().await; + + let pubkeys: Vec = (0..MAX_LOOKUP_PUBKEYS + 1) + .map(|_| SecretKey::generate().public_key()) + .collect(); + + assert!(matches!( + mint.get_mint_quote_by_pubkey(pubkeys, vec![]).await, + Err(Error::BatchSizeExceeded { .. }) + )); +}