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