Skip to main content

basil_core/
unlock.rs

1//! Startup unlock: build the method registry from flags, check bundle perms,
2//! open the sealed bundle, and exchange `AppRole` creds for a short-lived token.
3//!
4//! Every step is fallible-by-`Result` and **fails closed** (clean non-zero exit,
5//! no panic, §1.3 / §5 of `designs/unlock-and-bundle.html`).
6
7use 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/// Default `age-plugin-yubikey` plugin name.
24#[cfg(feature = "unlock-age-yubikey")]
25const DEFAULT_YUBIKEY_PLUGIN: &str = "yubikey";
26
27/// Unlock-method selection flags.
28///
29/// These are independent operator toggles (one per unlock surface), not a state
30/// machine, so the several-bools shape is intentional.
31#[allow(clippy::struct_excessive_bools)]
32#[derive(Debug, Clone)]
33pub struct UnlockArgs {
34    /// Enable the age-yubikey slot via `age-plugin-yubikey` (drives PIN/touch).
35    pub age_yubikey: bool,
36
37    /// Read the 24-word BIP39 recovery phrase from this `0600` file (break-glass).
38    /// The phrase is never taken from argv/env.
39    pub bip39_phrase_file: Option<std::path::PathBuf>,
40
41    /// Enable the TPM2 sealed slot. Availability is the runtime `/dev/tpmrm0`
42    /// probe (no operator secret); requires the `unlock-tpm` feature.
43    pub tpm: bool,
44
45    /// Read the production passphrase from this file.
46    pub passphrase_file: Option<std::path::PathBuf>,
47
48    /// Do not wipe the passphrase file after reading it.
49    pub passphrase_no_wipe: bool,
50
51    /// Refuse to start if the bundle file is not `0600` (default: warn only).
52    pub strict_bundle_perms: bool,
53}
54
55/// Read + check perms of the bundle file, then unlock it into a [`CredBundle`].
56///
57/// The master KEK is recovered and zeroized inside `seal::open_bundle`; only the
58/// decrypted [`CredBundle`] is returned. Fails closed if no slot opens.
59pub 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    // Build the registry from the enabled+configured methods. We keep the
67    // method values alive for the duration of the open call.
68    #[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    // `mut` is only exercised when at least one unlock-* feature is enabled; the
77    // default + all-features builds always do. Allow the unused-mut in the
78    // degenerate no-method build rather than scatter cfgs.
79    #[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, &registry)
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    // The recipient is read from the slot params at recover time; here we only
135    // need a plugin identity that can decrypt. The recipient string is supplied
136    // by the slot, so we pass an empty placeholder and rely on the plugin.
137    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/// Build a recover-capable TPM method when `--tpm` is set. The PCR selection a
163/// new slot would bind is irrelevant for recovery: it reads the selection
164/// recorded in the slot, so a default instance opens any TPM slot. Availability
165/// is the runtime `/dev/tpmrm0` probe; there is no operator-supplied secret.
166#[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
196/// Read a secret file into a zeroizing buffer, trimming a trailing newline.
197fn read_secret_file(path: &Path) -> Result<Zeroizing<Vec<u8>>> {
198    let mut bytes = Zeroizing::new(std::fs::read(path)?);
199    // Trim a single trailing newline (common in editor-written files).
200    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
239/// Warn (or, with `strict-bundle-perms`, refuse) if the bundle is not `0600`.
240fn 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
265/// Exchange an `AppRole` `role_id` + `secret_id` for a short-lived token at the
266/// bao `AppRole` login endpoint (§5 step 6). Returns the client token.
267///
268/// Live verification is covered by `scripts/test-prefill-e2e.sh` when `bao` is
269/// available on `PATH`. The request shape matches Vault's
270/// `auth/approle/login`.
271pub 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}