nautilus_bybit/common/
credential.rs1#![allow(unused_assignments)] use std::fmt::Debug;
21
22use aws_lc_rs::hmac;
23use nautilus_core::{env::resolve_env_var_pair, hex, string::secret::REDACTED};
24use zeroize::ZeroizeOnDrop;
25
26use crate::common::enums::BybitEnvironment;
27
28#[must_use]
31pub fn credential_env_vars(environment: BybitEnvironment) -> (&'static str, &'static str) {
32 match environment {
33 BybitEnvironment::Demo => ("BYBIT_DEMO_API_KEY", "BYBIT_DEMO_API_SECRET"),
34 BybitEnvironment::Testnet => ("BYBIT_TESTNET_API_KEY", "BYBIT_TESTNET_API_SECRET"),
35 BybitEnvironment::Mainnet => ("BYBIT_API_KEY", "BYBIT_API_SECRET"),
36 }
37}
38
39#[derive(Clone, ZeroizeOnDrop)]
41pub struct Credential {
42 api_key: Box<str>,
43 api_secret: Box<[u8]>,
44}
45
46impl Debug for Credential {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct(stringify!(Credential))
49 .field("api_key", &self.api_key)
50 .field("api_secret", &REDACTED)
51 .finish()
52 }
53}
54
55impl Credential {
56 #[must_use]
61 pub fn resolve(
62 api_key: Option<String>,
63 api_secret: Option<String>,
64 environment: BybitEnvironment,
65 ) -> Option<Self> {
66 let (key_var, secret_var) = credential_env_vars(environment);
67 let (k, s) = resolve_env_var_pair(api_key, api_secret, key_var, secret_var)?;
68 Some(Self::new(k, s))
69 }
70
71 #[must_use]
73 pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
74 Self {
75 api_key: api_key.into().into_boxed_str(),
76 api_secret: api_secret.into().into_bytes().into_boxed_slice(),
77 }
78 }
79
80 #[must_use]
82 pub fn api_key(&self) -> &str {
83 &self.api_key
84 }
85
86 #[must_use]
91 pub fn api_key_masked(&self) -> String {
92 nautilus_core::string::secret::mask_api_key(&self.api_key)
93 }
94
95 #[must_use]
99 pub fn sign_websocket_auth(&self, expires: i64) -> String {
100 let message = format!("GET/realtime{expires}");
101 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
102 let tag = hmac::sign(&key, message.as_bytes());
103 hex::encode(tag.as_ref())
104 }
105
106 #[must_use]
112 pub fn sign_with_payload(
113 &self,
114 timestamp: &str,
115 recv_window_ms: u64,
116 payload: Option<&str>,
117 ) -> String {
118 let recv_window = recv_window_ms.to_string();
119 let payload_len = payload.map_or(0usize, str::len);
120 let mut message = String::with_capacity(
121 timestamp.len() + self.api_key.len() + recv_window.len() + payload_len,
122 );
123
124 message.push_str(timestamp);
125 message.push_str(&self.api_key);
126 message.push_str(&recv_window);
127
128 if let Some(payload) = payload {
129 message.push_str(payload);
130 }
131
132 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
133 let tag = hmac::sign(&key, message.as_bytes());
134 hex::encode(tag.as_ref())
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use rstest::rstest;
141
142 use super::*;
143
144 const API_KEY: &str = "test_api_key";
145 const API_SECRET: &str = "test_secret";
146 const RECV_WINDOW: u64 = 5_000;
147 const TIMESTAMP: &str = "1700000000000";
148
149 #[rstest]
150 fn sign_with_payload_matches_reference_get() {
151 let credential = Credential::new(API_KEY, API_SECRET);
152 let query = "category=linear&symbol=BTCUSDT";
153
154 let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(query));
155
156 assert_eq!(
157 signature,
158 "fd4f31228a46109dc6673062328693696df9a96c7ff04e6491a45e7f63a0fdd7"
159 );
160 }
161
162 #[rstest]
163 fn sign_with_payload_matches_reference_post() {
164 let credential = Credential::new(API_KEY, API_SECRET);
165 let body = "{\"category\": \"linear\", \"symbol\": \"BTCUSDT\", \"orderLinkId\": \"test-order-1\"}";
166
167 let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(body));
168
169 assert_eq!(
170 signature,
171 "2df4a0603d69c08d5dea29ba85b46eb7db64ce9e9ebd34a7802a3d69700cb2a1"
172 );
173 }
174
175 #[rstest]
176 fn sign_with_empty_payload_omits_tail() {
177 let credential = Credential::new(API_KEY, API_SECRET);
178
179 let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, None);
180
181 let expected = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(""));
182 assert_eq!(signature, expected);
183 }
184
185 #[rstest]
186 fn sign_websocket_auth_matches_reference() {
187 let credential = Credential::new(API_KEY, API_SECRET);
188 let expires: i64 = 1_700_000_000_000;
189
190 let signature = credential.sign_websocket_auth(expires);
191
192 assert_eq!(
193 signature,
194 "bacffe7500499eb829bb58c45d36d1b3e5ac67c14eaeba91df5e99ccee013925"
195 );
196 }
197
198 #[rstest]
199 fn test_debug_redacts_secret() {
200 let credential = Credential::new(API_KEY, API_SECRET);
201 let dbg_out = format!("{credential:?}");
202
203 assert!(dbg_out.contains(REDACTED));
204 assert!(!dbg_out.contains(API_SECRET));
205 }
206
207 #[rstest]
208 fn test_resolve_with_both_args() {
209 let result = Credential::resolve(
210 Some("my_key".to_string()),
211 Some("my_secret".to_string()),
212 BybitEnvironment::Mainnet,
213 );
214
215 assert!(result.is_some());
216 assert_eq!(result.unwrap().api_key(), "my_key");
217 }
218
219 #[rstest]
220 fn test_resolve_with_no_args_no_env() {
221 let (key_var, secret_var) = credential_env_vars(BybitEnvironment::Mainnet);
222 if std::env::var(key_var).is_ok() || std::env::var(secret_var).is_ok() {
223 return;
224 }
225
226 let result = Credential::resolve(None, None, BybitEnvironment::Mainnet);
227
228 assert!(result.is_none());
229 }
230}