ccxt_exchanges/okx/
auth.rs

1//! OKX API authentication module.
2//!
3//! Implements HMAC-SHA256 signing with Base64 encoding for OKX API requests.
4//! OKX requires the following headers for authenticated requests:
5//! - OK-ACCESS-KEY: API key
6//! - OK-ACCESS-SIGN: HMAC-SHA256 signature (Base64 encoded)
7//! - OK-ACCESS-TIMESTAMP: ISO 8601 timestamp
8//! - OK-ACCESS-PASSPHRASE: API passphrase
9
10use base64::{Engine as _, engine::general_purpose};
11use ccxt_core::credentials::SecretString;
12use hmac::{Hmac, Mac};
13use reqwest::header::{HeaderMap, HeaderValue};
14use sha2::Sha256;
15
16/// OKX API authenticator.
17///
18/// Handles request signing using HMAC-SHA256 and header construction
19/// for authenticated API requests.
20///
21/// Credentials are automatically zeroed from memory when dropped.
22#[derive(Debug, Clone)]
23pub struct OkxAuth {
24    /// API key for authentication (automatically zeroed on drop).
25    api_key: SecretString,
26    /// Secret key for HMAC signing (automatically zeroed on drop).
27    secret: SecretString,
28    /// Passphrase for additional authentication (automatically zeroed on drop).
29    passphrase: SecretString,
30}
31
32impl OkxAuth {
33    /// Creates a new OkxAuth instance.
34    ///
35    /// # Arguments
36    ///
37    /// * `api_key` - The API key from OKX.
38    /// * `secret` - The secret key from OKX.
39    /// * `passphrase` - The passphrase set when creating the API key.
40    ///
41    /// # Security
42    ///
43    /// Credentials are automatically zeroed from memory when the authenticator is dropped.
44    ///
45    /// # Example
46    ///
47    /// ```
48    /// use ccxt_exchanges::okx::OkxAuth;
49    ///
50    /// let auth = OkxAuth::new(
51    ///     "your-api-key".to_string(),
52    ///     "your-secret".to_string(),
53    ///     "your-passphrase".to_string(),
54    /// );
55    /// ```
56    pub fn new(api_key: String, secret: String, passphrase: String) -> Self {
57        Self {
58            api_key: SecretString::new(api_key),
59            secret: SecretString::new(secret),
60            passphrase: SecretString::new(passphrase),
61        }
62    }
63
64    /// Returns the API key.
65    pub fn api_key(&self) -> &str {
66        self.api_key.expose_secret()
67    }
68
69    /// Returns the passphrase.
70    pub fn passphrase(&self) -> &str {
71        self.passphrase.expose_secret()
72    }
73
74    /// Builds the signature string for HMAC signing.
75    ///
76    /// The signature string format is: `timestamp + method + path + body`
77    ///
78    /// # Arguments
79    ///
80    /// * `timestamp` - ISO 8601 timestamp (e.g., "2021-01-01T00:00:00.000Z").
81    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
82    /// * `path` - Request path including query string (e.g., "/api/v5/account/balance").
83    /// * `body` - Request body (empty string for GET requests).
84    ///
85    /// # Returns
86    ///
87    /// The concatenated string to be signed.
88    pub fn build_sign_string(
89        &self,
90        timestamp: &str,
91        method: &str,
92        path: &str,
93        body: &str,
94    ) -> String {
95        format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body)
96    }
97
98    /// Signs a request using HMAC-SHA256.
99    ///
100    /// # Arguments
101    ///
102    /// * `timestamp` - ISO 8601 timestamp (e.g., "2021-01-01T00:00:00.000Z").
103    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
104    /// * `path` - Request path including query string.
105    /// * `body` - Request body (empty string for GET requests).
106    ///
107    /// # Returns
108    ///
109    /// Base64-encoded HMAC-SHA256 signature.
110    ///
111    /// # Example
112    ///
113    /// ```
114    /// use ccxt_exchanges::okx::OkxAuth;
115    ///
116    /// let auth = OkxAuth::new(
117    ///     "api-key".to_string(),
118    ///     "secret".to_string(),
119    ///     "passphrase".to_string(),
120    /// );
121    ///
122    /// let signature = auth.sign("2021-01-01T00:00:00.000Z", "GET", "/api/v5/account/balance", "");
123    /// assert!(!signature.is_empty());
124    /// ```
125    pub fn sign(&self, timestamp: &str, method: &str, path: &str, body: &str) -> String {
126        let sign_string = self.build_sign_string(timestamp, method, path, body);
127        self.hmac_sha256_base64(&sign_string)
128    }
129
130    /// Computes HMAC-SHA256 and returns Base64-encoded result.
131    fn hmac_sha256_base64(&self, message: &str) -> String {
132        type HmacSha256 = Hmac<Sha256>;
133
134        // SAFETY: HMAC accepts keys of any length - this cannot fail
135        let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret_bytes())
136            .expect("HMAC-SHA256 accepts keys of any length; this is an infallible operation");
137        mac.update(message.as_bytes());
138        let result = mac.finalize().into_bytes();
139
140        general_purpose::STANDARD.encode(result)
141    }
142
143    /// Adds authentication headers to a HeaderMap.
144    ///
145    /// Adds the following headers:
146    /// - OK-ACCESS-KEY: API key
147    /// - OK-ACCESS-SIGN: HMAC-SHA256 signature
148    /// - OK-ACCESS-TIMESTAMP: ISO 8601 timestamp
149    /// - OK-ACCESS-PASSPHRASE: API passphrase
150    ///
151    /// # Arguments
152    ///
153    /// * `headers` - Mutable reference to HeaderMap to add headers to.
154    /// * `timestamp` - ISO 8601 timestamp.
155    /// * `sign` - Pre-computed signature from `sign()` method.
156    ///
157    /// # Example
158    ///
159    /// ```
160    /// use ccxt_exchanges::okx::OkxAuth;
161    /// use reqwest::header::HeaderMap;
162    ///
163    /// let auth = OkxAuth::new(
164    ///     "api-key".to_string(),
165    ///     "secret".to_string(),
166    ///     "passphrase".to_string(),
167    /// );
168    ///
169    /// let mut headers = HeaderMap::new();
170    /// let timestamp = "2021-01-01T00:00:00.000Z";
171    /// let signature = auth.sign(timestamp, "GET", "/api/v5/account/balance", "");
172    /// auth.add_auth_headers(&mut headers, timestamp, &signature);
173    ///
174    /// assert!(headers.contains_key("OK-ACCESS-KEY"));
175    /// assert!(headers.contains_key("OK-ACCESS-SIGN"));
176    /// assert!(headers.contains_key("OK-ACCESS-TIMESTAMP"));
177    /// assert!(headers.contains_key("OK-ACCESS-PASSPHRASE"));
178    /// ```
179    pub fn add_auth_headers(&self, headers: &mut HeaderMap, timestamp: &str, sign: &str) {
180        headers.insert(
181            "OK-ACCESS-KEY",
182            HeaderValue::from_str(self.api_key.expose_secret())
183                .unwrap_or_else(|_| HeaderValue::from_static("")),
184        );
185        headers.insert(
186            "OK-ACCESS-SIGN",
187            HeaderValue::from_str(sign).unwrap_or_else(|_| HeaderValue::from_static("")),
188        );
189        headers.insert(
190            "OK-ACCESS-TIMESTAMP",
191            HeaderValue::from_str(timestamp).unwrap_or_else(|_| HeaderValue::from_static("")),
192        );
193        headers.insert(
194            "OK-ACCESS-PASSPHRASE",
195            HeaderValue::from_str(self.passphrase.expose_secret())
196                .unwrap_or_else(|_| HeaderValue::from_static("")),
197        );
198    }
199
200    /// Creates authentication headers for a request.
201    ///
202    /// This is a convenience method that combines `sign()` and `add_auth_headers()`.
203    ///
204    /// # Arguments
205    ///
206    /// * `timestamp` - ISO 8601 timestamp.
207    /// * `method` - HTTP method (GET, POST, DELETE, etc.).
208    /// * `path` - Request path including query string.
209    /// * `body` - Request body (empty string for GET requests).
210    ///
211    /// # Returns
212    ///
213    /// A HeaderMap containing all authentication headers.
214    pub fn create_auth_headers(
215        &self,
216        timestamp: &str,
217        method: &str,
218        path: &str,
219        body: &str,
220    ) -> HeaderMap {
221        let sign = self.sign(timestamp, method, path, body);
222        let mut headers = HeaderMap::new();
223        self.add_auth_headers(&mut headers, timestamp, &sign);
224        headers
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_auth_new() {
234        let auth = OkxAuth::new(
235            "test-api-key".to_string(),
236            "test-secret".to_string(),
237            "test-passphrase".to_string(),
238        );
239
240        assert_eq!(auth.api_key(), "test-api-key");
241        assert_eq!(auth.passphrase(), "test-passphrase");
242    }
243
244    #[test]
245    fn test_build_sign_string() {
246        let auth = OkxAuth::new(
247            "api-key".to_string(),
248            "secret".to_string(),
249            "passphrase".to_string(),
250        );
251
252        let sign_string = auth.build_sign_string(
253            "2021-01-01T00:00:00.000Z",
254            "GET",
255            "/api/v5/account/balance",
256            "",
257        );
258        assert_eq!(
259            sign_string,
260            "2021-01-01T00:00:00.000ZGET/api/v5/account/balance"
261        );
262
263        let sign_string_post = auth.build_sign_string(
264            "2021-01-01T00:00:00.000Z",
265            "POST",
266            "/api/v5/trade/order",
267            r#"{"instId":"BTC-USDT","side":"buy"}"#,
268        );
269        assert_eq!(
270            sign_string_post,
271            r#"2021-01-01T00:00:00.000ZPOST/api/v5/trade/order{"instId":"BTC-USDT","side":"buy"}"#
272        );
273    }
274
275    #[test]
276    fn test_sign_deterministic() {
277        let auth = OkxAuth::new(
278            "api-key".to_string(),
279            "test-secret-key".to_string(),
280            "passphrase".to_string(),
281        );
282
283        let sig1 = auth.sign(
284            "2021-01-01T00:00:00.000Z",
285            "GET",
286            "/api/v5/account/balance",
287            "",
288        );
289        let sig2 = auth.sign(
290            "2021-01-01T00:00:00.000Z",
291            "GET",
292            "/api/v5/account/balance",
293            "",
294        );
295
296        assert_eq!(sig1, sig2);
297        assert!(!sig1.is_empty());
298    }
299
300    #[test]
301    fn test_sign_different_inputs() {
302        let auth = OkxAuth::new(
303            "api-key".to_string(),
304            "test-secret-key".to_string(),
305            "passphrase".to_string(),
306        );
307
308        let sig1 = auth.sign(
309            "2021-01-01T00:00:00.000Z",
310            "GET",
311            "/api/v5/account/balance",
312            "",
313        );
314        let sig2 = auth.sign(
315            "2021-01-01T00:00:01.000Z",
316            "GET",
317            "/api/v5/account/balance",
318            "",
319        );
320        let sig3 = auth.sign(
321            "2021-01-01T00:00:00.000Z",
322            "POST",
323            "/api/v5/account/balance",
324            "",
325        );
326
327        assert_ne!(sig1, sig2);
328        assert_ne!(sig1, sig3);
329    }
330
331    #[test]
332    fn test_add_auth_headers() {
333        let auth = OkxAuth::new(
334            "test-api-key".to_string(),
335            "test-secret".to_string(),
336            "test-passphrase".to_string(),
337        );
338
339        let mut headers = HeaderMap::new();
340        let timestamp = "2021-01-01T00:00:00.000Z";
341        let signature = auth.sign(timestamp, "GET", "/api/v5/account/balance", "");
342        auth.add_auth_headers(&mut headers, timestamp, &signature);
343
344        assert_eq!(headers.get("OK-ACCESS-KEY").unwrap(), "test-api-key");
345        assert_eq!(headers.get("OK-ACCESS-SIGN").unwrap(), &signature);
346        assert_eq!(
347            headers.get("OK-ACCESS-TIMESTAMP").unwrap(),
348            "2021-01-01T00:00:00.000Z"
349        );
350        assert_eq!(
351            headers.get("OK-ACCESS-PASSPHRASE").unwrap(),
352            "test-passphrase"
353        );
354    }
355
356    #[test]
357    fn test_create_auth_headers() {
358        let auth = OkxAuth::new(
359            "test-api-key".to_string(),
360            "test-secret".to_string(),
361            "test-passphrase".to_string(),
362        );
363
364        let headers = auth.create_auth_headers(
365            "2021-01-01T00:00:00.000Z",
366            "GET",
367            "/api/v5/account/balance",
368            "",
369        );
370
371        assert!(headers.contains_key("OK-ACCESS-KEY"));
372        assert!(headers.contains_key("OK-ACCESS-SIGN"));
373        assert!(headers.contains_key("OK-ACCESS-TIMESTAMP"));
374        assert!(headers.contains_key("OK-ACCESS-PASSPHRASE"));
375    }
376
377    #[test]
378    fn test_method_case_insensitive() {
379        let auth = OkxAuth::new(
380            "api-key".to_string(),
381            "test-secret".to_string(),
382            "passphrase".to_string(),
383        );
384
385        // The build_sign_string method converts method to uppercase
386        let sign_string_lower = auth.build_sign_string(
387            "2021-01-01T00:00:00.000Z",
388            "get",
389            "/api/v5/account/balance",
390            "",
391        );
392        let sign_string_upper = auth.build_sign_string(
393            "2021-01-01T00:00:00.000Z",
394            "GET",
395            "/api/v5/account/balance",
396            "",
397        );
398
399        assert_eq!(sign_string_lower, sign_string_upper);
400    }
401
402    #[test]
403    fn test_signature_is_base64() {
404        let auth = OkxAuth::new(
405            "api-key".to_string(),
406            "test-secret".to_string(),
407            "passphrase".to_string(),
408        );
409
410        let signature = auth.sign(
411            "2021-01-01T00:00:00.000Z",
412            "GET",
413            "/api/v5/account/balance",
414            "",
415        );
416
417        // Base64 characters are alphanumeric plus + / =
418        assert!(
419            signature
420                .chars()
421                .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
422        );
423
424        // Should be decodable as Base64
425        let decoded = general_purpose::STANDARD.decode(&signature);
426        assert!(decoded.is_ok());
427
428        // HMAC-SHA256 produces 32 bytes
429        assert_eq!(decoded.unwrap().len(), 32);
430    }
431
432    /// Test signature generation with known inputs and expected output.
433    /// This verifies the HMAC-SHA256 implementation produces correct signatures.
434    #[test]
435    fn test_signature_with_known_inputs() {
436        let auth = OkxAuth::new(
437            "okx_api_key".to_string(),
438            "okx-secret-key".to_string(),
439            "my-passphrase".to_string(),
440        );
441
442        // Known inputs
443        let timestamp = "2021-01-01T00:00:00.000Z";
444        let method = "GET";
445        let path = "/api/v5/account/balance";
446        let body = "";
447
448        // Generate signature
449        let signature = auth.sign(timestamp, method, path, body);
450
451        // The sign string should be: "2021-01-01T00:00:00.000ZGET/api/v5/account/balance"
452        let expected_sign_string = "2021-01-01T00:00:00.000ZGET/api/v5/account/balance";
453        assert_eq!(
454            auth.build_sign_string(timestamp, method, path, body),
455            expected_sign_string
456        );
457
458        // Verify signature is non-empty and valid Base64
459        assert!(!signature.is_empty());
460        let decoded = general_purpose::STANDARD.decode(&signature);
461        assert!(decoded.is_ok());
462        assert_eq!(decoded.unwrap().len(), 32);
463
464        // Verify the signature is reproducible
465        let signature2 = auth.sign(timestamp, method, path, body);
466        assert_eq!(signature, signature2);
467    }
468
469    /// Test signature generation for POST request with JSON body.
470    #[test]
471    fn test_signature_with_post_body() {
472        let auth = OkxAuth::new(
473            "okx_api_key".to_string(),
474            "okx-secret-key".to_string(),
475            "my-passphrase".to_string(),
476        );
477
478        let timestamp = "2021-01-01T00:00:00.000Z";
479        let method = "POST";
480        let path = "/api/v5/trade/order";
481        let body = r#"{"instId":"BTC-USDT","tdMode":"cash","side":"buy","ordType":"limit","px":"50000","sz":"0.001"}"#;
482
483        let signature = auth.sign(timestamp, method, path, body);
484
485        // Verify sign string format
486        let expected_sign_string = format!("{}{}{}{}", timestamp, method, path, body);
487        assert_eq!(
488            auth.build_sign_string(timestamp, method, path, body),
489            expected_sign_string
490        );
491
492        // Verify signature is valid
493        assert!(!signature.is_empty());
494        let decoded = general_purpose::STANDARD.decode(&signature);
495        assert!(decoded.is_ok());
496        assert_eq!(decoded.unwrap().len(), 32);
497    }
498
499    /// Test that all required headers are present and have correct values.
500    #[test]
501    fn test_header_values_correctness() {
502        let api_key = "my-api-key-12345";
503        let secret = "my-secret-key-67890";
504        let passphrase = "my-passphrase-abc";
505
506        let auth = OkxAuth::new(
507            api_key.to_string(),
508            secret.to_string(),
509            passphrase.to_string(),
510        );
511
512        let timestamp = "2021-01-01T00:00:00.000Z";
513        let method = "GET";
514        let path = "/api/v5/account/balance";
515        let body = "";
516
517        let headers = auth.create_auth_headers(timestamp, method, path, body);
518
519        // Verify OK-ACCESS-KEY header
520        assert_eq!(
521            headers.get("OK-ACCESS-KEY").unwrap().to_str().unwrap(),
522            api_key
523        );
524
525        // Verify OK-ACCESS-TIMESTAMP header
526        assert_eq!(
527            headers
528                .get("OK-ACCESS-TIMESTAMP")
529                .unwrap()
530                .to_str()
531                .unwrap(),
532            timestamp
533        );
534
535        // Verify OK-ACCESS-PASSPHRASE header
536        assert_eq!(
537            headers
538                .get("OK-ACCESS-PASSPHRASE")
539                .unwrap()
540                .to_str()
541                .unwrap(),
542            passphrase
543        );
544
545        // Verify OK-ACCESS-SIGN header exists and is valid Base64
546        let sign_header = headers.get("OK-ACCESS-SIGN").unwrap().to_str().unwrap();
547        assert!(!sign_header.is_empty());
548        let decoded = general_purpose::STANDARD.decode(sign_header);
549        assert!(decoded.is_ok());
550    }
551
552    /// Test signature with query parameters in path.
553    #[test]
554    fn test_signature_with_query_params() {
555        let auth = OkxAuth::new(
556            "api-key".to_string(),
557            "secret-key".to_string(),
558            "passphrase".to_string(),
559        );
560
561        let timestamp = "2021-01-01T00:00:00.000Z";
562        let method = "GET";
563        let path = "/api/v5/market/ticker?instId=BTC-USDT";
564        let body = "";
565
566        let signature = auth.sign(timestamp, method, path, body);
567
568        // Verify sign string includes query params
569        let sign_string = auth.build_sign_string(timestamp, method, path, body);
570        assert!(sign_string.contains("?instId=BTC-USDT"));
571
572        // Verify signature is valid
573        assert!(!signature.is_empty());
574        let decoded = general_purpose::STANDARD.decode(&signature);
575        assert!(decoded.is_ok());
576    }
577}