#[cfg(feature = "crypto")]
use base64::Engine;
use lexe_byte_array::ByteArray;
#[cfg(feature = "crypto")]
use lexe_crypto::{hpke, rng::Crng};
use lexe_sha256::sha256;
use crate::{
mailbox::Address,
request::{CredentialRequest, OneTimeSecret},
};
#[cfg(feature = "crypto")]
use crate::{requester::AcceptError, response::CredentialResponse};
#[cfg(feature = "crypto")]
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum SealError {
#[error("Request has no ephemeral_hpke_pubkey")]
NoPubkey,
#[error(transparent)]
Hpke(#[from] hpke::Error),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Blob(pub Vec<u8>);
#[cfg(feature = "crypto")]
impl Blob {
const BASE64URL: base64::engine::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub(crate) fn to_base64url(&self) -> String {
Self::BASE64URL.encode(&self.0)
}
pub(crate) fn from_base64url(s: &str) -> Result<Self, base64::DecodeError> {
Self::BASE64URL.decode(s).map(Self)
}
}
#[cfg(feature = "crypto")]
impl CredentialResponse {
pub fn seal(
self,
rng: &mut impl Crng,
request: &CredentialRequest,
) -> Result<Blob, SealError> {
let pk = request.ephemeral_hpke_pubkey.ok_or(SealError::NoPubkey)?;
let pk = pk.to_hpke();
let plaintext = self.to_json();
let sealed =
pk.seal(rng, &request.hpke_info(), request.hpke_aad(), &plaintext)?;
Ok(Blob(sealed))
}
pub(crate) fn open(
sealed: &[u8],
key_pair: &hpke::KeyPair,
request: &CredentialRequest,
) -> Result<Self, AcceptError> {
let plaintext =
key_pair.open(&request.hpke_info(), request.hpke_aad(), sealed)?;
Ok(Self::from_json(&plaintext)?)
}
}
impl CredentialRequest {
const DOMAIN_SEPARATOR: &str = "LexeConnect-v1";
const HPKE_INFO_LEN: usize =
Self::DOMAIN_SEPARATOR.len() + size_of::<OneTimeSecret>();
fn hpke_info(&self) -> [u8; Self::HPKE_INFO_LEN] {
let mut info = [0u8; Self::HPKE_INFO_LEN];
let (separator, secret) =
info.split_at_mut(Self::DOMAIN_SEPARATOR.len());
separator.copy_from_slice(Self::DOMAIN_SEPARATOR.as_bytes());
secret.copy_from_slice(self.one_time_secret.as_slice());
info
}
#[cfg(feature = "crypto")]
fn hpke_aad(&self) -> &[u8] {
self.params.account.as_deref().map_or(&[], str::as_bytes)
}
pub fn mailbox_address(&self) -> Address {
Address::from_array(sha256::digest(&self.hpke_info()).to_array())
}
}
#[cfg(all(test, feature = "crypto"))]
mod test {
use std::collections::BTreeSet;
use lexe_common::time::TimestampMs;
use lexe_crypto::rng::FixedRng;
use lexe_hex::hex;
use super::*;
use crate::response::{
CredentialError, CredentialResult, ErrorCode, Grant,
};
#[test]
fn spec_test_vectors() {
let ikm_r: [u8; 32] = std::array::from_fn(|i| i as u8);
let ikm_e = (0x20u8..0x40).collect::<Vec<u8>>();
let key_pair = hpke::KeyPair::from_seed(&ikm_r);
assert_eq!(
key_pair.public_key().to_string(),
"b1f1b840de7a3241b02748cf9b05b74dc8c5e8451298738817bd76aa8ebe8c2b"
);
let request = CredentialRequest::parse(
"https://lexe.app/connect?v=1\
&redirect_uri=https%3A%2F%2Fbillsplit.com%2Fcb\
&ephemeral_hpke_pubkey=\
b1f1b840de7a3241b02748cf9b05b74dc8c5e8451298738817bd76aa8ebe8c2b\
&one_time_secret=000102030405060708090a0b0c0d0e0f\
&account=%40janedoe&scopes=read_info,receive",
)
.unwrap();
assert_eq!(
hex::encode(&request.hpke_info()),
"4c657865436f6e6e6563742d7631000102030405060708090a0b0c0d0e0f"
);
assert_eq!(hex::encode(request.hpke_aad()), "406a616e65646f65");
let granted = CredentialResponse {
result: CredentialResult::Granted(Grant {
credential: "<client credential>".to_owned(),
scopes: ["read_info", "receive"].map(String::from).into(),
permissions: BTreeSet::new(),
expires_at: Some(
TimestampMs::from_millis(1821484800000).unwrap(),
),
}),
one_time_secret: request.one_time_secret,
account: request.params.account.clone(),
metadata: None,
};
let rejected = CredentialResponse {
result: CredentialResult::Error(CredentialError {
code: ErrorCode::UserRejected,
message: None,
}),
one_time_secret: request.one_time_secret,
account: request.params.account.clone(),
metadata: None,
};
let vectors = [
(
granted,
r#"{"credential":"<client credential>","one_time_secret":"000102030405060708090a0b0c0d0e0f","account":"@janedoe","scopes":["read_info","receive"],"permissions":[],"expires_at":1821484800000}"#,
"693658254630f73ad8da78fb331bf976cd42f90e0e9c9e83f40c51072a6f741718c87dfe078291112ed11fbd29fea41f7e497c02a693481c499717cb9629bf33148f84ebf928eb111ed0525e2d2cc20f23ed8043bba85f5bc6eed0b463e41461d1fc4b8238e0c59aea1e89b6041ebb0718ea5f8133994b74cd6aa22056e9e1827a5daac94185756c7a53dc4ab5d3c3f68aef2293063d337e3726489013a06e27ae68a35c9f8ae7b7fa819dafd8298949878936df24a556c2d92cd08241456aa0a7fc06f1d0562c0d0757c368b873f320f167487a45d80076451cf9135074fd7a81e94dde3e7f9ece9c5676",
),
(
rejected,
r#"{"error":"user_rejected","one_time_secret":"000102030405060708090a0b0c0d0e0f","account":"@janedoe"}"#,
"693658254630f73ad8da78fb331bf976cd42f90e0e9c9e83f40c51072a6f741718c87bfe1089865d609a0ba26eb6d951784f7004bc820c5d17d01cc09d02a23b15d4f9b4be24f711358601117843815b70add504a9a64d5ec6e8d1b361ec1768d5ad4ed03eb3c2cee24b80e04702fb5618b954c438dd0f6cc308a9225be3f0836b5dedff05a6d01574bfc9c06ccb2be20b0139",
),
];
for (response, plaintext, blob_hex) in vectors {
assert_eq!(response.clone().to_json(), plaintext.as_bytes());
let mut rng = FixedRng(ikm_e.clone());
let blob = response.seal(&mut rng, &request).unwrap();
assert_eq!(hex::encode(&blob.0), blob_hex);
let opened =
CredentialResponse::open(&blob.0, &key_pair, &request).unwrap();
assert_eq!(opened.to_json(), plaintext.as_bytes());
}
}
}