Skip to main content

basil_core/
unlock.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Startup unlock: build the method registry from flags, check bundle perms,
6//! open the sealed bundle, and exchange `AppRole` creds for a short-lived token.
7//!
8//! Every step is fallible-by-`Result` and **fails closed** (clean non-zero exit,
9//! no panic, §1.3 / §5 of `designs/unlock-and-bundle.html`).
10
11use 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/// Default `age-plugin-yubikey` plugin name.
28#[cfg(feature = "unlock-age-yubikey")]
29const DEFAULT_YUBIKEY_PLUGIN: &str = "yubikey";
30
31/// Unlock-method selection flags.
32///
33/// These are independent operator toggles (one per unlock surface), not a state
34/// machine, so the several-bools shape is intentional.
35#[allow(clippy::struct_excessive_bools)]
36#[derive(Debug, Clone)]
37pub struct UnlockArgs {
38    /// Enable the age-yubikey slot via `age-plugin-yubikey` (drives PIN/touch).
39    pub age_yubikey: bool,
40
41    /// Read the 24-word BIP39 recovery phrase from this `0600` file (break-glass).
42    /// The phrase is never taken from argv/env.
43    pub bip39_phrase_file: Option<std::path::PathBuf>,
44
45    /// Enable the TPM2 sealed slot. Availability is the runtime `/dev/tpmrm0`
46    /// probe (no operator secret); requires the `unlock-tpm` feature.
47    pub tpm: bool,
48
49    /// Read the production passphrase from this file.
50    pub passphrase_file: Option<std::path::PathBuf>,
51
52    /// Do not wipe the passphrase file after reading it.
53    pub passphrase_no_wipe: bool,
54
55    /// Refuse to start if the bundle file is not `0600` (default: warn only).
56    pub strict_bundle_perms: bool,
57}
58
59/// Read + check perms of the bundle file, then unlock it into a [`CredBundle`].
60///
61/// The master KEK is recovered and zeroized inside `seal::open_bundle`; only the
62/// decrypted [`CredBundle`] is returned. Fails closed if no slot opens.
63pub 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    // Best-effort logical anti-rollback, checked BEFORE any unlock slot is
71    // tried: a stale bundle is refused without decrypting its payload or
72    // applying its deposit log. This is not a boundary against a local writer
73    // (see `seal::verify_epoch_sidecar`).
74    crate::seal::verify_epoch_sidecar(&parsed, &epoch_sidecar_path(bundle_path))
75        .context("checking sealed-bundle epoch sidecar")?;
76
77    // Build the registry from the enabled+configured methods. We keep the
78    // method values alive for the duration of the open call.
79    #[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    // `mut` is only exercised when at least one unlock-* feature is enabled; the
88    // default + all-features builds always do. Allow the unused-mut in the
89    // degenerate no-method build rather than scatter cfgs.
90    #[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, &registry)
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    // The recipient is read from the slot params at recover time; here we only
144    // need a plugin identity that can decrypt. The recipient string is supplied
145    // by the slot, so we pass an empty placeholder and rely on the plugin.
146    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/// Build a recover-capable TPM method when `--tpm` is set. The PCR selection a
179/// new slot would bind is irrelevant for recovery: it reads the selection
180/// recorded in the slot, so a default instance opens any TPM slot. Availability
181/// is the runtime `/dev/tpmrm0` probe; there is no operator-supplied secret.
182#[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
212/// Read a secret file into a zeroizing buffer, trimming a trailing newline.
213fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
214    let mut bytes = Zeroizing::new(std::fs::read(path)?);
215    // Trim a single trailing newline (common in editor-written files).
216    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
255/// Warn (or, with `strict-bundle-perms`, refuse) if the bundle is not `0600`.
256fn 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
281/// Exchange an `AppRole` `role_id` + `secret_id` for a short-lived token at the
282/// bao `AppRole` login endpoint (§5 step 6). Returns the client token.
283///
284/// Live verification is covered by `scripts/test-prefill-e2e.sh` when `bao` is
285/// available on `PATH`. The request shape matches Vault's
286/// `auth/approle/login`.
287pub async fn approle_login(
288    addr: &str,
289    role_id: &str,
290    secret_id: &str,
291) -> Result<Zeroizing<String>> {
292    /// Typed view of the login response body: only the two fields the caller
293    /// needs are parsed; everything else is skipped as transient parser state
294    /// instead of landing in a `serde_json::Value` copy of the token.
295    #[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        // Fail before reading the body; it is never echoed into the error.
324        bail!("AppRole login failed (HTTP {status})");
325    }
326    // The raw body text carries the token: hold it in `Zeroizing` (wiped on
327    // drop) and deserialize the typed view above, moving (never copying) the
328    // token `String` into the returned `Zeroizing` handle.
329    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}