1use lexe_common::time::TimestampMs;
4#[cfg(feature = "crypto")]
5use lexe_crypto::rng::Crng;
6use lexe_uri::Uri;
7
8use crate::request::{CredentialRequest, Delivery};
9#[cfg(feature = "crypto")]
10use crate::{
11 request::RequestError,
12 response::{
13 CredentialError, CredentialResponse, CredentialResult, ErrorCode, Grant,
14 },
15 seal::SealError,
16};
17
18pub const VERIFIED_REQUESTERS: &[&str] = &["zaprite.com", "zaptrain.com"];
28
29#[cfg(feature = "crypto")]
30#[derive(Debug, thiserror::Error)]
31pub enum RespondError {
32 #[error(transparent)]
33 Request(#[from] RequestError),
34 #[error(transparent)]
35 Seal(#[from] SealError),
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum RequesterDisplay {
43 Verified { domain: String },
45 Unverified { scheme_host: Option<String> },
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct RequesterBranding {
53 pub name: String,
54 pub icon_url: Option<String>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum Outcome {
61 Approved {
63 credential: String,
64 expires_at: Option<TimestampMs>,
65 },
66 Rejected,
67 Failed(String),
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum DeliveryAction {
74 Redirect(String),
76 Http(HttpDelivery),
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct HttpDelivery {
85 pub url: String,
86 pub content_type: &'static str,
87 pub body: Vec<u8>,
88}
89
90#[cfg(feature = "crypto")]
91impl HttpDelivery {
92 const CONTENT_TYPE_JSON: &str = "application/json";
94 const CONTENT_TYPE_OCTET_STREAM: &str = "application/octet-stream";
95}
96
97impl CredentialRequest {
98 pub fn requester_display(&self) -> RequesterDisplay {
99 let uri = match &self.params.delivery {
100 Delivery::Redirect(uri) | Delivery::Post(uri) => Uri::parse(uri),
101 Delivery::Mailbox(_) =>
102 return RequesterDisplay::Unverified { scheme_host: None },
103 };
104
105 let Ok(uri) = uri else {
106 return RequesterDisplay::Unverified { scheme_host: None };
107 };
108 let scheme = uri.scheme;
109 match uri.host() {
110 Some(host) if uri.is_https() => RequesterDisplay::Verified {
111 domain: host.into_owned(),
112 },
113 Some(host) => RequesterDisplay::Unverified {
114 scheme_host: Some(format!("{scheme}://{host}")),
115 },
116 None => RequesterDisplay::Unverified {
117 scheme_host: Some(match uri.authority {
118 true => format!("{scheme}://"),
119 false => format!("{scheme}:"),
120 }),
121 },
122 }
123 }
124
125 pub fn requester_branding(&self) -> Option<RequesterBranding> {
129 let RequesterDisplay::Verified { domain } = self.requester_display()
130 else {
131 return None;
132 };
133 let verified = VERIFIED_REQUESTERS.iter().any(|verified_domain| {
134 domain == *verified_domain
135 || domain
136 .strip_suffix(verified_domain)
137 .is_some_and(|prefix| prefix.ends_with('.'))
138 });
139 if !verified {
140 return None;
141 }
142 let name = self.params.requester_name.clone()?;
143 let icon_url = self
144 .params
145 .requester_icon
146 .clone()
147 .filter(|url| Uri::parse(url).is_ok_and(|uri| uri.is_https()));
148 Some(RequesterBranding { name, icon_url })
149 }
150
151 #[cfg(feature = "crypto")]
155 pub fn respond(
156 &self,
157 rng: &mut impl Crng,
158 outcome: Outcome,
159 ) -> Result<DeliveryAction, RespondError> {
160 self.validate()?;
161
162 let result = match outcome {
163 Outcome::Approved {
164 credential,
165 expires_at,
166 } => CredentialResult::Granted(Grant {
167 credential,
168 scopes: self.params.scopes.clone(),
169 permissions: self.params.permissions.clone(),
170 expires_at,
171 }),
172 Outcome::Rejected => CredentialResult::Error(CredentialError {
173 code: ErrorCode::UserRejected,
174 message: None,
175 }),
176 Outcome::Failed(message) =>
177 CredentialResult::Error(CredentialError {
178 code: ErrorCode::Other,
179 message: Some(message),
180 }),
181 };
182 let response = CredentialResponse {
183 result,
184 one_time_secret: self.one_time_secret,
185 account: self.params.account.clone(),
186 metadata: self.params.metadata.clone(),
187 };
188
189 let action = match &self.params.delivery {
190 Delivery::Redirect(uri) => {
191 let blob = response.seal(rng, self)?.to_base64url();
192 let mut uri =
193 Uri::parse(uri).expect("validate() above parsed it");
194 uri.push_param("response", blob);
195 DeliveryAction::Redirect(uri.to_string())
196 }
197 Delivery::Post(url) if self.ephemeral_hpke_pubkey.is_some() =>
198 DeliveryAction::Http(HttpDelivery {
199 url: url.clone(),
200 content_type: HttpDelivery::CONTENT_TYPE_OCTET_STREAM,
201 body: response.seal(rng, self)?.0,
202 }),
203 Delivery::Post(url) => DeliveryAction::Http(HttpDelivery {
204 url: url.clone(),
205 content_type: HttpDelivery::CONTENT_TYPE_JSON,
206 body: response.to_json(),
207 }),
208 Delivery::Mailbox(url) => DeliveryAction::Http(HttpDelivery {
209 url: {
210 let mut url =
211 Uri::parse(url).expect("validate() above parsed it");
212 url.push_param(
213 "address",
214 self.mailbox_address().to_string(),
215 );
216 url.to_string()
217 },
218 content_type: HttpDelivery::CONTENT_TYPE_OCTET_STREAM,
219 body: response.seal(rng, self)?.0,
220 }),
221 };
222
223 Ok(action)
224 }
225}
226
227#[cfg(test)]
228mod test {
229 use lexe_byte_array::ByteArray;
230 use lexe_crypto::rng::SysRng;
231
232 use super::*;
233 use crate::request::{CredentialRequestParams, OneTimeSecret};
234
235 fn request(delivery: Delivery) -> CredentialRequest {
237 CredentialRequest {
238 ephemeral_hpke_pubkey: None,
239 one_time_secret: OneTimeSecret::from_array([0; 16]),
240 params: CredentialRequestParams {
241 delivery,
242 account: None,
243 metadata: None,
244 requester_name: None,
245 requester_icon: None,
246 scopes: ["read_info".to_owned()].into(),
247 permissions: Default::default(),
248 label: None,
249 expires_at: None,
250 },
251 }
252 }
253
254 #[test]
255 fn requester_display() {
256 let display =
257 |delivery: Delivery| request(delivery).requester_display();
258 let verified = |domain: &str| RequesterDisplay::Verified {
259 domain: domain.into(),
260 };
261 let unverified =
262 |scheme_host: Option<&str>| RequesterDisplay::Unverified {
263 scheme_host: scheme_host.map(String::from),
264 };
265 assert_eq!(
266 display(Delivery::Redirect("https://BillSplit.com/cb".into())),
267 verified("billsplit.com")
268 );
269 assert_eq!(
270 display(Delivery::Redirect("https://a@evil.com:1/".into())),
271 verified("evil.com")
272 );
273 assert_eq!(
274 display(Delivery::Post("https://paygate.com/x".into())),
275 verified("paygate.com")
276 );
277 assert_eq!(
278 display(Delivery::Redirect("myprotocol://".into())),
279 unverified(Some("myprotocol://"))
280 );
281 assert_eq!(
282 display(Delivery::Redirect("http://a.com/cb".into())),
283 unverified(Some("http://a.com"))
284 );
285 assert_eq!(
286 display(Delivery::Mailbox("https://lexe.app/mailbox".into())),
287 unverified(None)
288 );
289 }
290
291 #[test]
292 fn requester_branding() {
293 let branded = |delivery: Delivery, icon: &str| {
294 let mut request = request(delivery);
295 request.params.requester_name = Some("Zaprite P2P".into());
296 request.params.requester_icon = Some(icon.into());
297 request.requester_branding()
298 };
299 let zaprite = || Delivery::Redirect("https://zaprite.com/cb".into());
300 let icon = "https://zaprite.com/icon.png";
301 let branding = Some(RequesterBranding {
302 name: "Zaprite P2P".into(),
303 icon_url: Some(icon.into()),
304 });
305
306 assert_eq!(branded(zaprite(), icon), branding);
307 let p2p_zaprite =
309 Delivery::Redirect("https://p2p.zaprite.com/cb".into());
310 assert_eq!(branded(p2p_zaprite, icon), branding);
311 assert_eq!(
313 branded(zaprite(), "http://zaprite.com/icon.png")
314 .and_then(|b| b.icon_url),
315 None
316 );
317 for delivery in [
319 Delivery::Redirect("https://evil.com/cb".into()),
320 Delivery::Redirect("https://notzaprite.com/cb".into()),
321 Delivery::Redirect("zaprite://cb".into()),
322 Delivery::Mailbox("https://zaprite.com/mailbox".into()),
323 ] {
324 assert_eq!(branded(delivery, icon), None);
325 }
326 assert_eq!(request(zaprite()).requester_branding(), None);
328 }
329
330 #[test]
332 fn respond_validates() {
333 let mut rng = SysRng::new();
334 let insecure = request(Delivery::Post("http://paygate.com/x".into()));
335 assert!(matches!(
336 insecure.respond(&mut rng, Outcome::Rejected),
337 Err(RespondError::Request(RequestError::Invalid("post_url", _)))
338 ));
339 }
340}