nautilus_bybit/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//! 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, Formatter};
21
22use aws_lc_rs::hmac;
23use hex;
24use ustr::Ustr;
25use zeroize::ZeroizeOnDrop;
26
27/// API credentials required for signing Bybit REST requests.
28#[derive(Clone, ZeroizeOnDrop)]
29pub struct Credential {
30    #[zeroize(skip)]
31    api_key: Ustr,
32    api_secret: Box<[u8]>,
33}
34
35impl Debug for Credential {
36    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("Credential")
38            .field("api_key", &self.api_key)
39            .field("api_secret", &"<redacted>")
40            .finish()
41    }
42}
43
44impl Credential {
45    /// Creates a new [`Credential`] instance from the API key and secret.
46    #[must_use]
47    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
48        let api_key = api_key.into();
49        let api_secret_bytes = api_secret.into().into_bytes();
50
51        let api_key = Ustr::from(&api_key);
52
53        Self {
54            api_key,
55            api_secret: api_secret_bytes.into_boxed_slice(),
56        }
57    }
58
59    /// Returns the API key associated with this credential.
60    #[must_use]
61    pub fn api_key(&self) -> &Ustr {
62        &self.api_key
63    }
64
65    /// Returns a masked version of the API key for logging purposes.
66    ///
67    /// Shows first 4 and last 4 characters with ellipsis in between.
68    /// For keys shorter than 8 characters, shows asterisks only.
69    #[must_use]
70    pub fn api_key_masked(&self) -> String {
71        nautilus_core::string::mask_api_key(self.api_key.as_str())
72    }
73
74    /// Produces the Bybit WebSocket authentication signature for the provided expiry timestamp.
75    ///
76    /// `expires` should be the millisecond timestamp used by the login payload.
77    #[must_use]
78    pub fn sign_websocket_auth(&self, expires: i64) -> String {
79        let message = format!("GET/realtime{expires}");
80        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
81        let tag = hmac::sign(&key, message.as_bytes());
82        hex::encode(tag.as_ref())
83    }
84
85    /// Produces the Bybit HMAC signature for the provided payload.
86    ///
87    /// `payload` should contain either a URL-encoded query string (for GET requests)
88    /// or a JSON body (for POST requests). Callers are responsible for ensuring that
89    /// the encoding matches the bytes sent over the wire.
90    #[must_use]
91    pub fn sign_with_payload(
92        &self,
93        timestamp: &str,
94        recv_window_ms: u64,
95        payload: Option<&str>,
96    ) -> String {
97        let recv_window = recv_window_ms.to_string();
98        let payload_len = payload.map_or(0usize, str::len);
99        let mut message = String::with_capacity(
100            timestamp.len() + self.api_key.len() + recv_window.len() + payload_len,
101        );
102
103        message.push_str(timestamp);
104        message.push_str(self.api_key.as_str());
105        message.push_str(&recv_window);
106        if let Some(payload) = payload {
107            message.push_str(payload);
108        }
109
110        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
111        let tag = hmac::sign(&key, message.as_bytes());
112        hex::encode(tag.as_ref())
113    }
114}
115
116////////////////////////////////////////////////////////////////////////////////
117// Tests
118////////////////////////////////////////////////////////////////////////////////
119
120#[cfg(test)]
121mod tests {
122    use rstest::rstest;
123
124    use super::*;
125
126    const API_KEY: &str = "test_api_key";
127    const API_SECRET: &str = "test_secret";
128    const RECV_WINDOW: u64 = 5_000;
129    const TIMESTAMP: &str = "1700000000000";
130
131    #[rstest]
132    fn sign_with_payload_matches_reference_get() {
133        let credential = Credential::new(API_KEY, API_SECRET);
134        let query = "category=linear&symbol=BTCUSDT";
135
136        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(query));
137
138        assert_eq!(
139            signature,
140            "fd4f31228a46109dc6673062328693696df9a96c7ff04e6491a45e7f63a0fdd7"
141        );
142    }
143
144    #[rstest]
145    fn sign_with_payload_matches_reference_post() {
146        let credential = Credential::new(API_KEY, API_SECRET);
147        let body = "{\"category\": \"linear\", \"symbol\": \"BTCUSDT\", \"orderLinkId\": \"test-order-1\"}";
148
149        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(body));
150
151        assert_eq!(
152            signature,
153            "2df4a0603d69c08d5dea29ba85b46eb7db64ce9e9ebd34a7802a3d69700cb2a1"
154        );
155    }
156
157    #[rstest]
158    fn sign_with_empty_payload_omits_tail() {
159        let credential = Credential::new(API_KEY, API_SECRET);
160
161        let signature = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, None);
162
163        let expected = credential.sign_with_payload(TIMESTAMP, RECV_WINDOW, Some(""));
164        assert_eq!(signature, expected);
165    }
166
167    #[rstest]
168    fn sign_websocket_auth_matches_reference() {
169        let credential = Credential::new(API_KEY, API_SECRET);
170        let expires: i64 = 1_700_000_000_000;
171
172        let signature = credential.sign_websocket_auth(expires);
173
174        assert_eq!(
175            signature,
176            "bacffe7500499eb829bb58c45d36d1b3e5ac67c14eaeba91df5e99ccee013925"
177        );
178    }
179}