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