Skip to main content

nautilus_deribit/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Deribit API credential storage and request signing helpers.
17
18#![allow(unused_assignments)] // Fields are accessed externally, false positive from nightly
19
20use std::{collections::HashMap, fmt::Debug};
21
22use aws_lc_rs::hmac;
23use hex;
24use nautilus_core::{UUID4, time::get_atomic_clock_realtime};
25use thiserror::Error;
26use ustr::Ustr;
27use zeroize::ZeroizeOnDrop;
28
29use crate::http::error::DeribitHttpError;
30
31/// Errors that can occur when resolving credentials.
32#[derive(Debug, Error)]
33pub enum CredentialError {
34    /// API key was provided but secret is missing.
35    #[error("API key provided but secret is missing")]
36    MissingSecret,
37    /// API secret was provided but key is missing.
38    #[error("API secret provided but key is missing")]
39    MissingKey,
40}
41
42/// Deribit API credentials for signing requests.
43///
44/// Uses HMAC SHA256 for request signing as per Deribit API specifications.
45/// Secrets are automatically zeroized on drop for security.
46#[derive(Clone, ZeroizeOnDrop)]
47pub struct Credential {
48    #[zeroize(skip)]
49    pub api_key: Ustr,
50    api_secret: Box<[u8]>,
51}
52
53impl Debug for Credential {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct(stringify!(Credential))
56            .field("api_key", &self.api_key)
57            .field("api_secret", &"<redacted>")
58            .finish()
59    }
60}
61
62impl Credential {
63    /// Creates a new [`Credential`] instance.
64    #[must_use]
65    pub fn new(api_key: String, api_secret: String) -> Self {
66        Self {
67            api_key: api_key.into(),
68            api_secret: api_secret.into_bytes().into_boxed_slice(),
69        }
70    }
71
72    /// Load credentials from environment variables.
73    ///
74    /// For mainnet: Looks for `DERIBIT_API_KEY` and `DERIBIT_API_SECRET`.
75    /// For testnet: Looks for `DERIBIT_TESTNET_API_KEY` and `DERIBIT_TESTNET_API_SECRET`.
76    ///
77    /// Returns `None` if either key or secret is not set.
78    #[must_use]
79    pub fn from_env(is_testnet: bool) -> Option<Self> {
80        let (key_var, secret_var) = if is_testnet {
81            ("DERIBIT_TESTNET_API_KEY", "DERIBIT_TESTNET_API_SECRET")
82        } else {
83            ("DERIBIT_API_KEY", "DERIBIT_API_SECRET")
84        };
85
86        let key = std::env::var(key_var).ok()?;
87        let secret = std::env::var(secret_var).ok()?;
88
89        Some(Self::new(key, secret))
90    }
91
92    /// Resolves credentials from provided values or environment.
93    ///
94    /// If both `api_key` and `api_secret` are provided, uses those.
95    /// Otherwise falls back to loading from environment variables.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if only one of `api_key` or `api_secret` is provided.
100    pub fn resolve(
101        api_key: Option<String>,
102        api_secret: Option<String>,
103        is_testnet: bool,
104    ) -> Result<Option<Self>, CredentialError> {
105        Self::resolve_with_env_fallback(api_key, api_secret, is_testnet, true)
106    }
107
108    /// Resolves credentials with optional environment fallback.
109    ///
110    /// If both `api_key` and `api_secret` are provided, uses those.
111    /// If `env_fallback` is true and neither credential is provided, loads from environment.
112    /// If `env_fallback` is false and neither credential is provided, returns `Ok(None)`.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if only one of `api_key` or `api_secret` is provided (partial credentials).
117    /// This prevents silent fallback to environment variables when user intent is unclear.
118    pub fn resolve_with_env_fallback(
119        api_key: Option<String>,
120        api_secret: Option<String>,
121        is_testnet: bool,
122        env_fallback: bool,
123    ) -> Result<Option<Self>, CredentialError> {
124        match (api_key, api_secret) {
125            (Some(k), Some(s)) => Ok(Some(Self::new(k, s))),
126            (None, None) if env_fallback => Ok(Self::from_env(is_testnet)),
127            (None, None) => Ok(None),
128            (Some(_), None) => Err(CredentialError::MissingSecret),
129            (None, Some(_)) => Err(CredentialError::MissingKey),
130        }
131    }
132
133    /// Returns the API key associated with this credential.
134    #[must_use]
135    pub fn api_key(&self) -> &Ustr {
136        &self.api_key
137    }
138
139    /// Returns a masked version of the API key for logging purposes.
140    ///
141    /// Shows first 4 and last 4 characters with ellipsis in between.
142    /// For keys shorter than 8 characters, shows asterisks only.
143    #[must_use]
144    pub fn api_key_masked(&self) -> String {
145        nautilus_core::string::mask_api_key(self.api_key.as_str())
146    }
147
148    /// Signs a WebSocket authentication request according to Deribit specification.
149    ///
150    /// # Deribit WebSocket Signature Formula
151    ///
152    /// ```text
153    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + Data
154    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
155    /// ```
156    ///
157    /// # Returns
158    ///
159    /// Hex-encoded HMAC-SHA256 signature
160    #[must_use]
161    pub fn sign_ws_auth(&self, timestamp: u64, nonce: &str, data: &str) -> String {
162        // Build string to sign: timestamp + "\n" + nonce + "\n" + data
163        let string_to_sign = format!("{timestamp}\n{nonce}\n{data}");
164
165        // Sign with HMAC-SHA256
166        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
167        let tag = hmac::sign(&key, string_to_sign.as_bytes());
168
169        // Return hex-encoded signature
170        hex::encode(tag.as_ref())
171    }
172
173    /// Signs a request message according to the Deribit HTTP authentication scheme.
174    ///
175    /// # Deribit Signature Specification
176    ///
177    /// ```text
178    /// RequestData = UPPERCASE(HTTP_METHOD) + "\n" + URI + "\n" + RequestBody + "\n"
179    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + RequestData
180    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
181    /// ```
182    ///
183    /// # Parameters
184    ///
185    /// - `timestamp`: Milliseconds since UNIX epoch
186    /// - `nonce`: Random string (typically UUID v4)
187    /// - `request_data`: Pre-formatted string containing method, URI, and body
188    ///
189    /// # Returns
190    ///
191    /// Hex-encoded HMAC-SHA256 signature
192    #[must_use]
193    fn sign_message(&self, timestamp: i64, nonce: &str, request_data: &str) -> String {
194        // Build string to sign: timestamp + "\n" + nonce + "\n" + request_data
195        let string_to_sign = format!("{timestamp}\n{nonce}\n{request_data}");
196
197        // Sign with HMAC-SHA256
198        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
199        let tag = hmac::sign(&key, string_to_sign.as_bytes());
200
201        // Return hex-encoded signature (not base64 like OKX)
202        hex::encode(tag.as_ref())
203    }
204
205    /// Signs a request and generates authentication headers.
206    ///
207    /// # Deribit Authentication Scheme
208    ///
209    /// ```text
210    /// RequestData = UPPERCASE(HTTP_METHOD) + "\n" + URI + "\n" + RequestBody + "\n"
211    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + RequestData
212    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
213    /// Authorization: deri-hmac-sha256 id={ClientId},ts={Timestamp},nonce={Nonce},sig={Signature}
214    /// ```
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if credentials are not configured.
219    pub fn sign_auth_headers(
220        &self,
221        method: &str,
222        uri: &str,
223        body: &[u8],
224    ) -> Result<HashMap<String, String>, DeribitHttpError> {
225        // Generate timestamp (milliseconds since UNIX epoch)
226        let timestamp = get_atomic_clock_realtime().get_time_ms() as i64;
227
228        // Generate random nonce (UUID v4)
229        let nonce_uuid = UUID4::new();
230        let nonce = nonce_uuid.as_str();
231
232        // Build RequestData per Deribit specification
233        let request_data = format!(
234            "{}\n{}\n{}\n",
235            method.to_uppercase(),
236            uri,
237            String::from_utf8_lossy(body)
238        );
239
240        // Sign the request
241        let signature = self.sign_message(timestamp, nonce, &request_data);
242
243        // Build Authorization header
244        let auth_header = format!(
245            "deri-hmac-sha256 id={},ts={},nonce={},sig={}",
246            self.api_key(),
247            timestamp,
248            nonce,
249            signature
250        );
251
252        let mut headers = HashMap::new();
253        headers.insert("Authorization".to_string(), auth_header);
254
255        Ok(headers)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use std::time::Duration;
262
263    use rstest::rstest;
264
265    use super::*;
266
267    #[rstest]
268    #[case("test_api_key", "test_api_secret")]
269    #[case("my_key", "my_secret")]
270    fn test_credential_creation(#[case] api_key: &str, #[case] api_secret: &str) {
271        let credential = Credential::new(api_key.to_string(), api_secret.to_string());
272
273        assert_eq!(credential.api_key().as_str(), api_key);
274    }
275
276    #[rstest]
277    fn test_signature_generation() {
278        let credential = Credential::new(
279            "test_client_id".to_string(),
280            "test_client_secret".to_string(),
281        );
282
283        let timestamp = 1609459200000i64;
284        let nonce = "550e8400-e29b-41d4-a716-446655440000";
285        let request_data = "POST\n/api/v2\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"private/get_account_summaries\",\"params\":{}}\n";
286
287        let signature = credential.sign_message(timestamp, nonce, request_data);
288
289        // Verify it's a valid hex string
290        assert!(
291            signature.chars().all(|c| c.is_ascii_hexdigit()),
292            "Signature should be hex-encoded"
293        );
294
295        // SHA256 produces 32 bytes = 64 hex characters
296        assert_eq!(
297            signature.len(),
298            64,
299            "HMAC-SHA256 should produce 64 hex characters"
300        );
301
302        // Verify signature is deterministic
303        let signature2 = credential.sign_message(timestamp, nonce, request_data);
304        assert_eq!(signature, signature2, "Signature should be deterministic");
305    }
306
307    #[rstest]
308    #[case(1000, 2000)]
309    #[case(1000, 5000)]
310    fn test_signature_changes_with_timestamp(#[case] ts1: i64, #[case] ts2: i64) {
311        let credential = Credential::new("key".to_string(), "secret".to_string());
312        let nonce = "nonce";
313        let request_data = "POST\n/api/v2\n{}\n";
314
315        let sig1 = credential.sign_message(ts1, nonce, request_data);
316        let sig2 = credential.sign_message(ts2, nonce, request_data);
317
318        assert_ne!(sig1, sig2, "Signature should change with timestamp");
319    }
320
321    #[rstest]
322    #[case("nonce1", "nonce2")]
323    #[case("abc", "xyz")]
324    fn test_signature_changes_with_nonce(#[case] nonce1: &str, #[case] nonce2: &str) {
325        let credential = Credential::new("key".to_string(), "secret".to_string());
326        let timestamp = 1000;
327        let request_data = "POST\n/api/v2\n{}\n";
328
329        let sig1 = credential.sign_message(timestamp, nonce1, request_data);
330        let sig2 = credential.sign_message(timestamp, nonce2, request_data);
331
332        assert_ne!(sig1, sig2, "Signature should change with nonce");
333    }
334
335    #[rstest]
336    #[case("POST\n/api/v2\n{\"a\":1}\n", "POST\n/api/v2\n{\"b\":2}\n")]
337    #[case("GET\n/test\n\n", "POST\n/test\n\n")]
338    fn test_signature_changes_with_request_data(#[case] data1: &str, #[case] data2: &str) {
339        let credential = Credential::new("key".to_string(), "secret".to_string());
340        let timestamp = 1000;
341        let nonce = "nonce";
342
343        let sig1 = credential.sign_message(timestamp, nonce, data1);
344        let sig2 = credential.sign_message(timestamp, nonce, data2);
345
346        assert_ne!(sig1, sig2, "Signature should change with request data");
347    }
348
349    #[rstest]
350    fn test_debug_redacts_secret() {
351        let credential = Credential::new("my_api_key".to_string(), "super_secret".to_string());
352
353        let debug_output = format!("{credential:?}");
354
355        assert!(
356            debug_output.contains("<redacted>"),
357            "Debug output should redact secret"
358        );
359        assert!(
360            !debug_output.contains("super_secret"),
361            "Debug output should not contain raw secret"
362        );
363        assert!(
364            debug_output.contains("my_api_key"),
365            "Debug output should contain API key"
366        );
367    }
368
369    #[rstest]
370    #[case("short")]
371    #[case("xyz")]
372    fn test_api_key_masked_short_key(#[case] key: &str) {
373        let credential = Credential::new(key.to_string(), "secret".to_string());
374        let masked = credential.api_key_masked();
375
376        // Short keys should be masked differently (likely all asterisks)
377        assert_ne!(masked, key, "Short key should be masked");
378    }
379
380    #[rstest]
381    #[case("abcdefgh-1234-5678-ijkl", "abcd", "ijkl")]
382    #[case("very-long-api-key-12345", "very", "2345")]
383    fn test_api_key_masked_long_key(#[case] key: &str, #[case] start: &str, #[case] end: &str) {
384        let credential = Credential::new(key.to_string(), "secret".to_string());
385        let masked = credential.api_key_masked();
386
387        // Should show first 4 and last 4 characters
388        assert!(
389            masked.starts_with(start),
390            "Masked key should start with first 4 chars"
391        );
392        assert!(
393            masked.ends_with(end),
394            "Masked key should end with last 4 chars"
395        );
396        assert!(masked.contains("..."), "Masked key should contain ellipsis");
397    }
398
399    #[rstest]
400    #[case("POST", "/api/v2", b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"private/get_account_summaries\",\"params\":{}}")]
401    #[case("GET", "/api/v2/public/test", b"")]
402    #[case(
403        "POST",
404        "/api/v2/private/buy",
405        b"{\"instrument_name\":\"BTC-PERPETUAL\",\"amount\":100}"
406    )]
407    fn test_sign_auth_headers(#[case] method: &str, #[case] uri: &str, #[case] body: &[u8]) {
408        let credential = Credential::new(
409            "test_client_id".to_string(),
410            "test_client_secret".to_string(),
411        );
412
413        let result = credential.sign_auth_headers(method, uri, body);
414
415        assert!(result.is_ok(), "Should successfully sign auth headers");
416
417        let headers = result.unwrap();
418
419        // Verify Authorization header exists
420        assert!(
421            headers.contains_key("Authorization"),
422            "Should contain Authorization header"
423        );
424
425        let auth_header = headers.get("Authorization").unwrap();
426
427        // Verify header format: deri-hmac-sha256 id=...,ts=...,nonce=...,sig=...
428        assert!(
429            auth_header.starts_with("deri-hmac-sha256 "),
430            "Authorization header should start with 'deri-hmac-sha256 '"
431        );
432
433        // Verify it contains all required components
434        assert!(
435            auth_header.contains("id=test_client_id"),
436            "Should contain client ID"
437        );
438        assert!(auth_header.contains("ts="), "Should contain timestamp");
439        assert!(auth_header.contains("nonce="), "Should contain nonce");
440        assert!(auth_header.contains("sig="), "Should contain signature");
441
442        // Verify signature is hex-encoded (64 characters after sig=)
443        let sig_part = auth_header.split("sig=").nth(1).unwrap();
444        assert_eq!(
445            sig_part.len(),
446            64,
447            "Signature should be 64 hex characters (HMAC-SHA256)"
448        );
449        assert!(
450            sig_part.chars().all(|c| c.is_ascii_hexdigit()),
451            "Signature should be hex-encoded"
452        );
453    }
454
455    #[rstest]
456    fn test_sign_auth_headers_changes_each_call() {
457        let credential = Credential::new("key".to_string(), "secret".to_string());
458
459        let method = "POST";
460        let uri = "/api/v2";
461        let body = b"{}";
462
463        let headers1 = credential.sign_auth_headers(method, uri, body).unwrap();
464        // Sleep briefly to ensure different timestamp
465        std::thread::sleep(Duration::from_millis(10));
466        let headers2 = credential.sign_auth_headers(method, uri, body).unwrap();
467
468        let auth1 = headers1.get("Authorization").unwrap();
469        let auth2 = headers2.get("Authorization").unwrap();
470
471        // Headers should be different due to different timestamp and nonce
472        assert_ne!(
473            auth1, auth2,
474            "Authorization headers should differ between calls due to timestamp/nonce"
475        );
476    }
477
478    #[rstest]
479    fn test_sign_ws_auth_basic() {
480        let credential = Credential::new(
481            "test_client_id".to_string(),
482            "test_client_secret".to_string(),
483        );
484
485        let timestamp = 1576074319000u64;
486        let nonce = "1iqt2wls";
487        let data = "";
488
489        let signature = credential.sign_ws_auth(timestamp, nonce, data);
490
491        assert!(
492            signature.chars().all(|c| c.is_ascii_hexdigit()),
493            "Signature should be hex-encoded"
494        );
495        assert_eq!(
496            signature.len(),
497            64,
498            "HMAC-SHA256 should produce 64 hex characters"
499        );
500        let signature2 = credential.sign_ws_auth(timestamp, nonce, data);
501        assert_eq!(signature, signature2, "Signature should be deterministic");
502    }
503
504    #[rstest]
505    fn test_sign_ws_auth_with_known_values() {
506        // Test with known values from Deribit documentation example
507        // ClientSecret = "AMANDASECRECT", Timestamp = 1576074319000, Nonce = "1iqt2wls", Data = ""
508        // Expected signature from docs: 56590594f97921b09b18f166befe0d1319b198bbcdad7ca73382de2f88fe9aa1
509        let credential = Credential::new("AMANDA".to_string(), "AMANDASECRECT".to_string());
510
511        let timestamp = 1576074319000u64;
512        let nonce = "1iqt2wls";
513        let data = "";
514
515        let signature = credential.sign_ws_auth(timestamp, nonce, data);
516
517        assert_eq!(
518            signature, "56590594f97921b09b18f166befe0d1319b198bbcdad7ca73382de2f88fe9aa1",
519            "Signature should match Deribit documentation example"
520        );
521    }
522
523    #[rstest]
524    #[case(1000, 2000)]
525    #[case(1576074319000, 1576074320000)]
526    fn test_sign_ws_auth_changes_with_timestamp(#[case] ts1: u64, #[case] ts2: u64) {
527        let credential = Credential::new("key".to_string(), "secret".to_string());
528        let nonce = "nonce";
529        let data = "";
530
531        let sig1 = credential.sign_ws_auth(ts1, nonce, data);
532        let sig2 = credential.sign_ws_auth(ts2, nonce, data);
533
534        assert_ne!(sig1, sig2, "Signature should change with timestamp");
535    }
536
537    #[rstest]
538    #[case("nonce1", "nonce2")]
539    #[case("abc123", "xyz789")]
540    fn test_sign_ws_auth_changes_with_nonce(#[case] nonce1: &str, #[case] nonce2: &str) {
541        let credential = Credential::new("key".to_string(), "secret".to_string());
542        let timestamp = 1576074319000u64;
543        let data = "";
544
545        let sig1 = credential.sign_ws_auth(timestamp, nonce1, data);
546        let sig2 = credential.sign_ws_auth(timestamp, nonce2, data);
547
548        assert_ne!(sig1, sig2, "Signature should change with nonce");
549    }
550
551    #[rstest]
552    fn test_resolve_with_both_credentials() {
553        let result = Credential::resolve_with_env_fallback(
554            Some("key".to_string()),
555            Some("secret".to_string()),
556            false,
557            false,
558        );
559
560        assert!(result.is_ok());
561        let credential = result.unwrap();
562        assert!(credential.is_some());
563        assert_eq!(credential.unwrap().api_key().as_str(), "key");
564    }
565
566    #[rstest]
567    fn test_resolve_with_no_credentials_no_fallback() {
568        let result = Credential::resolve_with_env_fallback(None, None, false, false);
569
570        assert!(result.is_ok());
571        assert!(result.unwrap().is_none());
572    }
573
574    #[rstest]
575    fn test_resolve_partial_key_only_returns_error() {
576        let result =
577            Credential::resolve_with_env_fallback(Some("key".to_string()), None, false, false);
578
579        assert!(result.is_err());
580        assert!(matches!(
581            result.unwrap_err(),
582            CredentialError::MissingSecret
583        ));
584    }
585
586    #[rstest]
587    fn test_resolve_partial_secret_only_returns_error() {
588        let result =
589            Credential::resolve_with_env_fallback(None, Some("secret".to_string()), false, false);
590
591        assert!(result.is_err());
592        assert!(matches!(result.unwrap_err(), CredentialError::MissingKey));
593    }
594}