1#![allow(unused_assignments)] use 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#[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 #[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 #[must_use]
62 pub fn api_key(&self) -> &Ustr {
63 &self.api_key
64 }
65
66 #[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 #[must_use]
88 pub fn sign_ws_auth(&self, timestamp: u64, nonce: &str, data: &str) -> String {
89 let string_to_sign = format!("{timestamp}\n{nonce}\n{data}");
91
92 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
94 let tag = hmac::sign(&key, string_to_sign.as_bytes());
95
96 hex::encode(tag.as_ref())
98 }
99
100 #[must_use]
120 fn sign_message(&self, timestamp: i64, nonce: &str, request_data: &str) -> String {
121 let string_to_sign = format!("{timestamp}\n{nonce}\n{request_data}");
123
124 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
126 let tag = hmac::sign(&key, string_to_sign.as_bytes());
127
128 hex::encode(tag.as_ref())
130 }
131
132 pub fn sign_auth_headers(
147 &self,
148 method: &str,
149 uri: &str,
150 body: &[u8],
151 ) -> Result<HashMap<String, String>, DeribitHttpError> {
152 let timestamp = get_atomic_clock_realtime().get_time_ms() as i64;
154
155 let nonce_uuid = UUID4::new();
157 let nonce = nonce_uuid.as_str();
158
159 let request_data = format!(
161 "{}\n{}\n{}\n",
162 method.to_uppercase(),
163 uri,
164 String::from_utf8_lossy(body)
165 );
166
167 let signature = self.sign_message(timestamp, nonce, &request_data);
169
170 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 assert!(
216 signature.chars().all(|c| c.is_ascii_hexdigit()),
217 "Signature should be hex-encoded"
218 );
219
220 assert_eq!(
222 signature.len(),
223 64,
224 "HMAC-SHA256 should produce 64 hex characters"
225 );
226
227 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 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 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 assert!(
346 headers.contains_key("Authorization"),
347 "Should contain Authorization header"
348 );
349
350 let auth_header = headers.get("Authorization").unwrap();
351
352 assert!(
354 auth_header.starts_with("deri-hmac-sha256 "),
355 "Authorization header should start with 'deri-hmac-sha256 '"
356 );
357
358 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 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 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 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 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}