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(
85 wallet: &str,
86 signer: &PrivateKeySigner,
87) -> Result<AuthHeaders, AuthError> {
88 let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
89 build_rest_auth_headers_at(wallet, signer, now)
90}
91
92pub fn build_rest_auth_headers_at(
100 wallet: &str,
101 signer: &PrivateKeySigner,
102 now_ms: u64,
103) -> Result<AuthHeaders, AuthError> {
104 let timestamp = now_ms.to_string();
105 let signature = sign_message(×tamp, signer)?;
106 Ok(AuthHeaders {
107 wallet: wallet.to_owned(),
108 timestamp,
109 signature,
110 })
111}
112
113pub fn build_ws_login(wallet: &str, signer: &PrivateKeySigner) -> Result<WsLogin, AuthError> {
120 let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
121 build_ws_login_at(wallet, signer, now)
122}
123
124pub fn build_ws_login_at(
131 wallet: &str,
132 signer: &PrivateKeySigner,
133 now_ms: u64,
134) -> Result<WsLogin, AuthError> {
135 let timestamp = now_ms.to_string();
136 let signature = sign_message(×tamp, signer)?;
137 Ok(WsLogin {
138 wallet: wallet.to_owned(),
139 timestamp,
140 signature,
141 })
142}
143
144fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<SecretString, AuthError> {
145 let signature =
146 signer
147 .sign_message_sync(message.as_bytes())
148 .map_err(|e| AuthError::SigningFailed {
149 message: e.to_string(),
150 })?;
151 Ok(SecretString::from(format!(
152 "0x{}",
153 alloy_primitives::hex::encode(signature.as_bytes())
154 )))
155}
156
157#[cfg(test)]
158mod tests {
159 use alloy_primitives::{Address, Signature, eip191_hash_message, hex};
160 use rstest::rstest;
161
162 use super::*;
163
164 const SESSION_KEY_HEX: &str =
165 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
166 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
167
168 fn signer_address() -> Address {
169 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
170 signer.address()
171 }
172
173 #[rstest]
174 fn test_rest_headers_contain_three_fields() {
175 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
176 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
177 let debug = format!("{headers:?}");
178
179 assert_eq!(headers.wallet, TEST_WALLET);
180 assert_eq!(headers.timestamp, "1700000000000");
181 assert!(headers.signature.expose_secret().starts_with("0x"));
182 assert_eq!(headers.signature.expose_secret().len(), 2 + 130);
183 assert!(debug.contains(REDACTED));
184 assert!(!debug.contains(headers.signature.expose_secret()));
185 }
186
187 #[rstest]
188 fn test_rest_signature_recovers_signer_address() {
189 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
190 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
191 let raw = hex::decode(headers.signature.expose_secret().trim_start_matches("0x")).unwrap();
192 let signature = Signature::try_from(raw.as_slice()).unwrap();
193 let digest = eip191_hash_message(headers.timestamp.as_bytes());
196 let recovered = signature
197 .recover_address_from_prehash(&digest)
198 .expect("recover");
199 assert_eq!(recovered, signer_address());
200 }
201
202 #[rstest]
203 fn test_ws_login_matches_rest_signature_for_same_timestamp() {
204 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
205 let now = 1_700_000_001_234;
206 let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
207 let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
208 let debug = format!("{ws:?}");
209
210 assert_eq!(rest.timestamp, ws.timestamp);
211 assert_eq!(rest.signature, ws.signature);
212 assert_eq!(rest.wallet, ws.wallet);
213 assert!(debug.contains(REDACTED));
214 assert!(!debug.contains(ws.signature.expose_secret()));
215 }
216
217 #[rstest]
218 fn test_distinct_timestamps_produce_distinct_signatures() {
219 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
220 let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
221 let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
222 assert_ne!(a.signature, b.signature);
223 }
224
225 #[rstest]
226 fn test_signature_format_is_lowercase_hex() {
227 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
228 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
229 let sig = headers.signature.expose_secret().trim_start_matches("0x");
230 assert!(
231 sig.chars()
232 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
233 "expected lowercase hex, was {sig}",
234 );
235 }
236}