ccxt_exchanges/bitget/
auth.rs

1//! Bitget API authentication module.
2//!
3//! Implements HMAC-SHA256 signing for Bitget API requests.
4//! Bitget requires the following headers for authenticated requests:
5//! - ACCESS-KEY: API key
6//! - ACCESS-SIGN: HMAC-SHA256 signature (Base64 encoded)
7//! - ACCESS-TIMESTAMP: Unix timestamp in milliseconds
8//! - ACCESS-PASSPHRASE: API passphrase
9
10use base64::{Engine as _, engine::general_purpose};
11use hmac::{Hmac, Mac};
12use reqwest::header::{HeaderMap, HeaderValue};
13use sha2::Sha256;
14
15/// Bitget API authenticator.
16///
17/// Handles request signing using HMAC-SHA256 and header construction
18/// for authenticated API requests.
19#[derive(Debug, Clone)]
20pub struct BitgetAuth {
21    /// API key for authentication.
22    api_key: String,
23    /// Secret key for HMAC signing.
24    secret: String,
25    /// Passphrase for additional authentication.
26    passphrase: String,
27}
28
29impl BitgetAuth {
30    /// Creates a new BitgetAuth instance.
31    ///
32    /// # Arguments
33    ///
34    /// * `api_key` - The API key from Bitget.
35    /// * `secret` - The secret key from Bitget.
36    /// * `passphrase` - The passphrase set when creating the API key.
37    ///
38    /// # Example
39    ///
40    /// ```
41    /// use ccxt_exchanges::bitget::BitgetAuth;
42    ///
43    /// let auth = BitgetAuth::new(
44    ///     "your-api-key".to_string(),
45    ///     "your-secret".to_string(),
46    ///     "your-passphrase".to_string(),
47    /// );
48    /// ```
49    pub fn new(api_key: String, secret: String, passphrase: String) -> Self {
50        Self {
51            api_key,
52            secret,
53            passphrase,
54        }
55    }
56
57    /// Returns the API key.
58    pub fn api_key(&self) -> &str {
59        &self.api_key
60    }
61
62    /// Returns the passphrase.
63    pub fn passphrase(&self) -> &str {
64        &self.passphrase
65    }
66
67    /// Builds the signature string for HMAC signing.
68    ///
69    /// The signature string format is: `timestamp + method + path + body`
70    ///
71    /// # Arguments
72    ///
73    /// * `timestamp` - Unix timestamp in milliseconds as string.
74    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
75    /// * `path` - Request path including query string (e.g., "/api/v2/spot/account/assets").
76    /// * `body` - Request body (empty string for GET requests).
77    ///
78    /// # Returns
79    ///
80    /// The concatenated string to be signed.
81    pub fn build_sign_string(
82        &self,
83        timestamp: &str,
84        method: &str,
85        path: &str,
86        body: &str,
87    ) -> String {
88        format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body)
89    }
90
91    /// Signs a request using HMAC-SHA256.
92    ///
93    /// # Arguments
94    ///
95    /// * `timestamp` - Unix timestamp in milliseconds as string.
96    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
97    /// * `path` - Request path including query string.
98    /// * `body` - Request body (empty string for GET requests).
99    ///
100    /// # Returns
101    ///
102    /// Base64-encoded HMAC-SHA256 signature.
103    ///
104    /// # Example
105    ///
106    /// ```
107    /// use ccxt_exchanges::bitget::BitgetAuth;
108    ///
109    /// let auth = BitgetAuth::new(
110    ///     "api-key".to_string(),
111    ///     "secret".to_string(),
112    ///     "passphrase".to_string(),
113    /// );
114    ///
115    /// let signature = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
116    /// assert!(!signature.is_empty());
117    /// ```
118    pub fn sign(&self, timestamp: &str, method: &str, path: &str, body: &str) -> String {
119        let sign_string = self.build_sign_string(timestamp, method, path, body);
120        self.hmac_sha256_base64(&sign_string)
121    }
122
123    /// Computes HMAC-SHA256 and returns Base64-encoded result.
124    fn hmac_sha256_base64(&self, message: &str) -> String {
125        type HmacSha256 = Hmac<Sha256>;
126
127        // SAFETY: HMAC accepts keys of any length - this cannot fail
128        let mut mac = HmacSha256::new_from_slice(self.secret.as_bytes())
129            .expect("HMAC-SHA256 accepts keys of any length; this is an infallible operation");
130        mac.update(message.as_bytes());
131        let result = mac.finalize().into_bytes();
132
133        general_purpose::STANDARD.encode(result)
134    }
135
136    /// Adds authentication headers to a HeaderMap.
137    ///
138    /// Adds the following headers:
139    /// - ACCESS-KEY: API key
140    /// - ACCESS-SIGN: HMAC-SHA256 signature
141    /// - ACCESS-TIMESTAMP: Unix timestamp
142    /// - ACCESS-PASSPHRASE: API passphrase
143    ///
144    /// # Arguments
145    ///
146    /// * `headers` - Mutable reference to HeaderMap to add headers to.
147    /// * `timestamp` - Unix timestamp in milliseconds as string.
148    /// * `sign` - Pre-computed signature from `sign()` method.
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// use ccxt_exchanges::bitget::BitgetAuth;
154    /// use reqwest::header::HeaderMap;
155    ///
156    /// let auth = BitgetAuth::new(
157    ///     "api-key".to_string(),
158    ///     "secret".to_string(),
159    ///     "passphrase".to_string(),
160    /// );
161    ///
162    /// let mut headers = HeaderMap::new();
163    /// let timestamp = "1234567890";
164    /// let signature = auth.sign(timestamp, "GET", "/api/v2/spot/account/assets", "");
165    /// auth.add_auth_headers(&mut headers, timestamp, &signature);
166    ///
167    /// assert!(headers.contains_key("ACCESS-KEY"));
168    /// assert!(headers.contains_key("ACCESS-SIGN"));
169    /// assert!(headers.contains_key("ACCESS-TIMESTAMP"));
170    /// assert!(headers.contains_key("ACCESS-PASSPHRASE"));
171    /// ```
172    pub fn add_auth_headers(&self, headers: &mut HeaderMap, timestamp: &str, sign: &str) {
173        headers.insert(
174            "ACCESS-KEY",
175            HeaderValue::from_str(&self.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
176        );
177        headers.insert(
178            "ACCESS-SIGN",
179            HeaderValue::from_str(sign).unwrap_or_else(|_| HeaderValue::from_static("")),
180        );
181        headers.insert(
182            "ACCESS-TIMESTAMP",
183            HeaderValue::from_str(timestamp).unwrap_or_else(|_| HeaderValue::from_static("")),
184        );
185        headers.insert(
186            "ACCESS-PASSPHRASE",
187            HeaderValue::from_str(&self.passphrase)
188                .unwrap_or_else(|_| HeaderValue::from_static("")),
189        );
190    }
191
192    /// Creates authentication headers for a request.
193    ///
194    /// This is a convenience method that combines `sign()` and `add_auth_headers()`.
195    ///
196    /// # Arguments
197    ///
198    /// * `timestamp` - Unix timestamp in milliseconds as string.
199    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
200    /// * `path` - Request path including query string.
201    /// * `body` - Request body (empty string for GET requests).
202    ///
203    /// # Returns
204    ///
205    /// A HeaderMap containing all authentication headers.
206    pub fn create_auth_headers(
207        &self,
208        timestamp: &str,
209        method: &str,
210        path: &str,
211        body: &str,
212    ) -> HeaderMap {
213        let sign = self.sign(timestamp, method, path, body);
214        let mut headers = HeaderMap::new();
215        self.add_auth_headers(&mut headers, timestamp, &sign);
216        headers
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_auth_new() {
226        let auth = BitgetAuth::new(
227            "test-api-key".to_string(),
228            "test-secret".to_string(),
229            "test-passphrase".to_string(),
230        );
231
232        assert_eq!(auth.api_key(), "test-api-key");
233        assert_eq!(auth.passphrase(), "test-passphrase");
234    }
235
236    #[test]
237    fn test_build_sign_string() {
238        let auth = BitgetAuth::new(
239            "api-key".to_string(),
240            "secret".to_string(),
241            "passphrase".to_string(),
242        );
243
244        let sign_string =
245            auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
246        assert_eq!(sign_string, "1234567890GET/api/v2/spot/account/assets");
247
248        let sign_string_post = auth.build_sign_string(
249            "1234567890",
250            "POST",
251            "/api/v2/spot/trade/place-order",
252            r#"{"symbol":"BTCUSDT","side":"buy"}"#,
253        );
254        assert_eq!(
255            sign_string_post,
256            r#"1234567890POST/api/v2/spot/trade/place-order{"symbol":"BTCUSDT","side":"buy"}"#
257        );
258    }
259
260    #[test]
261    fn test_sign_deterministic() {
262        let auth = BitgetAuth::new(
263            "api-key".to_string(),
264            "test-secret-key".to_string(),
265            "passphrase".to_string(),
266        );
267
268        let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
269        let sig2 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
270
271        assert_eq!(sig1, sig2);
272        assert!(!sig1.is_empty());
273    }
274
275    #[test]
276    fn test_sign_different_inputs() {
277        let auth = BitgetAuth::new(
278            "api-key".to_string(),
279            "test-secret-key".to_string(),
280            "passphrase".to_string(),
281        );
282
283        let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
284        let sig2 = auth.sign("1234567891", "GET", "/api/v2/spot/account/assets", "");
285        let sig3 = auth.sign("1234567890", "POST", "/api/v2/spot/account/assets", "");
286
287        assert_ne!(sig1, sig2);
288        assert_ne!(sig1, sig3);
289    }
290
291    #[test]
292    fn test_add_auth_headers() {
293        let auth = BitgetAuth::new(
294            "test-api-key".to_string(),
295            "test-secret".to_string(),
296            "test-passphrase".to_string(),
297        );
298
299        let mut headers = HeaderMap::new();
300        let timestamp = "1234567890";
301        let signature = auth.sign(timestamp, "GET", "/api/v2/spot/account/assets", "");
302        auth.add_auth_headers(&mut headers, timestamp, &signature);
303
304        assert_eq!(headers.get("ACCESS-KEY").unwrap(), "test-api-key");
305        assert_eq!(headers.get("ACCESS-SIGN").unwrap(), &signature);
306        assert_eq!(headers.get("ACCESS-TIMESTAMP").unwrap(), "1234567890");
307        assert_eq!(headers.get("ACCESS-PASSPHRASE").unwrap(), "test-passphrase");
308    }
309
310    #[test]
311    fn test_create_auth_headers() {
312        let auth = BitgetAuth::new(
313            "test-api-key".to_string(),
314            "test-secret".to_string(),
315            "test-passphrase".to_string(),
316        );
317
318        let headers =
319            auth.create_auth_headers("1234567890", "GET", "/api/v2/spot/account/assets", "");
320
321        assert!(headers.contains_key("ACCESS-KEY"));
322        assert!(headers.contains_key("ACCESS-SIGN"));
323        assert!(headers.contains_key("ACCESS-TIMESTAMP"));
324        assert!(headers.contains_key("ACCESS-PASSPHRASE"));
325    }
326
327    #[test]
328    fn test_method_case_insensitive() {
329        let auth = BitgetAuth::new(
330            "api-key".to_string(),
331            "test-secret".to_string(),
332            "passphrase".to_string(),
333        );
334
335        // The build_sign_string method converts method to uppercase
336        let sign_string_lower =
337            auth.build_sign_string("1234567890", "get", "/api/v2/spot/account/assets", "");
338        let sign_string_upper =
339            auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
340
341        assert_eq!(sign_string_lower, sign_string_upper);
342    }
343
344    #[test]
345    fn test_signature_is_base64() {
346        let auth = BitgetAuth::new(
347            "api-key".to_string(),
348            "test-secret".to_string(),
349            "passphrase".to_string(),
350        );
351
352        let signature = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
353
354        // Base64 characters are alphanumeric plus + / =
355        assert!(
356            signature
357                .chars()
358                .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
359        );
360
361        // Should be decodable as Base64
362        let decoded = general_purpose::STANDARD.decode(&signature);
363        assert!(decoded.is_ok());
364
365        // HMAC-SHA256 produces 32 bytes
366        assert_eq!(decoded.unwrap().len(), 32);
367    }
368
369    /// Test signature generation with known inputs and expected output.
370    /// This verifies the HMAC-SHA256 implementation produces correct signatures.
371    ///
372    /// The expected signature is computed as:
373    /// HMAC-SHA256(secret="bitget-secret-key", message="1609459200000GET/api/v2/spot/account/assets")
374    /// Then Base64 encoded.
375    #[test]
376    fn test_signature_with_known_inputs() {
377        let auth = BitgetAuth::new(
378            "bg_api_key".to_string(),
379            "bitget-secret-key".to_string(),
380            "my-passphrase".to_string(),
381        );
382
383        // Known inputs
384        let timestamp = "1609459200000"; // 2021-01-01 00:00:00 UTC
385        let method = "GET";
386        let path = "/api/v2/spot/account/assets";
387        let body = "";
388
389        // Generate signature
390        let signature = auth.sign(timestamp, method, path, body);
391
392        // The sign string should be: "1609459200000GET/api/v2/spot/account/assets"
393        let expected_sign_string = "1609459200000GET/api/v2/spot/account/assets";
394        assert_eq!(
395            auth.build_sign_string(timestamp, method, path, body),
396            expected_sign_string
397        );
398
399        // Verify signature is non-empty and valid Base64
400        assert!(!signature.is_empty());
401        let decoded = general_purpose::STANDARD.decode(&signature);
402        assert!(decoded.is_ok());
403        assert_eq!(decoded.unwrap().len(), 32);
404
405        // Verify the signature is reproducible
406        let signature2 = auth.sign(timestamp, method, path, body);
407        assert_eq!(signature, signature2);
408    }
409
410    /// Test signature generation for POST request with JSON body.
411    #[test]
412    fn test_signature_with_post_body() {
413        let auth = BitgetAuth::new(
414            "bg_api_key".to_string(),
415            "bitget-secret-key".to_string(),
416            "my-passphrase".to_string(),
417        );
418
419        let timestamp = "1609459200000";
420        let method = "POST";
421        let path = "/api/v2/spot/trade/place-order";
422        let body = r#"{"symbol":"BTCUSDT","side":"buy","orderType":"limit","price":"50000","size":"0.001"}"#;
423
424        let signature = auth.sign(timestamp, method, path, body);
425
426        // Verify sign string format
427        let expected_sign_string = format!("{}{}{}{}", timestamp, method, path, body);
428        assert_eq!(
429            auth.build_sign_string(timestamp, method, path, body),
430            expected_sign_string
431        );
432
433        // Verify signature is valid
434        assert!(!signature.is_empty());
435        let decoded = general_purpose::STANDARD.decode(&signature);
436        assert!(decoded.is_ok());
437        assert_eq!(decoded.unwrap().len(), 32);
438    }
439
440    /// Test that all required headers are present and have correct values.
441    #[test]
442    fn test_header_values_correctness() {
443        let api_key = "my-api-key-12345";
444        let secret = "my-secret-key-67890";
445        let passphrase = "my-passphrase-abc";
446
447        let auth = BitgetAuth::new(
448            api_key.to_string(),
449            secret.to_string(),
450            passphrase.to_string(),
451        );
452
453        let timestamp = "1609459200000";
454        let method = "GET";
455        let path = "/api/v2/spot/account/assets";
456        let body = "";
457
458        let headers = auth.create_auth_headers(timestamp, method, path, body);
459
460        // Verify ACCESS-KEY header
461        assert_eq!(
462            headers.get("ACCESS-KEY").unwrap().to_str().unwrap(),
463            api_key
464        );
465
466        // Verify ACCESS-TIMESTAMP header
467        assert_eq!(
468            headers.get("ACCESS-TIMESTAMP").unwrap().to_str().unwrap(),
469            timestamp
470        );
471
472        // Verify ACCESS-PASSPHRASE header
473        assert_eq!(
474            headers.get("ACCESS-PASSPHRASE").unwrap().to_str().unwrap(),
475            passphrase
476        );
477
478        // Verify ACCESS-SIGN header exists and is valid Base64
479        let sign_header = headers.get("ACCESS-SIGN").unwrap().to_str().unwrap();
480        assert!(!sign_header.is_empty());
481        let decoded = general_purpose::STANDARD.decode(sign_header);
482        assert!(decoded.is_ok());
483    }
484
485    /// Test signature with query parameters in path.
486    #[test]
487    fn test_signature_with_query_params() {
488        let auth = BitgetAuth::new(
489            "api-key".to_string(),
490            "secret-key".to_string(),
491            "passphrase".to_string(),
492        );
493
494        let timestamp = "1609459200000";
495        let method = "GET";
496        let path = "/api/v2/spot/market/tickers?symbol=BTCUSDT";
497        let body = "";
498
499        let signature = auth.sign(timestamp, method, path, body);
500
501        // Verify sign string includes query params
502        let sign_string = auth.build_sign_string(timestamp, method, path, body);
503        assert!(sign_string.contains("?symbol=BTCUSDT"));
504
505        // Verify signature is valid
506        assert!(!signature.is_empty());
507        let decoded = general_purpose::STANDARD.decode(&signature);
508        assert!(decoded.is_ok());
509    }
510}