Skip to content

Commit 53fd300

Browse files
committed
fix: improve legacy quote signature fallback
Log accepted legacy NUT-20 quote signatures on mint-side verification for single and batch mint requests. Preserve the original mint rejection when wallet legacy fallback preparation, signing, or retry fails, while logging the fallback error. Reject legacy batch signing when request quotes and quote metadata are not positionally aligned.
1 parent 2d60a93 commit 53fd300

3 files changed

Lines changed: 137 additions & 41 deletions

File tree

crates/cashu/src/nuts/nut20.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,21 @@ where
119119

120120
let signature = Signature::from_str(signature).map_err(|_| Error::InvalidSignature)?;
121121

122-
let msg_to_sign = self.msg_to_sign();
122+
let quote_id = self.quote.to_string();
123+
let msg_to_sign = mint_quote_msg_to_sign(&quote_id, &self.outputs);
123124

124125
match pubkey.verify(&msg_to_sign, &signature) {
125-
Ok(()) => Ok(()),
126-
Err(_) => pubkey.verify(&self.legacy_msg_to_sign(), &signature),
127-
}?;
126+
Ok(()) => return Ok(()),
127+
Err(_) => {
128+
let legacy_msg = legacy_mint_quote_msg_to_sign(&quote_id, &self.outputs);
129+
pubkey.verify(&legacy_msg, &signature)?;
130+
tracing::warn!(
131+
quote_id = %quote_id,
132+
output_count = self.outputs.len(),
133+
"Accepted legacy mint quote signature format"
134+
);
135+
}
136+
}
128137

129138
Ok(())
130139
}

crates/cashu/src/nuts/nut29.rs

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,23 @@ where
117117
let signature = signature
118118
.parse::<Signature>()
119119
.map_err(|_| super::nut20::Error::InvalidSignature)?;
120-
let msg = self.msg_to_sign(quote);
120+
let quote_id = quote.to_string();
121+
let msg = mint_quote_msg_to_sign(&quote_id, &self.outputs);
121122

122123
match pubkey.verify(&msg, &signature) {
123-
Ok(()) => Ok(()),
124-
Err(_) => pubkey.verify(&self.legacy_msg_to_sign(quote), &signature),
124+
Ok(()) => return Ok(()),
125+
Err(_) => {
126+
let legacy_msg = legacy_mint_quote_msg_to_sign(&quote_id, &self.outputs);
127+
pubkey
128+
.verify(&legacy_msg, &signature)
129+
.map_err(|_| super::nut20::Error::InvalidSignature)?;
130+
tracing::warn!(
131+
quote_id = %quote_id,
132+
output_count = self.outputs.len(),
133+
"Accepted legacy batch mint quote signature format"
134+
);
135+
}
125136
}
126-
.map_err(|_| super::nut20::Error::InvalidSignature)?;
127137

128138
Ok(())
129139
}
@@ -240,6 +250,28 @@ mod tests {
240250
.expect("verification should succeed with correct key");
241251
}
242252

253+
#[test]
254+
fn test_sign_and_verify_batch_quote_legacy_roundtrip() {
255+
let secret_key = SecretKey::generate();
256+
let pubkey = secret_key.public_key();
257+
let quote_id = "test-quote-id-legacy".to_string();
258+
let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
259+
let request = BatchMintRequest {
260+
quotes: vec![quote_id.clone()],
261+
quote_amounts: None,
262+
outputs,
263+
signatures: None,
264+
};
265+
266+
let signature = request
267+
.sign_quote_legacy(&quote_id, &secret_key)
268+
.expect("legacy signing should succeed");
269+
270+
request
271+
.verify_quote_signature(&quote_id, &signature, &pubkey)
272+
.expect("verification should fall back to the legacy message format");
273+
}
274+
243275
#[test]
244276
fn test_verify_batch_quote_wrong_key() {
245277
let signing_key = SecretKey::generate();
@@ -371,26 +403,4 @@ mod tests {
371403
assert!(!Settings::new(None, Some(vec!["bolt11".to_string()])).is_empty());
372404
assert!(!Settings::new(Some(10), Some(vec!["bolt11".to_string()])).is_empty());
373405
}
374-
375-
#[test]
376-
fn msg_to_sign_concatenates_quote_and_each_output_secret() {
377-
let quote_id = "quote-id".to_string();
378-
let output_1 = dummy_blinded_message();
379-
let output_2 = dummy_blinded_message();
380-
let expected = format!(
381-
"{}{}{}",
382-
quote_id,
383-
output_1.blinded_secret.to_hex(),
384-
output_2.blinded_secret.to_hex()
385-
)
386-
.into_bytes();
387-
let request = BatchMintRequest {
388-
quotes: vec![quote_id.clone()],
389-
quote_amounts: None,
390-
outputs: vec![output_1, output_2],
391-
signatures: None,
392-
};
393-
394-
assert_eq!(request.msg_to_sign(&quote_id), expected);
395-
}
396406
}

crates/cdk/src/wallet/issue/saga/mod.rs

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,44 @@ async fn post_mint_request_with_legacy_fallback(
8888
{
8989
Ok(response) => Ok(response),
9090
Err(error) if should_retry_with_legacy_quote_signature(&error) => {
91-
let Some(secret_key) = wallet.mint_quote_signing_key(quote_info).await? else {
92-
return Err(error);
91+
let secret_key = match wallet.mint_quote_signing_key(quote_info).await {
92+
Ok(Some(secret_key)) => secret_key,
93+
Ok(None) => return Err(error),
94+
Err(fallback_error) => {
95+
tracing::warn!(
96+
original_error = %error,
97+
fallback_error = %fallback_error,
98+
"Could not prepare legacy mint quote signature retry; returning original mint error"
99+
);
100+
return Err(error);
101+
}
93102
};
94103

95104
tracing::info!(
96105
"Mint request rejected with new NUT-20 signature format; retrying legacy format"
97106
);
98107

99108
let mut retry_request = request.clone();
100-
retry_request.sign_legacy(secret_key)?;
109+
if let Err(fallback_error) = retry_request.sign_legacy(secret_key) {
110+
tracing::warn!(
111+
original_error = %error,
112+
fallback_error = %fallback_error,
113+
"Could not sign legacy mint quote retry; returning original mint error"
114+
);
115+
return Err(error);
116+
}
101117

102-
wallet.client.post_mint(payment_method, retry_request).await
118+
match wallet.client.post_mint(payment_method, retry_request).await {
119+
Ok(response) => Ok(response),
120+
Err(fallback_error) => {
121+
tracing::warn!(
122+
original_error = %error,
123+
fallback_error = %fallback_error,
124+
"Legacy mint quote signature retry failed; returning original mint error"
125+
);
126+
Err(error)
127+
}
128+
}
103129
}
104130
Err(error) => Err(error),
105131
},
@@ -115,10 +141,23 @@ async fn post_mint_request_with_legacy_fallback(
115141
{
116142
Ok(response) => Ok(response),
117143
Err(error) if should_retry_with_legacy_quote_signature(&error) => {
118-
let Some(legacy_signatures) =
119-
legacy_batch_signatures(wallet, request, quote_infos).await?
120-
else {
121-
return Err(error);
144+
let legacy_signatures = match legacy_batch_signatures(
145+
wallet,
146+
request,
147+
quote_infos,
148+
)
149+
.await
150+
{
151+
Ok(Some(legacy_signatures)) => legacy_signatures,
152+
Ok(None) => return Err(error),
153+
Err(fallback_error) => {
154+
tracing::warn!(
155+
original_error = %error,
156+
fallback_error = %fallback_error,
157+
"Could not prepare legacy batch mint quote signature retry; returning original mint error"
158+
);
159+
return Err(error);
160+
}
122161
};
123162

124163
tracing::info!(
@@ -132,6 +171,14 @@ async fn post_mint_request_with_legacy_fallback(
132171
.client
133172
.post_batch_mint(payment_method, retry_request)
134173
.await
174+
.map_err(|fallback_error| {
175+
tracing::warn!(
176+
original_error = %error,
177+
fallback_error = %fallback_error,
178+
"Legacy batch mint quote signature retry failed; returning original mint error"
179+
);
180+
error
181+
})
135182
}
136183
Err(error) => Err(error),
137184
}
@@ -156,6 +203,10 @@ async fn legacy_batch_signatures(
156203
for ((quote_id, quote_info), signature) in
157204
request.quotes.iter().zip(quote_infos).zip(signatures)
158205
{
206+
if quote_info.id.as_str() != quote_id.as_str() {
207+
return Ok(None);
208+
}
209+
159210
if signature.is_some() {
160211
let Some(secret_key) = wallet.mint_quote_signing_key(quote_info).await? else {
161212
return Ok(None);
@@ -964,7 +1015,7 @@ mod tests {
9641015
use cdk_common::nuts::MintQuoteState;
9651016

9661017
use super::*;
967-
use crate::nuts::{BlindSignature, BlindedMessage, MintResponse};
1018+
use crate::nuts::{BatchMintRequest, BlindSignature, BlindedMessage, MintResponse};
9681019
use crate::wallet::test_utils::{
9691020
create_test_db, create_test_wallet_with_mock, test_mint_quote, test_mint_url,
9701021
MockMintConnector,
@@ -1022,7 +1073,9 @@ mod tests {
10221073
.expect("prepare mint saga");
10231074

10241075
mock_client.push_post_mint_response(Err(Error::SignatureMissingOrInvalid));
1025-
mock_client.push_post_mint_response(Err(Error::SignatureMissingOrInvalid));
1076+
mock_client.push_post_mint_response(Err(Error::Custom(
1077+
"legacy retry should not replace original error".to_string(),
1078+
)));
10261079

10271080
let result = prepared.execute().await;
10281081

@@ -1079,7 +1132,9 @@ mod tests {
10791132
.expect("prepare batch mint saga");
10801133

10811134
mock_client.push_post_batch_mint_response(Err(Error::SignatureMissingOrInvalid));
1082-
mock_client.push_post_batch_mint_response(Err(Error::SignatureMissingOrInvalid));
1135+
mock_client.push_post_batch_mint_response(Err(Error::Custom(
1136+
"legacy retry should not replace original error".to_string(),
1137+
)));
10831138

10841139
let result = prepared.execute().await;
10851140

@@ -1126,6 +1181,28 @@ mod tests {
11261181
assert_eq!(first_request.outputs, legacy_request.outputs);
11271182
}
11281183

1184+
#[tokio::test]
1185+
async fn test_legacy_batch_signatures_rejects_misaligned_quote_infos() {
1186+
let db = create_test_db().await;
1187+
let mint_url = test_mint_url();
1188+
let mock_client = Arc::new(MockMintConnector::new());
1189+
let wallet = create_test_wallet_with_mock(db, mock_client).await;
1190+
1191+
let request = BatchMintRequest {
1192+
quotes: vec!["request-quote-id".to_string()],
1193+
quote_amounts: None,
1194+
outputs: vec![],
1195+
signatures: Some(vec![Some("signature-placeholder".to_string())]),
1196+
};
1197+
let quote_infos = vec![test_mint_quote(mint_url)];
1198+
1199+
let result = legacy_batch_signatures(&wallet, &request, &quote_infos)
1200+
.await
1201+
.expect("alignment check should not fail");
1202+
1203+
assert!(result.is_none());
1204+
}
1205+
11291206
#[tokio::test]
11301207
async fn test_execute_rejects_signature_with_mismatched_amount() {
11311208
let db = create_test_db().await;

0 commit comments

Comments
 (0)