Skip to main content

lexe_connect/
requester.rs

1//! The REQUESTER side: build requests, accept and verify responses.
2
3use lexe_crypto::{hpke, rng::Crng};
4use lexe_uri::Uri;
5
6use crate::{
7    mailbox::Address,
8    request::{
9        CredentialRequest, CredentialRequestParams, Delivery, HpkePubkey,
10        OneTimeSecret, RequestError,
11    },
12    response::{CredentialResponse, CredentialResult, ResponseError},
13    seal::Blob,
14};
15
16#[derive(Debug, thiserror::Error)]
17pub enum AcceptError {
18    #[error("Redirect url has no response param")]
19    MissingResponse,
20    #[error("Response is not valid base64url: {0}")]
21    Base64(#[from] base64::DecodeError),
22    #[error("Response could not be decrypted: {0}")]
23    Decrypt(#[from] hpke::Error),
24    #[error(transparent)]
25    Response(#[from] ResponseError),
26    #[error("Response one_time_secret does not match the request")]
27    SecretMismatch,
28    #[error("Response account does not match the request")]
29    AccountMismatch,
30    #[error("Granted scopes or permissions differ from the request")]
31    GrantMismatch,
32}
33
34/// An outstanding request and the secrets needed to accept its response.
35/// Keep it until a response is accepted or the request expires, then drop it
36/// so a second response can't be accepted.
37#[derive(Debug)]
38pub struct PendingRequest {
39    request: CredentialRequest,
40    ephemeral_key_pair: Option<hpke::KeyPair>,
41}
42
43impl PendingRequest {
44    /// A request whose response is encrypted. Required for redirect and
45    /// mailbox delivery; recommended for post.
46    pub fn new(
47        rng: &mut impl Crng,
48        params: CredentialRequestParams,
49    ) -> Result<Self, RequestError> {
50        let key_pair = hpke::KeyPair::from_rng(rng);
51        Self::build(rng, params, Some(key_pair))
52    }
53
54    /// A request whose response is plaintext JSON. Only valid with
55    /// [`Delivery::Post`], where TLS protects the response in transit.
56    pub fn new_plaintext(
57        rng: &mut impl Crng,
58        params: CredentialRequestParams,
59    ) -> Result<Self, RequestError> {
60        Self::build(rng, params, None)
61    }
62
63    fn build(
64        rng: &mut impl Crng,
65        params: CredentialRequestParams,
66        ephemeral_key_pair: Option<hpke::KeyPair>,
67    ) -> Result<Self, RequestError> {
68        let request = CredentialRequest {
69            ephemeral_hpke_pubkey: ephemeral_key_pair
70                .as_ref()
71                .map(|key_pair| HpkePubkey::from_hpke(key_pair.public_key())),
72            one_time_secret: OneTimeSecret::from_rng(rng),
73            params,
74        };
75        request.validate()?;
76
77        Ok(Self {
78            request,
79            ephemeral_key_pair,
80        })
81    }
82
83    pub fn request(&self) -> &CredentialRequest {
84        &self.request
85    }
86
87    /// The connection string to open, QR-encode, or have the user paste.
88    pub fn connection_string(&self) -> String {
89        self.request.to_string()
90    }
91
92    /// Whether the response arrives at a mailbox, and if so, which address to
93    /// poll.
94    pub fn mailbox(&self) -> Option<(&str, Address)> {
95        match &self.request.params.delivery {
96            Delivery::Mailbox(url) =>
97                Some((url, self.request.mailbox_address())),
98            _ => None,
99        }
100    }
101
102    /// Accept the url the WALLET redirected the user to.
103    pub fn accept_redirect(
104        &self,
105        url: &str,
106    ) -> Result<CredentialResponse, AcceptError> {
107        let encoded = Uri::parse(url)
108            .ok()
109            .and_then(|uri| {
110                uri.params
111                    .into_iter()
112                    .find(|param| param.key == "response")
113                    .map(|param| param.value.into_owned())
114            })
115            .ok_or(AcceptError::MissingResponse)?;
116        let blob = Blob::from_base64url(&encoded)?;
117
118        self.accept_body(&blob.0)
119    }
120
121    /// Accept a POST body or mailbox blob. Decrypts it if this request's
122    /// response is encrypted, then checks that the response matches this
123    /// request:
124    ///
125    /// - `one_time_secret` and `account` are echoed.
126    /// - A grant carries exactly the requested scopes and permissions.
127    //
128    // Spec: Protocol fields and Response forgery.
129    pub fn accept_body(
130        &self,
131        body: &[u8],
132    ) -> Result<CredentialResponse, AcceptError> {
133        let response = match &self.ephemeral_key_pair {
134            Some(key_pair) =>
135                CredentialResponse::open(body, key_pair, &self.request)?,
136            None => CredentialResponse::from_json(body)?,
137        };
138
139        let request = &self.request;
140        if response.one_time_secret != request.one_time_secret {
141            return Err(AcceptError::SecretMismatch);
142        }
143        if response.account != request.params.account {
144            return Err(AcceptError::AccountMismatch);
145        }
146        if let CredentialResult::Granted(grant) = &response.result
147            && (grant.scopes != request.params.scopes
148                || grant.permissions != request.params.permissions)
149        {
150            return Err(AcceptError::GrantMismatch);
151        }
152
153        Ok(response)
154    }
155}