auth_framework/protocols/
siwe.rs1use crate::errors::{AuthError, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SiweMessage {
19 pub domain: String,
21 pub address: String,
23 pub statement: Option<String>,
25 pub uri: String,
27 pub chain_id: u64,
29 pub nonce: String,
31 pub issued_at: DateTime<Utc>,
33 pub expiration_time: Option<DateTime<Utc>>,
35 pub not_before: Option<DateTime<Utc>>,
37 pub request_id: Option<String>,
39 pub resources: Vec<String>,
41 pub version: String,
43}
44
45impl SiweMessage {
46 pub fn new(domain: &str, address: &str, uri: &str, chain_id: u64) -> Result<Self> {
48 validate_address(address)?;
49 if domain.is_empty() {
50 return Err(AuthError::validation("Domain cannot be empty"));
51 }
52 if uri.is_empty() {
53 return Err(AuthError::validation("URI cannot be empty"));
54 }
55 let nonce = generate_nonce()?;
56 Ok(Self {
57 domain: domain.to_string(),
58 address: address.to_string(),
59 statement: None,
60 uri: uri.to_string(),
61 chain_id,
62 nonce,
63 issued_at: Utc::now(),
64 expiration_time: None,
65 not_before: None,
66 request_id: None,
67 resources: Vec::new(),
68 version: "1".to_string(),
69 })
70 }
71
72 pub fn to_message_string(&self) -> String {
74 let mut msg = format!(
75 "{domain} wants you to sign in with your Ethereum account:\n\
76 {address}\n",
77 domain = self.domain,
78 address = self.address,
79 );
80
81 if let Some(ref stmt) = self.statement {
82 msg.push('\n');
83 msg.push_str(stmt);
84 msg.push('\n');
85 }
86
87 msg.push_str(&format!(
88 "\nURI: {uri}\n\
89 Version: {ver}\n\
90 Chain ID: {chain}\n\
91 Nonce: {nonce}\n\
92 Issued At: {iat}",
93 uri = self.uri,
94 ver = self.version,
95 chain = self.chain_id,
96 nonce = self.nonce,
97 iat = self.issued_at.to_rfc3339(),
98 ));
99
100 if let Some(ref exp) = self.expiration_time {
101 msg.push_str(&format!("\nExpiration Time: {}", exp.to_rfc3339()));
102 }
103 if let Some(ref nb) = self.not_before {
104 msg.push_str(&format!("\nNot Before: {}", nb.to_rfc3339()));
105 }
106 if let Some(ref rid) = self.request_id {
107 msg.push_str(&format!("\nRequest ID: {}", rid));
108 }
109 if !self.resources.is_empty() {
110 msg.push_str("\nResources:");
111 for r in &self.resources {
112 msg.push_str(&format!("\n- {}", r));
113 }
114 }
115
116 msg
117 }
118
119 pub fn message_hash(&self) -> [u8; 32] {
121 let msg = self.to_message_string();
122 let prefixed = format!("\x19Ethereum Signed Message:\n{}{}", msg.len(), msg);
123 Sha256::digest(prefixed.as_bytes()).into()
124 }
125}
126
127pub fn parse_siwe_message(text: &str) -> Result<SiweMessage> {
129 let lines: Vec<&str> = text.lines().collect();
130
131 if lines.len() < 7 {
132 return Err(AuthError::validation("SIWE message has too few lines"));
133 }
134
135 let domain = lines[0]
137 .strip_suffix(" wants you to sign in with your Ethereum account:")
138 .ok_or_else(|| AuthError::validation("Missing SIWE preamble"))?
139 .to_string();
140
141 let address = lines[1].trim().to_string();
143 validate_address(&address)?;
144
145 let mut statement = None;
147 let mut uri = String::new();
148 let mut version = String::new();
149 let mut chain_id: u64 = 1;
150 let mut nonce = String::new();
151 let mut issued_at = Utc::now();
152 let mut expiration_time = None;
153 let mut not_before = None;
154 let mut request_id = None;
155 let mut resources = Vec::new();
156 let mut in_resources = false;
157
158 for line in &lines[2..] {
159 let line = line.trim();
160 if line.is_empty() {
161 continue;
162 }
163 if in_resources {
164 if let Some(r) = line.strip_prefix("- ") {
165 resources.push(r.to_string());
166 continue;
167 }
168 in_resources = false;
169 }
170
171 if let Some(v) = line.strip_prefix("URI: ") {
172 uri = v.to_string();
173 } else if let Some(v) = line.strip_prefix("Version: ") {
174 version = v.to_string();
175 } else if let Some(v) = line.strip_prefix("Chain ID: ") {
176 chain_id = v.parse().unwrap_or(1);
177 } else if let Some(v) = line.strip_prefix("Nonce: ") {
178 nonce = v.to_string();
179 } else if let Some(v) = line.strip_prefix("Issued At: ") {
180 issued_at = DateTime::parse_from_rfc3339(v)
181 .map(|dt| dt.with_timezone(&Utc))
182 .unwrap_or_else(|_| Utc::now());
183 } else if let Some(v) = line.strip_prefix("Expiration Time: ") {
184 expiration_time = DateTime::parse_from_rfc3339(v)
185 .map(|dt| dt.with_timezone(&Utc))
186 .ok();
187 } else if let Some(v) = line.strip_prefix("Not Before: ") {
188 not_before = DateTime::parse_from_rfc3339(v)
189 .map(|dt| dt.with_timezone(&Utc))
190 .ok();
191 } else if let Some(v) = line.strip_prefix("Request ID: ") {
192 request_id = Some(v.to_string());
193 } else if line == "Resources:" {
194 in_resources = true;
195 } else if statement.is_none()
196 && !line.starts_with("URI:")
197 && !line.starts_with("Version:")
198 {
199 statement = Some(line.to_string());
200 }
201 }
202
203 Ok(SiweMessage {
204 domain,
205 address,
206 statement,
207 uri,
208 chain_id,
209 nonce,
210 issued_at,
211 expiration_time,
212 not_before,
213 request_id,
214 resources,
215 version,
216 })
217}
218
219pub fn verify_siwe_message(
225 msg: &SiweMessage,
226 expected_domain: &str,
227 expected_nonce: Option<&str>,
228) -> Result<()> {
229 if msg.domain != expected_domain {
230 return Err(AuthError::validation("Domain mismatch"));
231 }
232
233 if let Some(expected) = expected_nonce {
234 if msg.nonce != expected {
235 return Err(AuthError::validation("Nonce mismatch"));
236 }
237 }
238
239 let now = Utc::now();
240 if let Some(ref exp) = msg.expiration_time {
241 if &now > exp {
242 return Err(AuthError::validation("SIWE message has expired"));
243 }
244 }
245 if let Some(ref nb) = msg.not_before {
246 if &now < nb {
247 return Err(AuthError::validation("SIWE message is not yet valid"));
248 }
249 }
250
251 validate_address(&msg.address)?;
252
253 Ok(())
254}
255
256fn validate_address(address: &str) -> Result<()> {
258 if !address.starts_with("0x") || address.len() != 42 {
259 return Err(AuthError::validation(
260 "Invalid Ethereum address: must be 0x followed by 40 hex characters",
261 ));
262 }
263 if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
264 return Err(AuthError::validation(
265 "Invalid Ethereum address: contains non-hex characters",
266 ));
267 }
268 Ok(())
269}
270
271fn generate_nonce() -> Result<String> {
273 use ring::rand::{SecureRandom, SystemRandom};
274 let rng = SystemRandom::new();
275 let mut buf = [0u8; 16];
276 rng.fill(&mut buf)
277 .map_err(|_| AuthError::crypto("Failed to generate nonce".to_string()))?;
278 Ok(hex::encode(buf))
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use chrono::Duration;
285
286 const TEST_ADDR: &str = "0xAb5801a7D398351b8bE11C439e05C5b3259aec9B";
287
288 #[test]
289 fn test_create_siwe_message() {
290 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com/login", 1).unwrap();
291 assert_eq!(msg.domain, "example.com");
292 assert_eq!(msg.address, TEST_ADDR);
293 assert_eq!(msg.version, "1");
294 assert_eq!(msg.chain_id, 1);
295 assert!(!msg.nonce.is_empty());
296 }
297
298 #[test]
299 fn test_empty_domain_rejected() {
300 assert!(SiweMessage::new("", TEST_ADDR, "https://example.com", 1).is_err());
301 }
302
303 #[test]
304 fn test_invalid_address_rejected() {
305 assert!(SiweMessage::new("example.com", "not-an-address", "https://example.com", 1).is_err());
306 assert!(SiweMessage::new("example.com", "0xZZZZ", "https://example.com", 1).is_err());
307 }
308
309 #[test]
310 fn test_message_string_format() {
311 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com/login", 1).unwrap();
312 let text = msg.to_message_string();
313 assert!(text.contains("example.com wants you to sign in with your Ethereum account:"));
314 assert!(text.contains(TEST_ADDR));
315 assert!(text.contains("URI: https://example.com/login"));
316 assert!(text.contains("Version: 1"));
317 assert!(text.contains("Chain ID: 1"));
318 assert!(text.contains("Nonce: "));
319 }
320
321 #[test]
322 fn test_message_string_with_statement() {
323 let mut msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
324 msg.statement = Some("I accept the Terms of Service".to_string());
325 let text = msg.to_message_string();
326 assert!(text.contains("I accept the Terms of Service"));
327 }
328
329 #[test]
330 fn test_message_string_with_resources() {
331 let mut msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
332 msg.resources = vec![
333 "https://example.com/resource1".to_string(),
334 "https://example.com/resource2".to_string(),
335 ];
336 let text = msg.to_message_string();
337 assert!(text.contains("Resources:"));
338 assert!(text.contains("- https://example.com/resource1"));
339 assert!(text.contains("- https://example.com/resource2"));
340 }
341
342 #[test]
343 fn test_parse_siwe_message_roundtrip() {
344 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com/login", 1).unwrap();
345 let text = msg.to_message_string();
346 let parsed = parse_siwe_message(&text).unwrap();
347 assert_eq!(parsed.domain, "example.com");
348 assert_eq!(parsed.address, TEST_ADDR);
349 assert_eq!(parsed.uri, "https://example.com/login");
350 assert_eq!(parsed.chain_id, 1);
351 assert_eq!(parsed.nonce, msg.nonce);
352 }
353
354 #[test]
355 fn test_verify_valid_message() {
356 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
357 let nonce = msg.nonce.clone();
358 verify_siwe_message(&msg, "example.com", Some(&nonce)).unwrap();
359 }
360
361 #[test]
362 fn test_verify_domain_mismatch() {
363 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
364 assert!(verify_siwe_message(&msg, "other.com", None).is_err());
365 }
366
367 #[test]
368 fn test_verify_nonce_mismatch() {
369 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
370 assert!(verify_siwe_message(&msg, "example.com", Some("wrong-nonce")).is_err());
371 }
372
373 #[test]
374 fn test_verify_expired_message() {
375 let mut msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
376 msg.expiration_time = Some(Utc::now() - Duration::hours(1));
377 assert!(verify_siwe_message(&msg, "example.com", None).is_err());
378 }
379
380 #[test]
381 fn test_verify_not_yet_valid() {
382 let mut msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
383 msg.not_before = Some(Utc::now() + Duration::hours(1));
384 assert!(verify_siwe_message(&msg, "example.com", None).is_err());
385 }
386
387 #[test]
388 fn test_message_hash_deterministic() {
389 let msg = SiweMessage::new("example.com", TEST_ADDR, "https://example.com", 1).unwrap();
390 let h1 = msg.message_hash();
391 let h2 = msg.message_hash();
392 assert_eq!(h1, h2);
393 }
394
395 #[test]
396 fn test_validate_address_formats() {
397 assert!(validate_address("0xAb5801a7D398351b8bE11C439e05C5b3259aec9B").is_ok());
398 assert!(validate_address("0x0000000000000000000000000000000000000000").is_ok());
399 assert!(validate_address("Ab5801a7D398351b8bE11C439e05C5b3259aec9B").is_err()); assert!(validate_address("0xAb5801").is_err()); }
402}