1use std::{fmt, str::FromStr, sync::Arc};
4
5use anyhow::{Context, anyhow};
6use base64::Engine;
7use lexe_api_core::revocable_clients::models::CreateRevocableClientResponse;
8use lexe_common::{
9 api::{
10 auth::{BearerAuthToken, LexeScope},
11 user::UserPk,
12 },
13 env::DeployEnv,
14 root_seed::RootSeed,
15};
16#[cfg(any(test, feature = "test-utils"))]
17use lexe_crypto::rng::FastRng;
18use lexe_crypto::{ed25519, rng::Crng};
19#[cfg(any(test, feature = "test-utils"))]
20use lexe_tls::shared_seed::certs::{
21 EphemeralIssuingCaCert, RevocableClientCert, RevocableIssuingCaCert,
22};
23use lexe_tls::{
24 rustls, shared_seed,
25 types::{LxCertificateDer, LxPrivatePkcs8KeyDer},
26};
27#[cfg(any(test, feature = "test-utils"))]
28use proptest::{
29 prelude::{Arbitrary, any},
30 strategy::{BoxedStrategy, Strategy},
31};
32use serde::{Deserialize, Serialize};
33
34use crate::auth::BearerAuthenticator;
35
36pub enum Credentials {
40 RootSeed(RootSeed),
42 ClientCredentials(ClientCredentials),
44}
45
46#[derive(Copy, Clone)]
48pub enum CredentialsRef<'a> {
49 RootSeed(&'a RootSeed),
51 ClientCredentials(&'a ClientCredentials),
53}
54
55#[derive(Clone, Serialize, Deserialize)]
59#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
60pub struct ClientCredentials {
61 #[serde(skip_serializing_if = "Option::is_none")]
65 pub user_pk: Option<UserPk>,
70 pub client_pk: ed25519::PublicKey,
72 pub rev_client_key_der: LxPrivatePkcs8KeyDer,
74 pub rev_client_cert_der: LxCertificateDer,
76 pub eph_ca_cert_der: LxCertificateDer,
78 #[serde(
82 rename = "lexe_auth_token",
83 alias = "gateway_proxy_token",
84 skip_serializing_if = "Option::is_none"
85 )]
86 pub gateway_proxy_token: Option<BearerAuthToken>,
87}
88
89impl Credentials {
92 pub fn as_ref(&self) -> CredentialsRef<'_> {
93 match self {
94 Credentials::RootSeed(root_seed) =>
95 CredentialsRef::RootSeed(root_seed),
96 Credentials::ClientCredentials(client_credentials) =>
97 CredentialsRef::ClientCredentials(client_credentials),
98 }
99 }
100}
101
102impl From<RootSeed> for Credentials {
103 fn from(root_seed: RootSeed) -> Self {
104 Credentials::RootSeed(root_seed)
105 }
106}
107
108impl From<ClientCredentials> for Credentials {
109 fn from(client_credentials: ClientCredentials) -> Self {
110 Credentials::ClientCredentials(client_credentials)
111 }
112}
113
114impl<'a> From<&'a RootSeed> for CredentialsRef<'a> {
115 fn from(root_seed: &'a RootSeed) -> Self {
116 CredentialsRef::RootSeed(root_seed)
117 }
118}
119
120impl<'a> From<&'a ClientCredentials> for CredentialsRef<'a> {
121 fn from(client_credentials: &'a ClientCredentials) -> Self {
122 CredentialsRef::ClientCredentials(client_credentials)
123 }
124}
125
126impl<'a> CredentialsRef<'a> {
127 pub fn user_pk(&self) -> Option<UserPk> {
131 match self {
132 Self::RootSeed(root_seed) => Some(root_seed.derive_user_pk()),
133 Self::ClientCredentials(cc) => cc.user_pk,
134 }
135 }
136
137 pub fn bearer_authenticator(
142 &self,
143 ) -> anyhow::Result<Arc<BearerAuthenticator>> {
144 match self {
145 Self::RootSeed(root_seed) => Ok(Arc::new(
147 BearerAuthenticator::new(root_seed.derive_user_key_pair()),
148 )),
149 Self::ClientCredentials(client_credentials) => {
152 let gateway_proxy_token =
153 client_credentials.gateway_proxy_token.clone().context(
154 "Cannot connect to note; exported client credentials \
155 are missing a gateway proxy token.",
156 )?;
157 Ok(Arc::new(BearerAuthenticator::new_static_token(
158 gateway_proxy_token,
159 LexeScope::GatewayProxy,
160 )))
161 }
162 }
163 }
164
165 pub fn tls_config(
167 &self,
168 rng: &mut impl Crng,
169 deploy_env: DeployEnv,
170 ) -> anyhow::Result<rustls::ClientConfig> {
171 match self {
172 Self::RootSeed(root_seed) =>
173 shared_seed::user_node_run_root_seed_client_config(
174 rng, deploy_env, root_seed,
175 )
176 .context("Failed to build RootSeed TLS client config"),
177 Self::ClientCredentials(client_credentials) =>
178 shared_seed::user_node_run_revocable_client_config(
179 deploy_env,
180 &client_credentials.eph_ca_cert_der,
181 client_credentials.rev_client_cert_der.clone(),
182 client_credentials.rev_client_key_der.clone(),
183 )
184 .context("Failed to build revocable client TLS config"),
185 }
186 }
187}
188
189impl fmt::Debug for ClientCredentials {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.write_str("ClientCredentials(..)")
194 }
195}
196
197impl FromStr for ClientCredentials {
198 type Err = anyhow::Error;
199
200 fn from_str(s: &str) -> Result<Self, Self::Err> {
201 Self::try_from_base64_blob(s)
202 }
203}
204
205impl From<CreateRevocableClientResponse> for ClientCredentials {
206 fn from(resp: CreateRevocableClientResponse) -> Self {
207 ClientCredentials {
208 user_pk: resp.user_pk,
209 client_pk: resp.pubkey,
210 rev_client_key_der: LxPrivatePkcs8KeyDer(
211 resp.rev_client_cert_key_der,
212 ),
213 rev_client_cert_der: LxCertificateDer(resp.rev_client_cert_der),
214 eph_ca_cert_der: LxCertificateDer(resp.eph_ca_cert_der),
215 gateway_proxy_token: resp.gateway_proxy_token,
216 }
217 }
218}
219
220impl ClientCredentials {
221 pub fn to_base64_blob(&self) -> String {
236 let json_str =
237 serde_json::to_string(self).expect("Failed to JSON serialize");
238 base64::engine::general_purpose::STANDARD_NO_PAD
239 .encode(json_str.as_bytes())
240 }
241
242 pub fn try_from_base64_blob(s: &str) -> anyhow::Result<Self> {
251 let s = s.trim().trim_end_matches('=');
252 let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
253 .decode(s)
254 .context("String is not valid base64")?;
255 let string =
256 String::from_utf8(bytes).context("String is not valid UTF-8")?;
257 let cc = serde_json::from_str::<ClientCredentials>(&string)
258 .context("Failed to deserialize")?;
259
260 let rev_client_keypair = ed25519::KeyPair::deserialize_pkcs8_der(
263 cc.rev_client_key_der.as_bytes(),
264 )
265 .map_err(|_| anyhow!("Client key is invalid or corrupted"))?;
266 if rev_client_keypair.public_key() != &cc.client_pk {
267 return Err(anyhow!("Client key does not match client public key"));
268 }
269
270 Ok(cc)
271 }
272}
273
274#[cfg(any(test, feature = "test-utils"))]
276impl Arbitrary for ClientCredentials {
277 type Parameters = ();
278 type Strategy = BoxedStrategy<ClientCredentials>;
279 fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
280 let root_seed = any::<RootSeed>();
281 let rng = any::<FastRng>();
282 let gateway_proxy_token = any::<Option<BearerAuthToken>>();
283 let has_user_pk = any::<bool>();
284
285 (root_seed, rng, gateway_proxy_token, has_user_pk)
286 .prop_map(
287 |(root_seed, mut rng, gateway_proxy_token, has_user_pk)| {
288 let eph_ca_cert =
290 EphemeralIssuingCaCert::from_root_seed(&root_seed);
291 let rev_ca_cert =
292 RevocableIssuingCaCert::from_root_seed(&root_seed);
293 let rev_client_cert =
294 RevocableClientCert::generate_from_rng(&mut rng);
295
296 let user_pk =
298 has_user_pk.then(|| root_seed.derive_user_pk());
299 let client_pk = rev_client_cert.public_key().to_owned();
300 let rev_client_key_der =
301 rev_client_cert.serialize_key_der();
302 let rev_client_cert_der = rev_client_cert
303 .serialize_der_ca_signed(&rev_ca_cert)
304 .unwrap();
305 let eph_ca_cert_der =
306 eph_ca_cert.serialize_der_self_signed().unwrap();
307
308 ClientCredentials {
309 user_pk,
310 client_pk,
311 rev_client_key_der,
312 rev_client_cert_der,
313 eph_ca_cert_der,
314 gateway_proxy_token,
315 }
316 },
317 )
318 .boxed()
319 }
320}
321
322#[cfg(test)]
323mod test {
324 use std::fs;
325
326 use lexe_common::{
327 byte_str::ByteStr,
328 test_utils::{arbitrary, snapshot},
329 };
330 use lexe_crypto::rng::FastRng;
331 use lexe_tls::shared_seed::certs::{
332 EphemeralIssuingCaCert, RevocableIssuingCaCert,
333 };
334 use proptest::{prelude::any, prop_assert_eq, proptest};
335
336 use super::*;
337
338 #[test]
344 fn prop_client_credentials_base64_roundtrip() {
345 proptest!(|(creds1 in proptest::prelude::any::<ClientCredentials>())| {
346 {
349 let new_base64_blob = creds1.to_base64_blob();
350
351 let creds2 =
352 ClientCredentials::try_from_base64_blob(&new_base64_blob)
353 .expect("Failed to decode from new format");
354
355 prop_assert_eq!(&creds1, &creds2);
356 }
357
358 {
362 let json_str = serde_json::to_string(&creds1)
363 .expect("Failed to JSON serialize");
364 let old_base64_blob = base64::engine::general_purpose::STANDARD
365 .encode(json_str.as_bytes());
366 let creds2 =
367 ClientCredentials::try_from_base64_blob(&old_base64_blob)
368 .expect("Failed to decode from old format");
369
370 prop_assert_eq!(&creds1, &creds2);
371 }
372 });
373 }
374
375 #[test]
378 fn prop_base64_pad_to_no_pad_compat() {
379 proptest!(|(bytes1 in any::<Vec<u8>>())| {
380 let string =
381 base64::engine::general_purpose::STANDARD.encode(&bytes1);
382 let trimmed = string.trim_end_matches('=');
383 let bytes2 = base64::engine::general_purpose::STANDARD_NO_PAD
384 .decode(trimmed)
385 .expect("Failed to decode base64");
386 prop_assert_eq!(bytes1, bytes2);
387 })
388 }
389
390 #[test]
391 fn test_client_auth_encoding() {
392 let mut rng = FastRng::from_u64(202505121546);
393 let root_seed = RootSeed::from_rng(&mut rng);
394
395 let user_pk = root_seed.derive_user_pk();
396
397 let eph_ca_cert = EphemeralIssuingCaCert::from_root_seed(&root_seed);
398 let eph_ca_cert_der = eph_ca_cert.serialize_der_self_signed().unwrap();
399
400 let rev_ca_cert = RevocableIssuingCaCert::from_root_seed(&root_seed);
401
402 let rev_client_cert = RevocableClientCert::generate_from_rng(&mut rng);
403 let rev_client_cert_der = rev_client_cert
404 .serialize_der_ca_signed(&rev_ca_cert)
405 .unwrap();
406 let rev_client_key_der = rev_client_cert.serialize_key_der();
407 let client_pk = rev_client_cert.public_key();
408
409 let credentials = ClientCredentials {
410 user_pk: Some(user_pk),
411 client_pk: *client_pk,
412 rev_client_key_der,
413 rev_client_cert_der,
414 eph_ca_cert_der,
415 gateway_proxy_token: Some(BearerAuthToken(ByteStr::from_static(
416 "9dTCUvC8y7qcNyUbqynz3nwIQQHbQqPVKeMhXUj1Afr-vgj9E217_2tCS1IQM7LFqfBUC8Ec9fcb-dQiCRy6ot2FN-kR60edRFJUztAa2Rxao1Q0BS1s6vE8grgfhMYIAJDLMWgAAAAASE4zaAAAAABpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaQE",
417 ))),
418 };
419
420 let credentials_str = credentials.to_base64_blob();
421
422 let expected_str = "eyJ1c2VyX3BrIjoiNmZkNzY0MTU2OTMwNTA5ZmFkNTM2MWQzYjIyYjYxZjc1YWE5MWVkNjQwMjE1YjJjNDFjMmZmODZiMmJmYzQ3MiIsImNsaWVudF9wayI6IjcwODhhZjFmYzEyYWIwNGFkNmRkMTY1YmMzYTNjNWViMzA2MmI0MTFhMmY1NWExNjZiMGU0MDBiMzkwZmU0ZGIiLCJyZXZfY2xpZW50X2tleV9kZXIiOiIzMDUxMDIwMTAxMzAwNTA2MDMyYjY1NzAwNDIyMDQyMDBmNTgwZDM0NjFjNGVhMGIzNmI4MzZkNDUxYzFjMTk5ZWUzZTA2NDZhZDBkNjQyMzUzNzk3MzlkNjg2OTkyODk4MTIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiIiwicmV2X2NsaWVudF9jZXJ0X2RlciI6IjMwODIwMTgzMzA4MjAxMzVhMDAzMDIwMTAyMDIxNDQwYmVkYzU2ZDAzZDZiNTJmMjg0MmQ2NGRmOTBkMDJkNmRhMzZhNWIzMDA1MDYwMzJiNjU3MDMwNTYzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTI3MzAyNTA2MDM1NTA0MDMwYzFlNGM2NTc4NjUyMDcyNjU3NjZmNjM2MTYyNmM2NTIwNjk3MzczNzU2OTZlNjcyMDQzNDEyMDYzNjU3Mjc0MzAyMDE3MGQzNzM1MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTE4MGYzNDMwMzkzNjMwMzEzMDMxMzAzMDMwMzAzMDMwNWEzMDUyMzEwYjMwMDkwNjAzNTUwNDA2MGMwMjU1NTMzMTBiMzAwOTA2MDM1NTA0MDgwYzAyNDM0MTMxMTEzMDBmMDYwMzU1MDQwYTBjMDg2YzY1Nzg2NTJkNjE3MDcwMzEyMzMwMjEwNjAzNTUwNDAzMGMxYTRjNjU3ODY1MjA3MjY1NzY2ZjYzNjE2MjZjNjUyMDYzNmM2OTY1NmU3NDIwNjM2NTcyNzQzMDJhMzAwNTA2MDMyYjY1NzAwMzIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiYTMxNzMwMTUzMDEzMDYwMzU1MWQxMTA0MGMzMDBhODIwODZjNjU3ODY1MmU2MTcwNzAzMDA1MDYwMzJiNjU3MDAzNDEwMDdiMTdiYzk1MzgyNjdiMzU0ZjA3MjZkODljYjFlYzMxMGIxMDJlNDIyYWI5Njk2Yjg3ZDlhZTcwMGNlZjJlODNjMTM2NmQwYWQxOTAzNWQ5ZTNlZDA0Y2Y1ZjdmMDVkZWY2OGE3MWRlMjEyYjg5ODM0NDc3OTQyYWU3NjNhMjBmIiwiZXBoX2NhX2NlcnRfZGVyIjoiMzA4MjAxYWUzMDgyMDE2MGEwMDMwMjAxMDIwMjE0MTBjZDVjOTk4OWY5NjUyMDk0OWUwZTlhYjRjZTRkYmUxNDc2NjcxMDMwMDUwNjAzMmI2NTcwMzA1MDMxMGIzMDA5MDYwMzU1MDQwNjBjMDI1NTUzMzEwYjMwMDkwNjAzNTUwNDA4MGMwMjQzNDEzMTExMzAwZjA2MDM1NTA0MGEwYzA4NmM2NTc4NjUyZDYxNzA3MDMxMjEzMDFmMDYwMzU1MDQwMzBjMTg0YzY1Nzg2NTIwNzM2ODYxNzI2NTY0MjA3MzY1NjU2NDIwNDM0MTIwNjM2NTcyNzQzMDIwMTcwZDM3MzUzMDMxMzAzMTMwMzAzMDMwMzAzMDVhMTgwZjM0MzAzOTM2MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTMwNTAzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTIxMzAxZjA2MDM1NTA0MDMwYzE4NGM2NTc4NjUyMDczNjg2MTcyNjU2NDIwNzM2NTY1NjQyMDQzNDEyMDYzNjU3Mjc0MzAyYTMwMDUwNjAzMmI2NTcwMDMyMTAwZWZlOWNlMWFiY2FlYWJjZWY4ZWEyZjU0YTU2OTU1MGRjZWQ0YThmM2E4Y2JiMDRjZDk0NWQxYjRlMjQ1ZjY4N2EzNGEzMDQ4MzAxMzA2MDM1NTFkMTEwNDBjMzAwYTgyMDg2YzY1Nzg2NTJlNjE3MDcwMzAxZDA2MDM1NTFkMGUwNDE2MDQxNDkwY2Q1Yzk5ODlmOTY1MjA5NDllMGU5YWI0Y2U0ZGJlMTQ3NjY3MTAzMDEyMDYwMzU1MWQxMzAxMDFmZjA0MDgzMDA2MDEwMWZmMDIwMTAwMzAwNTA2MDMyYjY1NzAwMzQxMDAzNzI1NDI5ZjViY2E4MDU2MjFjMmIyZGM0NDU4MDJlZDIxY2FiMjQ2YjQ1YWQxMjFkZDJhNDMyZWZhMmY5M2VmNzI1ZWZhMTc4MmU2NDEwOGQyMjk4ZTg2OTRmNDY4NmNlZDk4Y2U5MjgwZWQ3NDlkMGFkNGI0NGE0YTFjZWUwZCIsImxleGVfYXV0aF90b2tlbiI6IjlkVENVdkM4eTdxY055VWJxeW56M253SVFRSGJRcVBWS2VNaFhVajFBZnItdmdqOUUyMTdfMnRDUzFJUU03TEZxZkJVQzhFYzlmY2ItZFFpQ1J5Nm90MkZOLWtSNjBlZFJGSlV6dEFhMlJ4YW8xUTBCUzFzNnZFOGdyZ2ZoTVlJQUpETE1XZ0FBQUFBU0U0emFBQUFBQUJwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFRRSJ9";
428 assert_eq!(credentials_str, expected_str);
429
430 let credentials2 =
431 ClientCredentials::try_from_base64_blob(&credentials_str)
432 .expect("Failed to decode ClientAuth");
433 assert_eq!(credentials, credentials2);
434 }
435
436 #[test]
442 #[ignore]
443 fn take_client_credentials_snapshot() {
444 let mut rng = FastRng::from_u64(202512210138);
445 const N: usize = 3;
446
447 let samples: Vec<ClientCredentials> =
448 arbitrary::gen_values(&mut rng, any::<ClientCredentials>(), N);
449
450 for sample in samples {
451 println!("{}", serde_json::to_string(&sample).unwrap());
452 }
453 }
454
455 #[test]
457 fn client_credentials_deser_compat() {
458 let snapshot =
459 fs::read_to_string("test_data/client_credentials_snapshot.txt")
460 .unwrap();
461
462 for input in snapshot::parse_sample_data(&snapshot) {
463 let value1: ClientCredentials =
464 serde_json::from_str(input).unwrap();
465 let output = serde_json::to_string(&value1).unwrap();
466 let value2: ClientCredentials =
467 serde_json::from_str(&output).unwrap();
468 assert_eq!(value1, value2);
469 }
470 }
471}