nautilus_derive/signing/
auth.rs1use alloy::signers::{SignerSync, local::PrivateKeySigner};
35use thiserror::Error;
36
37use crate::signing::encoding::utc_now_ms;
38
39#[derive(Debug, Error)]
41pub enum AuthError {
42 #[error("system clock is before UNIX epoch")]
44 ClockBeforeEpoch,
45 #[error("signing failed: {message}")]
47 SigningFailed {
48 message: String,
50 },
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AuthHeaders {
56 pub wallet: String,
58 pub timestamp: String,
60 pub signature: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct WsLogin {
67 pub wallet: String,
69 pub timestamp: String,
71 pub signature: String,
73}
74
75pub fn build_rest_auth_headers(
82 wallet: &str,
83 signer: &PrivateKeySigner,
84) -> Result<AuthHeaders, AuthError> {
85 let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
86 build_rest_auth_headers_at(wallet, signer, now)
87}
88
89pub fn build_rest_auth_headers_at(
97 wallet: &str,
98 signer: &PrivateKeySigner,
99 now_ms: u64,
100) -> Result<AuthHeaders, AuthError> {
101 let timestamp = now_ms.to_string();
102 let signature = sign_message(×tamp, signer)?;
103 Ok(AuthHeaders {
104 wallet: wallet.to_owned(),
105 timestamp,
106 signature,
107 })
108}
109
110pub 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(
128 wallet: &str,
129 signer: &PrivateKeySigner,
130 now_ms: u64,
131) -> Result<WsLogin, AuthError> {
132 let timestamp = now_ms.to_string();
133 let signature = sign_message(×tamp, signer)?;
134 Ok(WsLogin {
135 wallet: wallet.to_owned(),
136 timestamp,
137 signature,
138 })
139}
140
141fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<String, AuthError> {
142 let signature =
143 signer
144 .sign_message_sync(message.as_bytes())
145 .map_err(|e| AuthError::SigningFailed {
146 message: e.to_string(),
147 })?;
148 Ok(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 assert_eq!(headers.wallet, TEST_WALLET);
175 assert_eq!(headers.timestamp, "1700000000000");
176 assert!(headers.signature.starts_with("0x"));
177 assert_eq!(headers.signature.len(), 2 + 130);
178 }
179
180 #[rstest]
181 fn test_rest_signature_recovers_signer_address() {
182 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
183 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
184 let raw = hex::decode(headers.signature.trim_start_matches("0x")).unwrap();
185 let signature = Signature::try_from(raw.as_slice()).unwrap();
186 let digest = eip191_hash_message(headers.timestamp.as_bytes());
189 let recovered = signature
190 .recover_address_from_prehash(&digest)
191 .expect("recover");
192 assert_eq!(recovered, signer_address());
193 }
194
195 #[rstest]
196 fn test_ws_login_matches_rest_signature_for_same_timestamp() {
197 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
198 let now = 1_700_000_001_234;
199 let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
200 let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
201 assert_eq!(rest.timestamp, ws.timestamp);
202 assert_eq!(rest.signature, ws.signature);
203 assert_eq!(rest.wallet, ws.wallet);
204 }
205
206 #[rstest]
207 fn test_distinct_timestamps_produce_distinct_signatures() {
208 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
209 let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
210 let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
211 assert_ne!(a.signature, b.signature);
212 }
213
214 #[rstest]
215 fn test_signature_format_is_lowercase_hex() {
216 let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
217 let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
218 let sig = headers.signature.trim_start_matches("0x");
219 assert!(
220 sig.chars()
221 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
222 "expected lowercase hex, was {sig}",
223 );
224 }
225}