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