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