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 storage and request signing helpers.
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)]
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    /// Creates a new [`Credential`] instance.
56    #[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    /// Resolves credentials from provided values or environment variables.
66    #[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    /// Returns the API key.
84    #[must_use]
85    pub fn api_key(&self) -> &str {
86        &self.api_key
87    }
88
89    /// Returns the API passphrase.
90    #[must_use]
91    pub fn api_passphrase(&self) -> &str {
92        &self.api_passphrase
93    }
94
95    /// Signs a request message according to the OKX authentication scheme.
96    ///
97    /// This string-based variant is preserved for compatibility with callers
98    /// that already have a UTF-8 body string. Prefer [`Self::sign_bytes`] when you
99    /// have the original body bytes to avoid any possibility of encoding drift.
100    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    /// Signs a request message using raw body bytes to avoid any UTF-8 conversion
105    /// or re-serialization differences between the signed content and the bytes sent.
106    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    /// Returns a masked version of the API key for logging purposes.
130    ///
131    /// Shows first 4 and last 4 characters with ellipsis in between.
132    /// For keys shorter than 8 characters, shows asterisks only.
133    #[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        // Verify the message is constructed correctly
186        let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
187
188        // Recreate signature to verify message construction
189        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        // Test with a simple JSON body
204        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        // Test with an algo order JSON body (array format as OKX expects)
225        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        // Verify the message is constructed correctly
237        let expected_message =
238            format!("2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{body}");
239
240        // Recreate signature to verify message construction
241        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        // Key and secret provided but passphrase missing (env var not set)
319        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}