nautilus_deribit/common/
credential.rs

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