Skip to main content

lexe_node_client/
credentials.rs

1//! Client credentials for authentication with Lexe services.
2
3use std::{fmt, str::FromStr, sync::Arc};
4
5use anyhow::Context;
6use base64::Engine;
7use lexe_api::auth::BearerAuthenticator;
8use lexe_common::{
9    api::{
10        auth::{BearerAuthToken, Scope},
11        revocable_clients::CreateRevocableClientResponse,
12        user::UserPk,
13    },
14    env::DeployEnv,
15    root_seed::RootSeed,
16};
17use lexe_crypto::{ed25519, rng::Crng};
18use lexe_tls::{
19    rustls, shared_seed,
20    types::{LxCertificateDer, LxPrivatePkcs8KeyDer},
21};
22#[cfg(any(test, feature = "test-utils"))]
23use proptest::{prelude::any, strategy::Strategy};
24#[cfg(any(test, feature = "test-utils"))]
25use proptest_derive::Arbitrary;
26use serde::{Deserialize, Serialize};
27
28/// Credentials used to authenticate with a Lexe user node.
29//
30// Required to connect to a user node via mTLS.
31pub enum Credentials {
32    /// Using a [`RootSeed`]. Ex: app.
33    RootSeed(RootSeed),
34    /// Using a revocable client cert. Ex: SDK sidecar.
35    ClientCredentials(ClientCredentials),
36}
37
38/// Borrowed version of [`Credentials`].
39#[derive(Copy, Clone)]
40pub enum CredentialsRef<'a> {
41    /// Using a [`RootSeed`]. Ex: app.
42    RootSeed(&'a RootSeed),
43    /// Using a revocable client cert. Ex: SDK sidecar.
44    ClientCredentials(&'a ClientCredentials),
45}
46
47/// All secrets required for an SDK client to authenticate with a user's node.
48/// Encoded as a base64 JSON blob for easy transport (e.g. via env var or
49/// config file).
50#[derive(Clone, Serialize, Deserialize)]
51#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary, Eq, PartialEq))]
52pub struct ClientCredentials {
53    /// The user public key.
54    ///
55    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    #[cfg_attr(
58        any(test, feature = "test-utils"),
59        proptest(strategy = "any::<UserPk>().prop_map(Some)")
60    )]
61    pub user_pk: Option<UserPk>,
62    /// The base64 encoded long-lived connect token.
63    pub lexe_auth_token: BearerAuthToken,
64    /// The hex-encoded client public key.
65    pub client_pk: ed25519::PublicKey,
66    /// The DER-encoded client key.
67    pub rev_client_key_der: LxPrivatePkcs8KeyDer,
68    /// The DER-encoded cert of the revocable client.
69    pub rev_client_cert_der: LxCertificateDer,
70    /// The DER-encoded cert of the ephemeral issuing CA.
71    pub eph_ca_cert_der: LxCertificateDer,
72}
73
74// --- impl Credentials / CredentialsRef --- //
75
76impl Credentials {
77    pub fn as_ref(&self) -> CredentialsRef<'_> {
78        match self {
79            Credentials::RootSeed(root_seed) =>
80                CredentialsRef::RootSeed(root_seed),
81            Credentials::ClientCredentials(client_credentials) =>
82                CredentialsRef::ClientCredentials(client_credentials),
83        }
84    }
85}
86
87impl From<RootSeed> for Credentials {
88    fn from(root_seed: RootSeed) -> Self {
89        Credentials::RootSeed(root_seed)
90    }
91}
92
93impl From<ClientCredentials> for Credentials {
94    fn from(client_credentials: ClientCredentials) -> Self {
95        Credentials::ClientCredentials(client_credentials)
96    }
97}
98
99impl<'a> From<&'a RootSeed> for CredentialsRef<'a> {
100    fn from(root_seed: &'a RootSeed) -> Self {
101        CredentialsRef::RootSeed(root_seed)
102    }
103}
104
105impl<'a> From<&'a ClientCredentials> for CredentialsRef<'a> {
106    fn from(client_credentials: &'a ClientCredentials) -> Self {
107        CredentialsRef::ClientCredentials(client_credentials)
108    }
109}
110
111impl<'a> CredentialsRef<'a> {
112    /// Returns the user public key.
113    ///
114    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
115    pub fn user_pk(&self) -> Option<UserPk> {
116        match self {
117            Self::RootSeed(root_seed) => Some(root_seed.derive_user_pk()),
118            Self::ClientCredentials(cc) => cc.user_pk,
119        }
120    }
121
122    /// Create a [`BearerAuthenticator`] appropriate for the given credentials.
123    ///
124    /// Currently limits to [`Scope::NodeConnect`] for [`RootSeed`] credentials.
125    pub fn bearer_authenticator(&self) -> Arc<BearerAuthenticator> {
126        match self {
127            Self::RootSeed(root_seed) => {
128                let maybe_cached_token = None;
129                Arc::new(BearerAuthenticator::new_with_scope(
130                    root_seed.derive_user_key_pair(),
131                    maybe_cached_token,
132                    Some(Scope::NodeConnect),
133                ))
134            }
135            Self::ClientCredentials(client_credentials) =>
136                Arc::new(BearerAuthenticator::new_static_token(
137                    client_credentials.lexe_auth_token.clone(),
138                )),
139        }
140    }
141
142    /// Build a TLS client config appropriate for the given credentials.
143    pub fn tls_config(
144        &self,
145        rng: &mut impl Crng,
146        deploy_env: DeployEnv,
147    ) -> anyhow::Result<rustls::ClientConfig> {
148        match self {
149            Self::RootSeed(root_seed) =>
150                shared_seed::app_node_run_client_config(
151                    rng, deploy_env, root_seed,
152                )
153                .context("Failed to build RootSeed TLS client config"),
154            Self::ClientCredentials(client_credentials) =>
155                shared_seed::sdk_node_run_client_config(
156                    deploy_env,
157                    &client_credentials.eph_ca_cert_der,
158                    client_credentials.rev_client_cert_der.clone(),
159                    client_credentials.rev_client_key_der.clone(),
160                )
161                .context("Failed to build revocable client TLS config"),
162        }
163    }
164}
165
166// --- impl ClientCredentials --- //
167
168impl fmt::Debug for ClientCredentials {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str("ClientCredentials(..)")
171    }
172}
173
174impl FromStr for ClientCredentials {
175    type Err = anyhow::Error;
176
177    fn from_str(s: &str) -> Result<Self, Self::Err> {
178        Self::try_from_base64_blob(s)
179    }
180}
181
182impl ClientCredentials {
183    pub fn from_response(
184        lexe_auth_token: BearerAuthToken,
185        resp: CreateRevocableClientResponse,
186    ) -> Self {
187        ClientCredentials {
188            user_pk: resp.user_pk,
189            lexe_auth_token,
190            client_pk: resp.pubkey,
191            rev_client_key_der: LxPrivatePkcs8KeyDer(
192                resp.rev_client_cert_key_der,
193            ),
194            rev_client_cert_der: LxCertificateDer(resp.rev_client_cert_der),
195            eph_ca_cert_der: LxCertificateDer(resp.eph_ca_cert_der),
196        }
197    }
198
199    /// Encodes a [`ClientCredentials`] to a base64 blob using
200    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
201    //
202    // We use `STANDARD_NO_PAD` because trailing `=`s cause problems with
203    // autocomplete on iPhone. For example, if the base64 string ends with:
204    //
205    // - `NzB2mIn0=`
206    // - `NzBm2In0=`
207    //
208    // the iPhone autocompletes it to the following respectively when pasted
209    // into iMessage, even if you 'tap away' to reject the suggestion:
210    //
211    // - `NzB2mIn0=120 secs`
212    // - `NzBm2In0=0 in`
213    pub fn to_base64_blob(&self) -> String {
214        let json_str =
215            serde_json::to_string(self).expect("Failed to JSON serialize");
216        base64::engine::general_purpose::STANDARD_NO_PAD
217            .encode(json_str.as_bytes())
218    }
219
220    /// Decodes a [`ClientCredentials`] from a base64 blob encoded with either
221    /// [`base64::engine::general_purpose::STANDARD`] or
222    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
223    //
224    // NOTE: This function accepts `STANDARD` encodings because historical
225    // client credentials were encoded with the `STANDARD` engine until we
226    // discovered that iPhones interpret the trailing `=` as part of a unit
227    // conversion, resulting in unintended autocompletions.
228    pub fn try_from_base64_blob(s: &str) -> anyhow::Result<Self> {
229        let s = s.trim().trim_end_matches('=');
230        let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
231            .decode(s)
232            .context("String is not valid base64")?;
233        let string =
234            String::from_utf8(bytes).context("String is not valid UTF-8")?;
235        serde_json::from_str(&string).context("Failed to deserialize")
236    }
237}
238
239#[cfg(test)]
240mod test {
241    use std::fs;
242
243    use lexe_common::{
244        byte_str::ByteStr,
245        test_utils::{arbitrary, snapshot},
246    };
247    use lexe_crypto::rng::FastRng;
248    use lexe_tls::shared_seed::certs::{
249        EphemeralIssuingCaCert, RevocableClientCert, RevocableIssuingCaCert,
250    };
251    use proptest::{prelude::any, prop_assert_eq, proptest};
252
253    use super::*;
254
255    /// Tests [`ClientCredentials`] roundtrip to/from base64.
256    ///
257    /// We also test compatibility: client credentials encoded with the old
258    /// STANDARD engine can be decoded with the new try_from_base64_blob method
259    /// which should accept both STANDARD and STANDARD_NO_PAD.
260    #[test]
261    fn prop_client_credentials_base64_roundtrip() {
262        proptest!(|(creds1 in proptest::prelude::any::<ClientCredentials>())| {
263            // Encode using `to_base64_blob` (STANDARD_NO_PAD).
264            // Decode using `try_from_base64_blob`.
265            {
266                let new_base64_blob = creds1.to_base64_blob();
267
268                let creds2 =
269                    ClientCredentials::try_from_base64_blob(&new_base64_blob)
270                        .expect("Failed to decode from new format");
271
272                prop_assert_eq!(&creds1, &creds2);
273            }
274
275            // Compatibility test:
276            // Encode using the engine used by old clients (STANDARD).
277            // Decode using `try_from_base64_blob`.
278            {
279                let json_str = serde_json::to_string(&creds1)
280                    .expect("Failed to JSON serialize");
281                let old_base64_blob = base64::engine::general_purpose::STANDARD
282                    .encode(json_str.as_bytes());
283                let creds2 =
284                    ClientCredentials::try_from_base64_blob(&old_base64_blob)
285                        .expect("Failed to decode from old format");
286
287                prop_assert_eq!(&creds1, &creds2);
288            }
289        });
290    }
291
292    /// Tests that the `STANDARD_NO_PAD` engine can decode any base64 string
293    /// encoded with the `STANDARD` engine after removing trailing `=`s.
294    #[test]
295    fn prop_base64_pad_to_no_pad_compat() {
296        proptest!(|(bytes1 in any::<Vec<u8>>())| {
297            let string =
298                base64::engine::general_purpose::STANDARD.encode(&bytes1);
299            let trimmed = string.trim_end_matches('=');
300            let bytes2 = base64::engine::general_purpose::STANDARD_NO_PAD
301                .decode(trimmed)
302                .expect("Failed to decode base64");
303            prop_assert_eq!(bytes1, bytes2);
304        })
305    }
306
307    #[test]
308    fn test_client_auth_encoding() {
309        let mut rng = FastRng::from_u64(202505121546);
310        let root_seed = RootSeed::from_rng(&mut rng);
311
312        let user_pk = root_seed.derive_user_pk();
313
314        let eph_ca_cert = EphemeralIssuingCaCert::from_root_seed(&root_seed);
315        let eph_ca_cert_der = eph_ca_cert.serialize_der_self_signed().unwrap();
316
317        let rev_ca_cert = RevocableIssuingCaCert::from_root_seed(&root_seed);
318
319        let rev_client_cert = RevocableClientCert::generate_from_rng(&mut rng);
320        let rev_client_cert_der = rev_client_cert
321            .serialize_der_ca_signed(&rev_ca_cert)
322            .unwrap();
323        let rev_client_key_der = rev_client_cert.serialize_key_der();
324        let client_pk = rev_client_cert.public_key();
325
326        let credentials = ClientCredentials {
327            user_pk: Some(user_pk),
328            lexe_auth_token: BearerAuthToken(ByteStr::from_static(
329                "9dTCUvC8y7qcNyUbqynz3nwIQQHbQqPVKeMhXUj1Afr-vgj9E217_2tCS1IQM7LFqfBUC8Ec9fcb-dQiCRy6ot2FN-kR60edRFJUztAa2Rxao1Q0BS1s6vE8grgfhMYIAJDLMWgAAAAASE4zaAAAAABpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaQE",
330            )),
331            client_pk: *client_pk,
332            rev_client_key_der,
333            rev_client_cert_der,
334            eph_ca_cert_der,
335        };
336
337        let credentials_str = credentials.to_base64_blob();
338
339        // let json_len = serde_json::to_string(&credentials).unwrap().len();
340        // let base64_len = credentials_str.len();
341        // println!("json: {json_len}, base64: {base64_len}");
342
343        // json: 2259 B, base64(json): 3012 B
344        let expected_str = "eyJ1c2VyX3BrIjoiNmZkNzY0MTU2OTMwNTA5ZmFkNTM2MWQzYjIyYjYxZjc1YWE5MWVkNjQwMjE1YjJjNDFjMmZmODZiMmJmYzQ3MiIsImxleGVfYXV0aF90b2tlbiI6IjlkVENVdkM4eTdxY055VWJxeW56M253SVFRSGJRcVBWS2VNaFhVajFBZnItdmdqOUUyMTdfMnRDUzFJUU03TEZxZkJVQzhFYzlmY2ItZFFpQ1J5Nm90MkZOLWtSNjBlZFJGSlV6dEFhMlJ4YW8xUTBCUzFzNnZFOGdyZ2ZoTVlJQUpETE1XZ0FBQUFBU0U0emFBQUFBQUJwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFRRSIsImNsaWVudF9wayI6IjcwODhhZjFmYzEyYWIwNGFkNmRkMTY1YmMzYTNjNWViMzA2MmI0MTFhMmY1NWExNjZiMGU0MDBiMzkwZmU0ZGIiLCJyZXZfY2xpZW50X2tleV9kZXIiOiIzMDUxMDIwMTAxMzAwNTA2MDMyYjY1NzAwNDIyMDQyMDBmNTgwZDM0NjFjNGVhMGIzNmI4MzZkNDUxYzFjMTk5ZWUzZTA2NDZhZDBkNjQyMzUzNzk3MzlkNjg2OTkyODk4MTIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiIiwicmV2X2NsaWVudF9jZXJ0X2RlciI6IjMwODIwMTgzMzA4MjAxMzVhMDAzMDIwMTAyMDIxNDQwYmVkYzU2ZDAzZDZiNTJmMjg0MmQ2NGRmOTBkMDJkNmRhMzZhNWIzMDA1MDYwMzJiNjU3MDMwNTYzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTI3MzAyNTA2MDM1NTA0MDMwYzFlNGM2NTc4NjUyMDcyNjU3NjZmNjM2MTYyNmM2NTIwNjk3MzczNzU2OTZlNjcyMDQzNDEyMDYzNjU3Mjc0MzAyMDE3MGQzNzM1MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTE4MGYzNDMwMzkzNjMwMzEzMDMxMzAzMDMwMzAzMDMwNWEzMDUyMzEwYjMwMDkwNjAzNTUwNDA2MGMwMjU1NTMzMTBiMzAwOTA2MDM1NTA0MDgwYzAyNDM0MTMxMTEzMDBmMDYwMzU1MDQwYTBjMDg2YzY1Nzg2NTJkNjE3MDcwMzEyMzMwMjEwNjAzNTUwNDAzMGMxYTRjNjU3ODY1MjA3MjY1NzY2ZjYzNjE2MjZjNjUyMDYzNmM2OTY1NmU3NDIwNjM2NTcyNzQzMDJhMzAwNTA2MDMyYjY1NzAwMzIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiYTMxNzMwMTUzMDEzMDYwMzU1MWQxMTA0MGMzMDBhODIwODZjNjU3ODY1MmU2MTcwNzAzMDA1MDYwMzJiNjU3MDAzNDEwMDdiMTdiYzk1MzgyNjdiMzU0ZjA3MjZkODljYjFlYzMxMGIxMDJlNDIyYWI5Njk2Yjg3ZDlhZTcwMGNlZjJlODNjMTM2NmQwYWQxOTAzNWQ5ZTNlZDA0Y2Y1ZjdmMDVkZWY2OGE3MWRlMjEyYjg5ODM0NDc3OTQyYWU3NjNhMjBmIiwiZXBoX2NhX2NlcnRfZGVyIjoiMzA4MjAxYWUzMDgyMDE2MGEwMDMwMjAxMDIwMjE0MTBjZDVjOTk4OWY5NjUyMDk0OWUwZTlhYjRjZTRkYmUxNDc2NjcxMDMwMDUwNjAzMmI2NTcwMzA1MDMxMGIzMDA5MDYwMzU1MDQwNjBjMDI1NTUzMzEwYjMwMDkwNjAzNTUwNDA4MGMwMjQzNDEzMTExMzAwZjA2MDM1NTA0MGEwYzA4NmM2NTc4NjUyZDYxNzA3MDMxMjEzMDFmMDYwMzU1MDQwMzBjMTg0YzY1Nzg2NTIwNzM2ODYxNzI2NTY0MjA3MzY1NjU2NDIwNDM0MTIwNjM2NTcyNzQzMDIwMTcwZDM3MzUzMDMxMzAzMTMwMzAzMDMwMzAzMDVhMTgwZjM0MzAzOTM2MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTMwNTAzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTIxMzAxZjA2MDM1NTA0MDMwYzE4NGM2NTc4NjUyMDczNjg2MTcyNjU2NDIwNzM2NTY1NjQyMDQzNDEyMDYzNjU3Mjc0MzAyYTMwMDUwNjAzMmI2NTcwMDMyMTAwZWZlOWNlMWFiY2FlYWJjZWY4ZWEyZjU0YTU2OTU1MGRjZWQ0YThmM2E4Y2JiMDRjZDk0NWQxYjRlMjQ1ZjY4N2EzNGEzMDQ4MzAxMzA2MDM1NTFkMTEwNDBjMzAwYTgyMDg2YzY1Nzg2NTJlNjE3MDcwMzAxZDA2MDM1NTFkMGUwNDE2MDQxNDkwY2Q1Yzk5ODlmOTY1MjA5NDllMGU5YWI0Y2U0ZGJlMTQ3NjY3MTAzMDEyMDYwMzU1MWQxMzAxMDFmZjA0MDgzMDA2MDEwMWZmMDIwMTAwMzAwNTA2MDMyYjY1NzAwMzQxMDAzNzI1NDI5ZjViY2E4MDU2MjFjMmIyZGM0NDU4MDJlZDIxY2FiMjQ2YjQ1YWQxMjFkZDJhNDMyZWZhMmY5M2VmNzI1ZWZhMTc4MmU2NDEwOGQyMjk4ZTg2OTRmNDY4NmNlZDk4Y2U5MjgwZWQ3NDlkMGFkNGI0NGE0YTFjZWUwZCJ9";
345        assert_eq!(credentials_str, expected_str);
346
347        let credentials2 =
348            ClientCredentials::try_from_base64_blob(&credentials_str)
349                .expect("Failed to decode ClientAuth");
350        assert_eq!(credentials, credentials2);
351    }
352
353    /// Generate serialized `ClientCredentials` sample json data:
354    ///
355    /// ```bash
356    /// $ cargo test -p lexe-node-client --lib -- take_client_credentials_snapshot --ignored --nocapture
357    /// ```
358    #[test]
359    #[ignore]
360    fn take_client_credentials_snapshot() {
361        let mut rng = FastRng::from_u64(202512210138);
362        const N: usize = 3;
363
364        let samples: Vec<ClientCredentials> =
365            arbitrary::gen_values(&mut rng, any::<ClientCredentials>(), N);
366
367        for sample in samples {
368            println!("{}", serde_json::to_string(&sample).unwrap());
369        }
370    }
371
372    // NOTE: see `take_client_credentials_snapshot` to generate new sample data.
373    #[test]
374    fn client_credentials_deser_compat() {
375        let snapshot =
376            fs::read_to_string("test_data/client_credentials_snapshot.txt")
377                .unwrap();
378
379        for input in snapshot::parse_sample_data(&snapshot) {
380            let value1: ClientCredentials =
381                serde_json::from_str(input).unwrap();
382            let output = serde_json::to_string(&value1).unwrap();
383            let value2: ClientCredentials =
384                serde_json::from_str(&output).unwrap();
385            assert_eq!(value1, value2);
386        }
387    }
388}