lodestone_client/
bootstrap_credential.rs1use 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
18pub 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 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}