use compact_str::CompactString;
use r402_protocol::extension::{AdvertiseContext, Extension};
use r402_protocol::payment::ExtensionEntry;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::{DEFAULT_CHALLENGE_TTL, DEFAULT_STATEMENT, SIWX_KEY, SiwxError, SiwxOrigin};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SiwxChain {
pub chain_id: CompactString,
pub signature_type: CompactString,
}
impl SiwxChain {
#[must_use]
pub fn eip191(chain_id: impl Into<CompactString>) -> Self {
Self {
chain_id: chain_id.into(),
signature_type: CompactString::from("eip191"),
}
}
#[must_use]
pub fn ed25519(chain_id: impl Into<CompactString>) -> Self {
Self {
chain_id: chain_id.into(),
signature_type: CompactString::from("ed25519"),
}
}
}
#[derive(Debug, Clone)]
pub struct SiwxExtension {
origin: SiwxOrigin,
supported_chains: Vec<SiwxChain>,
statement: Option<CompactString>,
}
impl SiwxExtension {
#[must_use]
pub fn new(origin: SiwxOrigin) -> Self {
Self {
origin,
supported_chains: Vec::new(),
statement: Some(CompactString::from(DEFAULT_STATEMENT)),
}
}
#[must_use]
pub fn with_chain(mut self, chain: SiwxChain) -> Self {
self.supported_chains.push(chain);
self
}
#[must_use]
pub fn with_statement(mut self, statement: impl Into<CompactString>) -> Self {
self.statement = Some(statement.into());
self
}
#[must_use]
pub const fn origin(&self) -> &SiwxOrigin {
&self.origin
}
pub fn challenge(
&self,
path: &str,
nonce_hex: &str,
issued_at: &str,
expiration_time: &str,
) -> Result<ExtensionEntry, SiwxError> {
if nonce_hex.len() != 32 || !nonce_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SiwxError::Nonce);
}
let uri = self.origin.uri(path);
let mut info = json!({
"domain": self.origin.domain(),
"uri": uri,
"version": "1",
"nonce": nonce_hex,
"issuedAt": issued_at,
"expirationTime": expiration_time,
"resources": [uri],
});
if let Some(statement) = &self.statement
&& let Some(obj) = info.as_object_mut()
{
let _ = obj.insert("statement".into(), Value::String(statement.to_string()));
}
let supported: Vec<Value> = self
.supported_chains
.iter()
.map(|c| {
json!({
"chainId": c.chain_id,
"type": c.signature_type,
})
})
.collect();
Ok(ExtensionEntry::raw(json!({
"info": info,
"supportedChains": supported,
"schema": client_proof_schema(),
})))
}
pub fn challenge_now(&self, path: &str) -> Result<ExtensionEntry, SiwxError> {
let issued = OffsetDateTime::now_utc();
let expires = issued + DEFAULT_CHALLENGE_TTL;
let issued_at = issued.format(&Rfc3339).map_err(|_| SiwxError::IssuedAt)?;
let expiration_time = expires
.format(&Rfc3339)
.map_err(|_| SiwxError::ExpirationTime)?;
self.challenge(path, &random_nonce_hex(), &issued_at, &expiration_time)
}
}
fn random_nonce_hex() -> String {
use rand::Rng;
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
impl Extension for SiwxExtension {
fn id(&self) -> &'static str {
SIWX_KEY
}
fn advertise(&self, _ctx: &AdvertiseContext<'_>) -> Option<ExtensionEntry> {
None
}
}
fn client_proof_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"domain": { "type": "string" },
"address": { "type": "string" },
"statement": { "type": "string" },
"uri": { "type": "string", "format": "uri" },
"version": { "type": "string" },
"chainId": { "type": "string" },
"type": { "type": "string" },
"nonce": { "type": "string" },
"issuedAt": { "type": "string", "format": "date-time" },
"expirationTime": { "type": "string", "format": "date-time" },
"notBefore": { "type": "string", "format": "date-time" },
"requestId": { "type": "string" },
"resources": { "type": "array", "items": { "type": "string", "format": "uri" } },
"signature": { "type": "string" }
},
"required": [
"domain",
"address",
"uri",
"version",
"chainId",
"type",
"nonce",
"issuedAt",
"signature"
]
})
}