Skip to main content

r402_extensions/siwx/
advertise.rs

1//! 402 challenge advertisement for `sign-in-with-x`.
2//!
3//! Per-request nonce and timestamps are supplied by the HTTP layer. This
4//! module does not read `Host`.
5
6use 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/// One entry in `supportedChains`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SiwxChain {
18    /// CAIP-2 chain identifier.
19    pub chain_id: CompactString,
20    /// Signature algorithm (`eip191` or `ed25519`).
21    pub signature_type: CompactString,
22}
23
24impl SiwxChain {
25    /// EVM personal-sign chain.
26    #[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    /// Solana ed25519 chain.
35    #[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/// Resource-server SIWX declaration. Challenge fields are per-request.
45#[derive(Debug, Clone)]
46pub struct SiwxExtension {
47    origin: SiwxOrigin,
48    supported_chains: Vec<SiwxChain>,
49    statement: Option<CompactString>,
50}
51
52impl SiwxExtension {
53    /// Constructs an extension bound to a configured public origin.
54    ///
55    /// Challenges include [`super::DEFAULT_STATEMENT`] until [`Self::with_statement`].
56    #[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    /// Adds a supported authentication chain.
66    #[must_use]
67    pub fn with_chain(mut self, chain: SiwxChain) -> Self {
68        self.supported_chains.push(chain);
69        self
70    }
71
72    /// Sets the CAIP-122 statement shown to the wallet.
73    #[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    /// Configured public origin (never `Host`).
80    #[must_use]
81    pub const fn origin(&self) -> &SiwxOrigin {
82        &self.origin
83    }
84
85    /// Builds the per-request 402 challenge entry.
86    ///
87    /// `nonce_hex` must be 32 lowercase/uppercase hex characters.
88    /// `issued_at` / `expiration_time` are ISO 8601 timestamps supplied by
89    /// the HTTP layer (default expiry is issuedAt + 5 minutes).
90    ///
91    /// # Errors
92    ///
93    /// [`SiwxError::Nonce`] when `nonce_hex` is not 32 hex characters.
94    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    /// Fresh nonce and timestamps for this request path.
137    ///
138    /// `expirationTime` is `issuedAt` + 5 minutes. Domain/URI come from the
139    /// configured origin, never `Host`.
140    ///
141    /// # Errors
142    ///
143    /// [`SiwxError::IssuedAt`] / [`SiwxError::ExpirationTime`] if RFC 3339
144    /// formatting fails.
145    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        // Nonce and timestamps are per-request; HTTP calls [`Self::challenge`].
170        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}