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 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/// Errors that can occur when resolving credentials.
33#[derive(Debug, Error)]
34pub enum CredentialError {
35    /// API key was provided but secret is missing.
36    #[error("API key provided but secret is missing")]
37    MissingSecret,
38    /// API secret was provided but key is missing.
39    #[error("API secret provided but key is missing")]
40    MissingKey,
41}
42
43/// Returns the environment variable names for API credentials,
44/// based on the network.
45#[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/// Deribit API credentials for signing requests.
54///
55/// Uses HMAC SHA256 for request signing as per Deribit API specifications.
56/// Secrets are automatically zeroized on drop for security.
57#[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    /// Creates a new [`Credential`] instance.
74    #[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    /// Load credentials from environment variables.
83    ///
84    /// For mainnet: Looks for `DERIBIT_API_KEY` and `DERIBIT_API_SECRET`.
85    /// For testnet: Looks for `DERIBIT_TESTNET_API_KEY` and `DERIBIT_TESTNET_API_SECRET`.
86    ///
87    /// Returns `None` if either key or secret is not set.
88    #[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    /// Resolves credentials from provided values or environment.
96    ///
97    /// If both `api_key` and `api_secret` are provided, uses those.
98    /// Otherwise falls back to loading from environment variables.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if only one of `api_key` or `api_secret` is provided.
103    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    /// Resolves credentials with optional environment fallback.
112    ///
113    /// If both `api_key` and `api_secret` are provided, uses those.
114    /// If `env_fallback` is true and neither credential is provided, loads from environment.
115    /// If `env_fallback` is false and neither credential is provided, returns `Ok(None)`.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if only one of `api_key` or `api_secret` is provided (partial credentials).
120    /// This prevents silent fallback to environment variables when user intent is unclear.
121    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    /// Returns the API key associated with this credential.
137    #[must_use]
138    pub fn api_key(&self) -> &str {
139        &self.api_key
140    }
141
142    /// Returns a masked version of the API key for logging purposes.
143    ///
144    /// Shows first 4 and last 4 characters with ellipsis in between.
145    /// For keys shorter than 8 characters, shows asterisks only.
146    #[must_use]
147    pub fn api_key_masked(&self) -> String {
148        nautilus_core::string::secret::mask_api_key(&self.api_key)
149    }
150
151    /// Signs a WebSocket authentication request according to Deribit specification.
152    ///
153    /// # Deribit WebSocket Signature Formula
154    ///
155    /// ```text
156    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + Data
157    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
158    /// ```
159    ///
160    /// # Returns
161    ///
162    /// Hex-encoded HMAC-SHA256 signature
163    #[must_use]
164    pub fn sign_ws_auth(&self, timestamp: u64, nonce: &str, data: &str) -> String {
165        // Build string to sign: timestamp + "\n" + nonce + "\n" + data
166        let string_to_sign = format!("{timestamp}\n{nonce}\n{data}");
167
168        // Sign with HMAC-SHA256
169        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
170        let tag = hmac::sign(&key, string_to_sign.as_bytes());
171
172        // Return hex-encoded signature
173        hex::encode(tag.as_ref())
174    }
175
176    /// Signs a request message according to the Deribit HTTP authentication scheme.
177    ///
178    /// # Deribit Signature Specification
179    ///
180    /// ```text
181    /// RequestData = UPPERCASE(HTTP_METHOD) + "\n" + URI + "\n" + RequestBody + "\n"
182    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + RequestData
183    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
184    /// ```
185    ///
186    /// # Parameters
187    ///
188    /// - `timestamp`: Milliseconds since UNIX epoch
189    /// - `nonce`: Random string (typically UUID v4)
190    /// - `request_data`: Pre-formatted string containing method, URI, and body
191    ///
192    /// # Returns
193    ///
194    /// Hex-encoded HMAC-SHA256 signature
195    #[must_use]
196    fn sign_message(&self, timestamp: i64, nonce: &str, request_data: &str) -> String {
197        // Build string to sign: timestamp + "\n" + nonce + "\n" + request_data
198        let string_to_sign = format!("{timestamp}\n{nonce}\n{request_data}");
199
200        // Sign with HMAC-SHA256
201        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
202        let tag = hmac::sign(&key, string_to_sign.as_bytes());
203
204        // Return hex-encoded signature (not base64 like OKX)
205        hex::encode(tag.as_ref())
206    }
207
208    /// Signs a request and generates authentication headers.
209    ///
210    /// # Deribit Authentication Scheme
211    ///
212    /// ```text
213    /// RequestData = UPPERCASE(HTTP_METHOD) + "\n" + URI + "\n" + RequestBody + "\n"
214    /// StringToSign = Timestamp + "\n" + Nonce + "\n" + RequestData
215    /// Signature = HEX_STRING(HMAC-SHA256(ClientSecret, StringToSign))
216    /// Authorization: deri-hmac-sha256 id={ClientId},ts={Timestamp},nonce={Nonce},sig={Signature}
217    /// ```
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if credentials are not configured.
222    pub fn sign_auth_headers(
223        &self,
224        method: &str,
225        uri: &str,
226        body: &[u8],
227    ) -> Result<HashMap<String, String>, DeribitHttpError> {
228        // Generate timestamp (milliseconds since UNIX epoch)
229        let timestamp = get_atomic_clock_realtime().get_time_ms() as i64;
230
231        // Generate random nonce (UUID v4)
232        let nonce_uuid = UUID4::new();
233        let nonce = nonce_uuid.as_str();
234
235        // Build RequestData per Deribit specification
236        let request_data = format!(
237            "{}\n{}\n{}\n",
238            method.to_uppercase(),
239            uri,
240            String::from_utf8_lossy(body)
241        );
242
243        // Sign the request
244        let signature = self.sign_message(timestamp, nonce, &request_data);
245
246        // Build Authorization header
247        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        // Verify it's a valid hex string
293        assert!(
294            signature.chars().all(|c| c.is_ascii_hexdigit()),
295            "Signature should be hex-encoded"
296        );
297
298        // SHA256 produces 32 bytes = 64 hex characters
299        assert_eq!(
300            signature.len(),
301            64,
302            "HMAC-SHA256 should produce 64 hex characters"
303        );
304
305        // Verify signature is deterministic
306        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        // Short keys should be masked differently (likely all asterisks)
380        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        // Should show first 4 and last 4 characters
391        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        // Verify Authorization header exists
423        assert!(
424            headers.contains_key("Authorization"),
425            "Should contain Authorization header"
426        );
427
428        let auth_header = headers.get("Authorization").unwrap();
429
430        // Verify header format: deri-hmac-sha256 id=...,ts=...,nonce=...,sig=...
431        assert!(
432            auth_header.starts_with("deri-hmac-sha256 "),
433            "Authorization header should start with 'deri-hmac-sha256 '"
434        );
435
436        // Verify it contains all required components
437        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        // Verify signature is hex-encoded (64 characters after sig=)
446        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        // Sleep briefly to ensure different timestamp
468        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        // Headers should be different due to different timestamp and nonce
475        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        // Test with known values from Deribit documentation example
510        // ClientSecret = "AMANDASECRECT", Timestamp = 1576074319000, Nonce = "1iqt2wls", Data = ""
511        // Expected signature from docs: 56590594f97921b09b18f166befe0d1319b198bbcdad7ca73382de2f88fe9aa1
512        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}