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