1#[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#[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 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 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 const DOMAIN_SEPARATOR: &str = "LexeConnect-v1";
89
90 const HPKE_INFO_LEN: usize =
92 Self::DOMAIN_SEPARATOR.len() + size_of::<OneTimeSecret>();
93
94 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 #[cfg(feature = "crypto")]
110 fn hpke_aad(&self) -> &[u8] {
111 self.params.account.as_deref().map_or(&[], str::as_bytes)
112 }
113
114 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 #[test]
137 fn spec_test_vectors() {
138 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}