1#![allow(unused_assignments)] use std::{collections::HashMap, fmt::Debug};
21
22use aws_lc_rs::hmac;
23use nautilus_core::{
24 UUID4, env::resolve_env_var_pair, hex, string::secret::REDACTED,
25 time::get_atomic_clock_realtime,
26};
27use thiserror::Error;
28use zeroize::ZeroizeOnDrop;
29
30use crate::{common::enums::DeribitEnvironment, http::error::DeribitHttpError};
31
32#[derive(Debug, Error)]
34pub enum CredentialError {
35 #[error("API key provided but secret is missing")]
37 MissingSecret,
38 #[error("API secret provided but key is missing")]
40 MissingKey,
41}
42
43#[must_use]
46pub fn credential_env_vars(environment: DeribitEnvironment) -> (&'static str, &'static str) {
47 match environment {
48 DeribitEnvironment::Testnet => ("DERIBIT_TESTNET_API_KEY", "DERIBIT_TESTNET_API_SECRET"),
49 DeribitEnvironment::Mainnet => ("DERIBIT_API_KEY", "DERIBIT_API_SECRET"),
50 }
51}
52
53#[derive(Clone, ZeroizeOnDrop)]
58pub struct Credential {
59 api_key: Box<str>,
60 api_secret: Box<[u8]>,
61}
62
63impl Debug for Credential {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct(stringify!(Credential))
66 .field("api_key", &self.api_key)
67 .field("api_secret", &REDACTED)
68 .finish()
69 }
70}
71
72impl Credential {
73 #[must_use]
75 pub fn new(api_key: String, api_secret: String) -> Self {
76 Self {
77 api_key: api_key.into_boxed_str(),
78 api_secret: api_secret.into_bytes().into_boxed_slice(),
79 }
80 }
81
82 #[must_use]
89 pub fn from_env(environment: DeribitEnvironment) -> Option<Self> {
90 let (key_var, secret_var) = credential_env_vars(environment);
91 let (k, s) = resolve_env_var_pair(None, None, key_var, secret_var)?;
92 Some(Self::new(k, s))
93 }
94
95 pub fn resolve(
104 api_key: Option<String>,
105 api_secret: Option<String>,
106 environment: DeribitEnvironment,
107 ) -> Result<Option<Self>, CredentialError> {
108 Self::resolve_with_env_fallback(api_key, api_secret, environment, true)
109 }
110
111 pub fn resolve_with_env_fallback(
122 api_key: Option<String>,
123 api_secret: Option<String>,
124 environment: DeribitEnvironment,
125 env_fallback: bool,
126 ) -> Result<Option<Self>, CredentialError> {
127 match (api_key, api_secret) {
128 (Some(k), Some(s)) => Ok(Some(Self::new(k, s))),
129 (None, None) if env_fallback => Ok(Self::from_env(environment)),
130 (None, None) => Ok(None),
131 (Some(_), None) => Err(CredentialError::MissingSecret),
132 (None, Some(_)) => Err(CredentialError::MissingKey),
133 }
134 }
135
136 #[must_use]
138 pub fn api_key(&self) -> &str {
139 &self.api_key
140 }
141
142 #[must_use]
147 pub fn api_key_masked(&self) -> String {
148 nautilus_core::string::secret::mask_api_key(&self.api_key)
149 }
150
151 #[must_use]
164 pub fn sign_ws_auth(&self, timestamp: u64, nonce: &str, data: &str) -> String {
165 let string_to_sign = format!("{timestamp}\n{nonce}\n{data}");
167
168 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
170 let tag = hmac::sign(&key, string_to_sign.as_bytes());
171
172 hex::encode(tag.as_ref())
174 }
175
176 #[must_use]
196 fn sign_message(&self, timestamp: i64, nonce: &str, request_data: &str) -> String {
197 let string_to_sign = format!("{timestamp}\n{nonce}\n{request_data}");
199
200 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
202 let tag = hmac::sign(&key, string_to_sign.as_bytes());
203
204 hex::encode(tag.as_ref())
206 }
207
208 pub fn sign_auth_headers(
223 &self,
224 method: &str,
225 uri: &str,
226 body: &[u8],
227 ) -> Result<HashMap<String, String>, DeribitHttpError> {
228 let timestamp = get_atomic_clock_realtime().get_time_ms() as i64;
230
231 let nonce_uuid = UUID4::new();
233 let nonce = nonce_uuid.as_str();
234
235 let request_data = format!(
237 "{}\n{}\n{}\n",
238 method.to_uppercase(),
239 uri,
240 String::from_utf8_lossy(body)
241 );
242
243 let signature = self.sign_message(timestamp, nonce, &request_data);
245
246 let auth_header = format!(
248 "deri-hmac-sha256 id={},ts={},nonce={},sig={}",
249 self.api_key(),
250 timestamp,
251 nonce,
252 signature
253 );
254
255 let mut headers = HashMap::new();
256 headers.insert("Authorization".to_string(), auth_header);
257
258 Ok(headers)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use std::time::Duration;
265
266 use rstest::rstest;
267
268 use super::*;
269
270 #[rstest]
271 #[case("test_api_key", "test_api_secret")]
272 #[case("my_key", "my_secret")]
273 fn test_credential_creation(#[case] api_key: &str, #[case] api_secret: &str) {
274 let credential = Credential::new(api_key.to_string(), api_secret.to_string());
275
276 assert_eq!(credential.api_key(), api_key);
277 }
278
279 #[rstest]
280 fn test_signature_generation() {
281 let credential = Credential::new(
282 "test_client_id".to_string(),
283 "test_client_secret".to_string(),
284 );
285
286 let timestamp = 1609459200000i64;
287 let nonce = "550e8400-e29b-41d4-a716-446655440000";
288 let request_data = "POST\n/api/v2\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"private/get_account_summaries\",\"params\":{}}\n";
289
290 let signature = credential.sign_message(timestamp, nonce, request_data);
291
292 assert!(
294 signature.chars().all(|c| c.is_ascii_hexdigit()),
295 "Signature should be hex-encoded"
296 );
297
298 assert_eq!(
300 signature.len(),
301 64,
302 "HMAC-SHA256 should produce 64 hex characters"
303 );
304
305 let signature2 = credential.sign_message(timestamp, nonce, request_data);
307 assert_eq!(signature, signature2, "Signature should be deterministic");
308 }
309
310 #[rstest]
311 #[case(1000, 2000)]
312 #[case(1000, 5000)]
313 fn test_signature_changes_with_timestamp(#[case] ts1: i64, #[case] ts2: i64) {
314 let credential = Credential::new("key".to_string(), "secret".to_string());
315 let nonce = "nonce";
316 let request_data = "POST\n/api/v2\n{}\n";
317
318 let sig1 = credential.sign_message(ts1, nonce, request_data);
319 let sig2 = credential.sign_message(ts2, nonce, request_data);
320
321 assert_ne!(sig1, sig2, "Signature should change with timestamp");
322 }
323
324 #[rstest]
325 #[case("nonce1", "nonce2")]
326 #[case("abc", "xyz")]
327 fn test_signature_changes_with_nonce(#[case] nonce1: &str, #[case] nonce2: &str) {
328 let credential = Credential::new("key".to_string(), "secret".to_string());
329 let timestamp = 1000;
330 let request_data = "POST\n/api/v2\n{}\n";
331
332 let sig1 = credential.sign_message(timestamp, nonce1, request_data);
333 let sig2 = credential.sign_message(timestamp, nonce2, request_data);
334
335 assert_ne!(sig1, sig2, "Signature should change with nonce");
336 }
337
338 #[rstest]
339 #[case("POST\n/api/v2\n{\"a\":1}\n", "POST\n/api/v2\n{\"b\":2}\n")]
340 #[case("GET\n/test\n\n", "POST\n/test\n\n")]
341 fn test_signature_changes_with_request_data(#[case] data1: &str, #[case] data2: &str) {
342 let credential = Credential::new("key".to_string(), "secret".to_string());
343 let timestamp = 1000;
344 let nonce = "nonce";
345
346 let sig1 = credential.sign_message(timestamp, nonce, data1);
347 let sig2 = credential.sign_message(timestamp, nonce, data2);
348
349 assert_ne!(sig1, sig2, "Signature should change with request data");
350 }
351
352 #[rstest]
353 fn test_debug_redacts_secret() {
354 let credential = Credential::new("my_api_key".to_string(), "super_secret".to_string());
355
356 let debug_output = format!("{credential:?}");
357
358 assert!(
359 debug_output.contains(REDACTED),
360 "Debug output should redact secret"
361 );
362 assert!(
363 !debug_output.contains("super_secret"),
364 "Debug output should not contain raw secret"
365 );
366 assert!(
367 debug_output.contains("my_api_key"),
368 "Debug output should contain API key"
369 );
370 }
371
372 #[rstest]
373 #[case("short")]
374 #[case("xyz")]
375 fn test_api_key_masked_short_key(#[case] key: &str) {
376 let credential = Credential::new(key.to_string(), "secret".to_string());
377 let masked = credential.api_key_masked();
378
379 assert_ne!(masked, key, "Short key should be masked");
381 }
382
383 #[rstest]
384 #[case("abcdefgh-1234-5678-ijkl", "abcd", "ijkl")]
385 #[case("very-long-api-key-12345", "very", "2345")]
386 fn test_api_key_masked_long_key(#[case] key: &str, #[case] start: &str, #[case] end: &str) {
387 let credential = Credential::new(key.to_string(), "secret".to_string());
388 let masked = credential.api_key_masked();
389
390 assert!(
392 masked.starts_with(start),
393 "Masked key should start with first 4 chars"
394 );
395 assert!(
396 masked.ends_with(end),
397 "Masked key should end with last 4 chars"
398 );
399 assert!(masked.contains("..."), "Masked key should contain ellipsis");
400 }
401
402 #[rstest]
403 #[case("POST", "/api/v2", b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"private/get_account_summaries\",\"params\":{}}")]
404 #[case("GET", "/api/v2/public/test", b"")]
405 #[case(
406 "POST",
407 "/api/v2/private/buy",
408 b"{\"instrument_name\":\"BTC-PERPETUAL\",\"amount\":100}"
409 )]
410 fn test_sign_auth_headers(#[case] method: &str, #[case] uri: &str, #[case] body: &[u8]) {
411 let credential = Credential::new(
412 "test_client_id".to_string(),
413 "test_client_secret".to_string(),
414 );
415
416 let result = credential.sign_auth_headers(method, uri, body);
417
418 assert!(result.is_ok(), "Should successfully sign auth headers");
419
420 let headers = result.unwrap();
421
422 assert!(
424 headers.contains_key("Authorization"),
425 "Should contain Authorization header"
426 );
427
428 let auth_header = headers.get("Authorization").unwrap();
429
430 assert!(
432 auth_header.starts_with("deri-hmac-sha256 "),
433 "Authorization header should start with 'deri-hmac-sha256 '"
434 );
435
436 assert!(
438 auth_header.contains("id=test_client_id"),
439 "Should contain client ID"
440 );
441 assert!(auth_header.contains("ts="), "Should contain timestamp");
442 assert!(auth_header.contains("nonce="), "Should contain nonce");
443 assert!(auth_header.contains("sig="), "Should contain signature");
444
445 let sig_part = auth_header.split("sig=").nth(1).unwrap();
447 assert_eq!(
448 sig_part.len(),
449 64,
450 "Signature should be 64 hex characters (HMAC-SHA256)"
451 );
452 assert!(
453 sig_part.chars().all(|c| c.is_ascii_hexdigit()),
454 "Signature should be hex-encoded"
455 );
456 }
457
458 #[rstest]
459 fn test_sign_auth_headers_changes_each_call() {
460 let credential = Credential::new("key".to_string(), "secret".to_string());
461
462 let method = "POST";
463 let uri = "/api/v2";
464 let body = b"{}";
465
466 let headers1 = credential.sign_auth_headers(method, uri, body).unwrap();
467 std::thread::sleep(Duration::from_millis(10));
469 let headers2 = credential.sign_auth_headers(method, uri, body).unwrap();
470
471 let auth1 = headers1.get("Authorization").unwrap();
472 let auth2 = headers2.get("Authorization").unwrap();
473
474 assert_ne!(
476 auth1, auth2,
477 "Authorization headers should differ between calls due to timestamp/nonce"
478 );
479 }
480
481 #[rstest]
482 fn test_sign_ws_auth_basic() {
483 let credential = Credential::new(
484 "test_client_id".to_string(),
485 "test_client_secret".to_string(),
486 );
487
488 let timestamp = 1576074319000u64;
489 let nonce = "1iqt2wls";
490 let data = "";
491
492 let signature = credential.sign_ws_auth(timestamp, nonce, data);
493
494 assert!(
495 signature.chars().all(|c| c.is_ascii_hexdigit()),
496 "Signature should be hex-encoded"
497 );
498 assert_eq!(
499 signature.len(),
500 64,
501 "HMAC-SHA256 should produce 64 hex characters"
502 );
503 let signature2 = credential.sign_ws_auth(timestamp, nonce, data);
504 assert_eq!(signature, signature2, "Signature should be deterministic");
505 }
506
507 #[rstest]
508 fn test_sign_ws_auth_with_known_values() {
509 let credential = Credential::new("AMANDA".to_string(), "AMANDASECRECT".to_string());
513
514 let timestamp = 1576074319000u64;
515 let nonce = "1iqt2wls";
516 let data = "";
517
518 let signature = credential.sign_ws_auth(timestamp, nonce, data);
519
520 assert_eq!(
521 signature, "56590594f97921b09b18f166befe0d1319b198bbcdad7ca73382de2f88fe9aa1",
522 "Signature should match Deribit documentation example"
523 );
524 }
525
526 #[rstest]
527 #[case(1000, 2000)]
528 #[case(1576074319000, 1576074320000)]
529 fn test_sign_ws_auth_changes_with_timestamp(#[case] ts1: u64, #[case] ts2: u64) {
530 let credential = Credential::new("key".to_string(), "secret".to_string());
531 let nonce = "nonce";
532 let data = "";
533
534 let sig1 = credential.sign_ws_auth(ts1, nonce, data);
535 let sig2 = credential.sign_ws_auth(ts2, nonce, data);
536
537 assert_ne!(sig1, sig2, "Signature should change with timestamp");
538 }
539
540 #[rstest]
541 #[case("nonce1", "nonce2")]
542 #[case("abc123", "xyz789")]
543 fn test_sign_ws_auth_changes_with_nonce(#[case] nonce1: &str, #[case] nonce2: &str) {
544 let credential = Credential::new("key".to_string(), "secret".to_string());
545 let timestamp = 1576074319000u64;
546 let data = "";
547
548 let sig1 = credential.sign_ws_auth(timestamp, nonce1, data);
549 let sig2 = credential.sign_ws_auth(timestamp, nonce2, data);
550
551 assert_ne!(sig1, sig2, "Signature should change with nonce");
552 }
553
554 #[rstest]
555 fn test_resolve_with_both_credentials() {
556 let result = Credential::resolve_with_env_fallback(
557 Some("key".to_string()),
558 Some("secret".to_string()),
559 DeribitEnvironment::Mainnet,
560 false,
561 );
562
563 assert!(result.is_ok());
564 let credential = result.unwrap();
565 assert!(credential.is_some());
566 assert_eq!(credential.unwrap().api_key(), "key");
567 }
568
569 #[rstest]
570 fn test_resolve_with_no_credentials_no_fallback() {
571 let result =
572 Credential::resolve_with_env_fallback(None, None, DeribitEnvironment::Mainnet, false);
573
574 assert!(result.is_ok());
575 assert!(result.unwrap().is_none());
576 }
577
578 #[rstest]
579 fn test_resolve_partial_key_only_returns_error() {
580 let result = Credential::resolve_with_env_fallback(
581 Some("key".to_string()),
582 None,
583 DeribitEnvironment::Mainnet,
584 false,
585 );
586
587 assert!(result.is_err());
588 assert!(matches!(
589 result.unwrap_err(),
590 CredentialError::MissingSecret
591 ));
592 }
593
594 #[rstest]
595 fn test_resolve_partial_secret_only_returns_error() {
596 let result = Credential::resolve_with_env_fallback(
597 None,
598 Some("secret".to_string()),
599 DeribitEnvironment::Mainnet,
600 false,
601 );
602
603 assert!(result.is_err());
604 assert!(matches!(result.unwrap_err(), CredentialError::MissingKey));
605 }
606}