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)]
104mod tests {
105 use rstest::rstest;
106
107 use super::*;
108
109 const API_KEY: &str = "985d5b66-57ce-40fb-b714-afc0b9787083";
110 const API_SECRET: &str = "chNOOS4KvNXR_Xq4k4c9qsfoKWvnDecLATCRlcBwyKDYnWgO";
111 const API_PASSPHRASE: &str = "1234567890";
112
113 #[rstest]
114 fn test_simple_get() {
115 let credential = Credential::new(
116 API_KEY.to_string(),
117 API_SECRET.to_string(),
118 API_PASSPHRASE.to_string(),
119 );
120
121 let signature = credential.sign(
122 "2020-12-08T09:08:57.715Z",
123 "GET",
124 "/api/v5/account/balance",
125 "",
126 );
127
128 assert_eq!(signature, "PJ61e1nb2F2Qd7D8SPiaIcx2gjdELc+o0ygzre9z33k=");
129 }
130
131 #[rstest]
132 fn test_get_with_query_params() {
133 let credential = Credential::new(
134 API_KEY.to_string(),
135 API_SECRET.to_string(),
136 API_PASSPHRASE.to_string(),
137 );
138
139 let signature = credential.sign(
140 "2020-12-08T09:08:57.715Z",
141 "GET",
142 "/api/v5/account/balance?ccy=BTC",
143 "",
144 );
145
146 assert!(!signature.is_empty());
147 assert!(BASE64_STANDARD.decode(&signature).is_ok());
148
149 let expected_message = "2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC";
151
152 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
154 let tag = hmac::sign(&key, expected_message.as_bytes());
155 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
156 assert_eq!(signature, expected_signature);
157 }
158
159 #[rstest]
160 fn test_post_with_json_body() {
161 let credential = Credential::new(
162 API_KEY.to_string(),
163 API_SECRET.to_string(),
164 API_PASSPHRASE.to_string(),
165 );
166
167 let body = r#"{"instId":"BTC-USD-200925","tdMode":"isolated","side":"buy","ordType":"limit","px":"432.11","sz":"2"}"#;
169 let signature = credential.sign(
170 "2020-12-08T09:08:57.715Z",
171 "POST",
172 "/api/v5/trade/order",
173 body,
174 );
175
176 assert!(!signature.is_empty());
177 assert!(BASE64_STANDARD.decode(&signature).is_ok());
178 }
179
180 #[rstest]
181 fn test_post_algo_order() {
182 let credential = Credential::new(
183 API_KEY.to_string(),
184 API_SECRET.to_string(),
185 API_PASSPHRASE.to_string(),
186 );
187
188 let body = r#"[{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","ordType":"trigger","sz":"0.01","triggerPx":"3000","orderPx":"-1","triggerPxType":"last"}]"#;
190 let signature = credential.sign(
191 "2025-01-20T10:30:45.123Z",
192 "POST",
193 "/api/v5/trade/order-algo",
194 body,
195 );
196
197 assert!(!signature.is_empty());
198 assert!(BASE64_STANDARD.decode(&signature).is_ok());
199
200 let expected_message =
202 format!("2025-01-20T10:30:45.123ZPOST/api/v5/trade/order-algo{body}");
203
204 let key = hmac::Key::new(hmac::HMAC_SHA256, API_SECRET.as_bytes());
206 let tag = hmac::sign(&key, expected_message.as_bytes());
207 let expected_signature = BASE64_STANDARD.encode(tag.as_ref());
208 assert_eq!(signature, expected_signature);
209 }
210
211 #[rstest]
212 fn test_debug_redacts_secret() {
213 let credential = Credential::new(
214 API_KEY.to_string(),
215 API_SECRET.to_string(),
216 API_PASSPHRASE.to_string(),
217 );
218 let dbg_out = format!("{credential:?}");
219 assert!(dbg_out.contains("api_secret: \"<redacted>\""));
220 assert!(!dbg_out.contains("chNOO"));
221 let secret_bytes_dbg = format!("{:?}", API_SECRET.as_bytes());
222 assert!(
223 !dbg_out.contains(&secret_bytes_dbg),
224 "Debug output must not contain raw secret bytes"
225 );
226 }
227
228 #[rstest]
229 fn test_api_key_masked_short() {
230 let credential = Credential::new(
231 "short".to_string(),
232 "secret".to_string(),
233 "pass".to_string(),
234 );
235 assert_eq!(credential.api_key_masked(), "*****");
236 }
237
238 #[rstest]
239 fn test_api_key_masked_long() {
240 let credential = Credential::new(
241 API_KEY.to_string(),
242 API_SECRET.to_string(),
243 API_PASSPHRASE.to_string(),
244 );
245 assert_eq!(credential.api_key_masked(), "985d...7083");
246 }
247}