1use std::{
4 collections::BTreeSet,
5 fmt::{self, Display},
6 str::FromStr,
7};
8
9use lexe_byte_array::ByteArray;
10use lexe_common::time::TimestampMs;
11#[cfg(feature = "crypto")]
12use lexe_crypto::hpke;
13use lexe_crypto::rng::{Crng, RngExt};
14use lexe_uri::Uri;
15
16#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
17pub enum RequestError {
18 #[error("Not a {} connection string", CredentialRequest::LEXE_BASE_URL)]
19 NotConnectUrl,
20 #[error("Unsupported LexeConnect version: {0}")]
21 UnsupportedVersion(String),
22 #[error("Missing param: {0}")]
23 Missing(&'static str),
24 #[error("Invalid param {0}: {1}")]
25 Invalid(&'static str, String),
26 #[error("Unsupported param: {0}")]
27 Unsupported(String),
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct CredentialRequest {
33 pub ephemeral_hpke_pubkey: Option<HpkePubkey>,
36 pub one_time_secret: OneTimeSecret,
38 pub params: CredentialRequestParams,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct CredentialRequestParams {
44 pub delivery: Delivery,
45 pub account: Option<String>,
49 pub metadata: Option<String>,
51 pub requester_name: Option<String>,
54 pub requester_icon: Option<String>,
56 pub scopes: BTreeSet<String>,
60 pub permissions: BTreeSet<String>,
62 pub label: Option<String>,
64 pub expires_at: Option<TimestampMs>,
66}
67
68impl CredentialRequestParams {
69 const MAX_ACCOUNT_LEN: usize = 64;
71 const MAX_METADATA_LEN: usize = 1024;
72 const MAX_LABEL_LEN: usize = 64;
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum Delivery {
79 Redirect(String),
82 Post(String),
84 Mailbox(String),
87}
88
89#[derive(Copy, Clone, Eq, Hash, PartialEq)]
94pub struct HpkePubkey([u8; 32]);
95
96lexe_byte_array::impl_byte_array!(HpkePubkey, 32);
97lexe_byte_array::impl_fromstr_fromhex!(HpkePubkey, 32);
98lexe_byte_array::impl_debug_display_as_hex!(HpkePubkey);
99
100#[cfg(feature = "crypto")]
101impl HpkePubkey {
102 pub(crate) fn from_hpke(pk: &hpke::PublicKey) -> Self {
103 Self(pk.to_array())
104 }
105
106 pub(crate) fn to_hpke(self) -> hpke::PublicKey {
107 hpke::PublicKey::from_array(self.0)
108 }
109}
110
111#[derive(Copy, Clone, Eq, Hash, PartialEq)]
115pub struct OneTimeSecret([u8; 16]);
116
117lexe_byte_array::impl_byte_array!(OneTimeSecret, 16);
118lexe_byte_array::impl_fromstr_fromhex!(OneTimeSecret, 16);
119lexe_byte_array::impl_debug_display_as_hex!(OneTimeSecret);
120lexe_serde::impl_serde_hexstr_or_bytes!(OneTimeSecret);
121
122impl OneTimeSecret {
123 pub fn from_rng(rng: &mut impl Crng) -> Self {
124 Self(rng.gen_bytes())
125 }
126}
127
128impl CredentialRequest {
129 const VERSION: u32 = 1;
133 pub const LEXE_BASE_URL: &str = "https://lexe.app/connect";
137
138 pub fn parse(s: &str) -> Result<Self, RequestError> {
140 let is_connect_url = s
141 .strip_prefix(Self::LEXE_BASE_URL)
142 .is_some_and(|rest| rest.is_empty() || rest.starts_with('?'));
143 if !is_connect_url {
144 return Err(RequestError::NotConnectUrl);
145 }
146
147 let uri = Uri::parse(s)
148 .map_err(|e| RequestError::Invalid("uri", e.to_string()))?;
149 Self::parse_uri(uri)
150 }
151
152 fn parse_uri(uri: Uri<'_>) -> Result<Self, RequestError> {
156 fn parse_param<T: FromStr>(
157 name: &'static str,
158 s: &str,
159 ) -> Result<T, RequestError>
160 where
161 T::Err: Display,
162 {
163 s.parse()
164 .map_err(|e: T::Err| RequestError::Invalid(name, e.to_string()))
165 }
166
167 fn parse_list(s: &str) -> BTreeSet<String> {
168 s.split(',').map(str::to_owned).collect()
169 }
170
171 let mut v = None;
172 let mut redirect_uri = None;
173 let mut post_url = None;
174 let mut mailbox_url = None;
175 let mut ephemeral_hpke_pubkey = None;
176 let mut one_time_secret = None;
177 let mut account = None;
178 let mut metadata = None;
179 let mut requester_name = None;
180 let mut requester_icon = None;
181 let mut scopes = BTreeSet::new();
182 let mut permissions = BTreeSet::new();
183 let mut label = None;
184 let mut expires_at = None;
185
186 for param in uri.params {
189 let value = param.value;
190 match param.key.as_ref() {
191 "v" => v = Some(value.into_owned()),
192 "redirect_uri" => redirect_uri = Some(value.into_owned()),
193 "post_url" => post_url = Some(value.into_owned()),
194 "mailbox_url" => mailbox_url = Some(value.into_owned()),
195 "ephemeral_hpke_pubkey" =>
196 ephemeral_hpke_pubkey =
197 Some(parse_param("ephemeral_hpke_pubkey", &value)?),
198 "one_time_secret" =>
199 one_time_secret =
200 Some(parse_param("one_time_secret", &value)?),
201 "account" => account = Some(value.into_owned()),
202 "metadata" => metadata = Some(value.into_owned()),
203 "requester_name" => requester_name = Some(value.into_owned()),
204 "requester_icon" => requester_icon = Some(value.into_owned()),
205 "scopes" => scopes = parse_list(&value),
206 "permissions" => permissions = parse_list(&value),
207 "label" => label = Some(value.into_owned()),
208 "expires_at" =>
209 expires_at = Some(parse_param("expires_at", &value)?),
210 key if key.starts_with("budget_") =>
214 return Err(RequestError::Unsupported(key.to_owned())),
215 _ => (),
216 }
217 }
218
219 let version = v.ok_or(RequestError::Missing("v"))?;
220 if version != Self::VERSION.to_string() {
221 return Err(RequestError::UnsupportedVersion(version));
222 }
223
224 let delivery = match (redirect_uri, post_url, mailbox_url) {
225 (Some(uri), None, None) => Delivery::Redirect(uri),
226 (None, Some(url), None) => Delivery::Post(url),
227 (None, None, Some(url)) => Delivery::Mailbox(url),
228 (None, None, None) =>
229 return Err(RequestError::Missing(
230 "redirect_uri, post_url, or mailbox_url",
231 )),
232 _ =>
233 return Err(RequestError::Invalid(
234 "delivery",
235 "Set exactly one of redirect_uri, post_url, mailbox_url"
236 .into(),
237 )),
238 };
239
240 let one_time_secret =
241 one_time_secret.ok_or(RequestError::Missing("one_time_secret"))?;
242
243 let request = Self {
244 ephemeral_hpke_pubkey,
245 one_time_secret,
246 params: CredentialRequestParams {
247 delivery,
248 account,
249 metadata,
250 requester_name,
251 requester_icon,
252 scopes,
253 permissions,
254 label,
255 expires_at,
256 },
257 };
258 request.validate()?;
259
260 Ok(request)
261 }
262
263 fn to_uri(&self) -> Uri<'_> {
266 let p = &self.params;
267 let (delivery_key, delivery_target) = match &p.delivery {
268 Delivery::Redirect(uri) => ("redirect_uri", uri.as_str()),
269 Delivery::Post(url) => ("post_url", url.as_str()),
270 Delivery::Mailbox(url) => ("mailbox_url", url.as_str()),
271 };
272 let join = |set: &BTreeSet<String>| {
273 (!set.is_empty()).then(|| {
274 set.iter().map(String::as_str).collect::<Vec<_>>().join(",")
275 })
276 };
277 let scopes = join(&p.scopes);
278 let permissions = join(&p.permissions);
279
280 let mut uri: Uri<'_> = Uri::parse(Self::LEXE_BASE_URL)
281 .expect("LEXE_BASE_URL is a valid uri");
282
283 uri.push_param("v", Self::VERSION.to_string());
284 uri.push_param(delivery_key, delivery_target);
285 if let Some(pk) = self.ephemeral_hpke_pubkey {
286 uri.push_param("ephemeral_hpke_pubkey", pk.to_string());
287 }
288 uri.push_param("one_time_secret", self.one_time_secret.to_string());
289 if let Some(account) = &p.account {
290 uri.push_param("account", account.as_str());
291 }
292 if let Some(metadata) = &p.metadata {
293 uri.push_param("metadata", metadata.as_str());
294 }
295 if let Some(name) = &p.requester_name {
296 uri.push_param("requester_name", name.as_str());
297 }
298 if let Some(icon) = &p.requester_icon {
299 uri.push_param("requester_icon", icon.as_str());
300 }
301 if let Some(scopes) = scopes {
302 uri.push_param("scopes", scopes);
303 }
304 if let Some(permissions) = permissions {
305 uri.push_param("permissions", permissions);
306 }
307 if let Some(label) = &p.label {
308 uri.push_param("label", label.as_str());
309 }
310 if let Some(expires_at) = p.expires_at {
311 uri.push_param("expires_at", expires_at.to_u64().to_string());
312 }
313
314 uri
315 }
316
317 pub(crate) fn validate(&self) -> Result<(), RequestError> {
321 fn https_host(url: &str) -> Option<String> {
322 let uri = Uri::parse(url).ok()?;
323 let host = uri.is_https().then(|| uri.host()).flatten()?;
324 Some(host.into_owned())
325 }
326
327 fn check_len(
328 name: &'static str,
329 value: Option<&str>,
330 max_len: usize,
331 ) -> Result<(), RequestError> {
332 match value {
333 Some(v) if v.len() > max_len => Err(RequestError::Invalid(
334 name,
335 format!("Exceeds {max_len} bytes"),
336 )),
337 _ => Ok(()),
338 }
339 }
340
341 let p = &self.params;
342 match &p.delivery {
343 Delivery::Redirect(uri) => {
344 if Uri::parse(uri).is_err() {
345 return Err(RequestError::Invalid(
346 "redirect_uri",
347 "Not a uri".into(),
348 ));
349 }
350 if uri.contains('#') {
351 return Err(RequestError::Invalid(
352 "redirect_uri",
353 "Must not contain a fragment".into(),
354 ));
355 }
356 }
357 Delivery::Post(url) if https_host(url).is_none() =>
358 return Err(RequestError::Invalid(
359 "post_url",
360 "Must be an https:// url".into(),
361 )),
362 Delivery::Mailbox(url) if https_host(url).is_none() =>
363 return Err(RequestError::Invalid(
364 "mailbox_url",
365 "Must be an https:// url".into(),
366 )),
367 Delivery::Post(_) | Delivery::Mailbox(_) => (),
368 }
369
370 if self.ephemeral_hpke_pubkey.is_none()
371 && !matches!(p.delivery, Delivery::Post(_))
372 {
373 return Err(RequestError::Missing("ephemeral_hpke_pubkey"));
374 }
375
376 check_len(
377 "account",
378 p.account.as_deref(),
379 CredentialRequestParams::MAX_ACCOUNT_LEN,
380 )?;
381 check_len(
382 "metadata",
383 p.metadata.as_deref(),
384 CredentialRequestParams::MAX_METADATA_LEN,
385 )?;
386 check_len(
387 "label",
388 p.label.as_deref(),
389 CredentialRequestParams::MAX_LABEL_LEN,
390 )?;
391
392 if p.scopes.is_empty() && p.permissions.is_empty() {
393 return Err(RequestError::Missing("scopes or permissions"));
394 }
395
396 Ok(())
397 }
398}
399
400impl fmt::Display for CredentialRequest {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 self.to_uri().fmt(f)
404 }
405}
406
407#[cfg(any(test, feature = "test-utils"))]
408mod arbitrary_impl {
409 use lexe_common::test_utils::arbitrary::any_bounded_string;
410 use proptest::{
411 arbitrary::{Arbitrary, any},
412 option, prop_oneof,
413 strategy::{BoxedStrategy, Strategy},
414 string::string_regex,
415 };
416
417 use super::*;
418
419 impl Arbitrary for CredentialRequest {
420 type Parameters = ();
421 type Strategy = BoxedStrategy<Self>;
422 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
423 let any_https_url = || {
424 string_regex("https://[a-z0-9]{1,12}\\.[a-z]{2,4}/[a-z]{0,8}")
425 .unwrap()
426 };
427 let any_delivery = prop_oneof![
428 any_https_url().prop_map(Delivery::Redirect),
429 any_https_url().prop_map(Delivery::Post),
430 any_https_url().prop_map(Delivery::Mailbox),
431 ];
432 let any_names = || {
433 proptest::collection::btree_set(
434 string_regex("[a-z_]{1,16}").unwrap(),
435 0..4,
436 )
437 };
438 let any_grant = (any_names(), any_names()).prop_filter(
439 "Must request something",
440 |(scopes, permissions)| {
441 !scopes.is_empty() || !permissions.is_empty()
442 },
443 );
444 (
445 any::<Option<HpkePubkey>>(),
446 any::<OneTimeSecret>(),
447 any_delivery,
448 option::of(any_bounded_string(
449 CredentialRequestParams::MAX_ACCOUNT_LEN,
450 )),
451 option::of(any_bounded_string(
452 CredentialRequestParams::MAX_METADATA_LEN,
453 )),
454 (
455 option::of(any_bounded_string(64)),
456 option::of(any_https_url()),
457 ),
458 any_grant,
459 option::of(any_bounded_string(
460 CredentialRequestParams::MAX_LABEL_LEN,
461 )),
462 any::<Option<TimestampMs>>(),
463 )
464 .prop_map(
465 |(
466 mut ephemeral_hpke_pubkey,
467 one_time_secret,
468 delivery,
469 account,
470 metadata,
471 (requester_name, requester_icon),
472 (scopes, permissions),
473 label,
474 expires_at,
475 )| {
476 if !matches!(delivery, Delivery::Post(_)) {
478 ephemeral_hpke_pubkey.get_or_insert_with(|| {
479 HpkePubkey::from_array([7; 32])
480 });
481 }
482 CredentialRequest {
483 ephemeral_hpke_pubkey,
484 one_time_secret,
485 params: CredentialRequestParams {
486 delivery,
487 account,
488 metadata,
489 requester_name,
490 requester_icon,
491 scopes,
492 permissions,
493 label,
494 expires_at,
495 },
496 }
497 },
498 )
499 .boxed()
500 }
501 }
502
503 impl Arbitrary for HpkePubkey {
504 type Parameters = ();
505 type Strategy = BoxedStrategy<Self>;
506 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
507 any::<[u8; 32]>().prop_map(Self).boxed()
508 }
509 }
510
511 impl Arbitrary for OneTimeSecret {
512 type Parameters = ();
513 type Strategy = BoxedStrategy<Self>;
514 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
515 any::<[u8; 16]>().prop_map(Self).boxed()
516 }
517 }
518}
519
520#[cfg(test)]
521mod test {
522 use lexe_crypto::rng::SysRng;
523 use proptest::{arbitrary::any, prop_assert_eq, proptest};
524
525 use super::*;
526
527 #[test]
528 fn connection_string_roundtrip_prop() {
529 proptest!(|(request in any::<CredentialRequest>())| {
530 let parsed = CredentialRequest::parse(&request.to_string());
531 prop_assert_eq!(parsed, Ok(request));
532 });
533 }
534
535 fn names<const N: usize>(names: [&str; N]) -> BTreeSet<String> {
536 names.map(String::from).into()
537 }
538
539 pub(crate) fn sample_params(delivery: Delivery) -> CredentialRequestParams {
540 CredentialRequestParams {
541 delivery,
542 account: Some("@janedoe".into()),
543 metadata: Some("{\"k\":\"v&=+\"}".into()),
544 requester_name: None,
545 requester_icon: None,
546 scopes: names(["read_info", "receive"]),
547 permissions: names(["cancel_payment"]),
548 label: Some("BillSplit App".into()),
549 expires_at: Some(TimestampMs::from_millis(1821484800000).unwrap()),
550 }
551 }
552
553 fn sample_request(delivery: Delivery) -> CredentialRequest {
554 let mut rng = SysRng::new();
555 CredentialRequest {
556 ephemeral_hpke_pubkey: Some(HpkePubkey::from_array(
557 rng.gen_bytes(),
558 )),
559 one_time_secret: OneTimeSecret::from_rng(&mut rng),
560 params: sample_params(delivery),
561 }
562 }
563
564 #[test]
565 fn connection_string_round_trip() {
566 for delivery in [
567 Delivery::Redirect("https://billsplit.com/cb?e=1".into()),
568 Delivery::Redirect("myprotocol://cb".into()),
569 Delivery::Post("https://paygate.com/lexe".into()),
570 Delivery::Mailbox(crate::LEXE_MAILBOX_URL.into()),
571 ] {
572 let request = sample_request(delivery);
573 let s = request.to_string();
574 assert!(s.starts_with("https://lexe.app/connect?v=1&"), "{s}");
575 assert_eq!(CredentialRequest::parse(&s).unwrap(), request);
576 }
577
578 let request = CredentialRequest {
580 ephemeral_hpke_pubkey: None,
581 params: CredentialRequestParams {
582 account: None,
583 metadata: None,
584 label: None,
585 expires_at: None,
586 ..sample_params(Delivery::Post("https://paygate.com".into()))
587 },
588 ..sample_request(Delivery::Post("https://paygate.com".into()))
589 };
590 let s = request.to_string();
591 assert_eq!(CredentialRequest::parse(&s).unwrap(), request);
592 }
593
594 #[test]
595 fn parse_spec_example_and_unknown_params() {
596 let s = "https://lexe.app/connect?v=1\
597 &redirect_uri=https%3A%2F%2Fbillsplit.com%2Fcb\
598 &ephemeral_hpke_pubkey=4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a\
599 &one_time_secret=000102030405060708090a0b0c0d0e0f\
600 &scopes=read_info,read_payments,receive\
601 &future_param=x";
602 let request = CredentialRequest::parse(s).unwrap();
603 assert_eq!(
604 request.params.scopes,
605 names(["read_info", "read_payments", "receive"])
606 );
607 assert_eq!(request.params.account, None);
608 }
609
610 #[test]
611 fn parse_errors() {
612 let base =
613 sample_request(Delivery::Redirect("https://a.com/cb".into()));
614 let ok = base.to_string();
615 let parse_err = |s: &str| CredentialRequest::parse(s).unwrap_err();
616
617 assert_eq!(
618 parse_err("https://lexe.app/other?v=1"),
619 RequestError::NotConnectUrl
620 );
621 assert_eq!(
622 parse_err(&ok.replace("v=1&", "v=2&")),
623 RequestError::UnsupportedVersion("2".into())
624 );
625 assert_eq!(
626 parse_err(&ok.replace("v=1&", "")),
627 RequestError::Missing("v")
628 );
629 assert_eq!(
630 parse_err(&format!("{ok}&post_url=https%3A%2F%2Fb.com")),
631 RequestError::Invalid(
632 "delivery",
633 "Set exactly one of redirect_uri, post_url, mailbox_url".into()
634 )
635 );
636 assert_eq!(
637 parse_err(&ok.replace(
638 "&scopes=read_info%2Creceive&permissions=cancel_payment",
639 ""
640 )),
641 RequestError::Missing("scopes or permissions")
642 );
643 assert!(matches!(
644 parse_err(&ok.replace("&one_time_secret=", "&one_time_secret=ab")),
645 RequestError::Invalid("one_time_secret", _)
646 ));
647 assert!(matches!(
648 parse_err(&ok.replace("&expires_at=", "&expires_at=-")),
649 RequestError::Invalid("expires_at", _)
650 ));
651 assert_eq!(
652 parse_err(&format!("{ok}&budget_limit=20")),
653 RequestError::Unsupported("budget_limit".into())
654 );
655
656 let with = |delivery: Delivery, pubkey: bool| {
657 let mut request = sample_request(delivery);
658 if !pubkey {
659 request.ephemeral_hpke_pubkey = None;
660 }
661 request.validate().unwrap_err()
662 };
663 assert_eq!(
664 with(Delivery::Redirect("https://a.com/cb#frag".into()), true),
665 RequestError::Invalid(
666 "redirect_uri",
667 "Must not contain a fragment".into()
668 )
669 );
670 assert_eq!(
671 with(Delivery::Redirect("no-scheme".into()), true),
672 RequestError::Invalid("redirect_uri", "Not a uri".into())
673 );
674 assert_eq!(
675 with(Delivery::Redirect("https://a.com/cb".into()), false),
676 RequestError::Missing("ephemeral_hpke_pubkey")
677 );
678 assert_eq!(
679 with(Delivery::Mailbox("https://m.com".into()), false),
680 RequestError::Missing("ephemeral_hpke_pubkey")
681 );
682 assert_eq!(
683 with(Delivery::Post("http://a.com".into()), true),
684 RequestError::Invalid("post_url", "Must be an https:// url".into())
685 );
686 assert_eq!(
687 with(Delivery::Mailbox("https://".into()), true),
688 RequestError::Invalid(
689 "mailbox_url",
690 "Must be an https:// url".into()
691 )
692 );
693
694 let mut request = base.clone();
695 request.params.account =
696 Some("x".repeat(CredentialRequestParams::MAX_ACCOUNT_LEN + 1));
697 assert_eq!(
698 request.validate().unwrap_err(),
699 RequestError::Invalid("account", "Exceeds 64 bytes".into())
700 );
701 let mut request = base;
702 request.params.metadata =
703 Some("x".repeat(CredentialRequestParams::MAX_METADATA_LEN + 1));
704 assert_eq!(
705 request.validate().unwrap_err(),
706 RequestError::Invalid("metadata", "Exceeds 1024 bytes".into())
707 );
708 }
709}