Skip to main content

lodestone_client/
bootstrap_credential.rs

1//! Redaction-safe generation and staging of the local bootstrap credential.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5
6use lodestone_wire::{Digest, Identity, U64};
7
8use crate::credential::{
9    CredentialActivationError, CredentialMaterial, CredentialProvider, CredentialProviderError, FileCredentialProvider,
10    SecretBytes, credential_verifier, decode_file_credential, encode_file_credential,
11};
12use crate::secure_publication::{PublishedFileIdentity, SecureCreateNewFilePublisher, SecurePublicationError};
13
14pub const BOOTSTRAP_API_KEY_GENERATION: u64 = 1;
15pub const BOOTSTRAP_API_KEY_ID: &str = "00000000000000000000000000000001";
16pub const MAX_BOOTSTRAP_CREDENTIAL_BYTES: usize = 4 * 1024;
17
18/// Owns the bootstrap secret without exposing its encoding to CLI callers.
19pub struct PreparedBootstrapCredential {
20    material: CredentialMaterial,
21}
22
23impl PreparedBootstrapCredential {
24    pub fn generate() -> Result<Self, BootstrapCredentialError> {
25        let material = CredentialMaterial::new(
26            Identity {
27                value: BOOTSTRAP_API_KEY_ID.to_owned(),
28            },
29            U64 {
30                value: BOOTSTRAP_API_KEY_GENERATION.to_string(),
31            },
32            SecretBytes::generate().map_err(BootstrapCredentialError::Credential)?,
33        )
34        .map_err(BootstrapCredentialError::Credential)?;
35        Ok(Self { material })
36    }
37
38    /// Loads one already-published bootstrap credential through the ordinary
39    /// strict file provider without exposing its secret bytes.
40    pub async fn load_reference(secret_reference: &str) -> Result<Self, BootstrapCredentialError> {
41        let material = FileCredentialProvider
42            .provide(secret_reference)
43            .await
44            .map_err(BootstrapCredentialError::Credential)?;
45        let prepared = Self { material };
46        prepared.validate()?;
47        Ok(prepared)
48    }
49
50    pub async fn load_staged(
51        publisher: &SecureCreateNewFilePublisher,
52    ) -> Result<Option<Self>, BootstrapCredentialError> {
53        let bytes = publisher
54            .read_stage(MAX_BOOTSTRAP_CREDENTIAL_BYTES)
55            .await
56            .map_err(BootstrapCredentialError::Publication)?;
57        Self::decode_optional(bytes)
58    }
59
60    pub async fn load_published(
61        publisher: &SecureCreateNewFilePublisher,
62    ) -> Result<Option<Self>, BootstrapCredentialError> {
63        let bytes = publisher
64            .read_published(MAX_BOOTSTRAP_CREDENTIAL_BYTES)
65            .await
66            .map_err(BootstrapCredentialError::Publication)?;
67        Self::decode_optional(bytes)
68    }
69
70    fn decode_optional(bytes: Option<Vec<u8>>) -> Result<Option<Self>, BootstrapCredentialError> {
71        let Some(mut bytes) = bytes else {
72            return Ok(None);
73        };
74        let decoded = decode_file_credential(&bytes);
75        bytes.fill(0);
76        std::hint::black_box(&mut bytes);
77        let material = decoded.map_err(BootstrapCredentialError::Credential)?;
78        let prepared = Self { material };
79        prepared.validate()?;
80        Ok(Some(prepared))
81    }
82
83    #[must_use]
84    pub const fn api_key_id(&self) -> &Identity {
85        self.material.api_key_id()
86    }
87
88    #[must_use]
89    pub const fn generation(&self) -> &U64 {
90        self.material.generation()
91    }
92
93    pub fn verifier(&self) -> Result<Digest, BootstrapCredentialError> {
94        self.validate()?;
95        credential_verifier(self.material.api_key_id(), self.material.secret())
96            .map_err(BootstrapCredentialError::Activation)
97    }
98
99    pub async fn stage(
100        &self,
101        publisher: &SecureCreateNewFilePublisher,
102        expected: Option<PublishedFileIdentity>,
103    ) -> Result<PublishedFileIdentity, BootstrapCredentialError> {
104        self.validate()?;
105        let bytes = encode_file_credential(
106            self.material.api_key_id(),
107            self.material.generation(),
108            self.material.secret(),
109        )
110        .map_err(BootstrapCredentialError::Credential)?;
111        publisher
112            .stage_sensitive(bytes, MAX_BOOTSTRAP_CREDENTIAL_BYTES, expected)
113            .await
114            .map_err(BootstrapCredentialError::Publication)
115    }
116
117    fn validate(&self) -> Result<(), BootstrapCredentialError> {
118        if crate::config::parse_identity(&self.material.api_key_id().value).is_none()
119            || self.material.generation().value != BOOTSTRAP_API_KEY_GENERATION.to_string()
120        {
121            return Err(BootstrapCredentialError::InvalidIdentity);
122        }
123        Ok(())
124    }
125}
126
127#[derive(Debug)]
128pub enum BootstrapCredentialError {
129    InvalidIdentity,
130    Credential(CredentialProviderError),
131    Activation(CredentialActivationError),
132    Publication(SecurePublicationError),
133}
134
135impl BootstrapCredentialError {
136    #[must_use]
137    pub const fn code(&self) -> &'static str {
138        match self {
139            Self::InvalidIdentity => "invalid_identity",
140            Self::Credential(_) => "credential",
141            Self::Activation(_) => "activation",
142            Self::Publication(error) => error.code(),
143        }
144    }
145}
146
147impl Display for BootstrapCredentialError {
148    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
149        formatter.write_str(match self {
150            Self::InvalidIdentity => "bootstrap credential identity or generation is invalid",
151            Self::Credential(_) => "bootstrap credential material is invalid",
152            Self::Activation(_) => "bootstrap credential verifier derivation failed",
153            Self::Publication(_) => "bootstrap credential secure publication failed",
154        })
155    }
156}
157
158impl Error for BootstrapCredentialError {}
159
160#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
161mod tests {
162    use std::fs;
163    use std::os::unix::fs::PermissionsExt as _;
164    use std::path::{Path, PathBuf};
165
166    use super::*;
167
168    struct TemporaryDirectory(PathBuf);
169
170    impl TemporaryDirectory {
171        fn create() -> Result<Self, Box<dyn Error>> {
172            let mut nonce = [0_u8; 16];
173            getrandom::fill(&mut nonce)?;
174            let root = fs::canonicalize(std::env::temp_dir())?
175                .join(format!("lodestone-bootstrap-credential-test-{}", lower_hex(&nonce)));
176            fs::create_dir(&root)?;
177            fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
178            Ok(Self(root))
179        }
180
181        fn path(&self) -> &Path {
182            &self.0
183        }
184    }
185
186    impl Drop for TemporaryDirectory {
187        fn drop(&mut self) {
188            let _ = fs::remove_dir_all(&self.0);
189        }
190    }
191
192    #[tokio::test]
193    async fn generated_material_stages_and_reloads_with_the_same_verifier() -> Result<(), Box<dyn Error>> {
194        let temporary = TemporaryDirectory::create()?;
195        let stage = temporary.path().join("stage");
196        fs::create_dir(&stage)?;
197        fs::set_permissions(&stage, fs::Permissions::from_mode(0o700))?;
198        let destination = temporary.path().join("bootstrap.json");
199        let publisher = SecureCreateNewFilePublisher::open(stage, "bootstrap.stage".to_owned(), destination).await?;
200        let generated = PreparedBootstrapCredential::generate()?;
201        assert_eq!(generated.api_key_id().value, BOOTSTRAP_API_KEY_ID);
202        let verifier = generated.verifier()?;
203        let identity = generated.stage(&publisher, None).await?;
204        let reloaded = PreparedBootstrapCredential::load_staged(&publisher)
205            .await?
206            .ok_or("staged credential missing")?;
207        assert_eq!(reloaded.verifier()?, verifier);
208        assert_eq!(reloaded.api_key_id(), generated.api_key_id());
209        publisher.publish(identity).await?;
210        Ok(())
211    }
212
213    fn lower_hex(bytes: &[u8]) -> String {
214        const HEX: &[u8; 16] = b"0123456789abcdef";
215        let mut output = String::with_capacity(bytes.len().saturating_mul(2));
216        for byte in bytes {
217            output.push(char::from(HEX[usize::from(byte >> 4)]));
218            output.push(char::from(HEX[usize::from(byte & 0x0f)]));
219        }
220        output
221    }
222}