1use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13
14use crate::seal::{CredBundle, MethodRegistry, format};
15use anyhow::{Context, Result, bail};
16use tracing::{info, warn};
17use zeroize::Zeroizing;
18
19#[cfg(feature = "unlock-age-yubikey")]
20use crate::seal::AgeYubikeyMethod;
21#[cfg(feature = "unlock-bip39")]
22use crate::seal::Bip39Method;
23use crate::seal::PassphraseMethod;
24#[cfg(feature = "unlock-tpm")]
25use crate::seal::TpmMethod;
26
27#[cfg(feature = "unlock-age-yubikey")]
29const DEFAULT_YUBIKEY_PLUGIN: &str = "yubikey";
30
31#[allow(clippy::struct_excessive_bools)]
36#[derive(Debug, Clone)]
37pub struct UnlockArgs {
38 pub age_yubikey: bool,
40
41 pub bip39_phrase_file: Option<std::path::PathBuf>,
44
45 pub tpm: bool,
48
49 pub passphrase_file: Option<std::path::PathBuf>,
51
52 pub passphrase_no_wipe: bool,
54
55 pub strict_bundle_perms: bool,
57}
58
59pub fn open_bundle_at_startup(bundle_path: &Path, args: &UnlockArgs) -> Result<CredBundle> {
64 check_bundle_perms(bundle_path, args.strict_bundle_perms)?;
65
66 let bytes = std::fs::read(bundle_path)
67 .with_context(|| format!("reading sealed bundle from {}", bundle_path.display()))?;
68 let parsed = format::decode(&bytes).context("parsing sealed bundle")?;
69
70 crate::seal::verify_epoch_sidecar(&parsed, &epoch_sidecar_path(bundle_path))
75 .context("checking sealed-bundle epoch sidecar")?;
76
77 #[cfg(feature = "unlock-age-yubikey")]
80 let age_method = build_age_method(args)?;
81 #[cfg(feature = "unlock-bip39")]
82 let bip39_method = build_bip39_method(args)?;
83 #[cfg(feature = "unlock-tpm")]
84 let tpm_method = build_tpm_method(args);
85 let passphrase_method = build_passphrase_method(args)?;
86
87 #[allow(unused_mut)]
91 let mut registry = MethodRegistry::new();
92 #[cfg(feature = "unlock-age-yubikey")]
93 if let Some(m) = &age_method {
94 registry = registry.with(m);
95 }
96 #[cfg(feature = "unlock-bip39")]
97 if let Some(m) = &bip39_method {
98 registry = registry.with(m);
99 }
100 #[cfg(feature = "unlock-tpm")]
101 if let Some(m) = &tpm_method {
102 registry = registry.with(m);
103 }
104 if let Some(m) = &passphrase_method {
105 registry = registry.with(m);
106 }
107
108 let mut creds = crate::seal::open_bundle(&parsed, ®istry)
109 .context("no unlock slot opened the bundle (fail closed)")?;
110 let reviews = crate::seal::apply_authorized_deposits(&parsed, &mut creds);
111 for review in reviews {
112 if review.status == crate::seal::DepositStatus::Effective {
113 info!(
114 backend_id = %review.backend_id,
115 contributor = %review.contributor_key_id,
116 seq = review.seq,
117 "sealed-bundle credential deposit applied"
118 );
119 } else {
120 warn!(
121 backend_id = %review.backend_id,
122 contributor = %review.contributor_key_id,
123 seq = review.seq,
124 status = review.status.as_str(),
125 "sealed-bundle credential deposit ignored"
126 );
127 }
128 }
129 Ok(creds)
130}
131
132fn epoch_sidecar_path(bundle_path: &Path) -> PathBuf {
133 let mut path = OsString::from(bundle_path.as_os_str());
134 path.push(".epoch");
135 PathBuf::from(path)
136}
137
138#[cfg(feature = "unlock-age-yubikey")]
139fn build_age_method(args: &UnlockArgs) -> Result<Option<AgeYubikeyMethod>> {
140 if !args.age_yubikey {
141 return Ok(None);
142 }
143 let method = AgeYubikeyMethod::with_plugin("", DEFAULT_YUBIKEY_PLUGIN)
147 .context("initializing age-plugin-yubikey identity")?;
148 info!(
149 plugin = DEFAULT_YUBIKEY_PLUGIN,
150 "age-yubikey unlock enabled"
151 );
152 Ok(Some(method))
153}
154
155#[cfg(feature = "unlock-bip39")]
156fn build_bip39_method(args: &UnlockArgs) -> Result<Option<Bip39Method>> {
157 let Some(path) = &args.bip39_phrase_file else {
158 return Ok(None);
159 };
160 let phrase = read_secret_file(path)
161 .with_context(|| format!("reading bip39 phrase from {}", path.display()))?;
162 let phrase = Zeroizing::new(
163 String::from_utf8(phrase.to_vec())
164 .map_err(|_| anyhow::anyhow!("bip39 phrase file is not valid UTF-8"))?,
165 );
166 let phrase = Zeroizing::new(phrase.trim().to_string());
167 info!("bip39 break-glass unlock enabled");
168 Ok(Some(Bip39Method::new(phrase)))
169}
170
171#[cfg(feature = "unlock-tpm")]
176fn build_tpm_method(args: &UnlockArgs) -> Option<TpmMethod> {
177 if !args.tpm {
178 return None;
179 }
180 info!("tpm sealed-slot unlock enabled");
181 Some(TpmMethod::new_default())
182}
183
184fn build_passphrase_method(args: &UnlockArgs) -> Result<Option<PassphraseMethod>> {
185 let Some(path) = &args.passphrase_file else {
186 return Ok(None);
187 };
188 let passphrase = read_secret_file(path)
189 .with_context(|| format!("reading passphrase from {}", path.display()))?;
190 if !args.passphrase_no_wipe {
191 wipe_secret_file(path).unwrap_or_else(|err| {
192 warn!(
193 path = %path.display(),
194 error = %err,
195 "passphrase file wipe failed; continuing after successful read"
196 );
197 });
198 }
199 info!("passphrase unlock enabled");
200 Ok(Some(PassphraseMethod::new(Zeroizing::new(
201 passphrase.to_vec(),
202 ))))
203}
204
205fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
207 let mut bytes = Zeroizing::new(std::fs::read(path)?);
208 if bytes.last() == Some(&b'\n') {
210 bytes.pop();
211 }
212 if bytes.last() == Some(&b'\r') {
213 bytes.pop();
214 }
215 Ok(bytes)
216}
217
218fn wipe_secret_file(path: &Path) -> Result<()> {
219 use std::io::Write as _;
220
221 let len = std::fs::metadata(path)
222 .with_context(|| format!("stat passphrase file {}", path.display()))?
223 .len();
224 let mut file = std::fs::OpenOptions::new()
225 .write(true)
226 .open(path)
227 .with_context(|| format!("opening passphrase file for wipe {}", path.display()))?;
228 let zeros = vec![0u8; 4096];
229 let mut remaining = len;
230 while remaining > 0 {
231 let n = usize::try_from(remaining.min(zeros.len() as u64))
232 .context("passphrase file length does not fit usize")?;
233 let chunk = zeros
234 .get(..n)
235 .context("passphrase wipe chunk length out of range")?;
236 file.write_all(chunk)
237 .with_context(|| format!("overwriting passphrase file {}", path.display()))?;
238 remaining -= u64::try_from(n).unwrap_or(0);
239 }
240 file.sync_all()
241 .with_context(|| format!("syncing passphrase file wipe {}", path.display()))?;
242 drop(file);
243 std::fs::remove_file(path)
244 .with_context(|| format!("removing wiped passphrase file {}", path.display()))?;
245 Ok(())
246}
247
248fn check_bundle_perms(path: &Path, strict: bool) -> Result<()> {
250 #[cfg(unix)]
251 {
252 use std::os::unix::fs::PermissionsExt;
253 let meta = std::fs::metadata(path)
254 .with_context(|| format!("stat sealed bundle {}", path.display()))?;
255 let mode = meta.permissions().mode() & 0o777;
256 if mode != 0o600 {
257 if strict {
258 bail!(
259 "sealed bundle {} has mode {:o}, expected 0600 (strict-bundle-perms)",
260 path.display(),
261 mode
262 );
263 }
264 warn!(
265 path = %path.display(),
266 mode = format!("{mode:o}"),
267 "sealed bundle is not 0600 (continuing; set strict-bundle-perms to refuse)"
268 );
269 }
270 }
271 Ok(())
272}
273
274pub async fn approle_login(
281 addr: &str,
282 role_id: &str,
283 secret_id: &str,
284) -> Result<Zeroizing<String>> {
285 #[derive(serde::Deserialize)]
289 struct LoginResponse {
290 auth: Option<LoginAuth>,
291 }
292 #[derive(serde::Deserialize)]
293 struct LoginAuth {
294 client_token: Option<String>,
295 #[serde(default)]
296 lease_duration: u64,
297 }
298
299 let addr = addr.trim_end_matches('/');
300 let url = format!("{addr}/v1/auth/approle/login");
301 crate::ensure_crypto_provider();
302 let client = reqwest::Client::builder()
303 .build()
304 .context("building http client for AppRole login")?;
305 let resp = client
306 .post(&url)
307 .json(&serde_json::json!({
308 "role_id": role_id,
309 "secret_id": secret_id,
310 }))
311 .send()
312 .await
313 .context("sending AppRole login request")?;
314 let status = resp.status();
315 if !status.is_success() {
316 bail!("AppRole login failed (HTTP {status})");
318 }
319 let body = Zeroizing::new(
323 resp.text()
324 .await
325 .context("reading AppRole login response")?,
326 );
327 let parsed: LoginResponse =
328 serde_json::from_str(&body).context("decoding AppRole login response")?;
329 let auth = parsed
330 .auth
331 .context("AppRole login response has no auth.client_token")?;
332 let token = auth
333 .client_token
334 .map(Zeroizing::new)
335 .context("AppRole login response has no auth.client_token")?;
336 info!(
337 lease_seconds = auth.lease_duration,
338 "exchanged AppRole secret_id for vault token"
339 );
340 Ok(token)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::seal::UnlockMethod as _;
347
348 fn temp_path(name: &str) -> PathBuf {
349 std::env::temp_dir().join(format!(
350 "basil-unlock-{name}-{}-{}",
351 std::process::id(),
352 std::time::SystemTime::now()
353 .duration_since(std::time::UNIX_EPOCH)
354 .expect("system time after epoch")
355 .as_nanos()
356 ))
357 }
358
359 fn write_secret(path: &Path, bytes: &[u8]) {
360 std::fs::write(path, bytes).expect("write secret");
361 #[cfg(unix)]
362 {
363 use std::os::unix::fs::PermissionsExt;
364 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
365 .expect("chmod secret");
366 }
367 }
368
369 fn args(path: PathBuf, no_wipe: bool) -> UnlockArgs {
370 UnlockArgs {
371 age_yubikey: false,
372 bip39_phrase_file: None,
373 tpm: false,
374 passphrase_file: Some(path),
375 passphrase_no_wipe: no_wipe,
376 strict_bundle_perms: false,
377 }
378 }
379
380 #[test]
381 fn passphrase_read_wipes_file_by_default() {
382 let path = temp_path("wipe");
383 write_secret(&path, b"secret\n");
384
385 let method = build_passphrase_method(&args(path.clone(), false))
386 .expect("build passphrase method")
387 .expect("method present");
388
389 assert!(method.available());
390 assert!(!path.exists());
391 }
392
393 #[test]
394 fn passphrase_no_wipe_leaves_file() {
395 let path = temp_path("no-wipe");
396 write_secret(&path, b"secret\n");
397
398 let method = build_passphrase_method(&args(path.clone(), true))
399 .expect("build passphrase method")
400 .expect("method present");
401
402 assert!(method.available());
403 assert!(path.exists());
404 let _ = std::fs::remove_file(path);
405 }
406
407 #[cfg(unix)]
408 #[test]
409 fn passphrase_wipe_failure_does_not_fail_unlock_method() {
410 use std::os::unix::fs::PermissionsExt;
411
412 let path = temp_path("wipe-fails");
413 write_secret(&path, b"secret\n");
414 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))
415 .expect("make secret read-only");
416
417 let method = build_passphrase_method(&args(path.clone(), false))
418 .expect("wipe failure is warning-only")
419 .expect("method present");
420
421 assert!(method.available());
422 assert!(path.exists());
423 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
424 let _ = std::fs::remove_file(path);
425 }
426}