Skip to main content

ccxt_exchanges/binance/
auth.rs

1//! Binance authentication and signature module.
2//!
3//! Implements HMAC-SHA256 signature algorithm for API request authentication.
4
5use ccxt_core::credentials::SecretString;
6use ccxt_core::{Error, Result};
7use hmac::{Hmac, Mac};
8use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
9use sha2::Sha256;
10use std::collections::{BTreeMap, HashMap};
11
12type HmacSha256 = Hmac<Sha256>;
13
14/// Binance authenticator.
15///
16/// Credentials are automatically zeroed from memory when dropped.
17#[derive(Debug, Clone)]
18pub struct BinanceAuth {
19    /// API key (automatically zeroed on drop).
20    api_key: SecretString,
21    /// Secret key (automatically zeroed on drop).
22    secret: SecretString,
23}
24
25impl BinanceAuth {
26    /// Creates a new authenticator.
27    ///
28    /// # Arguments
29    ///
30    /// * `api_key` - API key
31    /// * `secret` - Secret key
32    ///
33    /// # Security
34    ///
35    /// Credentials are automatically zeroed from memory when the authenticator is dropped.
36    pub fn new(api_key: impl Into<String>, secret: impl Into<String>) -> Self {
37        Self {
38            api_key: SecretString::new(api_key),
39            secret: SecretString::new(secret),
40        }
41    }
42
43    /// Returns the API key.
44    pub fn api_key(&self) -> &str {
45        self.api_key.expose_secret()
46    }
47
48    /// Returns the secret key.
49    pub fn secret(&self) -> &str {
50        self.secret.expose_secret()
51    }
52
53    /// Signs a query string using HMAC-SHA256.
54    ///
55    /// # Arguments
56    ///
57    /// * `query_string` - Query string to sign
58    ///
59    /// # Returns
60    ///
61    /// Returns the HMAC-SHA256 signature as a hex string.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the secret key is invalid.
66    pub fn sign(&self, query_string: &str) -> Result<String> {
67        let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret_bytes())
68            .map_err(|e| Error::authentication(format!("Invalid secret key: {}", e)))?;
69
70        mac.update(query_string.as_bytes());
71        let result = mac.finalize();
72        let signature = hex::encode(result.into_bytes());
73
74        Ok(signature)
75    }
76
77    /// Signs a parameter map.
78    ///
79    /// # Arguments
80    ///
81    /// * `params` - Parameter map
82    ///
83    /// # Returns
84    ///
85    /// Returns a new parameter map containing the signature.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if signature generation fails.
90    pub fn sign_params(
91        &self,
92        params: &BTreeMap<String, String>,
93    ) -> Result<BTreeMap<String, String>> {
94        let query_string = self.build_query_string(params);
95        let signature = self.sign(&query_string)?;
96
97        let mut signed_params = params.clone();
98        signed_params.insert("signature".to_string(), signature);
99
100        Ok(signed_params)
101    }
102
103    /// Signs parameters with timestamp and optional receive window.
104    ///
105    /// # Arguments
106    ///
107    /// * `params` - Parameter map
108    /// * `timestamp` - Timestamp in milliseconds
109    /// * `recv_window` - Optional receive window in milliseconds
110    ///
111    /// # Returns
112    ///
113    /// Returns a new parameter map containing timestamp and signature.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if signature generation fails.
118    pub fn sign_with_timestamp(
119        &self,
120        params: &BTreeMap<String, String>,
121        timestamp: i64,
122        recv_window: Option<u64>,
123    ) -> Result<BTreeMap<String, String>> {
124        let mut params_with_time = params.clone();
125        params_with_time.insert("timestamp".to_string(), timestamp.to_string());
126
127        if let Some(window) = recv_window {
128            params_with_time.insert("recvWindow".to_string(), window.to_string());
129        }
130
131        self.sign_params(&params_with_time)
132    }
133
134    /// Builds a query string from parameters.
135    ///
136    /// # Arguments
137    ///
138    /// * `params` - Parameter map
139    ///
140    /// # Returns
141    ///
142    /// Returns a URL-encoded query string with parameters sorted by key.
143    /// Both keys and values are URL-encoded to handle special characters.
144    #[allow(clippy::unused_self)]
145    pub(crate) fn build_query_string(&self, params: &BTreeMap<String, String>) -> String {
146        let mut pairs: Vec<_> = params.iter().collect();
147        pairs.sort_by_key(|(k, _)| *k);
148
149        pairs
150            .iter()
151            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
152            .collect::<Vec<_>>()
153            .join("&")
154    }
155
156    /// Adds authentication headers to the request.
157    ///
158    /// # Arguments
159    ///
160    /// * `headers` - Existing header map to modify
161    pub fn add_auth_headers(&self, headers: &mut HashMap<String, String>) {
162        headers.insert(
163            "X-MBX-APIKEY".to_string(),
164            self.api_key.expose_secret().to_string(),
165        );
166    }
167
168    /// Adds authentication headers to a `reqwest` request.
169    ///
170    /// # Arguments
171    ///
172    /// * `headers` - Reqwest `HeaderMap` to modify
173    pub fn add_auth_headers_reqwest(&self, headers: &mut HeaderMap) {
174        if let Ok(header_name) = HeaderName::from_bytes(b"X-MBX-APIKEY") {
175            if let Ok(header_value) = HeaderValue::from_str(self.api_key.expose_secret()) {
176                headers.insert(header_name, header_value);
177            }
178        }
179    }
180}
181
182/// Builds a signed URL with query parameters.
183///
184/// # Arguments
185///
186/// * `base_url` - Base URL
187/// * `endpoint` - API endpoint path
188/// * `params` - Parameter map (must include signature)
189///
190/// # Returns
191///
192/// Returns the complete URL with URL-encoded query parameters.
193pub fn build_signed_url<S: std::hash::BuildHasher>(
194    base_url: &str,
195    endpoint: &str,
196    params: &HashMap<String, String, S>,
197) -> String {
198    let query_string = params
199        .iter()
200        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
201        .collect::<Vec<_>>()
202        .join("&");
203
204    if query_string.is_empty() {
205        format!("{base_url}{endpoint}")
206    } else {
207        format!("{base_url}{endpoint}?{query_string}")
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_sign() {
217        let auth = BinanceAuth::new("test_key", "test_secret");
218        let query = "symbol=BTCUSDT&side=BUY&type=LIMIT&quantity=1&timestamp=1234567890";
219
220        let signature = auth.sign(query);
221        assert!(signature.is_ok());
222
223        let sig = signature.expect("Signature failed");
224        assert!(!sig.is_empty());
225        assert_eq!(sig.len(), 64); // HMAC-SHA256 produces 64 hex characters
226    }
227
228    #[test]
229    fn test_sign_params() {
230        let auth = BinanceAuth::new("test_key", "test_secret");
231        let mut params = BTreeMap::new();
232        params.insert("symbol".to_string(), "BTCUSDT".to_string());
233        params.insert("side".to_string(), "BUY".to_string());
234
235        let signed = auth.sign_params(&params);
236        assert!(signed.is_ok());
237
238        let signed_params = signed.expect("Sign params failed");
239        assert!(signed_params.contains_key("signature"));
240        assert_eq!(
241            signed_params.get("symbol").expect("Missing symbol"),
242            "BTCUSDT"
243        );
244    }
245
246    #[test]
247    fn test_sign_with_timestamp() {
248        let auth = BinanceAuth::new("test_key", "test_secret");
249        let params = BTreeMap::new();
250        let timestamp = 1234567890i64;
251
252        let signed = auth.sign_with_timestamp(&params, timestamp, Some(5000));
253        assert!(signed.is_ok());
254
255        let signed_params = signed.expect("Sign timestamp failed");
256        assert!(signed_params.contains_key("timestamp"));
257        assert!(signed_params.contains_key("recvWindow"));
258        assert!(signed_params.contains_key("signature"));
259        assert_eq!(
260            signed_params.get("timestamp").expect("Missing timestamp"),
261            "1234567890"
262        );
263        assert_eq!(
264            signed_params.get("recvWindow").expect("Missing recvWindow"),
265            "5000"
266        );
267    }
268
269    #[test]
270    fn test_build_query_string() {
271        let auth = BinanceAuth::new("test_key", "test_secret");
272        let mut params = BTreeMap::new();
273        params.insert("symbol".to_string(), "BTCUSDT".to_string());
274        params.insert("side".to_string(), "BUY".to_string());
275
276        let query = auth.build_query_string(&params);
277        assert!(query == "side=BUY&symbol=BTCUSDT" || query == "symbol=BTCUSDT&side=BUY");
278    }
279
280    #[test]
281    fn test_add_auth_headers() {
282        let auth = BinanceAuth::new("my_api_key", "test_secret");
283        let mut headers = HashMap::new();
284
285        auth.add_auth_headers(&mut headers);
286
287        assert!(headers.contains_key("X-MBX-APIKEY"));
288        assert_eq!(
289            headers.get("X-MBX-APIKEY").expect("Missing header"),
290            "my_api_key"
291        );
292    }
293
294    #[test]
295    fn test_build_signed_url() {
296        let mut params = HashMap::new();
297        params.insert("symbol".to_string(), "BTCUSDT".to_string());
298        params.insert("signature".to_string(), "abc123".to_string());
299
300        let url = build_signed_url("https://api.binance.com", "/api/v3/order", &params);
301
302        assert!(url.contains("https://api.binance.com/api/v3/order?"));
303        assert!(url.contains("symbol=BTCUSDT"));
304        assert!(url.contains("signature=abc123"));
305    }
306
307    #[test]
308    fn test_build_signed_url_empty_params() {
309        let params = HashMap::new();
310        let url = build_signed_url("https://api.binance.com", "/api/v3/time", &params);
311
312        assert_eq!(url, "https://api.binance.com/api/v3/time");
313    }
314
315    #[test]
316    fn test_build_query_string_url_encoding() {
317        let auth = BinanceAuth::new("test_key", "test_secret");
318        let mut params = BTreeMap::new();
319        // Test special characters that need URL encoding
320        params.insert("clientOrderId".to_string(), "order+123&test".to_string());
321        params.insert("symbol".to_string(), "BTC/USDT".to_string());
322
323        let query = auth.build_query_string(&params);
324
325        // Verify special characters are URL encoded
326        assert!(query.contains("clientOrderId=order%2B123%26test"));
327        assert!(query.contains("symbol=BTC%2FUSDT"));
328        // Verify & is used as separator (not encoded)
329        assert!(query.contains("&"));
330    }
331
332    #[test]
333    fn test_build_query_string_space_encoding() {
334        let auth = BinanceAuth::new("test_key", "test_secret");
335        let mut params = BTreeMap::new();
336        params.insert("note".to_string(), "hello world".to_string());
337
338        let query = auth.build_query_string(&params);
339
340        // Space should be encoded as %20
341        assert!(query.contains("note=hello%20world"));
342    }
343
344    #[test]
345    fn test_build_query_string_equals_encoding() {
346        let auth = BinanceAuth::new("test_key", "test_secret");
347        let mut params = BTreeMap::new();
348        params.insert("filter".to_string(), "price=100".to_string());
349
350        let query = auth.build_query_string(&params);
351
352        // = in value should be encoded as %3D
353        assert!(query.contains("filter=price%3D100"));
354    }
355
356    #[test]
357    fn test_signature_consistency_with_encoding() {
358        let auth = BinanceAuth::new("test_key", "test_secret");
359        let mut params = BTreeMap::new();
360        params.insert("symbol".to_string(), "BTC/USDT".to_string());
361        params.insert("side".to_string(), "BUY".to_string());
362
363        // Sign the same params twice
364        let signed1 = auth.sign_params(&params).expect("Sign failed");
365        let signed2 = auth.sign_params(&params).expect("Sign failed");
366
367        // Signatures should be identical for same input
368        assert_eq!(
369            signed1.get("signature").expect("Missing sig1"),
370            signed2.get("signature").expect("Missing sig2")
371        );
372    }
373
374    #[test]
375    fn test_signature_changes_with_different_params() {
376        let auth = BinanceAuth::new("test_key", "test_secret");
377
378        let mut params1 = BTreeMap::new();
379        params1.insert("symbol".to_string(), "BTCUSDT".to_string());
380
381        let mut params2 = BTreeMap::new();
382        params2.insert("symbol".to_string(), "ETHUSDT".to_string());
383
384        let signed1 = auth.sign_params(&params1).expect("Sign failed");
385        let signed2 = auth.sign_params(&params2).expect("Sign failed");
386
387        // Signatures should be different for different input
388        assert_ne!(
389            signed1.get("signature").expect("Missing sig1"),
390            signed2.get("signature").expect("Missing sig2")
391        );
392    }
393
394    #[test]
395    fn test_build_signed_url_with_special_chars() {
396        let mut params = HashMap::new();
397        params.insert("clientOrderId".to_string(), "test+order".to_string());
398        params.insert("signature".to_string(), "abc123".to_string());
399
400        let url = build_signed_url("https://api.binance.com", "/api/v3/order", &params);
401
402        // Special chars in values should be URL encoded
403        assert!(url.contains("clientOrderId=test%2Border"));
404    }
405
406    #[test]
407    fn test_alphabetical_sorting() {
408        let auth = BinanceAuth::new("test_key", "test_secret");
409        let mut params = BTreeMap::new();
410        params.insert("zebra".to_string(), "z".to_string());
411        params.insert("apple".to_string(), "a".to_string());
412        params.insert("mango".to_string(), "m".to_string());
413
414        let query = auth.build_query_string(&params);
415
416        // BTreeMap ensures alphabetical order
417        assert_eq!(query, "apple=a&mango=m&zebra=z");
418    }
419}