cashu/nuts/
nut20.rs

1//! Mint Quote Signatures
2
3use std::str::FromStr;
4
5use bitcoin::secp256k1::schnorr::Signature;
6use thiserror::Error;
7
8use super::{MintRequest, PublicKey, SecretKey};
9
10/// Nut19 Error
11#[derive(Debug, Error)]
12pub enum Error {
13    /// Signature not provided
14    #[error("Signature not provided")]
15    SignatureMissing,
16    /// Quote signature invalid signature
17    #[error("Quote signature invalid signature")]
18    InvalidSignature,
19    /// Nut01 error
20    #[error(transparent)]
21    NUT01(#[from] crate::nuts::nut01::Error),
22}
23
24impl<Q> MintRequest<Q>
25where
26    Q: ToString,
27{
28    /// Constructs the message to be signed according to NUT-20 specification.
29    ///
30    /// The message is constructed by concatenating (as UTF-8 encoded bytes):
31    /// 1. The quote ID (as UTF-8)
32    /// 2. All blinded secrets (B_0 through B_n) converted to hex strings (as UTF-8)
33    ///
34    /// Format: `quote_id || B_0 || B_1 || ... || B_n`
35    /// where each component is encoded as UTF-8 bytes
36    pub fn msg_to_sign(&self) -> Vec<u8> {
37        // Pre-calculate capacity to avoid reallocations
38        let quote_id = self.quote.to_string();
39        let capacity = quote_id.len() + (self.outputs.len() * 66);
40        let mut msg = Vec::with_capacity(capacity);
41        msg.append(&mut quote_id.clone().into_bytes()); // String.into_bytes() produces UTF-8
42        for output in &self.outputs {
43            // to_hex() creates a hex string, into_bytes() converts it to UTF-8 bytes
44            msg.append(&mut output.blinded_secret.to_hex().into_bytes());
45        }
46        msg
47    }
48
49    /// Sign [`MintRequest`]
50    pub fn sign(&mut self, secret_key: SecretKey) -> Result<(), Error> {
51        let msg = self.msg_to_sign();
52
53        let signature: Signature = secret_key.sign(&msg)?;
54
55        self.signature = Some(signature.to_string());
56
57        Ok(())
58    }
59
60    /// Verify signature on [`MintRequest`]
61    pub fn verify_signature(&self, pubkey: PublicKey) -> Result<(), Error> {
62        let signature = self.signature.as_ref().ok_or(Error::SignatureMissing)?;
63
64        let signature = Signature::from_str(signature).map_err(|_| Error::InvalidSignature)?;
65
66        let msg_to_sign = self.msg_to_sign();
67
68        pubkey.verify(&msg_to_sign, &signature)?;
69
70        Ok(())
71    }
72}
73
74#[cfg(test)]
75mod tests {
76
77    use uuid::Uuid;
78
79    use super::*;
80
81    #[test]
82    fn test_msg_to_sign() {
83        let request: MintRequest<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();
84
85        // let expected_msg_to_sign = "9d745270-1405-46de-b5c5-e2762b4f5e000342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c31102be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b5302209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79";
86
87        let expected_msg_to_sign = [
88            57, 100, 55, 52, 53, 50, 55, 48, 45, 49, 52, 48, 53, 45, 52, 54, 100, 101, 45, 98, 53,
89            99, 53, 45, 101, 50, 55, 54, 50, 98, 52, 102, 53, 101, 48, 48, 48, 51, 52, 50, 101, 53,
90            98, 99, 99, 55, 55, 102, 53, 98, 50, 97, 51, 99, 50, 97, 102, 98, 52, 48, 98, 98, 53,
91            57, 49, 97, 49, 101, 50, 55, 100, 97, 56, 51, 99, 100, 100, 99, 57, 54, 56, 97, 98,
92            100, 99, 48, 101, 99, 52, 57, 48, 52, 50, 48, 49, 97, 50, 48, 49, 56, 51, 52, 48, 51,
93            50, 102, 100, 51, 99, 52, 100, 99, 52, 57, 97, 50, 56, 52, 52, 97, 56, 57, 57, 57, 56,
94            100, 53, 101, 57, 100, 53, 98, 48, 102, 48, 98, 48, 48, 100, 100, 101, 57, 51, 49, 48,
95            48, 54, 51, 97, 99, 98, 56, 97, 57, 50, 101, 50, 102, 100, 97, 102, 97, 52, 49, 50, 54,
96            100, 52, 48, 51, 51, 98, 54, 102, 100, 101, 53, 48, 98, 54, 97, 48, 100, 102, 101, 54,
97            49, 97, 100, 49, 52, 56, 102, 102, 102, 49, 54, 55, 97, 100, 57, 99, 102, 56, 51, 48,
98            56, 100, 101, 100, 53, 102, 54, 102, 54, 98, 50, 102, 101, 48, 48, 48, 97, 48, 51, 54,
99            99, 52, 54, 52, 99, 51, 49, 49, 48, 50, 98, 101, 53, 97, 53, 53, 102, 48, 51, 101, 53,
100            99, 48, 97, 97, 101, 97, 55, 55, 53, 57, 53, 100, 53, 55, 52, 98, 99, 101, 57, 50, 99,
101            54, 100, 53, 55, 97, 50, 97, 48, 102, 98, 50, 98, 53, 57, 53, 53, 99, 48, 98, 56, 55,
102            101, 52, 53, 50, 48, 101, 48, 54, 98, 53, 51, 48, 50, 50, 48, 57, 102, 99, 50, 56, 55,
103            51, 102, 50, 56, 53, 50, 49, 99, 98, 100, 100, 101, 55, 102, 55, 98, 51, 98, 98, 49,
104            53, 50, 49, 48, 48, 50, 52, 54, 51, 102, 53, 57, 55, 57, 54, 56, 54, 102, 100, 49, 53,
105            54, 102, 50, 51, 102, 101, 54, 97, 56, 97, 97, 50, 98, 55, 57,
106        ]
107        .to_vec();
108
109        let request_msg_to_sign = request.msg_to_sign();
110
111        assert_eq!(expected_msg_to_sign, request_msg_to_sign);
112    }
113
114    #[test]
115    fn test_valid_signature() {
116        let pubkey = PublicKey::from_hex(
117            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
118        )
119        .unwrap();
120
121        let request: MintRequest<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();
122
123        assert!(request.verify_signature(pubkey).is_ok());
124    }
125
126    #[test]
127    fn test_mint_request_signature() {
128        let mut request: MintRequest<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();
129
130        let secret =
131            SecretKey::from_hex("50d7fd7aa2b2fe4607f41f4ce6f8794fc184dd47b8cdfbe4b3d1249aa02d35aa")
132                .unwrap();
133
134        request.sign(secret.clone()).unwrap();
135
136        assert!(request.verify_signature(secret.public_key()).is_ok());
137    }
138
139    #[test]
140    fn test_invalid_signature() {
141        let pubkey = PublicKey::from_hex(
142            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
143        )
144        .unwrap();
145
146        let request: MintRequest<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();
147
148        // Signature is on a different quote id verification should fail
149        assert!(request.verify_signature(pubkey).is_err());
150    }
151}