Skip to main content

nautilus_bybit/common/
credential.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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//! Bybit API credential storage and signing helpers.
17
18#![allow(unused_assignments)] // Fields are used in methods, false positive from nightly
19
20use std::fmt::Debug;
21
22use aws_lc_rs::hmac;
23use nautilus_core::{env::resolve_env_var_pair, hex, string::secret::REDACTED};
24use zeroize::ZeroizeOnDrop;
25
26use crate::common::enums::BybitEnvironment;
27
28/// Returns the environment variable names for API credentials,
29/// based on the trading environment.
30#[must_use]
31pub fn credential_env_vars(environment: BybitEnvironment) -> (&'static str, &'static str) {
32    match environment {
33        BybitEnvironment::Demo => ("BYBIT_DEMO_API_KEY", "BYBIT_DEMO_API_SECRET"),
34        BybitEnvironment::Testnet => ("BYBIT_TESTNET_API_KEY", "BYBIT_TESTNET_API_SECRET"),
35        BybitEnvironment::Mainnet => ("BYBIT_API_KEY", "BYBIT_API_SECRET"),
36    }
37}
38
39/// API credentials required for signing Bybit REST requests.
40#[derive(Clone, ZeroizeOnDrop)]
41pub struct Credential {
42    api_key: Box<str>,
43    api_secret: Box<[u8]>,
44}
45
46impl Debug for Credential {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct(stringify!(Credential))
49            .field("api_key", &self.api_key)
50            .field("api_secret", &REDACTED)
51            .finish()
52    }
53}
54
55impl Credential {
56    /// Resolves credentials from provided values or environment variables.
57    ///
58    /// If both `api_key` and `api_secret` are provided, uses those.
59    /// Otherwise falls back to environment variables based on the environment.
60    #[must_use]
61    pub fn resolve(
62        api_key: Option<String>,
63        api_secret: Option<String>,
64        environment: BybitEnvironment,
65    ) -> Option<Self> {
66        let (key_var, secret_var) = credential_env_vars(environment);
67        let (k, s) = resolve_env_var_pair(api_key, api_secret, key_var, secret_var)?;
68        Some(Self::new(k, s))
69    }
70
71    /// Creates a new [`Credential`] instance from the API key and secret.
72    #[must_use]
73    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
74        Self {
75            api_key: api_key.into().into_boxed_str(),
76            api_secret: api_secret.into().into_bytes().into_boxed_slice(),
77        }
78    }
79
80    /// Returns the API key associated with this credential.
81    #[must_use]
82    pub fn api_key(&self) -> &str {
83        &self.api_key
84    }
85
86    /// Returns a masked version of the API key for logging purposes.
87    ///
88    /// Shows first 4 and last 4 characters with ellipsis in between.
89    /// For keys shorter than 8 characters, shows asterisks only.
90    #[must_use]
91    pub fn api_key_masked(&self) -> String {
92        nautilus_core::string::secret::mask_api_key(&self.api_key)
93    }
94
95    /// Produces the Bybit WebSocket authentication signature for the provided expiry timestamp.
96    ///
97    /// `expires` should be the millisecond timestamp used by the login payload.
98    #[must_use]
99    pub fn sign_websocket_auth(&self, expires: i64) -> String {
100        let message = format!("GET/realtime{expires}");
101        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
102        let tag = hmac::sign(&key, message.as_bytes());
103        hex::encode(tag.as_ref())
104    }
105
106    /// Produces the Bybit HMAC signature for the provided payload.
107    ///
108    /// `payload` should contain either a URL-encoded query string (for GET requests)
109    /// or a JSON body (for POST requests). Callers are responsible for ensuring that
110    /// the encoding matches the bytes sent over the wire.
111    #[must_use]
112    pub fn sign_with_payload(
113        &self,
114        timestamp: &str,
115        recv_window_ms: u64,
116        payload: Option<&str>,
117    ) -> String {
118        let recv_window = recv_window_ms.to_string();
119        let payload_len = payload.map_or(0usize, str::len);
120        let mut message = String::with_capacity(
121            timestamp.len() + self.api_key.len() + recv_window.len() + payload_len,
122        );
123
124        message.push_str(timestamp);
125        message.push_str(&self.api_key);
126        message.push_str(&recv_window);
127
128        if let Some(payload) = payload {
129            message.push_str(payload);
130        }
131
132        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
133        let tag = hmac::sign(&key, message.as_bytes());
134        hex::encode(tag.as_ref())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use rstest::rstest;
141
142    use super::*;
143
144    const API_KEY: &str = "test_api_key";
145    const API_SECRET: &str = "test_secret";
146    const RECV_WINDOW: u64 = 5_000;
147    const TIMESTAMP: &str = "1700000000000";
148
149    #[rstest]
150    fn sign_with_payload_matches_reference_get() {
151        let credential = Credential::new(API_KEY, API_SECRET);
152        let query = "category=linear&symbol=BTCUSDT";
153
154        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(query));
155
156        assert_eq!(
157            signature,
158            "fd4f31228a46109dc6673062328693696df9a96c7ff04e6491a45e7f63a0fdd7"
159        );
160    }
161
162    #[rstest]
163    fn sign_with_payload_matches_reference_post() {
164        let credential = Credential::new(API_KEY, API_SECRET);
165        let body = "{\"category\": \"linear\", \"symbol\": \"BTCUSDT\", \"orderLinkId\": \"test-order-1\"}";
166
167        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(body));
168
169        assert_eq!(
170            signature,
171            "2df4a0603d69c08d5dea29ba85b46eb7db64ce9e9ebd34a7802a3d69700cb2a1"
172        );
173    }
174
175    #[rstest]
176    fn sign_with_empty_payload_omits_tail() {
177        let credential = Credential::new(API_KEY, API_SECRET);
178
179        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, None);
180
181        let expected = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(""));
182        assert_eq!(signature, expected);
183    }
184
185    #[rstest]
186    fn sign_websocket_auth_matches_reference() {
187        let credential = Credential::new(API_KEY, API_SECRET);
188        let expires: i64 = 1_700_000_000_000;
189
190        let signature = credential.sign_websocket_auth(expires);
191
192        assert_eq!(
193            signature,
194            "bacffe7500499eb829bb58c45d36d1b3e5ac67c14eaeba91df5e99ccee013925"
195        );
196    }
197
198    #[rstest]
199    fn test_debug_redacts_secret() {
200        let credential = Credential::new(API_KEY, API_SECRET);
201        let dbg_out = format!("{credential:?}");
202
203        assert!(dbg_out.contains(REDACTED));
204        assert!(!dbg_out.contains(API_SECRET));
205    }
206
207    #[rstest]
208    fn test_resolve_with_both_args() {
209        let result = Credential::resolve(
210            Some("my_key".to_string()),
211            Some("my_secret".to_string()),
212            BybitEnvironment::Mainnet,
213        );
214
215        assert!(result.is_some());
216        assert_eq!(result.unwrap().api_key(), "my_key");
217    }
218
219    #[rstest]
220    fn test_resolve_with_no_args_no_env() {
221        let (key_var, secret_var) = credential_env_vars(BybitEnvironment::Mainnet);
222        if std::env::var(key_var).is_ok() || std::env::var(secret_var).is_ok() {
223            return;
224        }
225
226        let result = Credential::resolve(None, None, BybitEnvironment::Mainnet);
227
228        assert!(result.is_none());
229    }
230}