Skip to main content

lexe_connect/
wallet.rs

1//! The WALLET side: approval screen model and response delivery.
2
3use 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
18/// Receiving domains whose REQUESTERs Lexe has verified out of band, so the
19/// approval screen may show their `requester_name` and `requester_icon`.
20/// Subdomains are included.
21//
22// TODO(max): In the future, we could have a more automated process where
23// REQUESTERs register directly with Lexe. This list could then be fetched from
24// Lexe's infra, and fall back to a hard-coded list (or nothing) if that fails.
25//
26// Spec: User Approval.
27pub 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/// How the approval screen identifies the REQUESTER.
39//
40// Spec: User Approval.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum RequesterDisplay {
43    /// The response goes to this domain, or to an app verified for it.
44    Verified { domain: String },
45    /// No receiving domain is known. `scheme_host` is the `redirect_uri`'s
46    /// scheme and host, e.g. `myprotocol://`; `None` for mailbox delivery.
47    Unverified { scheme_host: Option<String> },
48}
49
50/// A verified REQUESTER's own name and icon.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct RequesterBranding {
53    pub name: String,
54    /// An `https://` image url.
55    pub icon_url: Option<String>,
56}
57
58/// The user's decision on a request.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum Outcome {
61    /// A credential was created with the requested scopes and permissions.
62    Approved {
63        credential: String,
64        expires_at: Option<TimestampMs>,
65    },
66    Rejected,
67    /// The WALLET could not create the credential.
68    Failed(String),
69}
70
71/// How the WALLET delivers a response.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum DeliveryAction {
74    /// Open this url; the OS routes it to the REQUESTER.
75    Redirect(String),
76    /// Perform this request,
77    /// e.g. with `LexeConnectClient::deliver` from the `http` module.
78    Http(HttpDelivery),
79}
80
81/// A POST the WALLET must make. A mailbox accepts a byte-identical re-post
82/// but rejects any other, so retry with the same body rather than re-sealing.
83#[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    // Spec: `post_url` delivery and `mailbox_url` delivery.
93    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    /// The REQUESTER's `requester_name` and `requester_icon`, if its
126    /// receiving domain is in [`VERIFIED_REQUESTERS`].
127    /// A non-`https://` icon is dropped.
128    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    /// Build the response for `outcome` and package it for delivery.
152    //
153    // Spec: Credential response.
154    #[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    /// A hand-built request that skips [`CredentialRequest::parse`].
236    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        // Subdomains of a listed domain are verified too.
308        let p2p_zaprite =
309            Delivery::Redirect("https://p2p.zaprite.com/cb".into());
310        assert_eq!(branded(p2p_zaprite, icon), branding);
311        // Insecure icons are dropped, not the whole branding.
312        assert_eq!(
313            branded(zaprite(), "http://zaprite.com/icon.png")
314                .and_then(|b| b.icon_url),
315            None
316        );
317        // Unlisted and unverified domains get no branding.
318        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        // No name, no branding.
327        assert_eq!(request(zaprite()).requester_branding(), None);
328    }
329
330    /// `respond` validates, so an unparsed request can't reach the `expect`s.
331    #[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}