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    // Build the registry from the enabled+configured methods. We keep the
71    // method values alive for the duration of the open call.
72    #[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    // `mut` is only exercised when at least one unlock-* feature is enabled; the
81    // default + all-features builds always do. Allow the unused-mut in the
82    // degenerate no-method build rather than scatter cfgs.
83    #[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, &registry)
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    // The recipient is read from the slot params at recover time; here we only
139    // need a plugin identity that can decrypt. The recipient string is supplied
140    // by the slot, so we pass an empty placeholder and rely on the plugin.
141    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/// Build a recover-capable TPM method when `--tpm` is set. The PCR selection a
167/// new slot would bind is irrelevant for recovery: it reads the selection
168/// recorded in the slot, so a default instance opens any TPM slot. Availability
169/// is the runtime `/dev/tpmrm0` probe; there is no operator-supplied secret.
170#[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
200/// Read a secret file into a zeroizing buffer, trimming a trailing newline.
201fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
202    let mut bytes = Zeroizing::new(std::fs::read(path)?);
203    // Trim a single trailing newline (common in editor-written files).
204    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
243/// Warn (or, with `strict-bundle-perms`, refuse) if the bundle is not `0600`.
244fn 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
269/// Exchange an `AppRole` `role_id` + `secret_id` for a short-lived token at the
270/// bao `AppRole` login endpoint (§5 step 6). Returns the client token.
271///
272/// Live verification is covered by `scripts/test-prefill-e2e.sh` when `bao` is
273/// available on `PATH`. The request shape matches Vault's
274/// `auth/approle/login`.
275pub 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}