nautilus_okx/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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, Formatter};
21
22use aws_lc_rs::hmac;
23use base64::prelude::*;
24use ustr::Ustr;
25use zeroize::ZeroizeOnDrop;
26
27/// OKX API credentials for signing requests.
28///
29/// Uses HMAC SHA256 for request signing as per OKX API specifications.
30/// Secrets are automatically zeroized on drop for security.
31#[derive(Clone, ZeroizeOnDrop)]
32pub struct Credential {
33    #[zeroize(skip)]
34    pub api_key: Ustr,
35    pub api_passphrase: String,
36    api_secret: Box<[u8]>,
37}
38
39impl Debug for Credential {
40    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct(stringify!(Credential))
42            .field("api_key", &self.api_key)
43            .field("api_passphrase", &self.api_passphrase)
44            .field("api_secret", &"<redacted>")
45            .finish()
46    }
47}
48
49impl Credential {
50    /// Creates a new [`Credential`] instance.
51    #[must_use]
52    pub fn new(api_key: String, api_secret: String, api_passphrase: String) -> Self {
53        Self {
54            api_key: api_key.into(),
55            api_passphrase,
56            api_secret: api_secret.into_bytes().into_boxed_slice(),
57        }
58    }
59
60    /// Signs a request message according to the OKX authentication scheme.
61    ///
62    /// This string-based variant is preserved for compatibility with callers
63    /// that already have a UTF-8 body string. Prefer [`Self::sign_bytes`] when you
64    /// have the original body bytes to avoid any possibility of encoding drift.
65    pub fn sign(&self, timestamp: &str, method: &str, endpoint: &str, body: &str) -> String {
66        self.sign_bytes(timestamp, method, endpoint, Some(body.as_bytes()))
67    }
68
69    /// Signs a request message using raw body bytes to avoid any UTF-8 conversion
70    /// or re-serialization differences between the signed content and the bytes sent.
71    pub fn sign_bytes(
72        &self,
73        timestamp: &str,
74        method: &str,
75        endpoint: &str,
76        body: Option<&[u8]>,
77    ) -> String {
78        let mut message = Vec::with_capacity(
79            timestamp.len() + method.len() + endpoint.len() + body.map_or(0, |b| b.len()),
80        );
81        message.extend_from_slice(timestamp.as_bytes());
82        message.extend_from_slice(method.as_bytes());
83        message.extend_from_slice(endpoint.as_bytes());
84        if let Some(b) = body {
85            message.extend_from_slice(b);
86        }
87
88        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
89        let tag = hmac::sign(&key, &message);
90        BASE64_STANDARD.encode(tag.as_ref())
91    }
92
93    /// Returns a masked version of the API key for logging purposes.
94    ///
95    /// Shows first 4 and last 4 characters with ellipsis in between.
96    /// For keys shorter than 8 characters, shows asterisks only.
97    #[must_use]
98    pub fn api_key_masked(&self) -> String {
99        nautilus_core::string::mask_api_key(self.api_key.as_str())
100    }
101}
102
103////////////////////////////////////////////////////////////////////////////////
104// Tests
105////////////////////////////////////////////////////////////////////////////////
106
107#[cfg(test)]
108mod tests {
109    use rstest::rstest;
110
111    use super::*;
112
113    const API_KEY: &str = "985d5b66-57ce-40fb-b714-afc0b9787083";
114    const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
115    const API_PASSPHRASE: &str = "1234567890";
116
117    #[rstest]
118    fn test_simple_get() {
119        let credential = Credential::new(
120            API_KEY.to_string(),
121            API_SECRET.to_string(),
122            API_PASSPHRASE.to_string(),
123        );
124
125        let signature = credential.sign(
126            "2020-12-08T09:08:57.715Z",
127            "GET",
128            "/api/v5/account/balance",
129            "",
130        );
131
132        assert_eq!(signature, "PJ61e1nb2F2Qd7D8SPiaIcx2gjdELc+o0ygzre9z33k=");
133    }
134
135    #[rstest]
136    fn test_get_with_query_params() {
137        let credential = Credential::new(
138            API_KEY.to_string(),
139            API_SECRET.to_string(),
140            API_PASSPHRASE.to_string(),
141        );
142
143        let signature = credential.sign(
144            "2020-12-08T09:08:57.715Z",
145            "GET",
146            "/api/v5/account/balance?ccy=BTC",
147            "",
148        );
149
150        assert!(!signature.is_empty());
151        assert!(BASE64_STANDARD.decode(&signature).is_ok());
152
153        // Verify the message is constructed correctly
154        let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
155
156        // Recreate signature to verify message construction
157        let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
158        let tag = hmac::sign(&key, expected_message.as_bytes());
159        let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
160        assert_eq!(signature, expected_signature);
161    }
162
163    #[rstest]
164    fn test_post_with_json_body() {
165        let credential = Credential::new(
166            API_KEY.to_string(),
167            API_SECRET.to_string(),
168            API_PASSPHRASE.to_string(),
169        );
170
171        // Test with a simple JSON body
172        let body = r#"{"instId":"BTC-USD-200925","tdMode":"isolated","side":"buy","ordType":"limit","px":"432.11","sz":"2"}"#;
173        let signature = credential.sign(
174            "2020-12-08T09:08:57.715Z",
175            "POST",
176            "/api/v5/trade/order",
177            body,
178        );
179
180        assert!(!signature.is_empty());
181        assert!(BASE64_STANDARD.decode(&signature).is_ok());
182    }
183
184    #[rstest]
185    fn test_post_algo_order() {
186        let credential = Credential::new(
187            API_KEY.to_string(),
188            API_SECRET.to_string(),
189            API_PASSPHRASE.to_string(),
190        );
191
192        // Test with an algo order JSON body (array format as OKX expects)
193        let body = r#"[{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","ordType":"trigger","sz":"0.01","triggerPx":"3000","orderPx":"-1","triggerPxType":"last"}]"#;
194        let signature = credential.sign(
195            "2025-01-20T10:30:45.123Z",
196            "POST",
197            "/api/v5/trade/order-algo",
198            body,
199        );
200
201        assert!(!signature.is_empty());
202        assert!(BASE64_STANDARD.decode(&signature).is_ok());
203
204        // Verify the message is constructed correctly
205        let expected_message = format!(
206            "2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{}",
207            body
208        );
209
210        // Recreate signature to verify message construction
211        let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
212        let tag = hmac::sign(&key, expected_message.as_bytes());
213        let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
214        assert_eq!(signature, expected_signature);
215    }
216
217    #[rstest]
218    fn test_debug_redacts_secret() {
219        let credential = Credential::new(
220            API_KEY.to_string(),
221            API_SECRET.to_string(),
222            API_PASSPHRASE.to_string(),
223        );
224        let dbg_out = format!("{:?}", credential);
225        assert!(dbg_out.contains("api_secret: \"<redacted>\""));
226        assert!(!dbg_out.contains("chNOO"));
227        let secret_bytes_dbg = format!("{:?}", API_SECRET.as_bytes());
228        assert!(
229            !dbg_out.contains(&secret_bytes_dbg),
230            "Debug output must not contain raw secret bytes"
231        );
232    }
233
234    #[rstest]
235    fn test_api_key_masked_short() {
236        let credential = Credential::new(
237            "short".to_string(),
238            "secret".to_string(),
239            "pass".to_string(),
240        );
241        assert_eq!(credential.api_key_masked(), "*****");
242    }
243
244    #[rstest]
245    fn test_api_key_masked_long() {
246        let credential = Credential::new(
247            API_KEY.to_string(),
248            API_SECRET.to_string(),
249            API_PASSPHRASE.to_string(),
250        );
251        assert_eq!(credential.api_key_masked(), "985d...7083");
252    }
253}