1use 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#[derive(Debug, Clone)]
23pub struct BitgetAuth {
24 api_key: SecretString,
26 secret: SecretString,
28 passphrase: SecretString,
30}
31
32impl BitgetAuth {
33 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 pub fn api_key(&self) -> &str {
66 self.api_key.expose_secret()
67 }
68
69 pub fn passphrase(&self) -> &str {
71 self.passphrase.expose_secret()
72 }
73
74 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 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 fn hmac_sha256_base64(&self, message: &str) -> String {
132 type HmacSha256 = Hmac<Sha256>;
133
134 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 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 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 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 assert!(
365 signature
366 .chars()
367 .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
368 );
369
370 let decoded = general_purpose::STANDARD.decode(&signature);
372 assert!(decoded.is_ok());
373
374 assert_eq!(decoded.unwrap().len(), 32);
376 }
377
378 #[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 let timestamp = "1609459200000"; let method = "GET";
395 let path = "/api/v2/spot/account/assets";
396 let body = "";
397
398 let signature = auth.sign(timestamp, method, path, body);
400
401 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 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 let signature2 = auth.sign(timestamp, method, path, body);
416 assert_eq!(signature, signature2);
417 }
418
419 #[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 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 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]
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 assert_eq!(
471 headers.get("ACCESS-KEY").unwrap().to_str().unwrap(),
472 api_key
473 );
474
475 assert_eq!(
477 headers.get("ACCESS-TIMESTAMP").unwrap().to_str().unwrap(),
478 timestamp
479 );
480
481 assert_eq!(
483 headers.get("ACCESS-PASSPHRASE").unwrap().to_str().unwrap(),
484 passphrase
485 );
486
487 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]
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 let sign_string = auth.build_sign_string(timestamp, method, path, body);
512 assert!(sign_string.contains("?symbol=BTCUSDT"));
513
514 assert!(!signature.is_empty());
516 let decoded = general_purpose::STANDARD.decode(&signature);
517 assert!(decoded.is_ok());
518 }
519}