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