1use std::collections::HashMap;
26use std::sync::Mutex;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SiweMessage {
38 pub domain: String,
41 pub address: String,
43 pub statement: Option<String>,
45 pub uri: String,
46 pub version: String,
47 pub chain_id: u64,
48 pub nonce: String,
49 pub issued_at: String,
51 pub expiration_time: Option<String>,
52 pub not_before: Option<String>,
53 pub request_id: Option<String>,
54 pub resources: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SiweError {
59 Malformed,
60 NonceMismatch,
61 NonceMissing,
62 DomainMismatch,
63 Expired,
64 NotYetValid,
65 BadSignature,
66 AddressMismatch,
67}
68
69impl std::fmt::Display for SiweError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str(match self {
72 Self::Malformed => "SIWE message malformed",
73 Self::NonceMismatch => "nonce doesn't match issued challenge",
74 Self::NonceMissing => "no challenge issued for this address",
75 Self::DomainMismatch => "domain doesn't match expected origin",
76 Self::Expired => "message expiration_time has passed",
77 Self::NotYetValid => "not_before is in the future",
78 Self::BadSignature => "signature did not recover to message address",
79 Self::AddressMismatch => "address claimed in message ≠ recovered signer",
80 })
81 }
82}
83
84pub struct NonceStore {
87 nonces: Mutex<HashMap<String, (String, u64)>>, }
89
90impl Default for NonceStore {
91 fn default() -> Self {
92 Self {
93 nonces: Mutex::new(HashMap::new()),
94 }
95 }
96}
97
98impl NonceStore {
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 pub fn issue(&self, address: &str) -> String {
107 use rand::RngCore;
108 let mut bytes = [0u8; 16];
109 rand::thread_rng().fill_bytes(&mut bytes);
110 let nonce: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
113 let key = address.to_ascii_lowercase();
114 let expires_at = now_secs() + 5 * 60;
115 self.nonces
116 .lock()
117 .unwrap()
118 .insert(key, (nonce.clone(), expires_at));
119 nonce
120 }
121
122 pub fn take(&self, address: &str) -> Option<String> {
128 let key = address.to_ascii_lowercase();
129 let mut map = self.nonces.lock().unwrap();
130 let (nonce, exp) = map.get(&key)?.clone();
131 if exp <= now_secs() {
132 return None;
133 }
134 map.remove(&key);
135 Some(nonce)
136 }
137
138 pub fn peek(&self, address: &str) -> Option<String> {
143 let key = address.to_ascii_lowercase();
144 let map = self.nonces.lock().unwrap();
145 let (nonce, exp) = map.get(&key)?.clone();
146 if exp <= now_secs() {
147 return None;
148 }
149 Some(nonce)
150 }
151}
152
153pub fn parse_message(text: &str) -> Result<SiweMessage, SiweError> {
156 let mut lines = text.lines();
174 let header = lines.next().ok_or(SiweError::Malformed)?;
175 let domain = header
176 .strip_suffix(" wants you to sign in with your Ethereum account:")
177 .ok_or(SiweError::Malformed)?
178 .to_string();
179 let address = lines.next().ok_or(SiweError::Malformed)?.trim().to_string();
180 if !address.starts_with("0x") || address.len() != 42 {
181 return Err(SiweError::Malformed);
182 }
183
184 let mut statement_parts: Vec<String> = Vec::new();
187 let mut peeked: Option<&str> = None;
188 let mut seen_blank = false;
189 for l in lines.by_ref() {
190 if l.is_empty() {
191 seen_blank = true;
192 continue;
193 }
194 if l.starts_with("URI:") {
195 peeked = Some(l);
196 break;
197 }
198 if !statement_parts.is_empty() {
202 statement_parts.push("\n".into());
203 }
204 statement_parts.push(l.to_string());
205 }
206 let _ = seen_blank;
207 let statement = if statement_parts.is_empty() {
208 None
209 } else {
210 Some(statement_parts.concat())
211 };
212 let mut uri: Option<String> = None;
214 let mut version: Option<String> = None;
215 let mut chain_id: Option<u64> = None;
216 let mut nonce: Option<String> = None;
217 let mut issued_at: Option<String> = None;
218 let mut expiration_time: Option<String> = None;
219 let mut not_before: Option<String> = None;
220 let mut request_id: Option<String> = None;
221 let mut resources = Vec::new();
222 let mut in_resources = false;
223
224 let process = |line: &str,
225 uri: &mut Option<String>,
226 version: &mut Option<String>,
227 chain_id: &mut Option<u64>,
228 nonce: &mut Option<String>,
229 issued_at: &mut Option<String>,
230 expiration_time: &mut Option<String>,
231 not_before: &mut Option<String>,
232 request_id: &mut Option<String>,
233 resources: &mut Vec<String>,
234 in_resources: &mut bool| {
235 if let Some(v) = line.strip_prefix("URI:") {
236 *uri = Some(v.trim().to_string());
237 *in_resources = false;
238 } else if let Some(v) = line.strip_prefix("Version:") {
239 *version = Some(v.trim().to_string());
240 *in_resources = false;
241 } else if let Some(v) = line.strip_prefix("Chain ID:") {
242 *chain_id = v.trim().parse().ok();
243 *in_resources = false;
244 } else if let Some(v) = line.strip_prefix("Nonce:") {
245 *nonce = Some(v.trim().to_string());
246 *in_resources = false;
247 } else if let Some(v) = line.strip_prefix("Issued At:") {
248 *issued_at = Some(v.trim().to_string());
249 *in_resources = false;
250 } else if let Some(v) = line.strip_prefix("Expiration Time:") {
251 *expiration_time = Some(v.trim().to_string());
252 *in_resources = false;
253 } else if let Some(v) = line.strip_prefix("Not Before:") {
254 *not_before = Some(v.trim().to_string());
255 *in_resources = false;
256 } else if let Some(v) = line.strip_prefix("Request ID:") {
257 *request_id = Some(v.trim().to_string());
258 *in_resources = false;
259 } else if line.starts_with("Resources:") {
260 *in_resources = true;
261 } else if *in_resources {
262 if let Some(v) = line.strip_prefix("- ") {
263 resources.push(v.trim().to_string());
264 }
265 }
266 };
267 if let Some(line) = peeked {
268 process(
269 line,
270 &mut uri,
271 &mut version,
272 &mut chain_id,
273 &mut nonce,
274 &mut issued_at,
275 &mut expiration_time,
276 &mut not_before,
277 &mut request_id,
278 &mut resources,
279 &mut in_resources,
280 );
281 }
282 for line in lines {
283 process(
284 line,
285 &mut uri,
286 &mut version,
287 &mut chain_id,
288 &mut nonce,
289 &mut issued_at,
290 &mut expiration_time,
291 &mut not_before,
292 &mut request_id,
293 &mut resources,
294 &mut in_resources,
295 );
296 }
297
298 Ok(SiweMessage {
299 domain,
300 address,
301 statement,
302 uri: uri.ok_or(SiweError::Malformed)?,
303 version: version.ok_or(SiweError::Malformed)?,
304 chain_id: chain_id.ok_or(SiweError::Malformed)?,
305 nonce: nonce.ok_or(SiweError::Malformed)?,
306 issued_at: issued_at.ok_or(SiweError::Malformed)?,
307 expiration_time,
308 not_before,
309 request_id,
310 resources,
311 })
312}
313
314pub fn validate_message(
325 nonces: &NonceStore,
326 message: &SiweMessage,
327 expected_domain: &str,
328) -> Result<(), SiweError> {
329 if message.domain != expected_domain {
330 return Err(SiweError::DomainMismatch);
331 }
332 let issued = nonces
333 .peek(&message.address)
334 .ok_or(SiweError::NonceMissing)?;
335 if issued != message.nonce {
336 return Err(SiweError::NonceMismatch);
337 }
338 if let Some(exp) = &message.expiration_time {
339 if iso_to_unix(exp).map(|t| t <= now_secs()).unwrap_or(false) {
340 return Err(SiweError::Expired);
341 }
342 }
343 if let Some(nb) = &message.not_before {
344 if iso_to_unix(nb).map(|t| t > now_secs()).unwrap_or(false) {
345 return Err(SiweError::NotYetValid);
346 }
347 }
348 Ok(())
349}
350
351pub fn verify(
359 nonces: &NonceStore,
360 message: &SiweMessage,
361 signature_hex: &str,
362 expected_domain: &str,
363) -> Result<String, SiweError> {
364 validate_message(nonces, message, expected_domain)?;
365 let recovered = recover_address(message, signature_hex)?;
366 if !recovered.eq_ignore_ascii_case(&message.address) {
367 return Err(SiweError::AddressMismatch);
368 }
369 let _ = nonces.take(&message.address);
374 Ok(recovered)
375}
376
377pub fn recover_address(message: &SiweMessage, signature_hex: &str) -> Result<String, SiweError> {
381 let signed_text = serialize_for_signing(message);
382 let prefix = format!("\x19Ethereum Signed Message:\n{}", signed_text.len());
384 let mut to_hash = Vec::with_capacity(prefix.len() + signed_text.len());
385 to_hash.extend_from_slice(prefix.as_bytes());
386 to_hash.extend_from_slice(signed_text.as_bytes());
387 let digest = keccak256(&to_hash);
388
389 let sig_bytes =
390 decode_hex(signature_hex.trim_start_matches("0x")).map_err(|_| SiweError::BadSignature)?;
391 if sig_bytes.len() != 65 {
392 return Err(SiweError::BadSignature);
393 }
394 let v = sig_bytes[64];
396 let recovery_id = match v {
397 0 | 27 => 0u8,
398 1 | 28 => 1u8,
399 _ => return Err(SiweError::BadSignature),
400 };
401
402 use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
403 let sig = Signature::from_slice(&sig_bytes[..64]).map_err(|_| SiweError::BadSignature)?;
404 let rec_id = RecoveryId::from_byte(recovery_id).ok_or(SiweError::BadSignature)?;
405 let vk = VerifyingKey::recover_from_prehash(&digest, &sig, rec_id)
406 .map_err(|_| SiweError::BadSignature)?;
407 let pubkey_point = vk.to_encoded_point(false);
410 let pubkey_xy = &pubkey_point.as_bytes()[1..]; let h = keccak256(pubkey_xy);
412 let mut addr = [0u8; 20];
413 addr.copy_from_slice(&h[12..]);
414 Ok(format!("0x{}", bytes_to_hex(&addr)))
415}
416
417fn keccak256(input: &[u8]) -> [u8; 32] {
420 use sha3::{Digest, Keccak256};
421 let mut hasher = Keccak256::new();
422 hasher.update(input);
423 let out = hasher.finalize();
424 let mut buf = [0u8; 32];
425 buf.copy_from_slice(&out);
426 buf
427}
428
429fn decode_hex(s: &str) -> Result<Vec<u8>, ()> {
430 if s.len() % 2 != 0 {
431 return Err(());
432 }
433 let mut out = Vec::with_capacity(s.len() / 2);
434 for chunk in s.as_bytes().chunks(2) {
435 let hi = hex_digit(chunk[0])?;
436 let lo = hex_digit(chunk[1])?;
437 out.push((hi << 4) | lo);
438 }
439 Ok(out)
440}
441
442fn hex_digit(b: u8) -> Result<u8, ()> {
443 match b {
444 b'0'..=b'9' => Ok(b - b'0'),
445 b'a'..=b'f' => Ok(b - b'a' + 10),
446 b'A'..=b'F' => Ok(b - b'A' + 10),
447 _ => Err(()),
448 }
449}
450
451fn bytes_to_hex(bytes: &[u8]) -> String {
452 use std::fmt::Write;
453 let mut s = String::with_capacity(bytes.len() * 2);
454 for b in bytes {
455 let _ = write!(s, "{b:02x}");
456 }
457 s
458}
459
460pub fn serialize_for_signing(m: &SiweMessage) -> String {
463 let mut out = String::new();
464 out.push_str(&m.domain);
465 out.push_str(" wants you to sign in with your Ethereum account:\n");
466 out.push_str(&m.address);
467 out.push('\n');
468 if let Some(s) = &m.statement {
469 out.push('\n');
470 out.push_str(s);
471 out.push('\n');
472 }
473 out.push('\n');
474 out.push_str(&format!("URI: {}\n", m.uri));
475 out.push_str(&format!("Version: {}\n", m.version));
476 out.push_str(&format!("Chain ID: {}\n", m.chain_id));
477 out.push_str(&format!("Nonce: {}\n", m.nonce));
478 out.push_str(&format!("Issued At: {}", m.issued_at));
479 if let Some(v) = &m.expiration_time {
480 out.push_str(&format!("\nExpiration Time: {v}"));
481 }
482 if let Some(v) = &m.not_before {
483 out.push_str(&format!("\nNot Before: {v}"));
484 }
485 if let Some(v) = &m.request_id {
486 out.push_str(&format!("\nRequest ID: {v}"));
487 }
488 if !m.resources.is_empty() {
489 out.push_str("\nResources:");
490 for r in &m.resources {
491 out.push_str("\n- ");
492 out.push_str(r);
493 }
494 }
495 out
496}
497
498fn iso_to_unix(iso: &str) -> Option<u64> {
499 chrono::DateTime::parse_from_rfc3339(iso)
503 .ok()
504 .map(|dt| dt.timestamp() as u64)
505}
506
507fn now_secs() -> u64 {
508 use std::time::{SystemTime, UNIX_EPOCH};
509 SystemTime::now()
510 .duration_since(UNIX_EPOCH)
511 .unwrap_or_default()
512 .as_secs()
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn nonce_round_trip() {
521 let store = NonceStore::new();
522 let n = store.issue("0xABC");
523 assert_eq!(store.take("0xabc").as_deref(), Some(n.as_str()));
524 assert!(store.take("0xabc").is_none());
526 }
527
528 #[test]
529 fn parse_full_message() {
530 let raw = "example.com wants you to sign in with your Ethereum account:\n\
531 0x1111222233334444555566667777888899990000\n\
532 \n\
533 I accept the ToS\n\
534 \n\
535 URI: https://example.com\n\
536 Version: 1\n\
537 Chain ID: 1\n\
538 Nonce: abc123\n\
539 Issued At: 2026-01-01T00:00:00Z";
540 let m = parse_message(raw).expect("parse");
541 assert_eq!(m.domain, "example.com");
542 assert_eq!(m.address, "0x1111222233334444555566667777888899990000");
543 assert_eq!(m.statement.as_deref(), Some("I accept the ToS"));
544 assert_eq!(m.uri, "https://example.com");
545 assert_eq!(m.chain_id, 1);
546 assert_eq!(m.nonce, "abc123");
547 }
548
549 #[test]
550 fn parse_message_without_statement() {
551 let raw = "x.com wants you to sign in with your Ethereum account:\n\
552 0x1111222233334444555566667777888899990000\n\
553 \n\
554 URI: https://x.com\n\
555 Version: 1\n\
556 Chain ID: 1\n\
557 Nonce: deadbeef\n\
558 Issued At: 2026-01-01T00:00:00Z";
559 let m = parse_message(raw).expect("parse");
560 assert!(m.statement.is_none());
561 assert_eq!(m.nonce, "deadbeef");
562 }
563
564 #[test]
565 fn parse_rejects_bad_address_length() {
566 let raw = "x.com wants you to sign in with your Ethereum account:\n\
567 0xABC\n\
568 \n\
569 URI: x\nVersion: 1\nChain ID: 1\nNonce: n\nIssued At: t";
570 assert!(matches!(parse_message(raw), Err(SiweError::Malformed)));
571 }
572
573 #[test]
574 fn validate_rejects_domain_mismatch() {
575 let store = NonceStore::new();
576 store.issue("0x1111222233334444555566667777888899990000");
577 let m = SiweMessage {
578 domain: "evil.com".into(),
579 address: "0x1111222233334444555566667777888899990000".into(),
580 statement: None,
581 uri: "https://evil.com".into(),
582 version: "1".into(),
583 chain_id: 1,
584 nonce: "x".into(),
585 issued_at: "2026-01-01T00:00:00Z".into(),
586 expiration_time: None,
587 not_before: None,
588 request_id: None,
589 resources: vec![],
590 };
591 let err = validate_message(&store, &m, "good.com").unwrap_err();
592 assert_eq!(err, SiweError::DomainMismatch);
593 }
594
595 #[test]
596 fn validate_rejects_nonce_mismatch() {
597 let store = NonceStore::new();
598 store.issue("0x1111222233334444555566667777888899990000");
599 let m = SiweMessage {
600 domain: "good.com".into(),
601 address: "0x1111222233334444555566667777888899990000".into(),
602 statement: None,
603 uri: "https://good.com".into(),
604 version: "1".into(),
605 chain_id: 1,
606 nonce: "wrong".into(),
607 issued_at: "2026-01-01T00:00:00Z".into(),
608 expiration_time: None,
609 not_before: None,
610 request_id: None,
611 resources: vec![],
612 };
613 let err = validate_message(&store, &m, "good.com").unwrap_err();
614 assert_eq!(err, SiweError::NonceMismatch);
615 }
616
617 #[test]
622 fn validate_message_does_not_consume_on_failure() {
623 let store = NonceStore::new();
624 let real_nonce = store.issue("0x1111222233334444555566667777888899990000");
625 let m = SiweMessage {
626 domain: "good.com".into(),
627 address: "0x1111222233334444555566667777888899990000".into(),
628 statement: None,
629 uri: "https://good.com".into(),
630 version: "1".into(),
631 chain_id: 1,
632 nonce: "wrong".into(), issued_at: "2026-01-01T00:00:00Z".into(),
634 expiration_time: None,
635 not_before: None,
636 request_id: None,
637 resources: vec![],
638 };
639 let err = validate_message(&store, &m, "good.com").unwrap_err();
640 assert_eq!(err, SiweError::NonceMismatch);
641 let still_there = store.peek("0x1111222233334444555566667777888899990000");
644 assert_eq!(still_there.as_deref(), Some(real_nonce.as_str()));
645 }
646
647 #[test]
653 fn expired_take_does_not_remove_slot() {
654 let store = NonceStore::new();
655 store
657 .nonces
658 .lock()
659 .unwrap()
660 .insert("0xabc".into(), ("nonce-x".into(), 1));
661 assert!(store.take("0xabc").is_none());
663 assert!(store.nonces.lock().unwrap().contains_key("0xabc"));
665 }
666
667 #[test]
672 fn verify_real_signature_round_trip() {
673 use k256::ecdsa::{signature::hazmat::PrehashSigner, RecoveryId, Signature, SigningKey};
674 use sha3::{Digest, Keccak256};
675
676 let mut rng_bytes = [0u8; 32];
678 use rand::RngCore;
679 rand::thread_rng().fill_bytes(&mut rng_bytes);
680 let signing_key = SigningKey::from_slice(&rng_bytes).expect("valid scalar");
681 let verifying = signing_key.verifying_key();
682 let pk_point = verifying.to_encoded_point(false);
683 let pk_xy = &pk_point.as_bytes()[1..];
684 let mut h = Keccak256::new();
685 h.update(pk_xy);
686 let pk_hash = h.finalize();
687 let address = format!("0x{}", bytes_to_hex(&pk_hash[12..]));
688
689 let store = NonceStore::new();
691 let nonce = store.issue(&address);
692 let m = SiweMessage {
693 domain: "example.com".into(),
694 address: address.clone(),
695 statement: Some("Sign in to Example".into()),
696 uri: "https://example.com".into(),
697 version: "1".into(),
698 chain_id: 1,
699 nonce,
700 issued_at: "2026-01-01T00:00:00Z".into(),
701 expiration_time: None,
702 not_before: None,
703 request_id: None,
704 resources: vec![],
705 };
706
707 let signed_text = serialize_for_signing(&m);
709 let envelope = format!(
710 "\x19Ethereum Signed Message:\n{}{}",
711 signed_text.len(),
712 signed_text
713 );
714 let mut h = Keccak256::new();
715 h.update(envelope.as_bytes());
716 let digest = h.finalize();
717 let (sig, rec_id): (Signature, RecoveryId) =
718 signing_key.sign_prehash(&digest).expect("sign");
719 let mut sig_bytes = sig.to_bytes().to_vec();
720 sig_bytes.push(rec_id.to_byte() + 27); let sig_hex = format!("0x{}", bytes_to_hex(&sig_bytes));
722
723 let recovered = verify(&store, &m, &sig_hex, "example.com").expect("real-sig verify");
725 assert_eq!(recovered, address.to_ascii_lowercase());
726 }
727
728 #[test]
731 fn parse_handles_multiline_statement() {
732 let raw = "x.com wants you to sign in with your Ethereum account:\n\
733 0x1111222233334444555566667777888899990000\n\
734 \n\
735 line one\n\
736 line two\n\
737 \n\
738 URI: https://x.com\n\
739 Version: 1\n\
740 Chain ID: 1\n\
741 Nonce: n\n\
742 Issued At: 2026-01-01T00:00:00Z";
743 let m = parse_message(raw).expect("parse");
744 assert_eq!(m.statement.as_deref(), Some("line one\nline two"));
745 }
746}