Skip to main content

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 super::*;
78
79    #[test]
80    fn test_msg_to_sign() {
81        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();
82
83        // let expected_msg_to_sign = "9d745270-1405-46de-b5c5-e2762b4f5e000342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c31102be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b5302209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79";
84
85        let expected_msg_to_sign = [
86            57, 100, 55, 52, 53, 50, 55, 48, 45, 49, 52, 48, 53, 45, 52, 54, 100, 101, 45, 98, 53,
87            99, 53, 45, 101, 50, 55, 54, 50, 98, 52, 102, 53, 101, 48, 48, 48, 51, 52, 50, 101, 53,
88            98, 99, 99, 55, 55, 102, 53, 98, 50, 97, 51, 99, 50, 97, 102, 98, 52, 48, 98, 98, 53,
89            57, 49, 97, 49, 101, 50, 55, 100, 97, 56, 51, 99, 100, 100, 99, 57, 54, 56, 97, 98,
90            100, 99, 48, 101, 99, 52, 57, 48, 52, 50, 48, 49, 97, 50, 48, 49, 56, 51, 52, 48, 51,
91            50, 102, 100, 51, 99, 52, 100, 99, 52, 57, 97, 50, 56, 52, 52, 97, 56, 57, 57, 57, 56,
92            100, 53, 101, 57, 100, 53, 98, 48, 102, 48, 98, 48, 48, 100, 100, 101, 57, 51, 49, 48,
93            48, 54, 51, 97, 99, 98, 56, 97, 57, 50, 101, 50, 102, 100, 97, 102, 97, 52, 49, 50, 54,
94            100, 52, 48, 51, 51, 98, 54, 102, 100, 101, 53, 48, 98, 54, 97, 48, 100, 102, 101, 54,
95            49, 97, 100, 49, 52, 56, 102, 102, 102, 49, 54, 55, 97, 100, 57, 99, 102, 56, 51, 48,
96            56, 100, 101, 100, 53, 102, 54, 102, 54, 98, 50, 102, 101, 48, 48, 48, 97, 48, 51, 54,
97            99, 52, 54, 52, 99, 51, 49, 49, 48, 50, 98, 101, 53, 97, 53, 53, 102, 48, 51, 101, 53,
98            99, 48, 97, 97, 101, 97, 55, 55, 53, 57, 53, 100, 53, 55, 52, 98, 99, 101, 57, 50, 99,
99            54, 100, 53, 55, 97, 50, 97, 48, 102, 98, 50, 98, 53, 57, 53, 53, 99, 48, 98, 56, 55,
100            101, 52, 53, 50, 48, 101, 48, 54, 98, 53, 51, 48, 50, 50, 48, 57, 102, 99, 50, 56, 55,
101            51, 102, 50, 56, 53, 50, 49, 99, 98, 100, 100, 101, 55, 102, 55, 98, 51, 98, 98, 49,
102            53, 50, 49, 48, 48, 50, 52, 54, 51, 102, 53, 57, 55, 57, 54, 56, 54, 102, 100, 49, 53,
103            54, 102, 50, 51, 102, 101, 54, 97, 56, 97, 97, 50, 98, 55, 57,
104        ]
105        .to_vec();
106
107        let request_msg_to_sign = request.msg_to_sign();
108
109        assert_eq!(expected_msg_to_sign, request_msg_to_sign);
110    }
111
112    #[cfg(feature = "mint")]
113    #[test]
114    fn test_valid_signature() {
115        use uuid::Uuid;
116
117        let pubkey = PublicKey::from_hex(
118            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
119        )
120        .unwrap();
121
122        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();
123
124        assert!(request.verify_signature(pubkey).is_ok());
125    }
126
127    #[test]
128    fn test_mint_request_signature() {
129        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();
130
131        let secret =
132            SecretKey::from_hex("50d7fd7aa2b2fe4607f41f4ce6f8794fc184dd47b8cdfbe4b3d1249aa02d35aa")
133                .unwrap();
134
135        request.sign(secret.clone()).unwrap();
136
137        assert!(request.verify_signature(secret.public_key()).is_ok());
138    }
139
140    #[test]
141    fn test_invalid_signature() {
142        let pubkey = PublicKey::from_hex(
143            "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
144        )
145        .unwrap();
146
147        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();
148
149        // Signature is on a different quote id verification should fail
150        assert!(request.verify_signature(pubkey).is_err());
151    }
152}