Browse Source

Merge pull request #446 from thesimplekid/sign_mint

feat: nut-20 signature on mint witness
thesimplekid 3 months ago
parent
commit
ccb1ee77d0

File diff suppressed because it is too large
+ 325 - 132
Cargo.lock


+ 2 - 0
crates/cdk-integration-tests/src/lib.rs

@@ -160,6 +160,7 @@ pub async fn mint_proofs(
         amount,
         unit: CurrencyUnit::Sat,
         description,
+        pubkey: None,
     };
 
     let mint_quote = wallet_client.post_mint_quote(request).await?;
@@ -192,6 +193,7 @@ pub async fn mint_proofs(
     let request = MintBolt11Request {
         quote: mint_quote.quote,
         outputs: premint_secrets.blinded_messages(),
+        signature: None,
     };
 
     let mint_response = wallet_client.post_mint(request).await?;

+ 102 - 3
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -1,12 +1,12 @@
 use std::sync::Arc;
 
-use anyhow::Result;
+use anyhow::{bail, Result};
 use bip39::Mnemonic;
 use cdk::amount::SplitTarget;
 use cdk::cdk_database::WalletMemoryDatabase;
 use cdk::nuts::{
-    CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintQuoteState, NotificationPayload,
-    PreMintSecrets, State,
+    CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, MintQuoteState,
+    NotificationPayload, PreMintSecrets, SecretKey, State,
 };
 use cdk::wallet::client::{HttpClient, MintConnector};
 use cdk::wallet::{Wallet, WalletSubscription};
@@ -376,6 +376,105 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
     Ok(())
 }
 
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_fake_mint_with_witness() -> Result<()> {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(WalletMemoryDatabase::default()),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?;
+
+    let mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    assert!(mint_amount == 100.into());
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_fake_mint_without_witness() -> Result<()> {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(WalletMemoryDatabase::default()),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?;
+
+    let http_client = HttpClient::new(MINT_URL.parse()?);
+
+    let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
+
+    let premint_secrets =
+        PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap();
+
+    let request = MintBolt11Request {
+        quote: mint_quote.id,
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    let response = http_client.post_mint(request.clone()).await;
+
+    match response {
+        Err(cdk::error::Error::SignatureMissingOrInvalid) => Ok(()),
+        Err(err) => bail!("Wrong mint response for minting without witness: {}", err),
+        Ok(_) => bail!("Minting should not have succeed without a witness"),
+    }
+}
+
+// TODO: Rewrite this test to include witness wrong
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_fake_mint_with_wrong_witness() -> Result<()> {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(WalletMemoryDatabase::default()),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?;
+
+    let http_client = HttpClient::new(MINT_URL.parse()?);
+
+    let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
+
+    let premint_secrets =
+        PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap();
+
+    let mut request = MintBolt11Request {
+        quote: mint_quote.id,
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    let secret_key = SecretKey::generate();
+
+    request.sign(secret_key)?;
+
+    let response = http_client.post_mint(request.clone()).await;
+
+    match response {
+        Err(cdk::error::Error::SignatureMissingOrInvalid) => Ok(()),
+        Err(err) => bail!("Wrong mint response for minting without witness: {}", err),
+        Ok(_) => bail!("Minting should not have succeed without a witness"),
+    }
+}
+
 // Keep polling the state of the mint quote id until it's paid
 async fn wait_for_mint_to_be_paid(wallet: &Wallet, mint_quote_id: &str) -> Result<()> {
     let mut subscription = wallet

+ 2 - 0
crates/cdk-integration-tests/tests/mint.rs

@@ -78,6 +78,7 @@ async fn mint_proofs(
         amount,
         unix_time() + 36000,
         request_lookup.to_string(),
+        None,
     );
 
     mint.localstore.add_mint_quote(quote.clone()).await?;
@@ -90,6 +91,7 @@ async fn mint_proofs(
     let mint_request = MintBolt11Request {
         quote: quote.id,
         outputs: premint.blinded_messages(),
+        signature: None,
     };
 
     let after_mint = mint.process_mint_request(mint_request).await?;

+ 6 - 1
crates/cdk-integration-tests/tests/regtest.rs

@@ -381,11 +381,16 @@ async fn test_cached_mint() -> Result<()> {
     let premint_secrets =
         PreMintSecrets::random(active_keyset_id, 31.into(), &SplitTarget::default()).unwrap();
 
-    let request = MintBolt11Request {
+    let mut request = MintBolt11Request {
         quote: quote.id,
         outputs: premint_secrets.blinded_messages(),
+        signature: None,
     };
 
+    let secret_key = quote.secret_key;
+
+    request.sign(secret_key.expect("Secret key on quote"))?;
+
     let response = http_client.post_mint(request.clone()).await?;
     let response1 = http_client.post_mint(request).await?;
 

+ 1 - 0
crates/cdk-redb/src/mint/migrations.rs

@@ -210,6 +210,7 @@ impl From<V1MintQuote> for MintQuote {
             state: quote.state,
             expiry: quote.expiry,
             request_lookup_id: Bolt11Invoice::from_str(&quote.request).unwrap().to_string(),
+            pubkey: None,
         }
     }
 }

+ 1 - 0
crates/cdk-sqlite/src/mint/migrations/20241108093102_mint_mint_quote_pubkey.sql

@@ -0,0 +1 @@
+ALTER TABLE mint_quote ADD pubkey TEXT;

+ 9 - 2
crates/cdk-sqlite/src/mint/mod.rs

@@ -207,8 +207,8 @@ WHERE active = 1
         let res = sqlx::query(
             r#"
 INSERT OR REPLACE INTO mint_quote
-(id, mint_url, amount, unit, request, state, expiry, request_lookup_id)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?);
+(id, mint_url, amount, unit, request, state, expiry, request_lookup_id, pubkey)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
         "#,
         )
         .bind(quote.id.to_string())
@@ -219,6 +219,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?);
         .bind(quote.state.to_string())
         .bind(quote.expiry as i64)
         .bind(quote.request_lookup_id)
+        .bind(quote.pubkey.map(|p| p.to_string()))
         .execute(&mut transaction)
         .await;
 
@@ -1265,6 +1266,7 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result<MintQuote, Error> {
     let row_expiry: i64 = row.try_get("expiry").map_err(Error::from)?;
     let row_request_lookup_id: Option<String> =
         row.try_get("request_lookup_id").map_err(Error::from)?;
+    let row_pubkey: Option<String> = row.try_get("pubkey").map_err(Error::from)?;
 
     let request_lookup_id = match row_request_lookup_id {
         Some(id) => id,
@@ -1274,6 +1276,10 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result<MintQuote, Error> {
         },
     };
 
+    let pubkey = row_pubkey
+        .map(|key| PublicKey::from_str(&key))
+        .transpose()?;
+
     Ok(MintQuote {
         id: row_id.into_uuid(),
         mint_url: MintUrl::from_str(&row_mint_url)?,
@@ -1283,6 +1289,7 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result<MintQuote, Error> {
         state: MintQuoteState::from_str(&row_state).map_err(Error::from)?,
         expiry: row_expiry as u64,
         request_lookup_id,
+        pubkey,
     })
 }
 

+ 1 - 0
crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_secretkey.sql

@@ -0,0 +1 @@
+ALTER TABLE mint_quote ADD secret_key TEXT;

+ 10 - 3
crates/cdk-sqlite/src/wallet/mod.rs

@@ -10,7 +10,7 @@ use cdk::cdk_database::{self, WalletDatabase};
 use cdk::mint_url::MintUrl;
 use cdk::nuts::{
     CurrencyUnit, Id, KeySetInfo, Keys, MeltQuoteState, MintInfo, MintQuoteState, Proof, PublicKey,
-    SpendingConditions, State,
+    SecretKey, SpendingConditions, State,
 };
 use cdk::secret::Secret;
 use cdk::types::ProofInfo;
@@ -347,8 +347,8 @@ WHERE id=?
         sqlx::query(
             r#"
 INSERT OR REPLACE INTO mint_quote
-(id, mint_url, amount, unit, request, state, expiry)
-VALUES (?, ?, ?, ?, ?, ?, ?);
+(id, mint_url, amount, unit, request, state, expiry, secret_key)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?);
         "#,
         )
         .bind(quote.id.to_string())
@@ -358,6 +358,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?);
         .bind(quote.request)
         .bind(quote.state.to_string())
         .bind(quote.expiry as i64)
+        .bind(quote.secret_key.map(|p| p.to_string()))
         .execute(&self.pool)
         .await
         .map_err(Error::from)?;
@@ -832,9 +833,14 @@ fn sqlite_row_to_mint_quote(row: &SqliteRow) -> Result<MintQuote, Error> {
     let row_request: String = row.try_get("request").map_err(Error::from)?;
     let row_state: String = row.try_get("state").map_err(Error::from)?;
     let row_expiry: i64 = row.try_get("expiry").map_err(Error::from)?;
+    let row_secret: Option<String> = row.try_get("secret_key").map_err(Error::from)?;
 
     let state = MintQuoteState::from_str(&row_state)?;
 
+    let secret_key = row_secret
+        .map(|key| SecretKey::from_str(&key))
+        .transpose()?;
+
     Ok(MintQuote {
         id: row_id,
         mint_url: MintUrl::from_str(&row_mint_url)?,
@@ -843,6 +849,7 @@ fn sqlite_row_to_mint_quote(row: &SqliteRow) -> Result<MintQuote, Error> {
         request: row_request,
         state,
         expiry: row_expiry as u64,
+        secret_key,
     })
 }
 

+ 17 - 1
crates/cdk/src/error.rs

@@ -45,6 +45,9 @@ pub enum Error {
     /// Amount overflow
     #[error("Amount Overflow")]
     AmountOverflow,
+    /// Witness missing or invalid
+    #[error("Signature missing or invalid")]
+    SignatureMissingOrInvalid,
 
     // Mint Errors
     /// Minting is disabled
@@ -176,7 +179,7 @@ pub enum Error {
     /// Parse int error
     #[error(transparent)]
     ParseInt(#[from] std::num::ParseIntError),
-    /// Parse Url Error
+    /// Parse 9rl Error
     #[error(transparent)]
     UrlParseError(#[from] url::ParseError),
     /// Utf8 parse error
@@ -239,6 +242,9 @@ pub enum Error {
     /// NUT18 Error
     #[error(transparent)]
     NUT18(#[from] crate::nuts::nut18::Error),
+    /// NUT20 Error
+    #[error(transparent)]
+    NUT20(#[from] crate::nuts::nut20::Error),
     /// Database Error
     #[cfg(any(feature = "wallet", feature = "mint"))]
     #[error(transparent)]
@@ -373,6 +379,11 @@ impl From<Error> for ErrorResponse {
                 error: Some(err.to_string()),
                 detail: None,
             },
+            Error::NUT20(err) => ErrorResponse {
+                code: ErrorCode::WitnessMissingOrInvalid,
+                error: Some(err.to_string()),
+                detail: None,
+            },
             _ => ErrorResponse {
                 code: ErrorCode::Unknown(9999),
                 error: Some(err.to_string()),
@@ -402,6 +413,7 @@ impl From<ErrorResponse> for Error {
                 Self::AmountOutofLimitRange(Amount::default(), Amount::default(), Amount::default())
             }
             ErrorCode::TokenPending => Self::TokenPending,
+            ErrorCode::WitnessMissingOrInvalid => Self::SignatureMissingOrInvalid,
             _ => Self::UnknownErrorResponse(err.to_string()),
         }
     }
@@ -443,6 +455,8 @@ pub enum ErrorCode {
     TransactionUnbalanced,
     /// Amount outside of allowed range
     AmountOutofLimitRange,
+    /// Witness missing or invalid
+    WitnessMissingOrInvalid,
     /// Unknown error code
     Unknown(u16),
 }
@@ -467,6 +481,7 @@ impl ErrorCode {
             20005 => Self::QuotePending,
             20006 => Self::InvoiceAlreadyPaid,
             20007 => Self::QuoteExpired,
+            20008 => Self::WitnessMissingOrInvalid,
             _ => Self::Unknown(code),
         }
     }
@@ -490,6 +505,7 @@ impl ErrorCode {
             Self::QuotePending => 20005,
             Self::InvoiceAlreadyPaid => 20006,
             Self::QuoteExpired => 20007,
+            Self::WitnessMissingOrInvalid => 20008,
             Self::Unknown(code) => *code,
         }
     }

+ 2 - 1
crates/cdk/src/mint/builder.rs

@@ -46,7 +46,8 @@ impl MintBuilder {
             .nut10(true)
             .nut11(true)
             .nut12(true)
-            .nut14(true);
+            .nut14(true)
+            .nut20(true);
 
         builder.mint_info.nuts = nuts;
 

+ 9 - 0
crates/cdk/src/mint/mint_nut04.rs

@@ -65,6 +65,7 @@ impl Mint {
             amount,
             unit,
             description,
+            pubkey,
         } = mint_quote_request;
 
         self.check_mint_request_acceptable(amount, &unit)?;
@@ -105,6 +106,7 @@ impl Mint {
             amount,
             create_invoice_response.expiry.unwrap_or(0),
             create_invoice_response.request_lookup_id.clone(),
+            pubkey,
         );
 
         tracing::debug!(
@@ -150,6 +152,7 @@ impl Mint {
             request: quote.request,
             state,
             expiry: Some(quote.expiry),
+            pubkey: quote.pubkey,
         })
     }
 
@@ -281,6 +284,12 @@ impl Mint {
             MintQuoteState::Paid => (),
         }
 
+        // If the there is a public key provoided in mint quote request
+        // verify the signature is provided for the mint request
+        if let Some(pubkey) = mint_quote.pubkey {
+            mint_request.verify_signature(pubkey)?;
+        }
+
         let blinded_messages: Vec<PublicKey> = mint_request
             .outputs
             .iter()

+ 5 - 1
crates/cdk/src/mint/types.rs

@@ -3,7 +3,7 @@
 use serde::{Deserialize, Serialize};
 use uuid::Uuid;
 
-use super::CurrencyUnit;
+use super::{CurrencyUnit, PublicKey};
 use crate::mint_url::MintUrl;
 use crate::nuts::{MeltQuoteState, MintQuoteState};
 use crate::Amount;
@@ -27,6 +27,8 @@ pub struct MintQuote {
     pub expiry: u64,
     /// Value used by ln backend to look up state of request
     pub request_lookup_id: String,
+    /// Pubkey
+    pub pubkey: Option<PublicKey>,
 }
 
 impl MintQuote {
@@ -38,6 +40,7 @@ impl MintQuote {
         amount: Amount,
         expiry: u64,
         request_lookup_id: String,
+        pubkey: Option<PublicKey>,
     ) -> Self {
         let id = Uuid::new_v4();
 
@@ -50,6 +53,7 @@ impl MintQuote {
             state: MintQuoteState::Unpaid,
             expiry,
             request_lookup_id,
+            pubkey,
         }
     }
 }

+ 1 - 0
crates/cdk/src/nuts/mod.rs

@@ -21,6 +21,7 @@ pub mod nut15;
 pub mod nut17;
 pub mod nut18;
 pub mod nut19;
+pub mod nut20;
 
 pub use nut00::{
     BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, PreMint, PreMintSecrets, Proof,

+ 15 - 1
crates/cdk/src/nuts/nut04.rs

@@ -12,7 +12,7 @@ use thiserror::Error;
 use uuid::Uuid;
 
 use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod};
-use super::MintQuoteState;
+use super::{MintQuoteState, PublicKey};
 use crate::Amount;
 
 /// NUT04 Error
@@ -35,7 +35,11 @@ pub struct MintQuoteBolt11Request {
     /// Unit wallet would like to pay with
     pub unit: CurrencyUnit,
     /// Memo to create the invoice with
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub description: Option<String>,
+    /// NUT-19 Pubkey
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pubkey: Option<PublicKey>,
 }
 
 /// Possible states of a quote
@@ -94,6 +98,9 @@ pub struct MintQuoteBolt11Response<Q> {
     pub state: MintQuoteState,
     /// Unix timestamp until the quote is valid
     pub expiry: Option<u64>,
+    /// NUT-19 Pubkey
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pubkey: Option<PublicKey>,
 }
 
 impl<Q: ToString> MintQuoteBolt11Response<Q> {
@@ -104,6 +111,7 @@ impl<Q: ToString> MintQuoteBolt11Response<Q> {
             request: self.request.clone(),
             state: self.state,
             expiry: self.expiry,
+            pubkey: self.pubkey,
         }
     }
 }
@@ -116,6 +124,7 @@ impl From<MintQuoteBolt11Response<Uuid>> for MintQuoteBolt11Response<String> {
             request: value.request,
             state: value.state,
             expiry: value.expiry,
+            pubkey: value.pubkey,
         }
     }
 }
@@ -128,6 +137,7 @@ impl From<crate::mint::MintQuote> for MintQuoteBolt11Response<Uuid> {
             request: mint_quote.request,
             state: mint_quote.state,
             expiry: Some(mint_quote.expiry),
+            pubkey: mint_quote.pubkey,
         }
     }
 }
@@ -143,6 +153,9 @@ pub struct MintBolt11Request<Q> {
     /// Outputs
     #[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
     pub outputs: Vec<BlindedMessage>,
+    /// Signature
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub signature: Option<String>,
 }
 
 #[cfg(feature = "mint")]
@@ -153,6 +166,7 @@ impl TryFrom<MintBolt11Request<String>> for MintBolt11Request<Uuid> {
         Ok(Self {
             quote: Uuid::from_str(&value.quote)?,
             outputs: value.outputs,
+            signature: value.signature,
         })
     }
 }

+ 12 - 0
crates/cdk/src/nuts/nut06.rs

@@ -247,6 +247,10 @@ pub struct Nuts {
     #[serde(default)]
     #[serde(rename = "19")]
     pub nut19: nut19::Settings,
+    /// NUT20 Settings
+    #[serde(default)]
+    #[serde(rename = "20")]
+    pub nut20: SupportedSettings,
 }
 
 impl Nuts {
@@ -356,6 +360,14 @@ impl Nuts {
             ..self
         }
     }
+
+    /// Nut20 settings
+    pub fn nut20(self, supported: bool) -> Self {
+        Self {
+            nut20: SupportedSettings { supported },
+            ..self
+        }
+    }
 }
 
 /// Check state Settings

+ 151 - 0
crates/cdk/src/nuts/nut20.rs

@@ -0,0 +1,151 @@
+//! Mint Quote Signatures
+
+use std::str::FromStr;
+
+use bitcoin::secp256k1::schnorr::Signature;
+use thiserror::Error;
+
+use super::{MintBolt11Request, PublicKey, SecretKey};
+
+/// Nut19 Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Signature not provided
+    #[error("Signature not provided")]
+    SignatureMissing,
+    /// Quote signature invalid signature
+    #[error("Quote signature invalid signature")]
+    InvalidSignature,
+    /// Nut01 error
+    #[error(transparent)]
+    NUT01(#[from] crate::nuts::nut01::Error),
+}
+
+impl<Q> MintBolt11Request<Q>
+where
+    Q: ToString,
+{
+    /// Constructs the message to be signed according to NUT-20 specification.
+    ///
+    /// The message is constructed by concatenating (as UTF-8 encoded bytes):
+    /// 1. The quote ID (as UTF-8)
+    /// 2. All blinded secrets (B_0 through B_n) converted to hex strings (as UTF-8)
+    ///
+    /// Format: `quote_id || B_0 || B_1 || ... || B_n`
+    /// where each component is encoded as UTF-8 bytes
+    pub fn msg_to_sign(&self) -> Vec<u8> {
+        // Pre-calculate capacity to avoid reallocations
+        let quote_id = self.quote.to_string();
+        let capacity = quote_id.len() + (self.outputs.len() * 66);
+        let mut msg = Vec::with_capacity(capacity);
+        msg.append(&mut quote_id.clone().into_bytes()); // String.into_bytes() produces UTF-8
+        for output in &self.outputs {
+            // to_hex() creates a hex string, into_bytes() converts it to UTF-8 bytes
+            msg.append(&mut output.blinded_secret.to_hex().into_bytes());
+        }
+        msg
+    }
+
+    /// Sign [`MintBolt11Request`]
+    pub fn sign(&mut self, secret_key: SecretKey) -> Result<(), Error> {
+        let msg = self.msg_to_sign();
+
+        let signature: Signature = secret_key.sign(&msg)?;
+
+        self.signature = Some(signature.to_string());
+
+        Ok(())
+    }
+
+    /// Verify signature on [`MintBolt11Request`]
+    pub fn verify_signature(&self, pubkey: PublicKey) -> Result<(), Error> {
+        let signature = self.signature.as_ref().ok_or(Error::SignatureMissing)?;
+
+        let signature = Signature::from_str(signature).map_err(|_| Error::InvalidSignature)?;
+
+        let msg_to_sign = self.msg_to_sign();
+
+        pubkey.verify(&msg_to_sign, &signature)?;
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+
+    use uuid::Uuid;
+
+    use super::*;
+
+    #[test]
+    fn test_msg_to_sign() {
+        let request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
+
+        // let expected_msg_to_sign = "9d745270-1405-46de-b5c5-e2762b4f5e000342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c31102be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b5302209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79";
+
+        let expected_msg_to_sign = [
+            57, 100, 55, 52, 53, 50, 55, 48, 45, 49, 52, 48, 53, 45, 52, 54, 100, 101, 45, 98, 53,
+            99, 53, 45, 101, 50, 55, 54, 50, 98, 52, 102, 53, 101, 48, 48, 48, 51, 52, 50, 101, 53,
+            98, 99, 99, 55, 55, 102, 53, 98, 50, 97, 51, 99, 50, 97, 102, 98, 52, 48, 98, 98, 53,
+            57, 49, 97, 49, 101, 50, 55, 100, 97, 56, 51, 99, 100, 100, 99, 57, 54, 56, 97, 98,
+            100, 99, 48, 101, 99, 52, 57, 48, 52, 50, 48, 49, 97, 50, 48, 49, 56, 51, 52, 48, 51,
+            50, 102, 100, 51, 99, 52, 100, 99, 52, 57, 97, 50, 56, 52, 52, 97, 56, 57, 57, 57, 56,
+            100, 53, 101, 57, 100, 53, 98, 48, 102, 48, 98, 48, 48, 100, 100, 101, 57, 51, 49, 48,
+            48, 54, 51, 97, 99, 98, 56, 97, 57, 50, 101, 50, 102, 100, 97, 102, 97, 52, 49, 50, 54,
+            100, 52, 48, 51, 51, 98, 54, 102, 100, 101, 53, 48, 98, 54, 97, 48, 100, 102, 101, 54,
+            49, 97, 100, 49, 52, 56, 102, 102, 102, 49, 54, 55, 97, 100, 57, 99, 102, 56, 51, 48,
+            56, 100, 101, 100, 53, 102, 54, 102, 54, 98, 50, 102, 101, 48, 48, 48, 97, 48, 51, 54,
+            99, 52, 54, 52, 99, 51, 49, 49, 48, 50, 98, 101, 53, 97, 53, 53, 102, 48, 51, 101, 53,
+            99, 48, 97, 97, 101, 97, 55, 55, 53, 57, 53, 100, 53, 55, 52, 98, 99, 101, 57, 50, 99,
+            54, 100, 53, 55, 97, 50, 97, 48, 102, 98, 50, 98, 53, 57, 53, 53, 99, 48, 98, 56, 55,
+            101, 52, 53, 50, 48, 101, 48, 54, 98, 53, 51, 48, 50, 50, 48, 57, 102, 99, 50, 56, 55,
+            51, 102, 50, 56, 53, 50, 49, 99, 98, 100, 100, 101, 55, 102, 55, 98, 51, 98, 98, 49,
+            53, 50, 49, 48, 48, 50, 52, 54, 51, 102, 53, 57, 55, 57, 54, 56, 54, 102, 100, 49, 53,
+            54, 102, 50, 51, 102, 101, 54, 97, 56, 97, 97, 50, 98, 55, 57,
+        ]
+        .to_vec();
+
+        let request_msg_to_sign = request.msg_to_sign();
+
+        assert_eq!(expected_msg_to_sign, request_msg_to_sign);
+    }
+
+    #[test]
+    fn test_valid_signature() {
+        let pubkey = PublicKey::from_hex(
+            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
+        )
+        .unwrap();
+
+        let request: MintBolt11Request<Uuid> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}], "signature": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acbc0026a43c221fd773471dc30d6a32b04692e6837ddaccf0830a63128308e4ee0"}"#).unwrap();
+
+        assert!(request.verify_signature(pubkey).is_ok());
+    }
+
+    #[test]
+    fn test_mint_request_signature() {
+        let mut request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}]}"#).unwrap();
+
+        let secret =
+            SecretKey::from_hex("50d7fd7aa2b2fe4607f41f4ce6f8794fc184dd47b8cdfbe4b3d1249aa02d35aa")
+                .unwrap();
+
+        request.sign(secret.clone()).unwrap();
+
+        assert!(request.verify_signature(secret.public_key()).is_ok());
+    }
+
+    #[test]
+    fn test_invalid_signature() {
+        let pubkey = PublicKey::from_hex(
+            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
+        )
+        .unwrap();
+
+        let request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
+
+        // Signature is on a different quote id verification should fail
+        assert!(request.verify_signature(pubkey).is_err());
+    }
+}

+ 12 - 2
crates/cdk/src/wallet/mint.rs

@@ -6,7 +6,7 @@ use crate::dhke::construct_proofs;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{
     nut12, MintBolt11Request, MintQuoteBolt11Request, MintQuoteBolt11Response, PreMintSecrets,
-    SpendingConditions, State,
+    SecretKey, SpendingConditions, State,
 };
 use crate::types::ProofInfo;
 use crate::util::unix_time;
@@ -65,10 +65,13 @@ impl Wallet {
             }
         }
 
+        let secret_key = SecretKey::generate();
+
         let request = MintQuoteBolt11Request {
             amount,
             unit: unit.clone(),
             description,
+            pubkey: Some(secret_key.public_key()),
         };
 
         let quote_res = self.client.post_mint_quote(request).await?;
@@ -81,6 +84,7 @@ impl Wallet {
             request: quote_res.request,
             state: quote_res.state,
             expiry: quote_res.expiry.unwrap_or(0),
+            secret_key: Some(secret_key),
         };
 
         self.localstore.add_mint_quote(quote.clone()).await?;
@@ -121,6 +125,7 @@ impl Wallet {
             let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?;
 
             if mint_quote_response.state == MintQuoteState::Paid {
+                // TODO: Need to pass in keys here
                 let amount = self
                     .mint(&mint_quote.id, SplitTarget::default(), None)
                     .await?;
@@ -216,11 +221,16 @@ impl Wallet {
             )?,
         };
 
-        let request = MintBolt11Request {
+        let mut request = MintBolt11Request {
             quote: quote_id.to_string(),
             outputs: premint_secrets.blinded_messages(),
+            signature: None,
         };
 
+        if let Some(secret_key) = quote_info.secret_key {
+            request.sign(secret_key)?;
+        }
+
         let mint_res = self.client.post_mint(request).await?;
 
         let keys = self.get_keyset_keys(active_keyset_id).await?;

+ 4 - 2
crates/cdk/src/wallet/types.rs

@@ -3,11 +3,11 @@
 use serde::{Deserialize, Serialize};
 
 use crate::mint_url::MintUrl;
-use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState};
+use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
 use crate::Amount;
 
 /// Mint Quote Info
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct MintQuote {
     /// Quote id
     pub id: String,
@@ -23,6 +23,8 @@ pub struct MintQuote {
     pub state: MintQuoteState,
     /// Expiration time of quote
     pub expiry: u64,
+    /// Secretkey for signing mint quotes [NUT-20]
+    pub secret_key: Option<SecretKey>,
 }
 
 /// Melt Quote Info

+ 6 - 6
flake.lock

@@ -57,11 +57,11 @@
         "systems": "systems"
       },
       "locked": {
-        "lastModified": 1726560853,
-        "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "lastModified": 1731533236,
+        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
         "owner": "numtide",
         "repo": "flake-utils",
-        "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
         "type": "github"
       },
       "original": {
@@ -177,11 +177,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1731378398,
-        "narHash": "sha256-a0QWaiX8+AJ9/XBLGMDy6c90GD7HzpxKVdlFwCke5Pw=",
+        "lastModified": 1731637922,
+        "narHash": "sha256-6iuzRINXyPX4DfUQZIGafpJnzjFXjVRYMymB10/jFFY=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "0ae9fc2f2fe5361837d59c0bdebbda176427111e",
+        "rev": "db10c66da18e816030b884388545add8cf096647",
         "type": "github"
       },
       "original": {

+ 2 - 1
flake.nix

@@ -65,7 +65,7 @@
           targets = [ "wasm32-unknown-unknown" ]; # wasm
         };
 
-        # Nightly for creating lock files
+        # Nightly used for formatting
         nightly_toolchain = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
           extensions = [ "rustfmt" "clippy" "rust-analyzer" ];
         });
@@ -274,6 +274,7 @@
                 cargo update -p allocator-api2 --precise 0.2.18
                 cargo update -p async-compression --precise 0.4.3
                 cargo update -p zstd-sys --precise 2.0.8+zstd.1.5.5
+                cargo update -p redb --precise 2.2.0
               '';
               buildInputs = buildInputs ++ WASMInputs ++ [ db_msrv_toolchain ];
               inherit nativeBuildInputs;

Some files were not shown because too many files changed in this diff