lexe-node-client 0.1.7

Client types used to connect to your Lexe Node
Documentation
//! Client credentials for authentication with Lexe services.

use std::{fmt, str::FromStr, sync::Arc};

use anyhow::Context;
use base64::Engine;
use lexe_api::auth::BearerAuthenticator;
use lexe_common::{
    api::{
        auth::{BearerAuthToken, Scope},
        revocable_clients::CreateRevocableClientResponse,
        user::UserPk,
    },
    env::DeployEnv,
    root_seed::RootSeed,
};
use lexe_crypto::{ed25519, rng::Crng};
use lexe_tls::{
    rustls, shared_seed,
    types::{LxCertificateDer, LxPrivatePkcs8KeyDer},
};
#[cfg(any(test, feature = "test-utils"))]
use proptest::{prelude::any, strategy::Strategy};
#[cfg(any(test, feature = "test-utils"))]
use proptest_derive::Arbitrary;
use serde::{Deserialize, Serialize};

/// Credentials used to authenticate with a Lexe user node.
//
// Required to connect to a user node via mTLS.
pub enum Credentials {
    /// Using a [`RootSeed`]. Ex: app.
    RootSeed(RootSeed),
    /// Using a revocable client cert. Ex: SDK sidecar.
    ClientCredentials(ClientCredentials),
}

/// Borrowed version of [`Credentials`].
#[derive(Copy, Clone)]
pub enum CredentialsRef<'a> {
    /// Using a [`RootSeed`]. Ex: app.
    RootSeed(&'a RootSeed),
    /// Using a revocable client cert. Ex: SDK sidecar.
    ClientCredentials(&'a ClientCredentials),
}

/// All secrets required for an SDK client to authenticate with a user's node.
/// Encoded as a base64 JSON blob for easy transport (e.g. via env var or
/// config file).
#[derive(Clone, Serialize, Deserialize)]
#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary, Eq, PartialEq))]
pub struct ClientCredentials {
    /// The user public key.
    ///
    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[cfg_attr(
        any(test, feature = "test-utils"),
        proptest(strategy = "any::<UserPk>().prop_map(Some)")
    )]
    pub user_pk: Option<UserPk>,
    /// The base64 encoded long-lived connect token.
    pub lexe_auth_token: BearerAuthToken,
    /// The hex-encoded client public key.
    pub client_pk: ed25519::PublicKey,
    /// The DER-encoded client key.
    pub rev_client_key_der: LxPrivatePkcs8KeyDer,
    /// The DER-encoded cert of the revocable client.
    pub rev_client_cert_der: LxCertificateDer,
    /// The DER-encoded cert of the ephemeral issuing CA.
    pub eph_ca_cert_der: LxCertificateDer,
}

// --- impl Credentials / CredentialsRef --- //

impl Credentials {
    pub fn as_ref(&self) -> CredentialsRef<'_> {
        match self {
            Credentials::RootSeed(root_seed) =>
                CredentialsRef::RootSeed(root_seed),
            Credentials::ClientCredentials(client_credentials) =>
                CredentialsRef::ClientCredentials(client_credentials),
        }
    }
}

impl From<RootSeed> for Credentials {
    fn from(root_seed: RootSeed) -> Self {
        Credentials::RootSeed(root_seed)
    }
}

impl From<ClientCredentials> for Credentials {
    fn from(client_credentials: ClientCredentials) -> Self {
        Credentials::ClientCredentials(client_credentials)
    }
}

impl<'a> From<&'a RootSeed> for CredentialsRef<'a> {
    fn from(root_seed: &'a RootSeed) -> Self {
        CredentialsRef::RootSeed(root_seed)
    }
}

impl<'a> From<&'a ClientCredentials> for CredentialsRef<'a> {
    fn from(client_credentials: &'a ClientCredentials) -> Self {
        CredentialsRef::ClientCredentials(client_credentials)
    }
}

impl<'a> CredentialsRef<'a> {
    /// Returns the user public key.
    ///
    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
    pub fn user_pk(&self) -> Option<UserPk> {
        match self {
            Self::RootSeed(root_seed) => Some(root_seed.derive_user_pk()),
            Self::ClientCredentials(cc) => cc.user_pk,
        }
    }

    /// Create a [`BearerAuthenticator`] appropriate for the given credentials.
    ///
    /// Currently limits to [`Scope::NodeConnect`] for [`RootSeed`] credentials.
    pub fn bearer_authenticator(&self) -> Arc<BearerAuthenticator> {
        match self {
            Self::RootSeed(root_seed) => {
                let maybe_cached_token = None;
                Arc::new(BearerAuthenticator::new_with_scope(
                    root_seed.derive_user_key_pair(),
                    maybe_cached_token,
                    Some(Scope::NodeConnect),
                ))
            }
            Self::ClientCredentials(client_credentials) =>
                Arc::new(BearerAuthenticator::new_static_token(
                    client_credentials.lexe_auth_token.clone(),
                )),
        }
    }

    /// Build a TLS client config appropriate for the given credentials.
    pub fn tls_config(
        &self,
        rng: &mut impl Crng,
        deploy_env: DeployEnv,
    ) -> anyhow::Result<rustls::ClientConfig> {
        match self {
            Self::RootSeed(root_seed) =>
                shared_seed::app_node_run_client_config(
                    rng, deploy_env, root_seed,
                )
                .context("Failed to build RootSeed TLS client config"),
            Self::ClientCredentials(client_credentials) =>
                shared_seed::sdk_node_run_client_config(
                    deploy_env,
                    &client_credentials.eph_ca_cert_der,
                    client_credentials.rev_client_cert_der.clone(),
                    client_credentials.rev_client_key_der.clone(),
                )
                .context("Failed to build revocable client TLS config"),
        }
    }
}

// --- impl ClientCredentials --- //

impl fmt::Debug for ClientCredentials {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("ClientCredentials(..)")
    }
}

impl FromStr for ClientCredentials {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::try_from_base64_blob(s)
    }
}

impl ClientCredentials {
    pub fn from_response(
        lexe_auth_token: BearerAuthToken,
        resp: CreateRevocableClientResponse,
    ) -> Self {
        ClientCredentials {
            user_pk: resp.user_pk,
            lexe_auth_token,
            client_pk: resp.pubkey,
            rev_client_key_der: LxPrivatePkcs8KeyDer(
                resp.rev_client_cert_key_der,
            ),
            rev_client_cert_der: LxCertificateDer(resp.rev_client_cert_der),
            eph_ca_cert_der: LxCertificateDer(resp.eph_ca_cert_der),
        }
    }

    /// Encodes a [`ClientCredentials`] to a base64 blob using
    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
    //
    // We use `STANDARD_NO_PAD` because trailing `=`s cause problems with
    // autocomplete on iPhone. For example, if the base64 string ends with:
    //
    // - `NzB2mIn0=`
    // - `NzBm2In0=`
    //
    // the iPhone autocompletes it to the following respectively when pasted
    // into iMessage, even if you 'tap away' to reject the suggestion:
    //
    // - `NzB2mIn0=120 secs`
    // - `NzBm2In0=0 in`
    pub fn to_base64_blob(&self) -> String {
        let json_str =
            serde_json::to_string(self).expect("Failed to JSON serialize");
        base64::engine::general_purpose::STANDARD_NO_PAD
            .encode(json_str.as_bytes())
    }

    /// Decodes a [`ClientCredentials`] from a base64 blob encoded with either
    /// [`base64::engine::general_purpose::STANDARD`] or
    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
    //
    // NOTE: This function accepts `STANDARD` encodings because historical
    // client credentials were encoded with the `STANDARD` engine until we
    // discovered that iPhones interpret the trailing `=` as part of a unit
    // conversion, resulting in unintended autocompletions.
    pub fn try_from_base64_blob(s: &str) -> anyhow::Result<Self> {
        let s = s.trim().trim_end_matches('=');
        let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
            .decode(s)
            .context("String is not valid base64")?;
        let string =
            String::from_utf8(bytes).context("String is not valid UTF-8")?;
        serde_json::from_str(&string).context("Failed to deserialize")
    }
}

#[cfg(test)]
mod test {
    use std::fs;

    use lexe_common::{
        byte_str::ByteStr,
        test_utils::{arbitrary, snapshot},
    };
    use lexe_crypto::rng::FastRng;
    use lexe_tls::shared_seed::certs::{
        EphemeralIssuingCaCert, RevocableClientCert, RevocableIssuingCaCert,
    };
    use proptest::{prelude::any, prop_assert_eq, proptest};

    use super::*;

    /// Tests [`ClientCredentials`] roundtrip to/from base64.
    ///
    /// We also test compatibility: client credentials encoded with the old
    /// STANDARD engine can be decoded with the new try_from_base64_blob method
    /// which should accept both STANDARD and STANDARD_NO_PAD.
    #[test]
    fn prop_client_credentials_base64_roundtrip() {
        proptest!(|(creds1 in proptest::prelude::any::<ClientCredentials>())| {
            // Encode using `to_base64_blob` (STANDARD_NO_PAD).
            // Decode using `try_from_base64_blob`.
            {
                let new_base64_blob = creds1.to_base64_blob();

                let creds2 =
                    ClientCredentials::try_from_base64_blob(&new_base64_blob)
                        .expect("Failed to decode from new format");

                prop_assert_eq!(&creds1, &creds2);
            }

            // Compatibility test:
            // Encode using the engine used by old clients (STANDARD).
            // Decode using `try_from_base64_blob`.
            {
                let json_str = serde_json::to_string(&creds1)
                    .expect("Failed to JSON serialize");
                let old_base64_blob = base64::engine::general_purpose::STANDARD
                    .encode(json_str.as_bytes());
                let creds2 =
                    ClientCredentials::try_from_base64_blob(&old_base64_blob)
                        .expect("Failed to decode from old format");

                prop_assert_eq!(&creds1, &creds2);
            }
        });
    }

    /// Tests that the `STANDARD_NO_PAD` engine can decode any base64 string
    /// encoded with the `STANDARD` engine after removing trailing `=`s.
    #[test]
    fn prop_base64_pad_to_no_pad_compat() {
        proptest!(|(bytes1 in any::<Vec<u8>>())| {
            let string =
                base64::engine::general_purpose::STANDARD.encode(&bytes1);
            let trimmed = string.trim_end_matches('=');
            let bytes2 = base64::engine::general_purpose::STANDARD_NO_PAD
                .decode(trimmed)
                .expect("Failed to decode base64");
            prop_assert_eq!(bytes1, bytes2);
        })
    }

    #[test]
    fn test_client_auth_encoding() {
        let mut rng = FastRng::from_u64(202505121546);
        let root_seed = RootSeed::from_rng(&mut rng);

        let user_pk = root_seed.derive_user_pk();

        let eph_ca_cert = EphemeralIssuingCaCert::from_root_seed(&root_seed);
        let eph_ca_cert_der = eph_ca_cert.serialize_der_self_signed().unwrap();

        let rev_ca_cert = RevocableIssuingCaCert::from_root_seed(&root_seed);

        let rev_client_cert = RevocableClientCert::generate_from_rng(&mut rng);
        let rev_client_cert_der = rev_client_cert
            .serialize_der_ca_signed(&rev_ca_cert)
            .unwrap();
        let rev_client_key_der = rev_client_cert.serialize_key_der();
        let client_pk = rev_client_cert.public_key();

        let credentials = ClientCredentials {
            user_pk: Some(user_pk),
            lexe_auth_token: BearerAuthToken(ByteStr::from_static(
                "9dTCUvC8y7qcNyUbqynz3nwIQQHbQqPVKeMhXUj1Afr-vgj9E217_2tCS1IQM7LFqfBUC8Ec9fcb-dQiCRy6ot2FN-kR60edRFJUztAa2Rxao1Q0BS1s6vE8grgfhMYIAJDLMWgAAAAASE4zaAAAAABpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaQE",
            )),
            client_pk: *client_pk,
            rev_client_key_der,
            rev_client_cert_der,
            eph_ca_cert_der,
        };

        let credentials_str = credentials.to_base64_blob();

        // let json_len = serde_json::to_string(&credentials).unwrap().len();
        // let base64_len = credentials_str.len();
        // println!("json: {json_len}, base64: {base64_len}");

        // json: 2259 B, base64(json): 3012 B
        let expected_str = "eyJ1c2VyX3BrIjoiNmZkNzY0MTU2OTMwNTA5ZmFkNTM2MWQzYjIyYjYxZjc1YWE5MWVkNjQwMjE1YjJjNDFjMmZmODZiMmJmYzQ3MiIsImxleGVfYXV0aF90b2tlbiI6IjlkVENVdkM4eTdxY055VWJxeW56M253SVFRSGJRcVBWS2VNaFhVajFBZnItdmdqOUUyMTdfMnRDUzFJUU03TEZxZkJVQzhFYzlmY2ItZFFpQ1J5Nm90MkZOLWtSNjBlZFJGSlV6dEFhMlJ4YW8xUTBCUzFzNnZFOGdyZ2ZoTVlJQUpETE1XZ0FBQUFBU0U0emFBQUFBQUJwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFRRSIsImNsaWVudF9wayI6IjcwODhhZjFmYzEyYWIwNGFkNmRkMTY1YmMzYTNjNWViMzA2MmI0MTFhMmY1NWExNjZiMGU0MDBiMzkwZmU0ZGIiLCJyZXZfY2xpZW50X2tleV9kZXIiOiIzMDUxMDIwMTAxMzAwNTA2MDMyYjY1NzAwNDIyMDQyMDBmNTgwZDM0NjFjNGVhMGIzNmI4MzZkNDUxYzFjMTk5ZWUzZTA2NDZhZDBkNjQyMzUzNzk3MzlkNjg2OTkyODk4MTIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiIiwicmV2X2NsaWVudF9jZXJ0X2RlciI6IjMwODIwMTgzMzA4MjAxMzVhMDAzMDIwMTAyMDIxNDQwYmVkYzU2ZDAzZDZiNTJmMjg0MmQ2NGRmOTBkMDJkNmRhMzZhNWIzMDA1MDYwMzJiNjU3MDMwNTYzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTI3MzAyNTA2MDM1NTA0MDMwYzFlNGM2NTc4NjUyMDcyNjU3NjZmNjM2MTYyNmM2NTIwNjk3MzczNzU2OTZlNjcyMDQzNDEyMDYzNjU3Mjc0MzAyMDE3MGQzNzM1MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTE4MGYzNDMwMzkzNjMwMzEzMDMxMzAzMDMwMzAzMDMwNWEzMDUyMzEwYjMwMDkwNjAzNTUwNDA2MGMwMjU1NTMzMTBiMzAwOTA2MDM1NTA0MDgwYzAyNDM0MTMxMTEzMDBmMDYwMzU1MDQwYTBjMDg2YzY1Nzg2NTJkNjE3MDcwMzEyMzMwMjEwNjAzNTUwNDAzMGMxYTRjNjU3ODY1MjA3MjY1NzY2ZjYzNjE2MjZjNjUyMDYzNmM2OTY1NmU3NDIwNjM2NTcyNzQzMDJhMzAwNTA2MDMyYjY1NzAwMzIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiYTMxNzMwMTUzMDEzMDYwMzU1MWQxMTA0MGMzMDBhODIwODZjNjU3ODY1MmU2MTcwNzAzMDA1MDYwMzJiNjU3MDAzNDEwMDdiMTdiYzk1MzgyNjdiMzU0ZjA3MjZkODljYjFlYzMxMGIxMDJlNDIyYWI5Njk2Yjg3ZDlhZTcwMGNlZjJlODNjMTM2NmQwYWQxOTAzNWQ5ZTNlZDA0Y2Y1ZjdmMDVkZWY2OGE3MWRlMjEyYjg5ODM0NDc3OTQyYWU3NjNhMjBmIiwiZXBoX2NhX2NlcnRfZGVyIjoiMzA4MjAxYWUzMDgyMDE2MGEwMDMwMjAxMDIwMjE0MTBjZDVjOTk4OWY5NjUyMDk0OWUwZTlhYjRjZTRkYmUxNDc2NjcxMDMwMDUwNjAzMmI2NTcwMzA1MDMxMGIzMDA5MDYwMzU1MDQwNjBjMDI1NTUzMzEwYjMwMDkwNjAzNTUwNDA4MGMwMjQzNDEzMTExMzAwZjA2MDM1NTA0MGEwYzA4NmM2NTc4NjUyZDYxNzA3MDMxMjEzMDFmMDYwMzU1MDQwMzBjMTg0YzY1Nzg2NTIwNzM2ODYxNzI2NTY0MjA3MzY1NjU2NDIwNDM0MTIwNjM2NTcyNzQzMDIwMTcwZDM3MzUzMDMxMzAzMTMwMzAzMDMwMzAzMDVhMTgwZjM0MzAzOTM2MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTMwNTAzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTIxMzAxZjA2MDM1NTA0MDMwYzE4NGM2NTc4NjUyMDczNjg2MTcyNjU2NDIwNzM2NTY1NjQyMDQzNDEyMDYzNjU3Mjc0MzAyYTMwMDUwNjAzMmI2NTcwMDMyMTAwZWZlOWNlMWFiY2FlYWJjZWY4ZWEyZjU0YTU2OTU1MGRjZWQ0YThmM2E4Y2JiMDRjZDk0NWQxYjRlMjQ1ZjY4N2EzNGEzMDQ4MzAxMzA2MDM1NTFkMTEwNDBjMzAwYTgyMDg2YzY1Nzg2NTJlNjE3MDcwMzAxZDA2MDM1NTFkMGUwNDE2MDQxNDkwY2Q1Yzk5ODlmOTY1MjA5NDllMGU5YWI0Y2U0ZGJlMTQ3NjY3MTAzMDEyMDYwMzU1MWQxMzAxMDFmZjA0MDgzMDA2MDEwMWZmMDIwMTAwMzAwNTA2MDMyYjY1NzAwMzQxMDAzNzI1NDI5ZjViY2E4MDU2MjFjMmIyZGM0NDU4MDJlZDIxY2FiMjQ2YjQ1YWQxMjFkZDJhNDMyZWZhMmY5M2VmNzI1ZWZhMTc4MmU2NDEwOGQyMjk4ZTg2OTRmNDY4NmNlZDk4Y2U5MjgwZWQ3NDlkMGFkNGI0NGE0YTFjZWUwZCJ9";
        assert_eq!(credentials_str, expected_str);

        let credentials2 =
            ClientCredentials::try_from_base64_blob(&credentials_str)
                .expect("Failed to decode ClientAuth");
        assert_eq!(credentials, credentials2);
    }

    /// Generate serialized `ClientCredentials` sample json data:
    ///
    /// ```bash
    /// $ cargo test -p lexe-node-client --lib -- take_client_credentials_snapshot --ignored --nocapture
    /// ```
    #[test]
    #[ignore]
    fn take_client_credentials_snapshot() {
        let mut rng = FastRng::from_u64(202512210138);
        const N: usize = 3;

        let samples: Vec<ClientCredentials> =
            arbitrary::gen_values(&mut rng, any::<ClientCredentials>(), N);

        for sample in samples {
            println!("{}", serde_json::to_string(&sample).unwrap());
        }
    }

    // NOTE: see `take_client_credentials_snapshot` to generate new sample data.
    #[test]
    fn client_credentials_deser_compat() {
        let snapshot =
            fs::read_to_string("test_data/client_credentials_snapshot.txt")
                .unwrap();

        for input in snapshot::parse_sample_data(&snapshot) {
            let value1: ClientCredentials =
                serde_json::from_str(input).unwrap();
            let output = serde_json::to_string(&value1).unwrap();
            let value2: ClientCredentials =
                serde_json::from_str(&output).unwrap();
            assert_eq!(value1, value2);
        }
    }
}