1#![allow(unused_assignments)] use std::fmt::Debug;
21
22use aws_lc_rs::hmac;
23use base64::prelude::*;
24use nautilus_core::{env::get_or_env_var_opt, string::secret::REDACTED};
25use zeroize::ZeroizeOnDrop;
26
27#[must_use]
29pub fn credential_env_vars() -> (&'static str, &'static str, &'static str) {
30 ("OKX_API_KEY", "OKX_API_SECRET", "OKX_API_PASSPHRASE")
31}
32
33#[derive(Clone, ZeroizeOnDrop)]
38#[allow(
39 clippy::struct_field_names,
40 reason = "fields mirror the OKX API credential naming (api_key, api_passphrase, api_secret)"
41)]
42pub struct Credential {
43 api_key: Box<str>,
44 api_passphrase: Box<str>,
45 api_secret: Box<[u8]>,
46}
47
48impl Debug for Credential {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct(stringify!(Credential))
51 .field("api_key", &REDACTED)
52 .field("api_passphrase", &REDACTED)
53 .field("api_secret", &REDACTED)
54 .finish()
55 }
56}
57
58impl Credential {
59 #[must_use]
61 pub fn new(api_key: String, api_secret: String, api_passphrase: String) -> Self {
62 Self {
63 api_key: api_key.into_boxed_str(),
64 api_passphrase: api_passphrase.into_boxed_str(),
65 api_secret: api_secret.into_bytes().into_boxed_slice(),
66 }
67 }
68
69 #[must_use]
71 pub fn resolve(
72 api_key: Option<String>,
73 api_secret: Option<String>,
74 api_passphrase: Option<String>,
75 ) -> Option<Self> {
76 let (key_var, secret_var, passphrase_var) = credential_env_vars();
77 let key = get_or_env_var_opt(api_key, key_var);
78 let secret = get_or_env_var_opt(api_secret, secret_var);
79 let passphrase = get_or_env_var_opt(api_passphrase, passphrase_var);
80
81 match (key, secret, passphrase) {
82 (Some(k), Some(s), Some(p)) => Some(Self::new(k, s, p)),
83 _ => None,
84 }
85 }
86
87 #[must_use]
89 pub fn api_key(&self) -> &str {
90 &self.api_key
91 }
92
93 #[must_use]
95 pub fn api_passphrase(&self) -> &str {
96 &self.api_passphrase
97 }
98
99 pub fn sign(&self, timestamp: &str, method: &str, endpoint: &str, body: &str) -> String {
105 self.sign_bytes(timestamp, method, endpoint, Some(body.as_bytes()))
106 }
107
108 pub fn sign_bytes(
111 &self,
112 timestamp: &str,
113 method: &str,
114 endpoint: &str,
115 body: Option<&[u8]>,
116 ) -> String {
117 let mut message = Vec::with_capacity(
118 timestamp.len() + method.len() + endpoint.len() + body.map_or(0, <[u8]>::len),
119 );
120 message.extend_from_slice(timestamp.as_bytes());
121 message.extend_from_slice(method.as_bytes());
122 message.extend_from_slice(endpoint.as_bytes());
123
124 if let Some(b) = body {
125 message.extend_from_slice(b);
126 }
127
128 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
129 let tag = hmac::sign(&key, &message);
130 BASE64_STANDARD.encode(tag.as_ref())
131 }
132
133 #[must_use]
138 pub fn api_key_masked(&self) -> String {
139 nautilus_core::string::secret::mask_api_key(&self.api_key)
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use rstest::rstest;
146
147 use super::*;
148
149 const API_KEY: &str = "985d5b66-57ce-40fb-b714-afc0b9787083";
150 const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
151 const API_PASSPHRASE: &str = "1234567890";
152
153 #[rstest]
154 fn test_simple_get() {
155 let credential = Credential::new(
156 API_KEY.to_string(),
157 API_SECRET.to_string(),
158 API_PASSPHRASE.to_string(),
159 );
160
161 let signature = credential.sign(
162 "2020-12-08T09:08:57.715Z",
163 "GET",
164 "/api/v5/account/balance",
165 "",
166 );
167
168 assert_eq!(signature, "PJ61e1nb2F2Qd7D8SPiaIcx2gjdELc+o0ygzre9z33k=");
169 }
170
171 #[rstest]
172 fn test_get_with_query_params() {
173 let credential = Credential::new(
174 API_KEY.to_string(),
175 API_SECRET.to_string(),
176 API_PASSPHRASE.to_string(),
177 );
178
179 let signature = credential.sign(
180 "2020-12-08T09:08:57.715Z",
181 "GET",
182 "/api/v5/account/balance?ccy=BTC",
183 "",
184 );
185
186 assert!(!signature.is_empty());
187 BASE64_STANDARD.decode(&signature).unwrap();
188
189 let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
191
192 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
194 let tag = hmac::sign(&key, expected_message.as_bytes());
195 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
196 assert_eq!(signature, expected_signature);
197 }
198
199 #[rstest]
200 fn test_post_with_json_body() {
201 let credential = Credential::new(
202 API_KEY.to_string(),
203 API_SECRET.to_string(),
204 API_PASSPHRASE.to_string(),
205 );
206
207 let body = r#"{"instId":"BTC-USD-200925","tdMode":"isolated","side":"buy","ordType":"limit","px":"432.11","sz":"2"}"#;
209 let signature = credential.sign(
210 "2020-12-08T09:08:57.715Z",
211 "POST",
212 "/api/v5/trade/order",
213 body,
214 );
215
216 assert!(!signature.is_empty());
217 BASE64_STANDARD.decode(&signature).unwrap();
218 }
219
220 #[rstest]
221 fn test_post_algo_order() {
222 let credential = Credential::new(
223 API_KEY.to_string(),
224 API_SECRET.to_string(),
225 API_PASSPHRASE.to_string(),
226 );
227
228 let body = r#"[{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","ordType":"trigger","sz":"0.01","triggerPx":"3000","orderPx":"-1","triggerPxType":"last"}]"#;
230 let signature = credential.sign(
231 "2025-01-20T10:30:45.123Z",
232 "POST",
233 "/api/v5/trade/order-algo",
234 body,
235 );
236
237 assert!(!signature.is_empty());
238 BASE64_STANDARD.decode(&signature).unwrap();
239
240 let expected_message =
242 format!("2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{body}");
243
244 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
246 let tag = hmac::sign(&key, expected_message.as_bytes());
247 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
248 assert_eq!(signature, expected_signature);
249 }
250
251 #[rstest]
252 fn test_debug_redacts_secrets() {
253 let credential = Credential::new(
254 API_KEY.to_string(),
255 API_SECRET.to_string(),
256 API_PASSPHRASE.to_string(),
257 );
258 let dbg_out = format!("{credential:?}");
259 assert_eq!(dbg_out.matches(REDACTED).count(), 3);
260 assert!(!dbg_out.contains(API_KEY));
261 assert!(dbg_out.contains("api_secret: \"<redacted>\""));
262 assert!(dbg_out.contains("api_passphrase: \"<redacted>\""));
263 assert!(!dbg_out.contains("chNOO"));
264 assert!(
265 !dbg_out.contains(API_PASSPHRASE),
266 "Debug output must not contain passphrase"
267 );
268 }
269
270 #[rstest]
271 fn test_api_key_masked_short() {
272 let credential = Credential::new(
273 "short".to_string(),
274 "secret".to_string(),
275 "pass".to_string(),
276 );
277 assert_eq!(credential.api_key_masked(), "*****");
278 }
279
280 #[rstest]
281 fn test_api_key_masked_long() {
282 let credential = Credential::new(
283 API_KEY.to_string(),
284 API_SECRET.to_string(),
285 API_PASSPHRASE.to_string(),
286 );
287 assert_eq!(credential.api_key_masked(), "985d...7083");
288 }
289
290 #[rstest]
291 fn test_resolve_with_all_args() {
292 let result = Credential::resolve(
293 Some("my_key".to_string()),
294 Some("my_secret".to_string()),
295 Some("my_pass".to_string()),
296 );
297
298 assert!(result.is_some());
299 assert_eq!(result.unwrap().api_key(), "my_key");
300 }
301
302 #[rstest]
303 fn test_resolve_with_no_args_no_env() {
304 let (key_var, secret_var, passphrase_var) = credential_env_vars();
305 if std::env::var(key_var).is_ok()
306 || std::env::var(secret_var).is_ok()
307 || std::env::var(passphrase_var).is_ok()
308 {
309 return;
310 }
311
312 let result = Credential::resolve(None, None, None);
313
314 assert!(result.is_none());
315 }
316
317 #[rstest]
318 fn test_resolve_with_partial_args_returns_none() {
319 let (_, _, passphrase_var) = credential_env_vars();
320 if std::env::var(passphrase_var).is_ok() {
321 return;
322 }
323
324 let result = Credential::resolve(
326 Some("my_key".to_string()),
327 Some("my_secret".to_string()),
328 None,
329 );
330
331 assert!(result.is_none());
332 }
333}