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