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 wipe_secret_file(path).unwrap_or_else(|err| {
168 warn!(
169 path = %path.display(),
170 error = %err,
171 "bip39 phrase file wipe failed; continuing after successful read"
172 );
173 });
174 info!("bip39 break-glass unlock enabled");
175 Ok(Some(Bip39Method::new(phrase)))
176}
177
178#[cfg(feature = "unlock-tpm")]
183fn build_tpm_method(args: &UnlockArgs) -> Option<TpmMethod> {
184 if !args.tpm {
185 return None;
186 }
187 info!("tpm sealed-slot unlock enabled");
188 Some(TpmMethod::new_default())
189}
190
191fn build_passphrase_method(args: &UnlockArgs) -> Result<Option<PassphraseMethod>> {
192 let Some(path) = &args.passphrase_file else {
193 return Ok(None);
194 };
195 let passphrase = read_secret_file(path)
196 .with_context(|| format!("reading passphrase from {}", path.display()))?;
197 if !args.passphrase_no_wipe {
198 wipe_secret_file(path).unwrap_or_else(|err| {
199 warn!(
200 path = %path.display(),
201 error = %err,
202 "passphrase file wipe failed; continuing after successful read"
203 );
204 });
205 }
206 info!("passphrase unlock enabled");
207 Ok(Some(PassphraseMethod::new(Zeroizing::new(
208 passphrase.to_vec(),
209 ))))
210}
211
212fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
214 let mut bytes = Zeroizing::new(std::fs::read(path)?);
215 if bytes.last() == Some(&b'\n') {
217 bytes.pop();
218 }
219 if bytes.last() == Some(&b'\r') {
220 bytes.pop();
221 }
222 Ok(bytes)
223}
224
225fn wipe_secret_file(path: &Path) -> Result<()> {
226 use std::io::Write as _;
227
228 let len = std::fs::metadata(path)
229 .with_context(|| format!("stat passphrase file {}", path.display()))?
230 .len();
231 let mut file = std::fs::OpenOptions::new()
232 .write(true)
233 .open(path)
234 .with_context(|| format!("opening passphrase file for wipe {}", path.display()))?;
235 let zeros = vec![0u8; 4096];
236 let mut remaining = len;
237 while remaining > 0 {
238 let n = usize::try_from(remaining.min(zeros.len() as u64))
239 .context("passphrase file length does not fit usize")?;
240 let chunk = zeros
241 .get(..n)
242 .context("passphrase wipe chunk length out of range")?;
243 file.write_all(chunk)
244 .with_context(|| format!("overwriting passphrase file {}", path.display()))?;
245 remaining -= u64::try_from(n).unwrap_or(0);
246 }
247 file.sync_all()
248 .with_context(|| format!("syncing passphrase file wipe {}", path.display()))?;
249 drop(file);
250 std::fs::remove_file(path)
251 .with_context(|| format!("removing wiped passphrase file {}", path.display()))?;
252 Ok(())
253}
254
255fn check_bundle_perms(path: &Path, strict: bool) -> Result<()> {
257 #[cfg(unix)]
258 {
259 use std::os::unix::fs::PermissionsExt;
260 let meta = std::fs::metadata(path)
261 .with_context(|| format!("stat sealed bundle {}", path.display()))?;
262 let mode = meta.permissions().mode() & 0o777;
263 if mode != 0o600 {
264 if strict {
265 bail!(
266 "sealed bundle {} has mode {:o}, expected 0600 (strict-bundle-perms)",
267 path.display(),
268 mode
269 );
270 }
271 warn!(
272 path = %path.display(),
273 mode = format!("{mode:o}"),
274 "sealed bundle is not 0600 (continuing; set strict-bundle-perms to refuse)"
275 );
276 }
277 }
278 Ok(())
279}
280
281pub async fn approle_login(
288 addr: &str,
289 role_id: &str,
290 secret_id: &str,
291) -> Result<Zeroizing<String>> {
292 #[derive(serde::Deserialize)]
296 struct LoginResponse {
297 auth: Option<LoginAuth>,
298 }
299 #[derive(serde::Deserialize)]
300 struct LoginAuth {
301 client_token: Option<String>,
302 #[serde(default)]
303 lease_duration: u64,
304 }
305
306 let addr = addr.trim_end_matches('/');
307 let url = format!("{addr}/v1/auth/approle/login");
308 crate::ensure_crypto_provider();
309 let client = reqwest::Client::builder()
310 .build()
311 .context("building http client for AppRole login")?;
312 let resp = client
313 .post(&url)
314 .json(&serde_json::json!({
315 "role_id": role_id,
316 "secret_id": secret_id,
317 }))
318 .send()
319 .await
320 .context("sending AppRole login request")?;
321 let status = resp.status();
322 if !status.is_success() {
323 bail!("AppRole login failed (HTTP {status})");
325 }
326 let body = Zeroizing::new(
330 resp.text()
331 .await
332 .context("reading AppRole login response")?,
333 );
334 let parsed: LoginResponse =
335 serde_json::from_str(&body).context("decoding AppRole login response")?;
336 let auth = parsed
337 .auth
338 .context("AppRole login response has no auth.client_token")?;
339 let token = auth
340 .client_token
341 .map(Zeroizing::new)
342 .context("AppRole login response has no auth.client_token")?;
343 info!(
344 lease_seconds = auth.lease_duration,
345 "exchanged AppRole secret_id for vault token"
346 );
347 Ok(token)
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::seal::UnlockMethod as _;
354
355 fn temp_path(name: &str) -> PathBuf {
356 std::env::temp_dir().join(format!(
357 "basil-unlock-{name}-{}-{}",
358 std::process::id(),
359 std::time::SystemTime::now()
360 .duration_since(std::time::UNIX_EPOCH)
361 .expect("system time after epoch")
362 .as_nanos()
363 ))
364 }
365
366 fn write_secret(path: &Path, bytes: &[u8]) {
367 std::fs::write(path, bytes).expect("write secret");
368 #[cfg(unix)]
369 {
370 use std::os::unix::fs::PermissionsExt;
371 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
372 .expect("chmod secret");
373 }
374 }
375
376 fn args(path: PathBuf, no_wipe: bool) -> UnlockArgs {
377 UnlockArgs {
378 age_yubikey: false,
379 bip39_phrase_file: None,
380 tpm: false,
381 passphrase_file: Some(path),
382 passphrase_no_wipe: no_wipe,
383 strict_bundle_perms: false,
384 }
385 }
386
387 fn bip39_args(path: PathBuf) -> UnlockArgs {
388 UnlockArgs {
389 age_yubikey: false,
390 bip39_phrase_file: Some(path),
391 tpm: false,
392 passphrase_file: None,
393 passphrase_no_wipe: false,
394 strict_bundle_perms: false,
395 }
396 }
397
398 #[test]
399 fn passphrase_read_wipes_file_by_default() {
400 let path = temp_path("wipe");
401 write_secret(&path, b"secret\n");
402
403 let method = build_passphrase_method(&args(path.clone(), false))
404 .expect("build passphrase method")
405 .expect("method present");
406
407 assert!(method.available());
408 assert!(!path.exists());
409 }
410
411 #[test]
412 fn passphrase_no_wipe_leaves_file() {
413 let path = temp_path("no-wipe");
414 write_secret(&path, b"secret\n");
415
416 let method = build_passphrase_method(&args(path.clone(), true))
417 .expect("build passphrase method")
418 .expect("method present");
419
420 assert!(method.available());
421 assert!(path.exists());
422 let _ = std::fs::remove_file(path);
423 }
424
425 #[cfg(feature = "unlock-bip39")]
426 #[test]
427 fn bip39_phrase_read_wipes_file_by_default() {
428 let path = temp_path("bip39-wipe");
429 let phrase = Bip39Method::generate_phrase().expect("generate phrase");
430 write_secret(&path, phrase.as_bytes());
431
432 let method = build_bip39_method(&bip39_args(path.clone()))
433 .expect("build bip39 method")
434 .expect("method present");
435
436 assert!(method.available());
437 assert!(!path.exists());
438 }
439
440 #[cfg(unix)]
441 #[test]
442 fn passphrase_wipe_failure_does_not_fail_unlock_method() {
443 use std::os::unix::fs::PermissionsExt;
444
445 let path = temp_path("wipe-fails");
446 write_secret(&path, b"secret\n");
447 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))
448 .expect("make secret read-only");
449
450 let method = build_passphrase_method(&args(path.clone(), false))
451 .expect("wipe failure is warning-only")
452 .expect("method present");
453
454 assert!(method.available());
455 assert!(path.exists());
456 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
457 let _ = std::fs::remove_file(path);
458 }
459}