1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
16#[non_exhaustive]
17pub enum SiwxProofError {
18 #[error("SIGN-IN-WITH-X is not valid base64")]
20 InvalidEncoding,
21 #[error("SIGN-IN-WITH-X is not a JSON object")]
23 InvalidJson,
24 #[error("SIGN-IN-WITH-X is missing a required field")]
26 MissingField,
27 #[error("SIGN-IN-WITH-X has an invalid field")]
29 InvalidField,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SiwxProof {
35 pub address: CompactString,
37 pub signature: CompactString,
39 pub domain: CompactString,
41 pub uri: CompactString,
43 pub version: CompactString,
45 pub nonce: CompactString,
47 pub issued_at: CompactString,
49 pub expiration_time: Option<CompactString>,
51 pub not_before: Option<CompactString>,
53 pub statement: Option<CompactString>,
55 pub request_id: Option<CompactString>,
57 pub resources: Vec<CompactString>,
59 pub chain_id: CompactString,
61 pub signature_type: CompactString,
63 pub signature_scheme: Option<CompactString>,
65}
66
67impl SiwxProof {
68 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 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 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 pub fn signing_message(&self) -> Result<String, SiwxError> {
186 let (profile, message) = self.to_siwx_message()?;
187 Ok(profile.format(&message))
188 }
189
190 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 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 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}