nautilus_bitmex/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
16use std::fmt::Debug;
17
18use aws_lc_rs::hmac;
19use ustr::Ustr;
20use zeroize::ZeroizeOnDrop;
21
22/// BitMEX API credentials for signing requests.
23///
24/// Uses HMAC SHA256 for request signing as per BitMEX API specifications.
25/// Secrets are automatically zeroized on drop for security.
26#[derive(Clone, ZeroizeOnDrop)]
27pub struct Credential {
28    #[zeroize(skip)]
29    pub api_key: Ustr,
30    api_secret: Box<[u8]>,
31}
32
33impl Debug for Credential {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("Credential")
36            .field("api_key", &self.api_key)
37            .field("api_secret", &"<redacted>")
38            .finish()
39    }
40}
41
42impl Credential {
43    /// Creates a new [`Credential`] instance.
44    #[must_use]
45    pub fn new(api_key: String, api_secret: String) -> Self {
46        let boxed: Box<[u8]> = api_secret.into_bytes().into_boxed_slice();
47
48        Self {
49            api_key: api_key.into(),
50            api_secret: boxed,
51        }
52    }
53
54    /// Signs a request message according to the BitMEX authentication scheme.
55    #[must_use]
56    pub fn sign(&self, verb: &str, endpoint: &str, expires: i64, data: &str) -> String {
57        let sign_message = format!("{verb}{endpoint}{expires}{data}");
58        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
59        let signature = hmac::sign(&key, sign_message.as_bytes());
60        hex::encode(signature.as_ref())
61    }
62}
63
64////////////////////////////////////////////////////////////////////////////////
65// Tests
66////////////////////////////////////////////////////////////////////////////////
67
68/// Tests use examples from <https://www.bitmex.com/app/apiKeysUsage>.
69#[cfg(test)]
70mod tests {
71    use rstest::rstest;
72
73    use super::*;
74
75    const API_KEY: &str = "LAqUlngMIQkIUjXMUreyu3qn";
76    const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
77
78    #[rstest]
79    fn test_simple_get() {
80        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
81
82        let signature = credential.sign("GET", "/api/v1/instrument", 1518064236, "");
83
84        assert_eq!(
85            signature,
86            "c7682d435d0cfe87c16098df34ef2eb5a549d4c5a3c2b1f0f77b8af73423bf00"
87        );
88    }
89
90    #[rstest]
91    fn test_get_with_query() {
92        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
93
94        let signature = credential.sign(
95            "GET",
96            "/api/v1/instrument?filter=%7B%22symbol%22%3A+%22XBTM15%22%7D",
97            1518064237,
98            "",
99        );
100
101        assert_eq!(
102            signature,
103            "e2f422547eecb5b3cb29ade2127e21b858b235b386bfa45e1c1756eb3383919f"
104        );
105    }
106
107    #[rstest]
108    fn test_post_with_data() {
109        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
110
111        let data = r#"{"symbol":"XBTM15","price":219.0,"clOrdID":"mm_bitmex_1a/oemUeQ4CAJZgP3fjHsA","orderQty":98}"#;
112
113        let signature = credential.sign("POST", "/api/v1/order", 1518064238, data);
114
115        assert_eq!(
116            signature,
117            "1749cd2ccae4aa49048ae09f0b95110cee706e0944e6a14ad0b3a8cb45bd336b"
118        );
119    }
120
121    #[rstest]
122    fn test_debug_redacts_secret() {
123        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
124        let dbg_out = format!("{credential:?}");
125        assert!(dbg_out.contains("api_secret: \"<redacted>\""));
126        assert!(!dbg_out.contains("chNOO"));
127        let secret_bytes_dbg = format!("{:?}", API_SECRET.as_bytes());
128        assert!(
129            !dbg_out.contains(&secret_bytes_dbg),
130            "Debug output must not contain raw secret bytes"
131        );
132    }
133}