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