1#![allow(unused_assignments)] use std::fmt::{Debug, Formatter};
21
22use aws_lc_rs::hmac;
23use base64::prelude::*;
24use ustr::Ustr;
25use zeroize::ZeroizeOnDrop;
26
27#[derive(Clone, ZeroizeOnDrop)]
32pub struct Credential {
33 #[zeroize(skip)]
34 pub api_key: Ustr,
35 pub api_passphrase: String,
36 api_secret: Box<[u8]>,
37}
38
39impl Debug for Credential {
40 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct(stringify!(Credential))
42 .field("api_key", &self.api_key)
43 .field("api_passphrase", &self.api_passphrase)
44 .field("api_secret", &"<redacted>")
45 .finish()
46 }
47}
48
49impl Credential {
50 #[must_use]
52 pub fn new(api_key: String, api_secret: String, api_passphrase: String) -> Self {
53 Self {
54 api_key: api_key.into(),
55 api_passphrase,
56 api_secret: api_secret.into_bytes().into_boxed_slice(),
57 }
58 }
59
60 pub fn sign(&self, timestamp: &str, method: &str, endpoint: &str, body: &str) -> String {
66 self.sign_bytes(timestamp, method, endpoint, Some(body.as_bytes()))
67 }
68
69 pub fn sign_bytes(
72 &self,
73 timestamp: &str,
74 method: &str,
75 endpoint: &str,
76 body: Option<&[u8]>,
77 ) -> String {
78 let mut message = Vec::with_capacity(
79 timestamp.len() + method.len() + endpoint.len() + body.map_or(0, |b| b.len()),
80 );
81 message.extend_from_slice(timestamp.as_bytes());
82 message.extend_from_slice(method.as_bytes());
83 message.extend_from_slice(endpoint.as_bytes());
84 if let Some(b) = body {
85 message.extend_from_slice(b);
86 }
87
88 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret[..]);
89 let tag = hmac::sign(&key, &message);
90 BASE64_STANDARD.encode(tag.as_ref())
91 }
92
93 #[must_use]
98 pub fn api_key_masked(&self) -> String {
99 nautilus_core::string::mask_api_key(self.api_key.as_str())
100 }
101}
102
103#[cfg(test)]
108mod tests {
109 use rstest::rstest;
110
111 use super::*;
112
113 const API_KEY: &str = "985d5b66-57ce-40fb-b714-afc0b9787083";
114 const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
115 const API_PASSPHRASE: &str = "1234567890";
116
117 #[rstest]
118 fn test_simple_get() {
119 let credential = Credential::new(
120 API_KEY.to_string(),
121 API_SECRET.to_string(),
122 API_PASSPHRASE.to_string(),
123 );
124
125 let signature = credential.sign(
126 "2020-12-08T09:08:57.715Z",
127 "GET",
128 "/api/v5/account/balance",
129 "",
130 );
131
132 assert_eq!(signature, "PJ61e1nb2F2Qd7D8SPiaIcx2gjdELc+o0ygzre9z33k=");
133 }
134
135 #[rstest]
136 fn test_get_with_query_params() {
137 let credential = Credential::new(
138 API_KEY.to_string(),
139 API_SECRET.to_string(),
140 API_PASSPHRASE.to_string(),
141 );
142
143 let signature = credential.sign(
144 "2020-12-08T09:08:57.715Z",
145 "GET",
146 "/api/v5/account/balance?ccy=BTC",
147 "",
148 );
149
150 assert!(!signature.is_empty());
151 assert!(BASE64_STANDARD.decode(&signature).is_ok());
152
153 let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
155
156 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
158 let tag = hmac::sign(&key, expected_message.as_bytes());
159 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
160 assert_eq!(signature, expected_signature);
161 }
162
163 #[rstest]
164 fn test_post_with_json_body() {
165 let credential = Credential::new(
166 API_KEY.to_string(),
167 API_SECRET.to_string(),
168 API_PASSPHRASE.to_string(),
169 );
170
171 let body = r#"{"instId":"BTC-USD-200925","tdMode":"isolated","side":"buy","ordType":"limit","px":"432.11","sz":"2"}"#;
173 let signature = credential.sign(
174 "2020-12-08T09:08:57.715Z",
175 "POST",
176 "/api/v5/trade/order",
177 body,
178 );
179
180 assert!(!signature.is_empty());
181 assert!(BASE64_STANDARD.decode(&signature).is_ok());
182 }
183
184 #[rstest]
185 fn test_post_algo_order() {
186 let credential = Credential::new(
187 API_KEY.to_string(),
188 API_SECRET.to_string(),
189 API_PASSPHRASE.to_string(),
190 );
191
192 let body = r#"[{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","ordType":"trigger","sz":"0.01","triggerPx":"3000","orderPx":"-1","triggerPxType":"last"}]"#;
194 let signature = credential.sign(
195 "2025-01-20T10:30:45.123Z",
196 "POST",
197 "/api/v5/trade/order-algo",
198 body,
199 );
200
201 assert!(!signature.is_empty());
202 assert!(BASE64_STANDARD.decode(&signature).is_ok());
203
204 let expected_message = format!(
206 "2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{}",
207 body
208 );
209
210 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
212 let tag = hmac::sign(&key, expected_message.as_bytes());
213 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
214 assert_eq!(signature, expected_signature);
215 }
216
217 #[rstest]
218 fn test_debug_redacts_secret() {
219 let credential = Credential::new(
220 API_KEY.to_string(),
221 API_SECRET.to_string(),
222 API_PASSPHRASE.to_string(),
223 );
224 let dbg_out = format!("{:?}", credential);
225 assert!(dbg_out.contains("api_secret: \"<redacted>\""));
226 assert!(!dbg_out.contains("chNOO"));
227 let secret_bytes_dbg = format!("{:?}", API_SECRET.as_bytes());
228 assert!(
229 !dbg_out.contains(&secret_bytes_dbg),
230 "Debug output must not contain raw secret bytes"
231 );
232 }
233
234 #[rstest]
235 fn test_api_key_masked_short() {
236 let credential = Credential::new(
237 "short".to_string(),
238 "secret".to_string(),
239 "pass".to_string(),
240 );
241 assert_eq!(credential.api_key_masked(), "*****");
242 }
243
244 #[rstest]
245 fn test_api_key_masked_long() {
246 let credential = Credential::new(
247 API_KEY.to_string(),
248 API_SECRET.to_string(),
249 API_PASSPHRASE.to_string(),
250 );
251 assert_eq!(credential.api_key_masked(), "985d...7083");
252 }
253}