Skip to main content

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    #![allow(clippy::disallowed_methods)]
231    use super::*;
232
233    #[test]
234    fn test_auth_new() {
235        let auth = BitgetAuth::new(
236            "test-api-key".to_string(),
237            "test-secret".to_string(),
238            "test-passphrase".to_string(),
239        );
240
241        assert_eq!(auth.api_key(), "test-api-key");
242        assert_eq!(auth.passphrase(), "test-passphrase");
243    }
244
245    #[test]
246    fn test_build_sign_string() {
247        let auth = BitgetAuth::new(
248            "api-key".to_string(),
249            "secret".to_string(),
250            "passphrase".to_string(),
251        );
252
253        let sign_string =
254            auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
255        assert_eq!(sign_string, "1234567890GET/api/v2/spot/account/assets");
256
257        let sign_string_post = auth.build_sign_string(
258            "1234567890",
259            "POST",
260            "/api/v2/spot/trade/place-order",
261            r#"{"symbol":"BTCUSDT","side":"buy"}"#,
262        );
263        assert_eq!(
264            sign_string_post,
265            r#"1234567890POST/api/v2/spot/trade/place-order{"symbol":"BTCUSDT","side":"buy"}"#
266        );
267    }
268
269    #[test]
270    fn test_sign_deterministic() {
271        let auth = BitgetAuth::new(
272            "api-key".to_string(),
273            "test-secret-key".to_string(),
274            "passphrase".to_string(),
275        );
276
277        let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
278        let sig2 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
279
280        assert_eq!(sig1, sig2);
281        assert!(!sig1.is_empty());
282    }
283
284    #[test]
285    fn test_sign_different_inputs() {
286        let auth = BitgetAuth::new(
287            "api-key".to_string(),
288            "test-secret-key".to_string(),
289            "passphrase".to_string(),
290        );
291
292        let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
293        let sig2 = auth.sign("1234567891", "GET", "/api/v2/spot/account/assets", "");
294        let sig3 = auth.sign("1234567890", "POST", "/api/v2/spot/account/assets", "");
295
296        assert_ne!(sig1, sig2);
297        assert_ne!(sig1, sig3);
298    }
299
300    #[test]
301    fn test_add_auth_headers() {
302        let auth = BitgetAuth::new(
303            "test-api-key".to_string(),
304            "test-secret".to_string(),
305            "test-passphrase".to_string(),
306        );
307
308        let mut headers = HeaderMap::new();
309        let timestamp = "1234567890";
310        let signature = auth.sign(timestamp, "GET", "/api/v2/spot/account/assets", "");
311        auth.add_auth_headers(&mut headers, timestamp, &signature);
312
313        assert_eq!(headers.get("ACCESS-KEY").unwrap(), "test-api-key");
314        assert_eq!(headers.get("ACCESS-SIGN").unwrap(), &signature);
315        assert_eq!(headers.get("ACCESS-TIMESTAMP").unwrap(), "1234567890");
316        assert_eq!(headers.get("ACCESS-PASSPHRASE").unwrap(), "test-passphrase");
317    }
318
319    #[test]
320    fn test_create_auth_headers() {
321        let auth = BitgetAuth::new(
322            "test-api-key".to_string(),
323            "test-secret".to_string(),
324            "test-passphrase".to_string(),
325        );
326
327        let headers =
328            auth.create_auth_headers("1234567890", "GET", "/api/v2/spot/account/assets", "");
329
330        assert!(headers.contains_key("ACCESS-KEY"));
331        assert!(headers.contains_key("ACCESS-SIGN"));
332        assert!(headers.contains_key("ACCESS-TIMESTAMP"));
333        assert!(headers.contains_key("ACCESS-PASSPHRASE"));
334    }
335
336    #[test]
337    fn test_method_case_insensitive() {
338        let auth = BitgetAuth::new(
339            "api-key".to_string(),
340            "test-secret".to_string(),
341            "passphrase".to_string(),
342        );
343
344        // The build_sign_string method converts method to uppercase
345        let sign_string_lower =
346            auth.build_sign_string("1234567890", "get", "/api/v2/spot/account/assets", "");
347        let sign_string_upper =
348            auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
349
350        assert_eq!(sign_string_lower, sign_string_upper);
351    }
352
353    #[test]
354    fn test_signature_is_base64() {
355        let auth = BitgetAuth::new(
356            "api-key".to_string(),
357            "test-secret".to_string(),
358            "passphrase".to_string(),
359        );
360
361        let signature = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
362
363        // Base64 characters are alphanumeric plus + / =
364        assert!(
365            signature
366                .chars()
367                .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
368        );
369
370        // Should be decodable as Base64
371        let decoded = general_purpose::STANDARD.decode(&signature);
372        assert!(decoded.is_ok());
373
374        // HMAC-SHA256 produces 32 bytes
375        assert_eq!(decoded.unwrap().len(), 32);
376    }
377
378    /// Test signature generation with known inputs and expected output.
379    /// This verifies the HMAC-SHA256 implementation produces correct signatures.
380    ///
381    /// The expected signature is computed as:
382    /// HMAC-SHA256(secret="bitget-secret-key", message="1609459200000GET/api/v2/spot/account/assets")
383    /// Then Base64 encoded.
384    #[test]
385    fn test_signature_with_known_inputs() {
386        let auth = BitgetAuth::new(
387            "bg_api_key".to_string(),
388            "bitget-secret-key".to_string(),
389            "my-passphrase".to_string(),
390        );
391
392        // Known inputs
393        let timestamp = "1609459200000"; // 2021-01-01 00:00:00 UTC
394        let method = "GET";
395        let path = "/api/v2/spot/account/assets";
396        let body = "";
397
398        // Generate signature
399        let signature = auth.sign(timestamp, method, path, body);
400
401        // The sign string should be: "1609459200000GET/api/v2/spot/account/assets"
402        let expected_sign_string = "1609459200000GET/api/v2/spot/account/assets";
403        assert_eq!(
404            auth.build_sign_string(timestamp, method, path, body),
405            expected_sign_string
406        );
407
408        // Verify signature is non-empty and valid Base64
409        assert!(!signature.is_empty());
410        let decoded = general_purpose::STANDARD.decode(&signature);
411        assert!(decoded.is_ok());
412        assert_eq!(decoded.unwrap().len(), 32);
413
414        // Verify the signature is reproducible
415        let signature2 = auth.sign(timestamp, method, path, body);
416        assert_eq!(signature, signature2);
417    }
418
419    /// Test signature generation for POST request with JSON body.
420    #[test]
421    fn test_signature_with_post_body() {
422        let auth = BitgetAuth::new(
423            "bg_api_key".to_string(),
424            "bitget-secret-key".to_string(),
425            "my-passphrase".to_string(),
426        );
427
428        let timestamp = "1609459200000";
429        let method = "POST";
430        let path = "/api/v2/spot/trade/place-order";
431        let body = r#"{"symbol":"BTCUSDT","side":"buy","orderType":"limit","price":"50000","size":"0.001"}"#;
432
433        let signature = auth.sign(timestamp, method, path, body);
434
435        // Verify sign string format
436        let expected_sign_string = format!("{}{}{}{}", timestamp, method, path, body);
437        assert_eq!(
438            auth.build_sign_string(timestamp, method, path, body),
439            expected_sign_string
440        );
441
442        // Verify signature is valid
443        assert!(!signature.is_empty());
444        let decoded = general_purpose::STANDARD.decode(&signature);
445        assert!(decoded.is_ok());
446        assert_eq!(decoded.unwrap().len(), 32);
447    }
448
449    /// Test that all required headers are present and have correct values.
450    #[test]
451    fn test_header_values_correctness() {
452        let api_key = "my-api-key-12345";
453        let secret = "my-secret-key-67890";
454        let passphrase = "my-passphrase-abc";
455
456        let auth = BitgetAuth::new(
457            api_key.to_string(),
458            secret.to_string(),
459            passphrase.to_string(),
460        );
461
462        let timestamp = "1609459200000";
463        let method = "GET";
464        let path = "/api/v2/spot/account/assets";
465        let body = "";
466
467        let headers = auth.create_auth_headers(timestamp, method, path, body);
468
469        // Verify ACCESS-KEY header
470        assert_eq!(
471            headers.get("ACCESS-KEY").unwrap().to_str().unwrap(),
472            api_key
473        );
474
475        // Verify ACCESS-TIMESTAMP header
476        assert_eq!(
477            headers.get("ACCESS-TIMESTAMP").unwrap().to_str().unwrap(),
478            timestamp
479        );
480
481        // Verify ACCESS-PASSPHRASE header
482        assert_eq!(
483            headers.get("ACCESS-PASSPHRASE").unwrap().to_str().unwrap(),
484            passphrase
485        );
486
487        // Verify ACCESS-SIGN header exists and is valid Base64
488        let sign_header = headers.get("ACCESS-SIGN").unwrap().to_str().unwrap();
489        assert!(!sign_header.is_empty());
490        let decoded = general_purpose::STANDARD.decode(sign_header);
491        assert!(decoded.is_ok());
492    }
493
494    /// Test signature with query parameters in path.
495    #[test]
496    fn test_signature_with_query_params() {
497        let auth = BitgetAuth::new(
498            "api-key".to_string(),
499            "secret-key".to_string(),
500            "passphrase".to_string(),
501        );
502
503        let timestamp = "1609459200000";
504        let method = "GET";
505        let path = "/api/v2/spot/market/tickers?symbol=BTCUSDT";
506        let body = "";
507
508        let signature = auth.sign(timestamp, method, path, body);
509
510        // Verify sign string includes query params
511        let sign_string = auth.build_sign_string(timestamp, method, path, body);
512        assert!(sign_string.contains("?symbol=BTCUSDT"));
513
514        // Verify signature is valid
515        assert!(!signature.is_empty());
516        let decoded = general_purpose::STANDARD.decode(&signature);
517        assert!(decoded.is_ok());
518    }
519}