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
16//! API credential utilities for signing BitMEX requests.
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 nautilus_core::string::mask_api_key;
24use ustr::Ustr;
25use zeroize::ZeroizeOnDrop;
26
27/// BitMEX API credentials for signing requests.
28///
29/// Uses HMAC SHA256 for request signing as per BitMEX 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    api_secret: Box<[u8]>,
36}
37
38impl Debug for Credential {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct(stringify!(Credential))
41            .field("api_key", &self.api_key)
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) -> Self {
51        let boxed: Box<[u8]> = api_secret.into_bytes().into_boxed_slice();
52
53        Self {
54            api_key: api_key.into(),
55            api_secret: boxed,
56        }
57    }
58
59    /// Signs a request message according to the BitMEX authentication scheme.
60    #[must_use]
61    pub fn sign(&self, verb: &str, endpoint: &str, expires: i64, data: &str) -> String {
62        let sign_message = format!("{verb}{endpoint}{expires}{data}");
63        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
64        let signature = hmac::sign(&key, sign_message.as_bytes());
65        hex::encode(signature.as_ref())
66    }
67
68    /// Returns a masked version of the API key for logging purposes.
69    ///
70    /// Shows first 4 and last 4 characters with ellipsis in between.
71    /// For keys shorter than 8 characters, shows asterisks only.
72    #[must_use]
73    pub fn api_key_masked(&self) -> String {
74        mask_api_key(self.api_key.as_str())
75    }
76}
77
78/// Tests use examples from <https://www.bitmex.com/app/apiKeysUsage>.
79#[cfg(test)]
80mod tests {
81    use rstest::rstest;
82
83    use super::*;
84    use crate::common::testing::load_test_json;
85
86    const API_KEY: &str = "LAqUlngMIQkIUjXMUreyu3qn";
87    const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
88
89    #[rstest]
90    fn test_simple_get() {
91        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
92
93        let signature = credential.sign("GET", "/api/v1/instrument", 1518064236, "");
94
95        assert_eq!(
96            signature,
97            "c7682d435d0cfe87c16098df34ef2eb5a549d4c5a3c2b1f0f77b8af73423bf00"
98        );
99    }
100
101    #[rstest]
102    fn test_get_with_query() {
103        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
104
105        let signature = credential.sign(
106            "GET",
107            "/api/v1/instrument?filter=%7B%22symbol%22%3A+%22XBTM15%22%7D",
108            1518064237,
109            "",
110        );
111
112        assert_eq!(
113            signature,
114            "e2f422547eecb5b3cb29ade2127e21b858b235b386bfa45e1c1756eb3383919f"
115        );
116    }
117
118    #[rstest]
119    fn test_post_with_data() {
120        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
121
122        let data = load_test_json("credential_post_order.json");
123
124        let signature = credential.sign("POST", "/api/v1/order", 1518064238, data.trim_end());
125
126        assert_eq!(
127            signature,
128            "1749cd2ccae4aa49048ae09f0b95110cee706e0944e6a14ad0b3a8cb45bd336b"
129        );
130    }
131
132    #[rstest]
133    fn test_debug_redacts_secret() {
134        let credential = Credential::new(API_KEY.to_string(), API_SECRET.to_string());
135        let dbg_out = format!("{credential:?}");
136        assert!(dbg_out.contains("api_secret: \"<redacted>\""));
137        assert!(!dbg_out.contains("chNOO"));
138        let secret_bytes_dbg = format!("{:?}", API_SECRET.as_bytes());
139        assert!(
140            !dbg_out.contains(&secret_bytes_dbg),
141            "Debug output must not contain raw secret bytes"
142        );
143    }
144}