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
113 .iter()
114 .map(|b| format!("{b:02x}"))
115 .collect();
116 let key = address.to_ascii_lowercase();
117 let expires_at = now_secs() + 5 * 60;
118 self.nonces
119 .lock()
120 .unwrap()
121 .insert(key, (nonce.clone(), expires_at));
122 nonce
123 }
124
125 pub fn take(&self, address: &str) -> Option<String> {
131 let key = address.to_ascii_lowercase();
132 let mut map = self.nonces.lock().unwrap();
133 let (nonce, exp) = map.get(&key)?.clone();
134 if exp <= now_secs() {
135 return None;
136 }
137 map.remove(&key);
138 Some(nonce)
139 }
140}
141
142pub fn parse_message(text: &str) -> Result<SiweMessage, SiweError> {
145 let mut lines = text.lines();
163 let header = lines.next().ok_or(SiweError::Malformed)?;
164 let domain = header
165 .strip_suffix(" wants you to sign in with your Ethereum account:")
166 .ok_or(SiweError::Malformed)?
167 .to_string();
168 let address = lines
169 .next()
170 .ok_or(SiweError::Malformed)?
171 .trim()
172 .to_string();
173 if !address.starts_with("0x") || address.len() != 42 {
174 return Err(SiweError::Malformed);
175 }
176
177 let mut statement_parts: Vec<String> = Vec::new();
180 let mut peeked: Option<&str> = None;
181 let mut seen_blank = false;
182 for l in lines.by_ref() {
183 if l.is_empty() {
184 seen_blank = true;
185 continue;
186 }
187 if l.starts_with("URI:") {
188 peeked = Some(l);
189 break;
190 }
191 if !statement_parts.is_empty() {
195 statement_parts.push("\n".into());
196 }
197 statement_parts.push(l.to_string());
198 }
199 let _ = seen_blank;
200 let statement = if statement_parts.is_empty() {
201 None
202 } else {
203 Some(statement_parts.concat())
204 };
205 let mut uri: Option<String> = None;
207 let mut version: Option<String> = None;
208 let mut chain_id: Option<u64> = None;
209 let mut nonce: Option<String> = None;
210 let mut issued_at: Option<String> = None;
211 let mut expiration_time: Option<String> = None;
212 let mut not_before: Option<String> = None;
213 let mut request_id: Option<String> = None;
214 let mut resources = Vec::new();
215 let mut in_resources = false;
216
217 let process = |line: &str,
218 uri: &mut Option<String>,
219 version: &mut Option<String>,
220 chain_id: &mut Option<u64>,
221 nonce: &mut Option<String>,
222 issued_at: &mut Option<String>,
223 expiration_time: &mut Option<String>,
224 not_before: &mut Option<String>,
225 request_id: &mut Option<String>,
226 resources: &mut Vec<String>,
227 in_resources: &mut bool| {
228 if let Some(v) = line.strip_prefix("URI:") {
229 *uri = Some(v.trim().to_string());
230 *in_resources = false;
231 } else if let Some(v) = line.strip_prefix("Version:") {
232 *version = Some(v.trim().to_string());
233 *in_resources = false;
234 } else if let Some(v) = line.strip_prefix("Chain ID:") {
235 *chain_id = v.trim().parse().ok();
236 *in_resources = false;
237 } else if let Some(v) = line.strip_prefix("Nonce:") {
238 *nonce = Some(v.trim().to_string());
239 *in_resources = false;
240 } else if let Some(v) = line.strip_prefix("Issued At:") {
241 *issued_at = Some(v.trim().to_string());
242 *in_resources = false;
243 } else if let Some(v) = line.strip_prefix("Expiration Time:") {
244 *expiration_time = Some(v.trim().to_string());
245 *in_resources = false;
246 } else if let Some(v) = line.strip_prefix("Not Before:") {
247 *not_before = Some(v.trim().to_string());
248 *in_resources = false;
249 } else if let Some(v) = line.strip_prefix("Request ID:") {
250 *request_id = Some(v.trim().to_string());
251 *in_resources = false;
252 } else if line.starts_with("Resources:") {
253 *in_resources = true;
254 } else if *in_resources {
255 if let Some(v) = line.strip_prefix("- ") {
256 resources.push(v.trim().to_string());
257 }
258 }
259 };
260 if let Some(line) = peeked {
261 process(
262 line,
263 &mut uri,
264 &mut version,
265 &mut chain_id,
266 &mut nonce,
267 &mut issued_at,
268 &mut expiration_time,
269 &mut not_before,
270 &mut request_id,
271 &mut resources,
272 &mut in_resources,
273 );
274 }
275 for line in lines {
276 process(
277 line,
278 &mut uri,
279 &mut version,
280 &mut chain_id,
281 &mut nonce,
282 &mut issued_at,
283 &mut expiration_time,
284 &mut not_before,
285 &mut request_id,
286 &mut resources,
287 &mut in_resources,
288 );
289 }
290
291 Ok(SiweMessage {
292 domain,
293 address,
294 statement,
295 uri: uri.ok_or(SiweError::Malformed)?,
296 version: version.ok_or(SiweError::Malformed)?,
297 chain_id: chain_id.ok_or(SiweError::Malformed)?,
298 nonce: nonce.ok_or(SiweError::Malformed)?,
299 issued_at: issued_at.ok_or(SiweError::Malformed)?,
300 expiration_time,
301 not_before,
302 request_id,
303 resources,
304 })
305}
306
307pub fn validate_message(
311 nonces: &NonceStore,
312 message: &SiweMessage,
313 expected_domain: &str,
314) -> Result<(), SiweError> {
315 if message.domain != expected_domain {
316 return Err(SiweError::DomainMismatch);
317 }
318 let issued = nonces
319 .take(&message.address)
320 .ok_or(SiweError::NonceMissing)?;
321 if issued != message.nonce {
322 return Err(SiweError::NonceMismatch);
323 }
324 if let Some(exp) = &message.expiration_time {
325 if iso_to_unix(exp).map(|t| t <= now_secs()).unwrap_or(false) {
326 return Err(SiweError::Expired);
327 }
328 }
329 if let Some(nb) = &message.not_before {
330 if iso_to_unix(nb).map(|t| t > now_secs()).unwrap_or(false) {
331 return Err(SiweError::NotYetValid);
332 }
333 }
334 Ok(())
335}
336
337pub fn verify(
345 nonces: &NonceStore,
346 message: &SiweMessage,
347 signature_hex: &str,
348 expected_domain: &str,
349) -> Result<String, SiweError> {
350 validate_message(nonces, message, expected_domain)?;
351 let recovered = recover_address(message, signature_hex)?;
352 if !recovered.eq_ignore_ascii_case(&message.address) {
353 return Err(SiweError::AddressMismatch);
354 }
355 Ok(recovered)
356}
357
358pub fn recover_address(message: &SiweMessage, signature_hex: &str) -> Result<String, SiweError> {
362 let signed_text = serialize_for_signing(message);
363 let prefix = format!("\x19Ethereum Signed Message:\n{}", signed_text.len());
365 let mut to_hash = Vec::with_capacity(prefix.len() + signed_text.len());
366 to_hash.extend_from_slice(prefix.as_bytes());
367 to_hash.extend_from_slice(signed_text.as_bytes());
368 let digest = keccak256(&to_hash);
369
370 let sig_bytes = decode_hex(signature_hex.trim_start_matches("0x"))
371 .map_err(|_| SiweError::BadSignature)?;
372 if sig_bytes.len() != 65 {
373 return Err(SiweError::BadSignature);
374 }
375 let v = sig_bytes[64];
377 let recovery_id = match v {
378 0 | 27 => 0u8,
379 1 | 28 => 1u8,
380 _ => return Err(SiweError::BadSignature),
381 };
382
383 use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
384 let sig = Signature::from_slice(&sig_bytes[..64]).map_err(|_| SiweError::BadSignature)?;
385 let rec_id = RecoveryId::from_byte(recovery_id).ok_or(SiweError::BadSignature)?;
386 let vk = VerifyingKey::recover_from_prehash(&digest, &sig, rec_id)
387 .map_err(|_| SiweError::BadSignature)?;
388 let pubkey_point = vk.to_encoded_point(false);
391 let pubkey_xy = &pubkey_point.as_bytes()[1..]; let h = keccak256(pubkey_xy);
393 let mut addr = [0u8; 20];
394 addr.copy_from_slice(&h[12..]);
395 Ok(format!("0x{}", bytes_to_hex(&addr)))
396}
397
398fn keccak256(input: &[u8]) -> [u8; 32] {
401 use sha3::{Digest, Keccak256};
402 let mut hasher = Keccak256::new();
403 hasher.update(input);
404 let out = hasher.finalize();
405 let mut buf = [0u8; 32];
406 buf.copy_from_slice(&out);
407 buf
408}
409
410fn decode_hex(s: &str) -> Result<Vec<u8>, ()> {
411 if s.len() % 2 != 0 {
412 return Err(());
413 }
414 let mut out = Vec::with_capacity(s.len() / 2);
415 for chunk in s.as_bytes().chunks(2) {
416 let hi = hex_digit(chunk[0])?;
417 let lo = hex_digit(chunk[1])?;
418 out.push((hi << 4) | lo);
419 }
420 Ok(out)
421}
422
423fn hex_digit(b: u8) -> Result<u8, ()> {
424 match b {
425 b'0'..=b'9' => Ok(b - b'0'),
426 b'a'..=b'f' => Ok(b - b'a' + 10),
427 b'A'..=b'F' => Ok(b - b'A' + 10),
428 _ => Err(()),
429 }
430}
431
432fn bytes_to_hex(bytes: &[u8]) -> String {
433 use std::fmt::Write;
434 let mut s = String::with_capacity(bytes.len() * 2);
435 for b in bytes {
436 let _ = write!(s, "{b:02x}");
437 }
438 s
439}
440
441pub fn serialize_for_signing(m: &SiweMessage) -> String {
444 let mut out = String::new();
445 out.push_str(&m.domain);
446 out.push_str(" wants you to sign in with your Ethereum account:\n");
447 out.push_str(&m.address);
448 out.push('\n');
449 if let Some(s) = &m.statement {
450 out.push('\n');
451 out.push_str(s);
452 out.push('\n');
453 }
454 out.push('\n');
455 out.push_str(&format!("URI: {}\n", m.uri));
456 out.push_str(&format!("Version: {}\n", m.version));
457 out.push_str(&format!("Chain ID: {}\n", m.chain_id));
458 out.push_str(&format!("Nonce: {}\n", m.nonce));
459 out.push_str(&format!("Issued At: {}", m.issued_at));
460 if let Some(v) = &m.expiration_time {
461 out.push_str(&format!("\nExpiration Time: {v}"));
462 }
463 if let Some(v) = &m.not_before {
464 out.push_str(&format!("\nNot Before: {v}"));
465 }
466 if let Some(v) = &m.request_id {
467 out.push_str(&format!("\nRequest ID: {v}"));
468 }
469 if !m.resources.is_empty() {
470 out.push_str("\nResources:");
471 for r in &m.resources {
472 out.push_str("\n- ");
473 out.push_str(r);
474 }
475 }
476 out
477}
478
479fn iso_to_unix(iso: &str) -> Option<u64> {
480 chrono::DateTime::parse_from_rfc3339(iso)
484 .ok()
485 .map(|dt| dt.timestamp() as u64)
486}
487
488fn now_secs() -> u64 {
489 use std::time::{SystemTime, UNIX_EPOCH};
490 SystemTime::now()
491 .duration_since(UNIX_EPOCH)
492 .unwrap_or_default()
493 .as_secs()
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn nonce_round_trip() {
502 let store = NonceStore::new();
503 let n = store.issue("0xABC");
504 assert_eq!(store.take("0xabc").as_deref(), Some(n.as_str()));
505 assert!(store.take("0xabc").is_none());
507 }
508
509 #[test]
510 fn parse_full_message() {
511 let raw = "example.com wants you to sign in with your Ethereum account:\n\
512 0x1111222233334444555566667777888899990000\n\
513 \n\
514 I accept the ToS\n\
515 \n\
516 URI: https://example.com\n\
517 Version: 1\n\
518 Chain ID: 1\n\
519 Nonce: abc123\n\
520 Issued At: 2026-01-01T00:00:00Z";
521 let m = parse_message(raw).expect("parse");
522 assert_eq!(m.domain, "example.com");
523 assert_eq!(m.address, "0x1111222233334444555566667777888899990000");
524 assert_eq!(m.statement.as_deref(), Some("I accept the ToS"));
525 assert_eq!(m.uri, "https://example.com");
526 assert_eq!(m.chain_id, 1);
527 assert_eq!(m.nonce, "abc123");
528 }
529
530 #[test]
531 fn parse_message_without_statement() {
532 let raw = "x.com wants you to sign in with your Ethereum account:\n\
533 0x1111222233334444555566667777888899990000\n\
534 \n\
535 URI: https://x.com\n\
536 Version: 1\n\
537 Chain ID: 1\n\
538 Nonce: deadbeef\n\
539 Issued At: 2026-01-01T00:00:00Z";
540 let m = parse_message(raw).expect("parse");
541 assert!(m.statement.is_none());
542 assert_eq!(m.nonce, "deadbeef");
543 }
544
545 #[test]
546 fn parse_rejects_bad_address_length() {
547 let raw = "x.com wants you to sign in with your Ethereum account:\n\
548 0xABC\n\
549 \n\
550 URI: x\nVersion: 1\nChain ID: 1\nNonce: n\nIssued At: t";
551 assert!(matches!(parse_message(raw), Err(SiweError::Malformed)));
552 }
553
554 #[test]
555 fn validate_rejects_domain_mismatch() {
556 let store = NonceStore::new();
557 store.issue("0x1111222233334444555566667777888899990000");
558 let m = SiweMessage {
559 domain: "evil.com".into(),
560 address: "0x1111222233334444555566667777888899990000".into(),
561 statement: None,
562 uri: "https://evil.com".into(),
563 version: "1".into(),
564 chain_id: 1,
565 nonce: "x".into(),
566 issued_at: "2026-01-01T00:00:00Z".into(),
567 expiration_time: None,
568 not_before: None,
569 request_id: None,
570 resources: vec![],
571 };
572 let err = validate_message(&store, &m, "good.com").unwrap_err();
573 assert_eq!(err, SiweError::DomainMismatch);
574 }
575
576 #[test]
577 fn validate_rejects_nonce_mismatch() {
578 let store = NonceStore::new();
579 store.issue("0x1111222233334444555566667777888899990000");
580 let m = SiweMessage {
581 domain: "good.com".into(),
582 address: "0x1111222233334444555566667777888899990000".into(),
583 statement: None,
584 uri: "https://good.com".into(),
585 version: "1".into(),
586 chain_id: 1,
587 nonce: "wrong".into(),
588 issued_at: "2026-01-01T00:00:00Z".into(),
589 expiration_time: None,
590 not_before: None,
591 request_id: None,
592 resources: vec![],
593 };
594 let err = validate_message(&store, &m, "good.com").unwrap_err();
595 assert_eq!(err, SiweError::NonceMismatch);
596 }
597
598 #[test]
604 fn expired_take_does_not_remove_slot() {
605 let store = NonceStore::new();
606 store
608 .nonces
609 .lock()
610 .unwrap()
611 .insert("0xabc".into(), ("nonce-x".into(), 1));
612 assert!(store.take("0xabc").is_none());
614 assert!(store.nonces.lock().unwrap().contains_key("0xabc"));
616 }
617
618 #[test]
623 fn verify_real_signature_round_trip() {
624 use k256::ecdsa::{signature::hazmat::PrehashSigner, RecoveryId, Signature, SigningKey};
625 use sha3::{Digest, Keccak256};
626
627 let mut rng_bytes = [0u8; 32];
629 use rand::RngCore;
630 rand::thread_rng().fill_bytes(&mut rng_bytes);
631 let signing_key = SigningKey::from_slice(&rng_bytes).expect("valid scalar");
632 let verifying = signing_key.verifying_key();
633 let pk_point = verifying.to_encoded_point(false);
634 let pk_xy = &pk_point.as_bytes()[1..];
635 let mut h = Keccak256::new();
636 h.update(pk_xy);
637 let pk_hash = h.finalize();
638 let address = format!("0x{}", bytes_to_hex(&pk_hash[12..]));
639
640 let store = NonceStore::new();
642 let nonce = store.issue(&address);
643 let m = SiweMessage {
644 domain: "example.com".into(),
645 address: address.clone(),
646 statement: Some("Sign in to Example".into()),
647 uri: "https://example.com".into(),
648 version: "1".into(),
649 chain_id: 1,
650 nonce,
651 issued_at: "2026-01-01T00:00:00Z".into(),
652 expiration_time: None,
653 not_before: None,
654 request_id: None,
655 resources: vec![],
656 };
657
658 let signed_text = serialize_for_signing(&m);
660 let envelope = format!("\x19Ethereum Signed Message:\n{}{}", signed_text.len(), signed_text);
661 let mut h = Keccak256::new();
662 h.update(envelope.as_bytes());
663 let digest = h.finalize();
664 let (sig, rec_id): (Signature, RecoveryId) =
665 signing_key.sign_prehash(&digest).expect("sign");
666 let mut sig_bytes = sig.to_bytes().to_vec();
667 sig_bytes.push(rec_id.to_byte() + 27); let sig_hex = format!("0x{}", bytes_to_hex(&sig_bytes));
669
670 let recovered =
672 verify(&store, &m, &sig_hex, "example.com").expect("real-sig verify");
673 assert_eq!(recovered, address.to_ascii_lowercase());
674 }
675
676 #[test]
679 fn parse_handles_multiline_statement() {
680 let raw = "x.com wants you to sign in with your Ethereum account:\n\
681 0x1111222233334444555566667777888899990000\n\
682 \n\
683 line one\n\
684 line two\n\
685 \n\
686 URI: https://x.com\n\
687 Version: 1\n\
688 Chain ID: 1\n\
689 Nonce: n\n\
690 Issued At: 2026-01-01T00:00:00Z";
691 let m = parse_message(raw).expect("parse");
692 assert_eq!(m.statement.as_deref(), Some("line one\nline two"));
693 }
694}