nautilus_derive/signing/
auth.rs1use alloy::signers::{SignerSync, local::PrivateKeySigner};
35#[cfg(test)]
36use nautilus_core::string::secret::REDACTED;
37use nautilus_core::string::secret::SecretString;
38use thiserror::Error;
39
40use crate::signing::encoding::utc_now_ms;
41
42#[derive(Debug, Error)]
44pub enum AuthError {
45 #[error("system clock is before UNIX epoch")]
47 ClockBeforeEpoch,
48 #[error("signing failed: {message}")]
50 SigningFailed {
51 message: String,
53 },
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AuthHeaders {
59 pub wallet: String,
61 pub timestamp: String,
63 pub signature: SecretString,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct WsLogin {
70 pub wallet: String,
72 pub timestamp: String,
74 pub signature: SecretString,
76}
77
78pub fn build_rest_auth_headers(
84 wallet: &str,
85 signer: &PrivateKeySigner,
86) -> Result<AuthHeaders, AuthError> {
87 let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
88 build_rest_auth_headers_at(wallet, signer, now)
89}
90
91pub fn build_rest_auth_headers_at(
98 wallet: &str,
99 signer: &PrivateKeySigner,
100 now_ms: u64,
101) -> Result<AuthHeaders, AuthError> {
102 let timestamp = now_ms.to_string();
103 let signature = sign_message(×tamp, signer)?;
104 Ok(AuthHeaders {
105 wallet: wallet.to_owned(),
106 timestamp,
107 signature,
108 })
109}
110
111pub fn build_ws_login(wallet: &str, signer: &PrivateKeySigner) -> Result<WsLogin, AuthError> {
117 let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
118 build_ws_login_at(wallet, signer, now)
119}
120
121pub fn build_ws_login_at(
127 wallet: &str,
128 signer: &PrivateKeySigner,
129 now_ms: u64,
130) -> Result<WsLogin, AuthError> {
131 let timestamp = now_ms.to_string();
132 let signature = sign_message(×tamp, signer)?;
133 Ok(WsLogin {
134 wallet: wallet.to_owned(),
135 timestamp,
136 signature,
137 })
138}
139
140fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<SecretString, AuthError> {
141 let signature =
142 signer
143 .sign_message_sync(message.as_bytes())
144 .map_err(|e| AuthError::SigningFailed {
145 message: e.to_string(),
146 })?;
147
148 Ok(SecretString::from(format!(
149 "0x{}",
150 alloy_primitives::hex::encode(signature.as_bytes())
151 )))
152}
153
154#[cfg(test)]
155mod tests {
156 use alloy_primitives::{Address, Signature, eip191_hash_message, hex};
157 use rstest::rstest;
158
159 use super::*;
160
161 const SESSION_KEY_HEX: &str =
162 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
163 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
164
165 fn signer_address() -> Address {
166 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
167 signer.address()
168 }
169
170 #[rstest]
171 fn test_rest_headers_contain_three_fields() {
172 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
173 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
174 let debug = format!("{headers:?}");
175
176 assert_eq!(headers.wallet, TEST_WALLET);
177 assert_eq!(headers.timestamp, "1700000000000");
178 assert!(headers.signature.expose_secret().starts_with("0x"));
179 assert_eq!(headers.signature.expose_secret().len(), 2 + 130);
180 assert!(debug.contains(REDACTED));
181 assert!(!debug.contains(headers.signature.expose_secret()));
182 }
183
184 #[rstest]
185 fn test_rest_signature_recovers_signer_address() {
186 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
187 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
188 let raw = hex::decode(headers.signature.expose_secret().trim_start_matches("0x")).unwrap();
189 let signature = Signature::try_from(raw.as_slice()).unwrap();
190 let digest = eip191_hash_message(headers.timestamp.as_bytes());
193 let recovered = signature
194 .recover_address_from_prehash(&digest)
195 .expect("recover");
196 assert_eq!(recovered, signer_address());
197 }
198
199 #[rstest]
200 fn test_ws_login_matches_rest_signature_for_same_timestamp() {
201 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
202 let now = 1_700_000_001_234;
203 let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
204 let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
205 let debug = format!("{ws:?}");
206
207 assert_eq!(rest.timestamp, ws.timestamp);
208 assert_eq!(rest.signature, ws.signature);
209 assert_eq!(rest.wallet, ws.wallet);
210 assert!(debug.contains(REDACTED));
211 assert!(!debug.contains(ws.signature.expose_secret()));
212 }
213
214 #[rstest]
215 fn test_distinct_timestamps_produce_distinct_signatures() {
216 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
217 let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
218 let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
219 assert_ne!(a.signature, b.signature);
220 }
221
222 #[rstest]
223 fn test_signature_format_is_lowercase_hex() {
224 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
225 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
226 let sig = headers.signature.expose_secret().trim_start_matches("0x");
227 assert!(
228 sig.chars()
229 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
230 "expected lowercase hex, was {sig}",
231 );
232 }
233}