Skip to main content

lexe_connect/
seal.rs

1//! HPKE sealing of responses.
2
3#[cfg(feature = "crypto")]
4use base64::Engine;
5use lexe_byte_array::ByteArray;
6#[cfg(feature = "crypto")]
7use lexe_crypto::{hpke, rng::Crng};
8use lexe_sha256::sha256;
9
10use crate::{
11    mailbox::Address,
12    request::{CredentialRequest, OneTimeSecret},
13};
14#[cfg(feature = "crypto")]
15use crate::{requester::AcceptError, response::CredentialResponse};
16
17#[cfg(feature = "crypto")]
18#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
19pub enum SealError {
20    #[error("Request has no ephemeral_hpke_pubkey")]
21    NoPubkey,
22    #[error(transparent)]
23    Hpke(#[from] hpke::Error),
24}
25
26/// A sealed [`CredentialResponse`]: the encapsulated key followed by the
27/// ciphertext, from single-shot HPKE under the request's
28/// `ephemeral_hpke_pubkey`, `hpke_info`, and `hpke_aad`.
29///
30/// [`CredentialResponse`]: crate::response::CredentialResponse
31//
32// Spec: Encryption.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Blob(pub Vec<u8>);
35
36#[cfg(feature = "crypto")]
37impl Blob {
38    const BASE64URL: base64::engine::GeneralPurpose =
39        base64::engine::general_purpose::URL_SAFE_NO_PAD;
40
41    pub(crate) fn to_base64url(&self) -> String {
42        Self::BASE64URL.encode(&self.0)
43    }
44
45    pub(crate) fn from_base64url(s: &str) -> Result<Self, base64::DecodeError> {
46        Self::BASE64URL.decode(s).map(Self)
47    }
48}
49
50#[cfg(feature = "crypto")]
51impl CredentialResponse {
52    /// Seal to the request's `ephemeral_hpke_pubkey`.
53    pub fn seal(
54        self,
55        rng: &mut impl Crng,
56        request: &CredentialRequest,
57    ) -> Result<Blob, SealError> {
58        let pk = request.ephemeral_hpke_pubkey.ok_or(SealError::NoPubkey)?;
59        let pk = pk.to_hpke();
60        let plaintext = self.to_json();
61
62        let sealed =
63            pk.seal(rng, &request.hpke_info(), request.hpke_aad(), &plaintext)?;
64
65        Ok(Blob(sealed))
66    }
67
68    /// Open a sealed blob with the REQUESTER's ephemeral key pair. This
69    /// proves the response was sealed to this request; see
70    /// [`PendingRequest::accept_body`](crate::requester::PendingRequest::accept_body)
71    /// for the checks a REQUESTER must still make.
72    pub(crate) fn open(
73        sealed: &[u8],
74        key_pair: &hpke::KeyPair,
75        request: &CredentialRequest,
76    ) -> Result<Self, AcceptError> {
77        let plaintext =
78            key_pair.open(&request.hpke_info(), request.hpke_aad(), sealed)?;
79
80        Ok(Self::from_json(&plaintext)?)
81    }
82}
83
84impl CredentialRequest {
85    /// Lexe's HPKE domain separator.
86    //
87    // Spec: WALLET-defined parts.
88    const DOMAIN_SEPARATOR: &str = "LexeConnect-v1";
89
90    /// The length of [`Self::hpke_info`].
91    const HPKE_INFO_LEN: usize =
92        Self::DOMAIN_SEPARATOR.len() + size_of::<OneTimeSecret>();
93
94    /// The HPKE `info`: `DOMAIN_SEPARATOR || one_time_secret`.
95    //
96    // Spec: Encryption.
97    fn hpke_info(&self) -> [u8; Self::HPKE_INFO_LEN] {
98        let mut info = [0u8; Self::HPKE_INFO_LEN];
99        let (separator, secret) =
100            info.split_at_mut(Self::DOMAIN_SEPARATOR.len());
101        separator.copy_from_slice(Self::DOMAIN_SEPARATOR.as_bytes());
102        secret.copy_from_slice(self.one_time_secret.as_slice());
103        info
104    }
105
106    /// The HPKE `aad`: the `account`, or empty if unset.
107    //
108    // Spec: Encryption.
109    #[cfg(feature = "crypto")]
110    fn hpke_aad(&self) -> &[u8] {
111        self.params.account.as_deref().map_or(&[], str::as_bytes)
112    }
113
114    /// Where a mailbox response is deposited: `SHA256(hpke_info)`.
115    //
116    // Spec: `mailbox_url` delivery.
117    pub fn mailbox_address(&self) -> Address {
118        Address::from_array(sha256::digest(&self.hpke_info()).to_array())
119    }
120}
121
122#[cfg(all(test, feature = "crypto"))]
123mod test {
124    use std::collections::BTreeSet;
125
126    use lexe_common::time::TimestampMs;
127    use lexe_crypto::rng::FixedRng;
128    use lexe_hex::hex;
129
130    use super::*;
131    use crate::response::{
132        CredentialError, CredentialResult, ErrorCode, Grant,
133    };
134
135    /// The spec's "Test vectors" section; keep the two in sync.
136    #[test]
137    fn spec_test_vectors() {
138        // `ikmR` is 0x00..=0x1f, `ikmE` is 0x20..=0x3f.
139        let ikm_r: [u8; 32] = std::array::from_fn(|i| i as u8);
140        let ikm_e = (0x20u8..0x40).collect::<Vec<u8>>();
141        let key_pair = hpke::KeyPair::from_seed(&ikm_r);
142        assert_eq!(
143            key_pair.public_key().to_string(),
144            "b1f1b840de7a3241b02748cf9b05b74dc8c5e8451298738817bd76aa8ebe8c2b"
145        );
146
147        let request = CredentialRequest::parse(
148            "https://lexe.app/connect?v=1\
149             &redirect_uri=https%3A%2F%2Fbillsplit.com%2Fcb\
150             &ephemeral_hpke_pubkey=\
151             b1f1b840de7a3241b02748cf9b05b74dc8c5e8451298738817bd76aa8ebe8c2b\
152             &one_time_secret=000102030405060708090a0b0c0d0e0f\
153             &account=%40janedoe&scopes=read_info,receive",
154        )
155        .unwrap();
156        assert_eq!(
157            hex::encode(&request.hpke_info()),
158            "4c657865436f6e6e6563742d7631000102030405060708090a0b0c0d0e0f"
159        );
160        assert_eq!(hex::encode(request.hpke_aad()), "406a616e65646f65");
161
162        let granted = CredentialResponse {
163            result: CredentialResult::Granted(Grant {
164                credential: "<client credential>".to_owned(),
165                scopes: ["read_info", "receive"].map(String::from).into(),
166                permissions: BTreeSet::new(),
167                expires_at: Some(
168                    TimestampMs::from_millis(1821484800000).unwrap(),
169                ),
170            }),
171            one_time_secret: request.one_time_secret,
172            account: request.params.account.clone(),
173            metadata: None,
174        };
175        let rejected = CredentialResponse {
176            result: CredentialResult::Error(CredentialError {
177                code: ErrorCode::UserRejected,
178                message: None,
179            }),
180            one_time_secret: request.one_time_secret,
181            account: request.params.account.clone(),
182            metadata: None,
183        };
184        let vectors = [
185            (
186                granted,
187                r#"{"credential":"<client credential>","one_time_secret":"000102030405060708090a0b0c0d0e0f","account":"@janedoe","scopes":["read_info","receive"],"permissions":[],"expires_at":1821484800000}"#,
188                "693658254630f73ad8da78fb331bf976cd42f90e0e9c9e83f40c51072a6f741718c87dfe078291112ed11fbd29fea41f7e497c02a693481c499717cb9629bf33148f84ebf928eb111ed0525e2d2cc20f23ed8043bba85f5bc6eed0b463e41461d1fc4b8238e0c59aea1e89b6041ebb0718ea5f8133994b74cd6aa22056e9e1827a5daac94185756c7a53dc4ab5d3c3f68aef2293063d337e3726489013a06e27ae68a35c9f8ae7b7fa819dafd8298949878936df24a556c2d92cd08241456aa0a7fc06f1d0562c0d0757c368b873f320f167487a45d80076451cf9135074fd7a81e94dde3e7f9ece9c5676",
189            ),
190            (
191                rejected,
192                r#"{"error":"user_rejected","one_time_secret":"000102030405060708090a0b0c0d0e0f","account":"@janedoe"}"#,
193                "693658254630f73ad8da78fb331bf976cd42f90e0e9c9e83f40c51072a6f741718c87bfe1089865d609a0ba26eb6d951784f7004bc820c5d17d01cc09d02a23b15d4f9b4be24f711358601117843815b70add504a9a64d5ec6e8d1b361ec1768d5ad4ed03eb3c2cee24b80e04702fb5618b954c438dd0f6cc308a9225be3f0836b5dedff05a6d01574bfc9c06ccb2be20b0139",
194            ),
195        ];
196
197        for (response, plaintext, blob_hex) in vectors {
198            assert_eq!(response.clone().to_json(), plaintext.as_bytes());
199
200            let mut rng = FixedRng(ikm_e.clone());
201            let blob = response.seal(&mut rng, &request).unwrap();
202            assert_eq!(hex::encode(&blob.0), blob_hex);
203
204            let opened =
205                CredentialResponse::open(&blob.0, &key_pair, &request).unwrap();
206            assert_eq!(opened.to_json(), plaintext.as_bytes());
207        }
208    }
209}