use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SiweMessage {
pub domain: String,
pub address: String,
pub statement: Option<String>,
pub uri: String,
pub version: String,
pub chain_id: u64,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SiweError {
Malformed,
NonceMismatch,
NonceMissing,
DomainMismatch,
Expired,
NotYetValid,
BadSignature,
AddressMismatch,
}
impl std::fmt::Display for SiweError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Malformed => "SIWE message malformed",
Self::NonceMismatch => "nonce doesn't match issued challenge",
Self::NonceMissing => "no challenge issued for this address",
Self::DomainMismatch => "domain doesn't match expected origin",
Self::Expired => "message expiration_time has passed",
Self::NotYetValid => "not_before is in the future",
Self::BadSignature => "signature did not recover to message address",
Self::AddressMismatch => "address claimed in message ≠ recovered signer",
})
}
}
pub struct NonceStore {
nonces: Mutex<HashMap<String, (String, u64)>>, }
impl Default for NonceStore {
fn default() -> Self {
Self {
nonces: Mutex::new(HashMap::new()),
}
}
}
impl NonceStore {
pub fn new() -> Self {
Self::default()
}
pub fn issue(&self, address: &str) -> String {
use rand::RngCore;
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
let nonce: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let key = address.to_ascii_lowercase();
let expires_at = now_secs() + 5 * 60;
self.nonces
.lock()
.unwrap()
.insert(key, (nonce.clone(), expires_at));
nonce
}
pub fn take(&self, address: &str) -> Option<String> {
let key = address.to_ascii_lowercase();
let mut map = self.nonces.lock().unwrap();
let (nonce, exp) = map.get(&key)?.clone();
if exp <= now_secs() {
return None;
}
map.remove(&key);
Some(nonce)
}
pub fn peek(&self, address: &str) -> Option<String> {
let key = address.to_ascii_lowercase();
let map = self.nonces.lock().unwrap();
let (nonce, exp) = map.get(&key)?.clone();
if exp <= now_secs() {
return None;
}
Some(nonce)
}
}
pub fn parse_message(text: &str) -> Result<SiweMessage, SiweError> {
let mut lines = text.lines();
let header = lines.next().ok_or(SiweError::Malformed)?;
let domain = header
.strip_suffix(" wants you to sign in with your Ethereum account:")
.ok_or(SiweError::Malformed)?
.to_string();
let address = lines.next().ok_or(SiweError::Malformed)?.trim().to_string();
if !address.starts_with("0x") || address.len() != 42 {
return Err(SiweError::Malformed);
}
let mut statement_parts: Vec<String> = Vec::new();
let mut peeked: Option<&str> = None;
let mut seen_blank = false;
for l in lines.by_ref() {
if l.is_empty() {
seen_blank = true;
continue;
}
if l.starts_with("URI:") {
peeked = Some(l);
break;
}
if !statement_parts.is_empty() {
statement_parts.push("\n".into());
}
statement_parts.push(l.to_string());
}
let _ = seen_blank;
let statement = if statement_parts.is_empty() {
None
} else {
Some(statement_parts.concat())
};
let mut uri: Option<String> = None;
let mut version: Option<String> = None;
let mut chain_id: Option<u64> = None;
let mut nonce: Option<String> = None;
let mut issued_at: Option<String> = None;
let mut expiration_time: Option<String> = None;
let mut not_before: Option<String> = None;
let mut request_id: Option<String> = None;
let mut resources = Vec::new();
let mut in_resources = false;
let process = |line: &str,
uri: &mut Option<String>,
version: &mut Option<String>,
chain_id: &mut Option<u64>,
nonce: &mut Option<String>,
issued_at: &mut Option<String>,
expiration_time: &mut Option<String>,
not_before: &mut Option<String>,
request_id: &mut Option<String>,
resources: &mut Vec<String>,
in_resources: &mut bool| {
if let Some(v) = line.strip_prefix("URI:") {
*uri = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Version:") {
*version = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Chain ID:") {
*chain_id = v.trim().parse().ok();
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Nonce:") {
*nonce = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Issued At:") {
*issued_at = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Expiration Time:") {
*expiration_time = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Not Before:") {
*not_before = Some(v.trim().to_string());
*in_resources = false;
} else if let Some(v) = line.strip_prefix("Request ID:") {
*request_id = Some(v.trim().to_string());
*in_resources = false;
} else if line.starts_with("Resources:") {
*in_resources = true;
} else if *in_resources {
if let Some(v) = line.strip_prefix("- ") {
resources.push(v.trim().to_string());
}
}
};
if let Some(line) = peeked {
process(
line,
&mut uri,
&mut version,
&mut chain_id,
&mut nonce,
&mut issued_at,
&mut expiration_time,
&mut not_before,
&mut request_id,
&mut resources,
&mut in_resources,
);
}
for line in lines {
process(
line,
&mut uri,
&mut version,
&mut chain_id,
&mut nonce,
&mut issued_at,
&mut expiration_time,
&mut not_before,
&mut request_id,
&mut resources,
&mut in_resources,
);
}
Ok(SiweMessage {
domain,
address,
statement,
uri: uri.ok_or(SiweError::Malformed)?,
version: version.ok_or(SiweError::Malformed)?,
chain_id: chain_id.ok_or(SiweError::Malformed)?,
nonce: nonce.ok_or(SiweError::Malformed)?,
issued_at: issued_at.ok_or(SiweError::Malformed)?,
expiration_time,
not_before,
request_id,
resources,
})
}
pub fn validate_message(
nonces: &NonceStore,
message: &SiweMessage,
expected_domain: &str,
) -> Result<(), SiweError> {
if message.domain != expected_domain {
return Err(SiweError::DomainMismatch);
}
let issued = nonces
.peek(&message.address)
.ok_or(SiweError::NonceMissing)?;
if issued != message.nonce {
return Err(SiweError::NonceMismatch);
}
if let Some(exp) = &message.expiration_time {
if iso_to_unix(exp).map(|t| t <= now_secs()).unwrap_or(false) {
return Err(SiweError::Expired);
}
}
if let Some(nb) = &message.not_before {
if iso_to_unix(nb).map(|t| t > now_secs()).unwrap_or(false) {
return Err(SiweError::NotYetValid);
}
}
Ok(())
}
pub fn verify(
nonces: &NonceStore,
message: &SiweMessage,
signature_hex: &str,
expected_domain: &str,
) -> Result<String, SiweError> {
validate_message(nonces, message, expected_domain)?;
let recovered = recover_address(message, signature_hex)?;
if !recovered.eq_ignore_ascii_case(&message.address) {
return Err(SiweError::AddressMismatch);
}
let _ = nonces.take(&message.address);
Ok(recovered)
}
pub fn recover_address(message: &SiweMessage, signature_hex: &str) -> Result<String, SiweError> {
let signed_text = serialize_for_signing(message);
let prefix = format!("\x19Ethereum Signed Message:\n{}", signed_text.len());
let mut to_hash = Vec::with_capacity(prefix.len() + signed_text.len());
to_hash.extend_from_slice(prefix.as_bytes());
to_hash.extend_from_slice(signed_text.as_bytes());
let digest = keccak256(&to_hash);
let sig_bytes =
decode_hex(signature_hex.trim_start_matches("0x")).map_err(|_| SiweError::BadSignature)?;
if sig_bytes.len() != 65 {
return Err(SiweError::BadSignature);
}
let v = sig_bytes[64];
let recovery_id = match v {
0 | 27 => 0u8,
1 | 28 => 1u8,
_ => return Err(SiweError::BadSignature),
};
use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
let sig = Signature::from_slice(&sig_bytes[..64]).map_err(|_| SiweError::BadSignature)?;
let rec_id = RecoveryId::from_byte(recovery_id).ok_or(SiweError::BadSignature)?;
let vk = VerifyingKey::recover_from_prehash(&digest, &sig, rec_id)
.map_err(|_| SiweError::BadSignature)?;
let pubkey_point = vk.to_encoded_point(false);
let pubkey_xy = &pubkey_point.as_bytes()[1..]; let h = keccak256(pubkey_xy);
let mut addr = [0u8; 20];
addr.copy_from_slice(&h[12..]);
Ok(format!("0x{}", bytes_to_hex(&addr)))
}
fn keccak256(input: &[u8]) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(input);
let out = hasher.finalize();
let mut buf = [0u8; 32];
buf.copy_from_slice(&out);
buf
}
fn decode_hex(s: &str) -> Result<Vec<u8>, ()> {
if s.len() % 2 != 0 {
return Err(());
}
let mut out = Vec::with_capacity(s.len() / 2);
for chunk in s.as_bytes().chunks(2) {
let hi = hex_digit(chunk[0])?;
let lo = hex_digit(chunk[1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_digit(b: u8) -> Result<u8, ()> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(()),
}
}
fn bytes_to_hex(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
pub fn serialize_for_signing(m: &SiweMessage) -> String {
let mut out = String::new();
out.push_str(&m.domain);
out.push_str(" wants you to sign in with your Ethereum account:\n");
out.push_str(&m.address);
out.push('\n');
if let Some(s) = &m.statement {
out.push('\n');
out.push_str(s);
out.push('\n');
}
out.push('\n');
out.push_str(&format!("URI: {}\n", m.uri));
out.push_str(&format!("Version: {}\n", m.version));
out.push_str(&format!("Chain ID: {}\n", m.chain_id));
out.push_str(&format!("Nonce: {}\n", m.nonce));
out.push_str(&format!("Issued At: {}", m.issued_at));
if let Some(v) = &m.expiration_time {
out.push_str(&format!("\nExpiration Time: {v}"));
}
if let Some(v) = &m.not_before {
out.push_str(&format!("\nNot Before: {v}"));
}
if let Some(v) = &m.request_id {
out.push_str(&format!("\nRequest ID: {v}"));
}
if !m.resources.is_empty() {
out.push_str("\nResources:");
for r in &m.resources {
out.push_str("\n- ");
out.push_str(r);
}
}
out
}
fn iso_to_unix(iso: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(iso)
.ok()
.map(|dt| dt.timestamp() as u64)
}
fn now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonce_round_trip() {
let store = NonceStore::new();
let n = store.issue("0xABC");
assert_eq!(store.take("0xabc").as_deref(), Some(n.as_str()));
assert!(store.take("0xabc").is_none());
}
#[test]
fn parse_full_message() {
let raw = "example.com wants you to sign in with your Ethereum account:\n\
0x1111222233334444555566667777888899990000\n\
\n\
I accept the ToS\n\
\n\
URI: https://example.com\n\
Version: 1\n\
Chain ID: 1\n\
Nonce: abc123\n\
Issued At: 2026-01-01T00:00:00Z";
let m = parse_message(raw).expect("parse");
assert_eq!(m.domain, "example.com");
assert_eq!(m.address, "0x1111222233334444555566667777888899990000");
assert_eq!(m.statement.as_deref(), Some("I accept the ToS"));
assert_eq!(m.uri, "https://example.com");
assert_eq!(m.chain_id, 1);
assert_eq!(m.nonce, "abc123");
}
#[test]
fn parse_message_without_statement() {
let raw = "x.com wants you to sign in with your Ethereum account:\n\
0x1111222233334444555566667777888899990000\n\
\n\
URI: https://x.com\n\
Version: 1\n\
Chain ID: 1\n\
Nonce: deadbeef\n\
Issued At: 2026-01-01T00:00:00Z";
let m = parse_message(raw).expect("parse");
assert!(m.statement.is_none());
assert_eq!(m.nonce, "deadbeef");
}
#[test]
fn parse_rejects_bad_address_length() {
let raw = "x.com wants you to sign in with your Ethereum account:\n\
0xABC\n\
\n\
URI: x\nVersion: 1\nChain ID: 1\nNonce: n\nIssued At: t";
assert!(matches!(parse_message(raw), Err(SiweError::Malformed)));
}
#[test]
fn validate_rejects_domain_mismatch() {
let store = NonceStore::new();
store.issue("0x1111222233334444555566667777888899990000");
let m = SiweMessage {
domain: "evil.com".into(),
address: "0x1111222233334444555566667777888899990000".into(),
statement: None,
uri: "https://evil.com".into(),
version: "1".into(),
chain_id: 1,
nonce: "x".into(),
issued_at: "2026-01-01T00:00:00Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec![],
};
let err = validate_message(&store, &m, "good.com").unwrap_err();
assert_eq!(err, SiweError::DomainMismatch);
}
#[test]
fn validate_rejects_nonce_mismatch() {
let store = NonceStore::new();
store.issue("0x1111222233334444555566667777888899990000");
let m = SiweMessage {
domain: "good.com".into(),
address: "0x1111222233334444555566667777888899990000".into(),
statement: None,
uri: "https://good.com".into(),
version: "1".into(),
chain_id: 1,
nonce: "wrong".into(),
issued_at: "2026-01-01T00:00:00Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec![],
};
let err = validate_message(&store, &m, "good.com").unwrap_err();
assert_eq!(err, SiweError::NonceMismatch);
}
#[test]
fn validate_message_does_not_consume_on_failure() {
let store = NonceStore::new();
let real_nonce = store.issue("0x1111222233334444555566667777888899990000");
let m = SiweMessage {
domain: "good.com".into(),
address: "0x1111222233334444555566667777888899990000".into(),
statement: None,
uri: "https://good.com".into(),
version: "1".into(),
chain_id: 1,
nonce: "wrong".into(), issued_at: "2026-01-01T00:00:00Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec![],
};
let err = validate_message(&store, &m, "good.com").unwrap_err();
assert_eq!(err, SiweError::NonceMismatch);
let still_there = store.peek("0x1111222233334444555566667777888899990000");
assert_eq!(still_there.as_deref(), Some(real_nonce.as_str()));
}
#[test]
fn expired_take_does_not_remove_slot() {
let store = NonceStore::new();
store
.nonces
.lock()
.unwrap()
.insert("0xabc".into(), ("nonce-x".into(), 1));
assert!(store.take("0xabc").is_none());
assert!(store.nonces.lock().unwrap().contains_key("0xabc"));
}
#[test]
fn verify_real_signature_round_trip() {
use k256::ecdsa::{signature::hazmat::PrehashSigner, RecoveryId, Signature, SigningKey};
use sha3::{Digest, Keccak256};
let mut rng_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut rng_bytes);
let signing_key = SigningKey::from_slice(&rng_bytes).expect("valid scalar");
let verifying = signing_key.verifying_key();
let pk_point = verifying.to_encoded_point(false);
let pk_xy = &pk_point.as_bytes()[1..];
let mut h = Keccak256::new();
h.update(pk_xy);
let pk_hash = h.finalize();
let address = format!("0x{}", bytes_to_hex(&pk_hash[12..]));
let store = NonceStore::new();
let nonce = store.issue(&address);
let m = SiweMessage {
domain: "example.com".into(),
address: address.clone(),
statement: Some("Sign in to Example".into()),
uri: "https://example.com".into(),
version: "1".into(),
chain_id: 1,
nonce,
issued_at: "2026-01-01T00:00:00Z".into(),
expiration_time: None,
not_before: None,
request_id: None,
resources: vec![],
};
let signed_text = serialize_for_signing(&m);
let envelope = format!(
"\x19Ethereum Signed Message:\n{}{}",
signed_text.len(),
signed_text
);
let mut h = Keccak256::new();
h.update(envelope.as_bytes());
let digest = h.finalize();
let (sig, rec_id): (Signature, RecoveryId) =
signing_key.sign_prehash(&digest).expect("sign");
let mut sig_bytes = sig.to_bytes().to_vec();
sig_bytes.push(rec_id.to_byte() + 27); let sig_hex = format!("0x{}", bytes_to_hex(&sig_bytes));
let recovered = verify(&store, &m, &sig_hex, "example.com").expect("real-sig verify");
assert_eq!(recovered, address.to_ascii_lowercase());
}
#[test]
fn parse_handles_multiline_statement() {
let raw = "x.com wants you to sign in with your Ethereum account:\n\
0x1111222233334444555566667777888899990000\n\
\n\
line one\n\
line two\n\
\n\
URI: https://x.com\n\
Version: 1\n\
Chain ID: 1\n\
Nonce: n\n\
Issued At: 2026-01-01T00:00:00Z";
let m = parse_message(raw).expect("parse");
assert_eq!(m.statement.as_deref(), Some("line one\nline two"));
}
}