lodestone-client 0.0.4

Current-only asynchronous Rust client for the Lodestone API
Documentation
//! Redaction-safe generation and staging of the local bootstrap credential.

use std::error::Error;
use std::fmt::{self, Display, Formatter};

use lodestone_wire::{Digest, Identity, U64};

use crate::credential::{
    CredentialActivationError, CredentialMaterial, CredentialProvider, CredentialProviderError, FileCredentialProvider,
    SecretBytes, credential_verifier, decode_file_credential, encode_file_credential,
};
use crate::secure_publication::{PublishedFileIdentity, SecureCreateNewFilePublisher, SecurePublicationError};

pub const BOOTSTRAP_API_KEY_GENERATION: u64 = 1;
pub const BOOTSTRAP_API_KEY_ID: &str = "00000000000000000000000000000001";
pub const MAX_BOOTSTRAP_CREDENTIAL_BYTES: usize = 4 * 1024;

/// Owns the bootstrap secret without exposing its encoding to CLI callers.
pub struct PreparedBootstrapCredential {
    material: CredentialMaterial,
}

impl PreparedBootstrapCredential {
    pub fn generate() -> Result<Self, BootstrapCredentialError> {
        let material = CredentialMaterial::new(
            Identity {
                value: BOOTSTRAP_API_KEY_ID.to_owned(),
            },
            U64 {
                value: BOOTSTRAP_API_KEY_GENERATION.to_string(),
            },
            SecretBytes::generate().map_err(BootstrapCredentialError::Credential)?,
        )
        .map_err(BootstrapCredentialError::Credential)?;
        Ok(Self { material })
    }

    /// Loads one already-published bootstrap credential through the ordinary
    /// strict file provider without exposing its secret bytes.
    pub async fn load_reference(secret_reference: &str) -> Result<Self, BootstrapCredentialError> {
        let material = FileCredentialProvider
            .provide(secret_reference)
            .await
            .map_err(BootstrapCredentialError::Credential)?;
        let prepared = Self { material };
        prepared.validate()?;
        Ok(prepared)
    }

    pub async fn load_staged(
        publisher: &SecureCreateNewFilePublisher,
    ) -> Result<Option<Self>, BootstrapCredentialError> {
        let bytes = publisher
            .read_stage(MAX_BOOTSTRAP_CREDENTIAL_BYTES)
            .await
            .map_err(BootstrapCredentialError::Publication)?;
        Self::decode_optional(bytes)
    }

    pub async fn load_published(
        publisher: &SecureCreateNewFilePublisher,
    ) -> Result<Option<Self>, BootstrapCredentialError> {
        let bytes = publisher
            .read_published(MAX_BOOTSTRAP_CREDENTIAL_BYTES)
            .await
            .map_err(BootstrapCredentialError::Publication)?;
        Self::decode_optional(bytes)
    }

    fn decode_optional(bytes: Option<Vec<u8>>) -> Result<Option<Self>, BootstrapCredentialError> {
        let Some(mut bytes) = bytes else {
            return Ok(None);
        };
        let decoded = decode_file_credential(&bytes);
        bytes.fill(0);
        std::hint::black_box(&mut bytes);
        let material = decoded.map_err(BootstrapCredentialError::Credential)?;
        let prepared = Self { material };
        prepared.validate()?;
        Ok(Some(prepared))
    }

    #[must_use]
    pub const fn api_key_id(&self) -> &Identity {
        self.material.api_key_id()
    }

    #[must_use]
    pub const fn generation(&self) -> &U64 {
        self.material.generation()
    }

    pub fn verifier(&self) -> Result<Digest, BootstrapCredentialError> {
        self.validate()?;
        credential_verifier(self.material.api_key_id(), self.material.secret())
            .map_err(BootstrapCredentialError::Activation)
    }

    pub async fn stage(
        &self,
        publisher: &SecureCreateNewFilePublisher,
        expected: Option<PublishedFileIdentity>,
    ) -> Result<PublishedFileIdentity, BootstrapCredentialError> {
        self.validate()?;
        let bytes = encode_file_credential(
            self.material.api_key_id(),
            self.material.generation(),
            self.material.secret(),
        )
        .map_err(BootstrapCredentialError::Credential)?;
        publisher
            .stage_sensitive(bytes, MAX_BOOTSTRAP_CREDENTIAL_BYTES, expected)
            .await
            .map_err(BootstrapCredentialError::Publication)
    }

    fn validate(&self) -> Result<(), BootstrapCredentialError> {
        if crate::config::parse_identity(&self.material.api_key_id().value).is_none()
            || self.material.generation().value != BOOTSTRAP_API_KEY_GENERATION.to_string()
        {
            return Err(BootstrapCredentialError::InvalidIdentity);
        }
        Ok(())
    }
}

#[derive(Debug)]
pub enum BootstrapCredentialError {
    InvalidIdentity,
    Credential(CredentialProviderError),
    Activation(CredentialActivationError),
    Publication(SecurePublicationError),
}

impl BootstrapCredentialError {
    #[must_use]
    pub const fn code(&self) -> &'static str {
        match self {
            Self::InvalidIdentity => "invalid_identity",
            Self::Credential(_) => "credential",
            Self::Activation(_) => "activation",
            Self::Publication(error) => error.code(),
        }
    }
}

impl Display for BootstrapCredentialError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::InvalidIdentity => "bootstrap credential identity or generation is invalid",
            Self::Credential(_) => "bootstrap credential material is invalid",
            Self::Activation(_) => "bootstrap credential verifier derivation failed",
            Self::Publication(_) => "bootstrap credential secure publication failed",
        })
    }
}

impl Error for BootstrapCredentialError {}

#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
    use std::fs;
    use std::os::unix::fs::PermissionsExt as _;
    use std::path::{Path, PathBuf};

    use super::*;

    struct TemporaryDirectory(PathBuf);

    impl TemporaryDirectory {
        fn create() -> Result<Self, Box<dyn Error>> {
            let mut nonce = [0_u8; 16];
            getrandom::fill(&mut nonce)?;
            let root = fs::canonicalize(std::env::temp_dir())?
                .join(format!("lodestone-bootstrap-credential-test-{}", lower_hex(&nonce)));
            fs::create_dir(&root)?;
            fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
            Ok(Self(root))
        }

        fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for TemporaryDirectory {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[tokio::test]
    async fn generated_material_stages_and_reloads_with_the_same_verifier() -> Result<(), Box<dyn Error>> {
        let temporary = TemporaryDirectory::create()?;
        let stage = temporary.path().join("stage");
        fs::create_dir(&stage)?;
        fs::set_permissions(&stage, fs::Permissions::from_mode(0o700))?;
        let destination = temporary.path().join("bootstrap.json");
        let publisher = SecureCreateNewFilePublisher::open(stage, "bootstrap.stage".to_owned(), destination).await?;
        let generated = PreparedBootstrapCredential::generate()?;
        assert_eq!(generated.api_key_id().value, BOOTSTRAP_API_KEY_ID);
        let verifier = generated.verifier()?;
        let identity = generated.stage(&publisher, None).await?;
        let reloaded = PreparedBootstrapCredential::load_staged(&publisher)
            .await?
            .ok_or("staged credential missing")?;
        assert_eq!(reloaded.verifier()?, verifier);
        assert_eq!(reloaded.api_key_id(), generated.api_key_id());
        publisher.publish(identity).await?;
        Ok(())
    }

    fn lower_hex(bytes: &[u8]) -> String {
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let mut output = String::with_capacity(bytes.len().saturating_mul(2));
        for byte in bytes {
            output.push(char::from(HEX[usize::from(byte >> 4)]));
            output.push(char::from(HEX[usize::from(byte & 0x0f)]));
        }
        output
    }
}