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