Skip to main content

auth_framework/protocols/
siwe.rs

1//! Sign-In with Ethereum (SIWE / ERC-4361) support.
2//!
3//! Provides message construction, parsing, and validation for the
4//! ERC-4361 standard which allows Ethereum account holders to authenticate
5//! with off-chain services by signing a structured message.
6//!
7//! # References
8//!
9//! - [ERC-4361: Sign-In with Ethereum](https://eips.ethereum.org/EIPS/eip-4361)
10
11use crate::errors::{AuthError, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16/// A SIWE message conforming to ERC-4361.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SiweMessage {
19    /// RFC 4501 `dns` authority (e.g., "example.com").
20    pub domain: String,
21    /// Ethereum address performing the signing (EIP-55 mixed-case).
22    pub address: String,
23    /// Human-readable statement (optional).
24    pub statement: Option<String>,
25    /// RFC 3986 URI for the signing request.
26    pub uri: String,
27    /// EIP-155 Chain ID (1 = mainnet).
28    pub chain_id: u64,
29    /// Random nonce to prevent replay attacks.
30    pub nonce: String,
31    /// ISO 8601 datetime when the message was issued.
32    pub issued_at: DateTime<Utc>,
33    /// ISO 8601 datetime when the message expires (optional).
34    pub expiration_time: Option<DateTime<Utc>>,
35    /// ISO 8601 datetime before which the message is not valid (optional).
36    pub not_before: Option<DateTime<Utc>>,
37    /// System-specific request ID (optional).
38    pub request_id: Option<String>,
39    /// List of resources the user wishes to access (optional).
40    pub resources: Vec<String>,
41    /// ERC-4361 version ("1").
42    pub version: String,
43}
44
45impl SiweMessage {
46    /// Create a new SIWE message with required fields.
47    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    /// Render the SIWE message to the ERC-4361 plaintext signing format.
73    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    /// Compute the EIP-191 hash of the message (SHA-256 for verification).
120    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
127/// Parse a SIWE plaintext message string back into a `SiweMessage`.
128pub 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    // Line 0: "{domain} wants you to sign in with your Ethereum account:"
136    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    // Line 1: address
142    let address = lines[1].trim().to_string();
143    validate_address(&address)?;
144
145    // Find field lines
146    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
219/// Verify the SIWE message fields are valid (time constraints, nonce, etc.).
220///
221/// **Note:** Signature verification against the Ethereum address requires
222/// an ECC library (secp256k1). This function validates the message structure
223/// and time windows only.
224pub 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
256/// Basic Ethereum address validation (EIP-55 format: 0x + 40 hex chars).
257fn 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
271/// Generate a cryptographically random 16-byte nonce (hex-encoded).
272fn 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()); // missing 0x
400        assert!(validate_address("0xAb5801").is_err()); // too short
401    }
402}