Skip to main content

r402_extensions/siwx/
proof.rs

1//! Parse and CAIP-122-verify the `SIGN-IN-WITH-X` header.
2
3use compact_str::CompactString;
4use r402_protocol::payment::Base64Bytes;
5use serde_json::Value;
6use siwx::{SiwxMessage, Verifier};
7use siwx_evm::EvmVerifier;
8use siwx_svm::Ed25519Verifier;
9use time::OffsetDateTime;
10use time::format_description::well_known::Rfc3339;
11
12use crate::siwx::{DEFAULT_MAX_ISSUED_AGE, SiwxError, SiwxOrigin};
13
14/// Failed to decode a `SIGN-IN-WITH-X` header.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
16#[non_exhaustive]
17pub enum SiwxProofError {
18    /// Header value is not standard base64.
19    #[error("SIGN-IN-WITH-X is not valid base64")]
20    InvalidEncoding,
21    /// Decoded bytes are not a JSON object.
22    #[error("SIGN-IN-WITH-X is not a JSON object")]
23    InvalidJson,
24    /// A required proof field is missing or the wrong JSON type.
25    #[error("SIGN-IN-WITH-X is missing a required field")]
26    MissingField,
27    /// `version` is not `"1"`.
28    #[error("SIGN-IN-WITH-X has an invalid field")]
29    InvalidField,
30}
31
32/// Client proof carried on `SIGN-IN-WITH-X` (base64 JSON).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SiwxProof {
35    /// Wallet address that signed the message.
36    pub address: CompactString,
37    /// Cryptographic signature (hex for EVM, Base58 for Solana).
38    pub signature: CompactString,
39    /// CAIP-122 domain.
40    pub domain: CompactString,
41    /// Resource URI.
42    pub uri: CompactString,
43    /// CAIP-122 version (always `"1"`).
44    pub version: CompactString,
45    /// Challenge nonce (32 hex).
46    pub nonce: CompactString,
47    /// ISO 8601 `issuedAt` as sent by the client (signing-string form).
48    pub issued_at: CompactString,
49    /// Optional ISO 8601 expiry, original string.
50    pub expiration_time: Option<CompactString>,
51    /// Optional ISO 8601 not-before, original string.
52    pub not_before: Option<CompactString>,
53    /// Optional CAIP-122 statement.
54    pub statement: Option<CompactString>,
55    /// Optional request id.
56    pub request_id: Option<CompactString>,
57    /// Optional resource URIs.
58    pub resources: Vec<CompactString>,
59    /// CAIP-2 chain identifier.
60    pub chain_id: CompactString,
61    /// Signature algorithm (`eip191` / `ed25519`).
62    pub signature_type: CompactString,
63    /// Spec `signatureScheme` hint, if the client sent one.
64    pub signature_scheme: Option<CompactString>,
65}
66
67impl SiwxProof {
68    /// Decodes a `SIGN-IN-WITH-X` header value.
69    ///
70    /// # Errors
71    ///
72    /// [`SiwxProofError`] when the header is not base64 JSON with the
73    /// required string fields, or `version` is not `"1"`.
74    pub fn parse_header(value: &str) -> Result<Self, SiwxProofError> {
75        let decoded = Base64Bytes(value.trim().as_bytes().to_vec())
76            .decode()
77            .map_err(|_| SiwxProofError::InvalidEncoding)?;
78        let json: Value =
79            serde_json::from_slice(&decoded).map_err(|_| SiwxProofError::InvalidJson)?;
80        let obj = json.as_object().ok_or(SiwxProofError::InvalidJson)?;
81        let field = |name: &str| -> Result<CompactString, SiwxProofError> {
82            obj.get(name)
83                .and_then(Value::as_str)
84                .map(CompactString::from)
85                .ok_or(SiwxProofError::MissingField)
86        };
87        let opt = |name: &str| -> Option<CompactString> {
88            obj.get(name)
89                .and_then(Value::as_str)
90                .map(CompactString::from)
91        };
92        let resources = obj
93            .get("resources")
94            .and_then(Value::as_array)
95            .map(|arr| {
96                arr.iter()
97                    .filter_map(Value::as_str)
98                    .map(CompactString::from)
99                    .collect()
100            })
101            .unwrap_or_default();
102        let version = field("version")?;
103        if version != "1" {
104            return Err(SiwxProofError::InvalidField);
105        }
106        Ok(Self {
107            address: field("address")?,
108            signature: field("signature")?,
109            domain: field("domain")?,
110            uri: field("uri")?,
111            version,
112            nonce: field("nonce")?,
113            issued_at: field("issuedAt")?,
114            expiration_time: opt("expirationTime"),
115            not_before: opt("notBefore"),
116            statement: opt("statement"),
117            request_id: opt("requestId"),
118            resources,
119            chain_id: field("chainId")?,
120            signature_type: field("type")?,
121            signature_scheme: opt("signatureScheme"),
122        })
123    }
124
125    /// Checks `domain` / `uri` against the configured origin. Never uses `Host`.
126    ///
127    /// # Errors
128    ///
129    /// [`SiwxError::DomainMismatch`] or [`SiwxError::UriMismatch`].
130    pub fn bind_origin(&self, origin: &SiwxOrigin, path: &str) -> Result<(), SiwxError> {
131        if self.domain != origin.domain() {
132            return Err(SiwxError::DomainMismatch);
133        }
134        if self.uri != origin.uri(path) {
135            return Err(SiwxError::UriMismatch);
136        }
137        Ok(())
138    }
139
140    /// Origin, URI, and temporal checks. `now` is injectable for tests.
141    ///
142    /// # Errors
143    ///
144    /// Spec `invalid_siwx_*` field codes.
145    pub fn validate_at(
146        &self,
147        origin: &SiwxOrigin,
148        path: &str,
149        now: OffsetDateTime,
150    ) -> Result<(), SiwxError> {
151        self.bind_origin(origin, path)?;
152        let issued_at = parse_ts(&self.issued_at, SiwxError::IssuedAt)?;
153        let age = now - issued_at;
154        if age.is_negative() {
155            return Err(SiwxError::IssuedAtInFuture);
156        }
157        if age > DEFAULT_MAX_ISSUED_AGE {
158            return Err(SiwxError::IssuedAtTooOld);
159        }
160        if let Some(raw) = &self.expiration_time {
161            let expiration = parse_ts(raw, SiwxError::ExpirationTime)?;
162            if now > expiration {
163                return Err(SiwxError::Expired);
164            }
165        }
166        if let Some(raw) = &self.not_before {
167            let not_before = parse_ts(raw, SiwxError::NotBefore)?;
168            if now < not_before {
169                return Err(SiwxError::NotYetValid);
170            }
171        }
172        Ok(())
173    }
174
175    /// Canonical CAIP-122 signing string for this proof's fields.
176    ///
177    /// Renders via [`EvmVerifier::format_message`] /
178    /// [`Ed25519Verifier::format_message`]. Timestamp fields are passed
179    /// through `*_raw` so the original RFC 3339 bytes are hashed.
180    ///
181    /// # Errors
182    ///
183    /// [`SiwxError::ChainId`], [`SiwxError::UnsupportedChain`], or a field
184    /// error when the proof cannot be turned into a [`SiwxMessage`].
185    pub fn signing_message(&self) -> Result<String, SiwxError> {
186        let (profile, message) = self.to_siwx_message()?;
187        Ok(profile.format(&message))
188    }
189
190    /// Field validation plus CAIP-122 signature verification at `now`.
191    ///
192    /// # Errors
193    ///
194    /// Spec `invalid_siwx_*` codes.
195    pub async fn verify_at(
196        &self,
197        origin: &SiwxOrigin,
198        path: &str,
199        now: OffsetDateTime,
200        evm: &EvmVerifier,
201    ) -> Result<(), SiwxError> {
202        self.validate_at(origin, path, now)?;
203        self.verify_signature(evm).await
204    }
205
206    /// [`Self::verify_at`] with the current UTC time and [`EvmVerifier::new`].
207    ///
208    /// # Errors
209    ///
210    /// Spec `invalid_siwx_*` codes.
211    pub async fn verify(&self, origin: &SiwxOrigin, path: &str) -> Result<(), SiwxError> {
212        self.verify_at(origin, path, OffsetDateTime::now_utc(), &EvmVerifier::new())
213            .await
214    }
215
216    /// Encodes this proof as a `SIGN-IN-WITH-X` header value (base64 JSON).
217    ///
218    /// # Errors
219    ///
220    /// [`SiwxProofError::InvalidJson`] when the proof cannot be serialized.
221    pub fn encode_header(&self) -> Result<String, SiwxProofError> {
222        let mut body = serde_json::Map::new();
223        let _ = body.insert("domain".into(), Value::String(self.domain.to_string()));
224        let _ = body.insert("address".into(), Value::String(self.address.to_string()));
225        let _ = body.insert("uri".into(), Value::String(self.uri.to_string()));
226        let _ = body.insert("version".into(), Value::String(self.version.to_string()));
227        let _ = body.insert("chainId".into(), Value::String(self.chain_id.to_string()));
228        let _ = body.insert(
229            "type".into(),
230            Value::String(self.signature_type.to_string()),
231        );
232        if let Some(scheme) = &self.signature_scheme {
233            let _ = body.insert("signatureScheme".into(), Value::String(scheme.to_string()));
234        }
235        let _ = body.insert("nonce".into(), Value::String(self.nonce.to_string()));
236        let _ = body.insert("issuedAt".into(), Value::String(self.issued_at.to_string()));
237        let _ = body.insert(
238            "signature".into(),
239            Value::String(self.signature.to_string()),
240        );
241        if let Some(statement) = &self.statement {
242            let _ = body.insert("statement".into(), Value::String(statement.to_string()));
243        }
244        if let Some(expiration) = &self.expiration_time {
245            let _ = body.insert(
246                "expirationTime".into(),
247                Value::String(expiration.to_string()),
248            );
249        }
250        if let Some(not_before) = &self.not_before {
251            let _ = body.insert("notBefore".into(), Value::String(not_before.to_string()));
252        }
253        if let Some(request_id) = &self.request_id {
254            let _ = body.insert("requestId".into(), Value::String(request_id.to_string()));
255        }
256        if !self.resources.is_empty() {
257            let resources = self
258                .resources
259                .iter()
260                .map(|r| Value::String(r.to_string()))
261                .collect();
262            let _ = body.insert("resources".into(), Value::Array(resources));
263        }
264        let bytes =
265            serde_json::to_vec(&Value::Object(body)).map_err(|_| SiwxProofError::InvalidJson)?;
266        Ok(Base64Bytes::encode(bytes).to_string())
267    }
268
269    async fn verify_signature(&self, evm: &EvmVerifier) -> Result<(), SiwxError> {
270        let (profile, message) = self.to_siwx_message()?;
271        let raw = profile.format(&message);
272        match profile {
273            ChainProfile::Evm => {
274                let sig = decode_evm_signature(&self.signature)?;
275                map_crypto(evm.verify(&message, &raw, &sig).await, false)
276            }
277            ChainProfile::Svm => {
278                let sig = decode_solana_signature(&self.signature)?;
279                map_crypto(
280                    Ed25519Verifier::new().verify(&message, &raw, &sig).await,
281                    true,
282                )
283            }
284        }
285    }
286
287    fn to_siwx_message(&self) -> Result<(ChainProfile, SiwxMessage), SiwxError> {
288        if self.version != "1" {
289            return Err(SiwxError::Signature);
290        }
291        let (profile, chain_ref) = chain_profile(&self.chain_id, &self.signature_type)?;
292        match profile {
293            ChainProfile::Evm => {
294                EvmVerifier::validate_chain_id(chain_ref).map_err(|err| map_message_err(&err))?;
295                if chain_ref == "0" {
296                    return Err(SiwxError::ChainId);
297                }
298            }
299            ChainProfile::Svm => {
300                Ed25519Verifier::validate_chain_id(chain_ref)
301                    .map_err(|err| map_message_err(&err))?;
302            }
303        }
304        let mut message = SiwxMessage::new(
305            self.domain.as_str(),
306            self.address.as_str(),
307            self.uri.as_str(),
308            chain_ref,
309            self.nonce.as_str(),
310        )
311        .map_err(|err| map_message_err(&err))?;
312        match profile {
313            ChainProfile::Evm => {
314                EvmVerifier::validate_address(self.address.as_str())
315                    .map_err(|err| map_message_err(&err))?;
316            }
317            ChainProfile::Svm => {
318                Ed25519Verifier::validate_address(self.address.as_str())
319                    .map_err(|err| map_message_err(&err))?;
320            }
321        }
322        message = message
323            .with_issued_at_raw(self.issued_at.as_str())
324            .map_err(|_| SiwxError::IssuedAt)?;
325        if let Some(statement) = &self.statement {
326            message = message
327                .with_statement(statement.as_str())
328                .map_err(|_| SiwxError::Signature)?;
329        }
330        if let Some(exp) = &self.expiration_time {
331            message = message
332                .with_expiration_time_raw(exp.as_str())
333                .map_err(|_| SiwxError::ExpirationTime)?;
334        }
335        if let Some(nbf) = &self.not_before {
336            message = message
337                .with_not_before_raw(nbf.as_str())
338                .map_err(|_| SiwxError::NotBefore)?;
339        }
340        if let Some(rid) = &self.request_id {
341            message = message
342                .with_request_id(rid.as_str())
343                .map_err(|_| SiwxError::Signature)?;
344        }
345        if !self.resources.is_empty() {
346            message = message
347                .with_resources(self.resources.iter().map(CompactString::as_str))
348                .map_err(|_| SiwxError::Signature)?;
349        }
350        Ok((profile, message))
351    }
352}
353
354fn parse_ts(raw: &str, on_err: SiwxError) -> Result<OffsetDateTime, SiwxError> {
355    OffsetDateTime::parse(raw, &Rfc3339).map_err(|_| on_err)
356}
357
358#[derive(Clone, Copy, Debug)]
359enum ChainProfile {
360    Evm,
361    Svm,
362}
363
364impl ChainProfile {
365    fn format(self, message: &SiwxMessage) -> String {
366        match self {
367            Self::Evm => EvmVerifier::format_message(message),
368            Self::Svm => Ed25519Verifier::format_message(message),
369        }
370    }
371}
372
373fn chain_profile<'a>(
374    chain_id: &'a str,
375    signature_type: &str,
376) -> Result<(ChainProfile, &'a str), SiwxError> {
377    if let Some(rest) = chain_id.strip_prefix("eip155:") {
378        if signature_type != "eip191" {
379            return Err(SiwxError::UnsupportedChain);
380        }
381        return Ok((ChainProfile::Evm, rest));
382    }
383    if let Some(rest) = chain_id.strip_prefix("solana:") {
384        if signature_type != "ed25519" {
385            return Err(SiwxError::UnsupportedChain);
386        }
387        return Ok((ChainProfile::Svm, rest));
388    }
389    Err(SiwxError::UnsupportedChain)
390}
391
392fn decode_evm_signature(signature: &str) -> Result<Vec<u8>, SiwxError> {
393    let hex_str = signature
394        .strip_prefix("0x")
395        .or_else(|| signature.strip_prefix("0X"))
396        .unwrap_or(signature);
397    hex::decode(hex_str).map_err(|_| SiwxError::MalformedSignature)
398}
399
400fn decode_solana_signature(signature: &str) -> Result<Vec<u8>, SiwxError> {
401    bs58::decode(signature)
402        .into_vec()
403        .map_err(|_| SiwxError::MalformedSignature)
404}
405
406fn map_crypto(result: Result<(), siwx::SiwxError>, solana: bool) -> Result<(), SiwxError> {
407    result.map_err(|err| match err {
408        siwx::SiwxError::InvalidSignature { .. } | siwx::SiwxError::InvalidAddress { .. } => {
409            SiwxError::MalformedSignature
410        }
411        siwx::SiwxError::VerificationFailed { .. } => SiwxError::Signature,
412        siwx::SiwxError::InvalidChainId { .. } => SiwxError::ChainId,
413        _ if solana => SiwxError::MalformedSignature,
414        _ => SiwxError::VerifierError,
415    })
416}
417
418const fn map_message_err(err: &siwx::SiwxError) -> SiwxError {
419    match err {
420        siwx::SiwxError::InvalidNonce { .. } => SiwxError::Nonce,
421        siwx::SiwxError::InvalidAddress { .. } => SiwxError::MalformedSignature,
422        siwx::SiwxError::InvalidDomain { .. } => SiwxError::DomainMismatch,
423        siwx::SiwxError::InvalidUri { .. } => SiwxError::UriMismatch,
424        siwx::SiwxError::InvalidChainId { .. } => SiwxError::ChainId,
425        _ => SiwxError::Signature,
426    }
427}