Skip to main content

pylon_auth/
siwe.rs

1//! Sign-In With Ethereum (EIP-4361).
2//!
3//! Wallet-based passwordless auth — the user signs a structured
4//! message in their wallet (MetaMask, WalletConnect, Coinbase
5//! Wallet, etc.), pylon recovers the signer's Ethereum address,
6//! and that address becomes the identity.
7//!
8//! Spec: <https://eips.ethereum.org/EIPS/eip-4361>
9//!
10//! Wire flow:
11//!   1. Frontend asks `/api/auth/siwe/nonce?address=0x…` →
12//!      pylon generates a random nonce, stashes it server-side
13//!      keyed by address (5-min expiry, single-use).
14//!   2. Frontend builds the EIP-4361 message including the nonce,
15//!      `domain`, `uri`, `chain_id`, etc., and asks the wallet
16//!      to `personal_sign` it.
17//!   3. Frontend POSTs `/api/auth/siwe/verify` with
18//!      `{ message, signature }`. Pylon recovers the signer
19//!      address from the signature using secp256k1 + keccak256
20//!      (the Ethereum signed-message scheme), validates the
21//!      message fields (nonce match, domain match, expiry,
22//!      not-before, chain_id), and mints a session keyed on
23//!      `siwe:<lowercased-address>`.
24
25use std::collections::HashMap;
26use std::sync::Mutex;
27
28/// Ethereum-signed-message recovery + EIP-4361 message validation.
29///
30/// pylon implements the recovery using `ring`'s low-level primitives
31/// to avoid pulling in a dedicated secp256k1 crate. If the signature
32/// verifier becomes a hot path, swap in `secp256k1` (the libsecp256k1
33/// bindings) — currently it'd be O(1 sign-in per minute per user)
34/// so the overhead is negligible.
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SiweMessage {
38    /// `<scheme>://<host>[:<port>]` — must match the configured
39    /// origin allowlist.
40    pub domain: String,
41    /// Lowercased EVM address (0x-prefixed, 42 chars).
42    pub address: String,
43    /// Optional human-readable statement — shown in the wallet UI.
44    pub statement: Option<String>,
45    pub uri: String,
46    pub version: String,
47    pub chain_id: u64,
48    pub nonce: String,
49    /// ISO-8601 timestamp.
50    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
84/// Per-address pending nonce (issued at /siwe/nonce, consumed at
85/// /siwe/verify). Single-use, 5-min TTL.
86pub struct NonceStore {
87    nonces: Mutex<HashMap<String, (String, u64)>>, // addr → (nonce, expires_at)
88}
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    /// Mint + stash a nonce for `address`. Overwrites any existing
104    /// nonce for that address (reissue is fine — only one in-flight
105    /// challenge per address).
106    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        // EIP-4361 says nonce is `[A-Za-z0-9]{8,}`. Hex-encode our
111        // random bytes (32 chars) — well within the allowed alphabet.
112        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    /// Consume the stored nonce for `address` (single-use). Returns
123    /// `None` for unknown OR expired entries — but DOESN'T remove an
124    /// expired entry early (an attacker repeatedly posting expired
125    /// nonces would otherwise burn the slot and DoS legitimate
126    /// retries).
127    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    /// Peek at the pending nonce WITHOUT consuming it. Used by the
139    /// verify path so a wrong-nonce / bad-signature attempt doesn't
140    /// burn the legit nonce (Wave-5 codex P1: nonce-bombing DoS).
141    /// Returns `None` for unknown / expired entries.
142    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
153/// Parse the EIP-4361 plaintext message format. Apps that need the
154/// full structured form should use this + `verify_signature` separately.
155pub fn parse_message(text: &str) -> Result<SiweMessage, SiweError> {
156    // Spec format:
157    // <domain> wants you to sign in with your Ethereum account:
158    // <address>
159    //
160    // [<statement>]
161    //
162    // URI: <uri>
163    // Version: <version>
164    // Chain ID: <chain_id>
165    // Nonce: <nonce>
166    // Issued At: <iso-8601>
167    // [Expiration Time: <iso-8601>]
168    // [Not Before: <iso-8601>]
169    // [Request ID: <id>]
170    // [Resources:
171    // - <uri>
172    // - <uri>]
173    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    // Skip the blank line after the address; collect the statement
185    // (which CAN span multiple lines per spec) until we hit URI:.
186    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        // Pre-URI non-blank lines are statement content. Re-introduce
199        // the inter-line newlines so re-serialization matches the
200        // wallet-signed bytes exactly.
201        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    // We may have already consumed the URI line into `peeked`.
213    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
314/// Validate the non-cryptographic parts of a SIWE message: domain,
315/// nonce, expiration, not-before. Use [`verify`] to also check the
316/// signature.
317///
318/// **Wave-5 codex P1 fix**: this function now PEEKS the nonce
319/// instead of consuming it. The caller (typically [`verify`]) must
320/// call [`NonceStore::take`] separately AFTER full success to
321/// actually consume. Otherwise an attacker who knows the victim's
322/// pending nonce can burn it by submitting any-old garbage to the
323/// verify endpoint, DoSing the legit user's sign-in.
324pub 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
351/// Validate the message + verify the signature, returning the
352/// recovered lowercased Ethereum address on success.
353///
354/// Signature is the standard 65-byte (r||s||v) hex form wallets
355/// produce, with `v ∈ {0, 1, 27, 28}` (both pre- and post-EIP-155
356/// recovery ids). Recovery uses k256 ECDSA + Keccak-256 (Ethereum's
357/// variant, not SHA-3) over the EIP-191 personal_sign envelope.
358pub 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    // Consume the nonce ONLY now that everything else has passed.
370    // Wave-5 codex P1: previously validate_message consumed the
371    // nonce up-front, letting an attacker DoS a victim's sign-in
372    // by repeatedly submitting bad signatures.
373    let _ = nonces.take(&message.address);
374    Ok(recovered)
375}
376
377/// Recover the Ethereum address that signed `message`. Returns the
378/// lowercase 0x-prefixed form. Standalone for callers that want to
379/// compose their own validation pipeline.
380pub fn recover_address(message: &SiweMessage, signature_hex: &str) -> Result<String, SiweError> {
381    let signed_text = serialize_for_signing(message);
382    // EIP-191 personal_sign envelope: "\x19Ethereum Signed Message:\n<len><msg>".
383    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    // v: pre-EIP-155 = {27, 28}, post-EIP-155 = {0, 1}. Map to {0, 1}.
395    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    // Public key in uncompressed SEC1 (65 bytes: 0x04 || X || Y).
408    // Ethereum address = last 20 bytes of keccak256(X||Y).
409    let pubkey_point = vk.to_encoded_point(false);
410    let pubkey_xy = &pubkey_point.as_bytes()[1..]; // strip 0x04 prefix
411    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
417/// Keccak-256 (Ethereum's variant — NOT NIST SHA-3). The two have
418/// different padding bytes (0x01 vs 0x06).
419fn 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
460/// Serialize a SIWE message back into its canonical wire form for
461/// signing. MUST be byte-identical to what the wallet hashed.
462pub 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    // Minimal RFC 3339 parser: YYYY-MM-DDTHH:MM:SSZ. Anything fancier
500    // (timezone offsets, fractional seconds) we punt to chrono — but
501    // pylon's auth crate already pulls in chrono via the workspace.
502    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        // Single-use.
525        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    /// Wave-5 codex P1 regression: a wrong-nonce or bad-signature
618    /// attempt must NOT burn the legit pending nonce. Otherwise
619    /// any attacker who knows the victim's address can DoS the
620    /// victim's sign-in by submitting bogus payloads.
621    #[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(), // attacker submits any-old garbage
633            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        // The legit nonce MUST still be retrievable for the real
642        // user's subsequent successful verify.
643        let still_there = store.peek("0x1111222233334444555566667777888899990000");
644        assert_eq!(still_there.as_deref(), Some(real_nonce.as_str()));
645    }
646
647    /// Codex-flagged P0-7: nonce-bombing. Posting an EXPIRED nonce
648    /// must NOT consume the slot. Otherwise an attacker who can
649    /// observe the (address, nonce) handshake could repeatedly
650    /// invalidate a target's pending nonce by posting any expired
651    /// version of it.
652    #[test]
653    fn expired_take_does_not_remove_slot() {
654        let store = NonceStore::new();
655        // Inject an expired entry directly.
656        store
657            .nonces
658            .lock()
659            .unwrap()
660            .insert("0xabc".into(), ("nonce-x".into(), 1));
661        // First take — sees expired, returns None, MUST keep the slot.
662        assert!(store.take("0xabc").is_none());
663        // Slot still present (the test would also trip if we did remove it).
664        assert!(store.nonces.lock().unwrap().contains_key("0xabc"));
665    }
666
667    /// End-to-end with REAL crypto: mint a key, sign a SIWE
668    /// message, recover the address, verify it matches the
669    /// signing key's address. Locks in the EIP-191 envelope +
670    /// keccak256 + ECDSA-recover wiring.
671    #[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        // 1. Random signing key + derived Ethereum address.
677        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        // 2. Build + issue a SIWE message for that address.
690        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        // 3. Sign the EIP-191 personal_sign envelope.
708        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); // pre-EIP-155 v
721        let sig_hex = format!("0x{}", bytes_to_hex(&sig_bytes));
722
723        // 4. Verify recovers the same address.
724        let recovered = verify(&store, &m, &sig_hex, "example.com").expect("real-sig verify");
725        assert_eq!(recovered, address.to_ascii_lowercase());
726    }
727
728    /// Multi-line statements per spec are real (any printable
729    /// character + LF). The serializer must round-trip them.
730    #[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}