r402_extensions/siwx/
advertise.rs1use compact_str::CompactString;
7use r402_protocol::extension::{AdvertiseContext, Extension};
8use r402_protocol::payment::ExtensionEntry;
9use serde_json::{Value, json};
10use time::OffsetDateTime;
11use time::format_description::well_known::Rfc3339;
12
13use super::{DEFAULT_CHALLENGE_TTL, DEFAULT_STATEMENT, SIWX_KEY, SiwxError, SiwxOrigin};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SiwxChain {
18 pub chain_id: CompactString,
20 pub signature_type: CompactString,
22}
23
24impl SiwxChain {
25 #[must_use]
27 pub fn eip191(chain_id: impl Into<CompactString>) -> Self {
28 Self {
29 chain_id: chain_id.into(),
30 signature_type: CompactString::from("eip191"),
31 }
32 }
33
34 #[must_use]
36 pub fn ed25519(chain_id: impl Into<CompactString>) -> Self {
37 Self {
38 chain_id: chain_id.into(),
39 signature_type: CompactString::from("ed25519"),
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct SiwxExtension {
47 origin: SiwxOrigin,
48 supported_chains: Vec<SiwxChain>,
49 statement: Option<CompactString>,
50}
51
52impl SiwxExtension {
53 #[must_use]
57 pub fn new(origin: SiwxOrigin) -> Self {
58 Self {
59 origin,
60 supported_chains: Vec::new(),
61 statement: Some(CompactString::from(DEFAULT_STATEMENT)),
62 }
63 }
64
65 #[must_use]
67 pub fn with_chain(mut self, chain: SiwxChain) -> Self {
68 self.supported_chains.push(chain);
69 self
70 }
71
72 #[must_use]
74 pub fn with_statement(mut self, statement: impl Into<CompactString>) -> Self {
75 self.statement = Some(statement.into());
76 self
77 }
78
79 #[must_use]
81 pub const fn origin(&self) -> &SiwxOrigin {
82 &self.origin
83 }
84
85 pub fn challenge(
95 &self,
96 path: &str,
97 nonce_hex: &str,
98 issued_at: &str,
99 expiration_time: &str,
100 ) -> Result<ExtensionEntry, SiwxError> {
101 if nonce_hex.len() != 32 || !nonce_hex.chars().all(|c| c.is_ascii_hexdigit()) {
102 return Err(SiwxError::Nonce);
103 }
104 let uri = self.origin.uri(path);
105 let mut info = json!({
106 "domain": self.origin.domain(),
107 "uri": uri,
108 "version": "1",
109 "nonce": nonce_hex,
110 "issuedAt": issued_at,
111 "expirationTime": expiration_time,
112 "resources": [uri],
113 });
114 if let Some(statement) = &self.statement
115 && let Some(obj) = info.as_object_mut()
116 {
117 let _ = obj.insert("statement".into(), Value::String(statement.to_string()));
118 }
119 let supported: Vec<Value> = self
120 .supported_chains
121 .iter()
122 .map(|c| {
123 json!({
124 "chainId": c.chain_id,
125 "type": c.signature_type,
126 })
127 })
128 .collect();
129 Ok(ExtensionEntry::raw(json!({
130 "info": info,
131 "supportedChains": supported,
132 "schema": client_proof_schema(),
133 })))
134 }
135
136 pub fn challenge_now(&self, path: &str) -> Result<ExtensionEntry, SiwxError> {
146 let issued = OffsetDateTime::now_utc();
147 let expires = issued + DEFAULT_CHALLENGE_TTL;
148 let issued_at = issued.format(&Rfc3339).map_err(|_| SiwxError::IssuedAt)?;
149 let expiration_time = expires
150 .format(&Rfc3339)
151 .map_err(|_| SiwxError::ExpirationTime)?;
152 self.challenge(path, &random_nonce_hex(), &issued_at, &expiration_time)
153 }
154}
155
156fn random_nonce_hex() -> String {
157 use rand::Rng;
158 let mut bytes = [0u8; 16];
159 rand::rng().fill_bytes(&mut bytes);
160 hex::encode(bytes)
161}
162
163impl Extension for SiwxExtension {
164 fn id(&self) -> &'static str {
165 SIWX_KEY
166 }
167
168 fn advertise(&self, _ctx: &AdvertiseContext<'_>) -> Option<ExtensionEntry> {
169 None
171 }
172}
173
174fn client_proof_schema() -> Value {
175 json!({
176 "$schema": "https://json-schema.org/draft/2020-12/schema",
177 "type": "object",
178 "properties": {
179 "domain": { "type": "string" },
180 "address": { "type": "string" },
181 "statement": { "type": "string" },
182 "uri": { "type": "string", "format": "uri" },
183 "version": { "type": "string" },
184 "chainId": { "type": "string" },
185 "type": { "type": "string" },
186 "nonce": { "type": "string" },
187 "issuedAt": { "type": "string", "format": "date-time" },
188 "expirationTime": { "type": "string", "format": "date-time" },
189 "notBefore": { "type": "string", "format": "date-time" },
190 "requestId": { "type": "string" },
191 "resources": { "type": "array", "items": { "type": "string", "format": "uri" } },
192 "signature": { "type": "string" }
193 },
194 "required": [
195 "domain",
196 "address",
197 "uri",
198 "version",
199 "chainId",
200 "type",
201 "nonce",
202 "issuedAt",
203 "signature"
204 ]
205 })
206}