Skip to main content

lexe_api/
credentials.rs

1//! Credentials (root seed / revocable client) used to control a Lexe wallet.
2
3use 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
36/// Credentials used to authenticate with a Lexe user node.
37//
38// Required to connect to a user node via mTLS.
39pub enum Credentials {
40    /// Using a [`RootSeed`]. Ex: app.
41    RootSeed(RootSeed),
42    /// Using a revocable client cert. Ex: SDK sidecar.
43    ClientCredentials(ClientCredentials),
44}
45
46/// Borrowed version of [`Credentials`].
47#[derive(Copy, Clone)]
48pub enum CredentialsRef<'a> {
49    /// Using a [`RootSeed`]. Ex: app.
50    RootSeed(&'a RootSeed),
51    /// Using a revocable client cert. Ex: SDK sidecar.
52    ClientCredentials(&'a ClientCredentials),
53}
54
55/// All secrets required for an SDK client to authenticate with a user's node.
56/// Encoded as a base64 JSON blob for easy transport (e.g. via env var or
57/// config file).
58#[derive(Clone, Serialize, Deserialize)]
59#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
60pub struct ClientCredentials {
61    /// The user public key.
62    ///
63    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    // #[cfg_attr(
66    //     any(test, feature = "test-utils"),
67    //     proptest(strategy = "any::<UserPk>().prop_map(Some)")
68    // )]
69    pub user_pk: Option<UserPk>,
70    /// The hex-encoded client public key.
71    pub client_pk: ed25519::PublicKey,
72    /// The DER-encoded client key.
73    pub rev_client_key_der: LxPrivatePkcs8KeyDer,
74    /// The DER-encoded cert of the revocable client.
75    pub rev_client_cert_der: LxCertificateDer,
76    /// The DER-encoded cert of the ephemeral issuing CA.
77    pub eph_ca_cert_der: LxCertificateDer,
78    /// The long-lived [`LexeScope::GatewayProxy`] token for connecting to the
79    /// user's node. Always `Some` for user nodes.
80    // compat: Alias added in lexe-node-client-v0.1.16
81    #[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
89// --- impl Credentials / CredentialsRef --- //
90
91impl 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    /// Returns the user public key.
128    ///
129    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
130    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    /// Create a [`BearerAuthenticator`] appropriate for the given credentials.
138    ///
139    /// Errors if these credentials are [`ClientCredentials`] and are missing a
140    /// [`LexeScope::GatewayProxy`] token.
141    pub fn bearer_authenticator(
142        &self,
143    ) -> anyhow::Result<Arc<BearerAuthenticator>> {
144        match self {
145            // A root seed can mint tokens of any scope on demand.
146            Self::RootSeed(root_seed) => Ok(Arc::new(
147                BearerAuthenticator::new(root_seed.derive_user_key_pair()),
148            )),
149            // A client credential holds a single long-lived `GatewayProxy`
150            // token, good only for connecting to the user's node.
151            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    /// Build a TLS client config appropriate for the given credentials.
166    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
189// --- impl ClientCredentials --- //
190
191impl 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    /// Encodes a [`ClientCredentials`] to a base64 blob using
222    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
223    //
224    // We use `STANDARD_NO_PAD` because trailing `=`s cause problems with
225    // autocomplete on iPhone. For example, if the base64 string ends with:
226    //
227    // - `NzB2mIn0=`
228    // - `NzBm2In0=`
229    //
230    // the iPhone autocompletes it to the following respectively when pasted
231    // into iMessage, even if you 'tap away' to reject the suggestion:
232    //
233    // - `NzB2mIn0=120 secs`
234    // - `NzBm2In0=0 in`
235    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    /// Decodes a [`ClientCredentials`] from a base64 blob encoded with either
243    /// [`base64::engine::general_purpose::STANDARD`] or
244    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
245    //
246    // NOTE: This function accepts `STANDARD` encodings because historical
247    // client credentials were encoded with the `STANDARD` engine until we
248    // discovered that iPhones interpret the trailing `=` as part of a unit
249    // conversion, resulting in unintended autocompletions.
250    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        // Check that the deserialized ClientCredentials are well formed;
261        // ensure that private and public keys are consistent.
262        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/// Arbitrary ClientCredentials should be self-consistent
275#[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                    // Derive intermediaries
289                    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                    // Derive fields
297                    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    /// Tests [`ClientCredentials`] roundtrip to/from base64.
339    ///
340    /// We also test compatibility: client credentials encoded with the old
341    /// STANDARD engine can be decoded with the new try_from_base64_blob method
342    /// which should accept both STANDARD and STANDARD_NO_PAD.
343    #[test]
344    fn prop_client_credentials_base64_roundtrip() {
345        proptest!(|(creds1 in proptest::prelude::any::<ClientCredentials>())| {
346            // Encode using `to_base64_blob` (STANDARD_NO_PAD).
347            // Decode using `try_from_base64_blob`.
348            {
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            // Compatibility test:
359            // Encode using the engine used by old clients (STANDARD).
360            // Decode using `try_from_base64_blob`.
361            {
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    /// Tests that the `STANDARD_NO_PAD` engine can decode any base64 string
376    /// encoded with the `STANDARD` engine after removing trailing `=`s.
377    #[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 json_len = serde_json::to_string(&credentials).unwrap().len();
423        // let base64_len = credentials_str.len();
424        // println!("json: {json_len}, base64: {base64_len}");
425
426        // json: 2259 B, base64(json): 3012 B
427        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    /// Generate serialized `ClientCredentials` sample json data:
437    ///
438    /// ```bash
439    /// $ cargo test -p lexe-node-client --lib -- take_client_credentials_snapshot --ignored --nocapture
440    /// ```
441    #[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    // NOTE: see `take_client_credentials_snapshot` to generate new sample data.
456    #[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}