ccxt_exchanges/bybit/
auth.rs

1//! Bybit API authentication module.
2//!
3//! Implements HMAC-SHA256 signing for Bybit API requests.
4//! Bybit requires the following headers for authenticated requests:
5//! - X-BAPI-API-KEY: API key
6//! - X-BAPI-SIGN: HMAC-SHA256 signature (hex encoded)
7//! - X-BAPI-TIMESTAMP: Unix timestamp in milliseconds
8//! - X-BAPI-RECV-WINDOW: Receive window in milliseconds
9
10use ccxt_core::credentials::SecretString;
11use hmac::{Hmac, Mac};
12use reqwest::header::{HeaderMap, HeaderValue};
13use sha2::Sha256;
14
15/// Bybit API authenticator.
16///
17/// Handles request signing using HMAC-SHA256 and header construction
18/// for authenticated API requests.
19///
20/// Credentials are automatically zeroed from memory when dropped.
21#[derive(Debug, Clone)]
22pub struct BybitAuth {
23    /// API key for authentication (automatically zeroed on drop).
24    api_key: SecretString,
25    /// Secret key for HMAC signing (automatically zeroed on drop).
26    secret: SecretString,
27}
28
29impl BybitAuth {
30    /// Creates a new BybitAuth instance.
31    ///
32    /// # Arguments
33    ///
34    /// * `api_key` - The API key from Bybit.
35    /// * `secret` - The secret key from Bybit.
36    ///
37    /// # Security
38    ///
39    /// Credentials are automatically zeroed from memory when the authenticator is dropped.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use ccxt_exchanges::bybit::BybitAuth;
45    ///
46    /// let auth = BybitAuth::new(
47    ///     "your-api-key".to_string(),
48    ///     "your-secret".to_string(),
49    /// );
50    /// ```
51    pub fn new(api_key: String, secret: String) -> Self {
52        Self {
53            api_key: SecretString::new(api_key),
54            secret: SecretString::new(secret),
55        }
56    }
57
58    /// Returns the API key.
59    pub fn api_key(&self) -> &str {
60        self.api_key.expose_secret()
61    }
62
63    /// Builds the signature string for HMAC signing.
64    ///
65    /// The signature string format is: `timestamp + api_key + recv_window + params`
66    ///
67    /// # Arguments
68    ///
69    /// * `timestamp` - Unix timestamp in milliseconds as string.
70    /// * `recv_window` - Receive window in milliseconds.
71    /// * `params` - Query string (for GET) or request body (for POST).
72    ///
73    /// # Returns
74    ///
75    /// The concatenated string to be signed.
76    pub fn build_sign_string(&self, timestamp: &str, recv_window: u64, params: &str) -> String {
77        format!(
78            "{}{}{}{}",
79            timestamp,
80            self.api_key.expose_secret(),
81            recv_window,
82            params
83        )
84    }
85
86    /// Signs a request using HMAC-SHA256.
87    ///
88    /// # Arguments
89    ///
90    /// * `timestamp` - Unix timestamp in milliseconds as string.
91    /// * `recv_window` - Receive window in milliseconds.
92    /// * `params` - Query string (for GET) or request body (for POST).
93    ///
94    /// # Returns
95    ///
96    /// Hex-encoded HMAC-SHA256 signature.
97    ///
98    /// # Example
99    ///
100    /// ```
101    /// use ccxt_exchanges::bybit::BybitAuth;
102    ///
103    /// let auth = BybitAuth::new(
104    ///     "api-key".to_string(),
105    ///     "secret".to_string(),
106    /// );
107    ///
108    /// let signature = auth.sign("1234567890000", 5000, "symbol=BTCUSDT");
109    /// assert!(!signature.is_empty());
110    /// ```
111    pub fn sign(&self, timestamp: &str, recv_window: u64, params: &str) -> String {
112        let sign_string = self.build_sign_string(timestamp, recv_window, params);
113        self.hmac_sha256_hex(&sign_string)
114    }
115
116    /// Computes HMAC-SHA256 and returns hex-encoded result.
117    fn hmac_sha256_hex(&self, message: &str) -> String {
118        type HmacSha256 = Hmac<Sha256>;
119
120        // SAFETY: HMAC accepts keys of any length - this cannot fail
121        let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret_bytes())
122            .expect("HMAC-SHA256 accepts keys of any length; this is an infallible operation");
123        mac.update(message.as_bytes());
124        let result = mac.finalize().into_bytes();
125
126        // Convert to hex string
127        hex::encode(result)
128    }
129
130    /// Adds authentication headers to a HeaderMap.
131    ///
132    /// Adds the following headers:
133    /// - X-BAPI-API-KEY: API key
134    /// - X-BAPI-SIGN: HMAC-SHA256 signature (hex encoded)
135    /// - X-BAPI-TIMESTAMP: Unix timestamp
136    /// - X-BAPI-RECV-WINDOW: Receive window
137    ///
138    /// # Arguments
139    ///
140    /// * `headers` - Mutable reference to HeaderMap to add headers to.
141    /// * `timestamp` - Unix timestamp in milliseconds as string.
142    /// * `sign` - Pre-computed signature from `sign()` method.
143    /// * `recv_window` - Receive window in milliseconds.
144    ///
145    /// # Example
146    ///
147    /// ```
148    /// use ccxt_exchanges::bybit::BybitAuth;
149    /// use reqwest::header::HeaderMap;
150    ///
151    /// let auth = BybitAuth::new(
152    ///     "api-key".to_string(),
153    ///     "secret".to_string(),
154    /// );
155    ///
156    /// let mut headers = HeaderMap::new();
157    /// let timestamp = "1234567890000";
158    /// let recv_window = 5000u64;
159    /// let signature = auth.sign(timestamp, recv_window, "symbol=BTCUSDT");
160    /// auth.add_auth_headers(&mut headers, timestamp, &signature, recv_window);
161    ///
162    /// assert!(headers.contains_key("X-BAPI-API-KEY"));
163    /// assert!(headers.contains_key("X-BAPI-SIGN"));
164    /// assert!(headers.contains_key("X-BAPI-TIMESTAMP"));
165    /// assert!(headers.contains_key("X-BAPI-RECV-WINDOW"));
166    /// ```
167    pub fn add_auth_headers(
168        &self,
169        headers: &mut HeaderMap,
170        timestamp: &str,
171        sign: &str,
172        recv_window: u64,
173    ) {
174        headers.insert(
175            "X-BAPI-API-KEY",
176            HeaderValue::from_str(self.api_key.expose_secret())
177                .unwrap_or_else(|_| HeaderValue::from_static("")),
178        );
179        headers.insert(
180            "X-BAPI-SIGN",
181            HeaderValue::from_str(sign).unwrap_or_else(|_| HeaderValue::from_static("")),
182        );
183        headers.insert(
184            "X-BAPI-TIMESTAMP",
185            HeaderValue::from_str(timestamp).unwrap_or_else(|_| HeaderValue::from_static("")),
186        );
187        headers.insert(
188            "X-BAPI-RECV-WINDOW",
189            HeaderValue::from_str(&recv_window.to_string())
190                .unwrap_or_else(|_| HeaderValue::from_static("")),
191        );
192    }
193
194    /// Creates authentication headers for a request.
195    ///
196    /// This is a convenience method that combines `sign()` and `add_auth_headers()`.
197    ///
198    /// # Arguments
199    ///
200    /// * `timestamp` - Unix timestamp in milliseconds as string.
201    /// * `recv_window` - Receive window in milliseconds.
202    /// * `params` - Query string (for GET) or request body (for POST).
203    ///
204    /// # Returns
205    ///
206    /// A HeaderMap containing all authentication headers.
207    pub fn create_auth_headers(
208        &self,
209        timestamp: &str,
210        recv_window: u64,
211        params: &str,
212    ) -> HeaderMap {
213        let sign = self.sign(timestamp, recv_window, params);
214        let mut headers = HeaderMap::new();
215        self.add_auth_headers(&mut headers, timestamp, &sign, recv_window);
216        headers
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_auth_new() {
226        let auth = BybitAuth::new("test-api-key".to_string(), "test-secret".to_string());
227
228        assert_eq!(auth.api_key(), "test-api-key");
229    }
230
231    #[test]
232    fn test_build_sign_string() {
233        let auth = BybitAuth::new("api-key".to_string(), "secret".to_string());
234
235        let sign_string = auth.build_sign_string("1234567890000", 5000, "symbol=BTCUSDT");
236        assert_eq!(sign_string, "1234567890000api-key5000symbol=BTCUSDT");
237
238        let sign_string_empty = auth.build_sign_string("1234567890000", 5000, "");
239        assert_eq!(sign_string_empty, "1234567890000api-key5000");
240    }
241
242    #[test]
243    fn test_sign_deterministic() {
244        let auth = BybitAuth::new("api-key".to_string(), "test-secret-key".to_string());
245
246        let sig1 = auth.sign("1234567890000", 5000, "symbol=BTCUSDT");
247        let sig2 = auth.sign("1234567890000", 5000, "symbol=BTCUSDT");
248
249        assert_eq!(sig1, sig2);
250        assert!(!sig1.is_empty());
251    }
252
253    #[test]
254    fn test_sign_different_inputs() {
255        let auth = BybitAuth::new("api-key".to_string(), "test-secret-key".to_string());
256
257        let sig1 = auth.sign("1234567890000", 5000, "symbol=BTCUSDT");
258        let sig2 = auth.sign("1234567890001", 5000, "symbol=BTCUSDT");
259        let sig3 = auth.sign("1234567890000", 10000, "symbol=BTCUSDT");
260        let sig4 = auth.sign("1234567890000", 5000, "symbol=ETHUSDT");
261
262        assert_ne!(sig1, sig2);
263        assert_ne!(sig1, sig3);
264        assert_ne!(sig1, sig4);
265    }
266
267    #[test]
268    fn test_add_auth_headers() {
269        let auth = BybitAuth::new("test-api-key".to_string(), "test-secret".to_string());
270
271        let mut headers = HeaderMap::new();
272        let timestamp = "1234567890000";
273        let recv_window = 5000u64;
274        let signature = auth.sign(timestamp, recv_window, "symbol=BTCUSDT");
275        auth.add_auth_headers(&mut headers, timestamp, &signature, recv_window);
276
277        assert_eq!(headers.get("X-BAPI-API-KEY").unwrap(), "test-api-key");
278        assert_eq!(headers.get("X-BAPI-SIGN").unwrap(), &signature);
279        assert_eq!(headers.get("X-BAPI-TIMESTAMP").unwrap(), "1234567890000");
280        assert_eq!(headers.get("X-BAPI-RECV-WINDOW").unwrap(), "5000");
281    }
282
283    #[test]
284    fn test_create_auth_headers() {
285        let auth = BybitAuth::new("test-api-key".to_string(), "test-secret".to_string());
286
287        let headers = auth.create_auth_headers("1234567890000", 5000, "symbol=BTCUSDT");
288
289        assert!(headers.contains_key("X-BAPI-API-KEY"));
290        assert!(headers.contains_key("X-BAPI-SIGN"));
291        assert!(headers.contains_key("X-BAPI-TIMESTAMP"));
292        assert!(headers.contains_key("X-BAPI-RECV-WINDOW"));
293    }
294
295    #[test]
296    fn test_signature_is_hex() {
297        let auth = BybitAuth::new("api-key".to_string(), "test-secret".to_string());
298
299        let signature = auth.sign("1234567890000", 5000, "symbol=BTCUSDT");
300
301        // Hex characters are 0-9 and a-f
302        assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
303
304        // HMAC-SHA256 produces 32 bytes = 64 hex characters
305        assert_eq!(signature.len(), 64);
306
307        // Should be decodable as hex
308        let decoded = hex::decode(&signature);
309        assert!(decoded.is_ok());
310        assert_eq!(decoded.unwrap().len(), 32);
311    }
312
313    /// Test signature generation with known inputs.
314    /// This verifies the HMAC-SHA256 implementation produces correct signatures.
315    #[test]
316    fn test_signature_with_known_inputs() {
317        let auth = BybitAuth::new("bybit_api_key".to_string(), "bybit-secret-key".to_string());
318
319        // Known inputs
320        let timestamp = "1609459200000"; // 2021-01-01 00:00:00 UTC
321        let recv_window = 5000u64;
322        let params = "category=spot&symbol=BTCUSDT";
323
324        // Generate signature
325        let signature = auth.sign(timestamp, recv_window, params);
326
327        // The sign string should be: "1609459200000bybit_api_key5000category=spot&symbol=BTCUSDT"
328        let expected_sign_string = "1609459200000bybit_api_key5000category=spot&symbol=BTCUSDT";
329        assert_eq!(
330            auth.build_sign_string(timestamp, recv_window, params),
331            expected_sign_string
332        );
333
334        // Verify signature is non-empty and valid hex
335        assert!(!signature.is_empty());
336        assert_eq!(signature.len(), 64);
337        let decoded = hex::decode(&signature);
338        assert!(decoded.is_ok());
339        assert_eq!(decoded.unwrap().len(), 32);
340
341        // Verify the signature is reproducible
342        let signature2 = auth.sign(timestamp, recv_window, params);
343        assert_eq!(signature, signature2);
344    }
345
346    /// Test signature generation for POST request with JSON body.
347    #[test]
348    fn test_signature_with_post_body() {
349        let auth = BybitAuth::new("bybit_api_key".to_string(), "bybit-secret-key".to_string());
350
351        let timestamp = "1609459200000";
352        let recv_window = 5000u64;
353        let body = r#"{"category":"spot","symbol":"BTCUSDT","side":"Buy","orderType":"Limit","qty":"0.001","price":"50000"}"#;
354
355        let signature = auth.sign(timestamp, recv_window, body);
356
357        // Verify sign string format
358        let expected_sign_string =
359            format!("{}{}{}{}", timestamp, auth.api_key(), recv_window, body);
360        assert_eq!(
361            auth.build_sign_string(timestamp, recv_window, body),
362            expected_sign_string
363        );
364
365        // Verify signature is valid
366        assert!(!signature.is_empty());
367        assert_eq!(signature.len(), 64);
368        let decoded = hex::decode(&signature);
369        assert!(decoded.is_ok());
370        assert_eq!(decoded.unwrap().len(), 32);
371    }
372
373    /// Test that all required headers are present and have correct values.
374    #[test]
375    fn test_header_values_correctness() {
376        let api_key = "my-api-key-12345";
377        let secret = "my-secret-key-67890";
378
379        let auth = BybitAuth::new(api_key.to_string(), secret.to_string());
380
381        let timestamp = "1609459200000";
382        let recv_window = 5000u64;
383        let params = "category=spot&symbol=BTCUSDT";
384
385        let headers = auth.create_auth_headers(timestamp, recv_window, params);
386
387        // Verify X-BAPI-API-KEY header
388        assert_eq!(
389            headers.get("X-BAPI-API-KEY").unwrap().to_str().unwrap(),
390            api_key
391        );
392
393        // Verify X-BAPI-TIMESTAMP header
394        assert_eq!(
395            headers.get("X-BAPI-TIMESTAMP").unwrap().to_str().unwrap(),
396            timestamp
397        );
398
399        // Verify X-BAPI-RECV-WINDOW header
400        assert_eq!(
401            headers.get("X-BAPI-RECV-WINDOW").unwrap().to_str().unwrap(),
402            "5000"
403        );
404
405        // Verify X-BAPI-SIGN header exists and is valid hex
406        let sign_header = headers.get("X-BAPI-SIGN").unwrap().to_str().unwrap();
407        assert!(!sign_header.is_empty());
408        assert_eq!(sign_header.len(), 64);
409        let decoded = hex::decode(sign_header);
410        assert!(decoded.is_ok());
411    }
412
413    /// Test signature with empty params (common for some GET requests).
414    #[test]
415    fn test_signature_with_empty_params() {
416        let auth = BybitAuth::new("api-key".to_string(), "secret-key".to_string());
417
418        let timestamp = "1609459200000";
419        let recv_window = 5000u64;
420        let params = "";
421
422        let signature = auth.sign(timestamp, recv_window, params);
423
424        // Verify sign string format with empty params
425        let sign_string = auth.build_sign_string(timestamp, recv_window, params);
426        assert_eq!(sign_string, "1609459200000api-key5000");
427
428        // Verify signature is valid
429        assert!(!signature.is_empty());
430        assert_eq!(signature.len(), 64);
431        let decoded = hex::decode(&signature);
432        assert!(decoded.is_ok());
433    }
434
435    /// Test different recv_window values.
436    #[test]
437    fn test_different_recv_window() {
438        let auth = BybitAuth::new("api-key".to_string(), "secret-key".to_string());
439
440        let timestamp = "1609459200000";
441        let params = "symbol=BTCUSDT";
442
443        let sig_5000 = auth.sign(timestamp, 5000, params);
444        let sig_10000 = auth.sign(timestamp, 10000, params);
445        let sig_20000 = auth.sign(timestamp, 20000, params);
446
447        // Different recv_window should produce different signatures
448        assert_ne!(sig_5000, sig_10000);
449        assert_ne!(sig_5000, sig_20000);
450        assert_ne!(sig_10000, sig_20000);
451    }
452}