Skip to main content

nautilus_okx/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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//! OKX API credential resolution, storage, and request signing.
17
18#![allow(unused_assignments)] // Fields are accessed externally, false positive from nightly
19
20use 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/// Returns the environment variable names for API credentials.
28#[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/// OKX API credentials for signing requests.
34///
35/// Uses HMAC SHA256 for request signing as per OKX API specifications.
36/// Secrets are automatically zeroized on drop for security.
37#[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    /// Creates a new [`Credential`] instance.
60    #[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    /// Resolves credentials from provided values or environment variables.
70    #[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    /// Returns the API key.
88    #[must_use]
89    pub fn api_key(&self) -> &str {
90        &self.api_key
91    }
92
93    /// Returns the API passphrase.
94    #[must_use]
95    pub fn api_passphrase(&self) -> &str {
96        &self.api_passphrase
97    }
98
99    /// Signs a request message according to the OKX authentication scheme.
100    ///
101    /// This string-based variant is preserved for compatibility with callers
102    /// that already have a UTF-8 body string. Prefer [`Self::sign_bytes`] when you
103    /// have the original body bytes to avoid any possibility of encoding drift.
104    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    /// Signs a request message using raw body bytes to avoid any UTF-8 conversion
109    /// or re-serialization differences between the signed content and the bytes sent.
110    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    /// Returns a masked version of the API key for logging purposes.
134    ///
135    /// Shows first 4 and last 4 characters with ellipsis in between.
136    /// For keys shorter than 8 characters, shows asterisks only.
137    #[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        // Verify the message is constructed correctly
190        let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
191
192        // Recreate signature to verify message construction
193        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        // Test with a simple JSON body
208        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        // Test with an algo order JSON body (array format as OKX expects)
229        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        // Verify the message is constructed correctly
241        let expected_message =
242            format!("2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{body}");
243
244        // Recreate signature to verify message construction
245        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        // Key and secret provided but passphrase missing (env var not set)
325        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}