envseal 0.3.10

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! `envseal security fido2-{enroll,disable,status}` ops.
//!
//! High-level facade over [`crate::vault::keychain::fido2_unlock`]
//! that drives a concrete authenticator backend, opens / re-wraps
//! the vault, and emits the audit events. CLI, desktop GUI, and
//! MCP all funnel through here so the policy ("you must
//! authenticate with the SAME passphrase before disabling FIDO2")
//! is enforced in one place.
//!
//! Backend selection at compile time:
//!
//! - **default `fido2` feature**: only the in-tree mock backend is
//!   available, so [`enroll`] and [`disable`] return a clear "build
//!   without `fido2-hardware` cannot drive a real security key"
//!   error rather than appearing to work and producing a vault
//!   only the test suite can open.
//! - **`fido2-hardware` feature**: the real `ctap-hid-fido2`
//!   backend is wired in. Operators run `cargo install envseal-cli
//!   --features fido2-hardware` once and every subsequent enroll /
//!   unlock drives whatever USB HID FIDO2 device is plugged in.

#![cfg(feature = "fido2")]

use zeroize::Zeroizing;

use crate::audit;
use crate::error::Error;
use crate::vault::fido2::Fido2Authenticator;
use crate::vault::keychain::fido2_unlock;
use crate::vault::keychain::fido2_unlock::{
    credential_id_hash, disable_fido2_keep_master, enroll_fido2_on_existing_master,
    fido2_status_at, unlock_master_key_with_fido2, Fido2Status,
};
use crate::vault::keychain::master_key_path;
use crate::vault::Vault;
use crate::{gui, security_config};

/// Public report shape used by `envseal security fido2-status` and
/// the doctor view. Stable JSON wire form across releases.
#[derive(Debug, Clone)]
pub struct Fido2StatusReport {
    /// Was the master.key file present at all?
    pub vault_initialized: bool,
    /// True iff the vault is currently a v3 envelope.
    pub enrolled: bool,
    /// SHA-256 of the credential id, hex-encoded. `None` when not
    /// enrolled. The raw id is intentionally not exposed because
    /// audit logs and forensics work fine with the hash and the
    /// hash leak does not aid an attacker.
    pub credential_id_hash: Option<String>,
    /// True iff this build supports a real hardware backend (i.e.
    /// the `fido2-hardware` feature was enabled at compile time).
    /// CLI surfaces this in the status output so operators don't
    /// run `fido2-enroll` on a build that can't drive their key.
    pub hardware_backend_available: bool,
}

/// Inspect the vault and report enrollment status. Doesn't touch
/// the authenticator — purely a filesystem read.
///
/// # Errors
///
/// Underlying I/O / envelope-parse errors.
pub fn status() -> Result<Fido2StatusReport, Error> {
    let root = crate::ops::vault_root()?;
    let mk_path = master_key_path(&root);
    let st = fido2_status_at(&mk_path)?;
    let (vault_initialized, enrolled, credential_id_hash) = match st {
        Fido2Status::NoVault => (false, false, None),
        Fido2Status::NotEnrolled => (true, false, None),
        Fido2Status::Enrolled { credential_id } => {
            (true, true, Some(self::credential_id_hash_local(&credential_id)))
        }
    };
    Ok(Fido2StatusReport {
        vault_initialized,
        enrolled,
        credential_id_hash,
        hardware_backend_available: cfg!(feature = "fido2-hardware"),
    })
}

/// Enroll the supplied authenticator against the existing vault.
///
/// Behavior:
/// 1. Opens the vault with the existing passphrase (legacy v1/v2
///    envelope only — refuses to enroll over an already-enrolled
///    v3 vault, surfacing [`Error::Fido2NotEnrolled`] inverted to
///    a clear "already enrolled" error so the user runs disable
///    first).
/// 2. Asks the authenticator to mint a credential and produce the
///    initial hmac-secret response.
/// 3. Re-wraps the existing master key bytes into a v3 envelope
///    (every secret in the vault stays decryptable — the master
///    key bytes never change).
/// 4. Emits a `fido2_enrolled` audit event with the credential id
///    hash.
///
/// # Errors
///
/// - [`Error::Fido2AssertionFailed`] when the authenticator refuses.
/// - [`Error::CryptoFailure`] when an enroll attempt is made on an
///   already-v3 vault.
/// - All errors from the underlying vault unlock (wrong passphrase,
///   hardware-seal mismatch, missing display).
pub fn enroll<A: Fido2Authenticator>(authenticator: &mut A) -> Result<(), Error> {
    let root = crate::ops::vault_root()?;
    let mk_path = master_key_path(&root);

    // Must be a non-FIDO2 vault to enroll. A v3 vault that already
    // has a credential needs to disable + re-enroll rather than
    // silently overwrite — overwriting the credential id without
    // confirmation would lock the user out of any backup workflow.
    match fido2_status_at(&mk_path)? {
        Fido2Status::NoVault => {
            return Err(Error::CryptoFailure(
                "vault does not exist — store a secret first to initialize it".to_string(),
            ));
        }
        Fido2Status::Enrolled { .. } => {
            return Err(Error::CryptoFailure(
                "vault is already FIDO2-enrolled. Run `envseal security \
                 fido2-disable` first if you want to switch authenticators."
                    .to_string(),
            ));
        }
        Fido2Status::NotEnrolled => {}
    }

    // Prompt for the passphrase explicitly. We can't reuse a
    // platform-GUI passphrase capture inside `Vault::open_default`
    // because that path doesn't expose the captured value — and we
    // need it to re-derive the v3 wrapping key. Capturing once at
    // the ops layer, then opening the vault with that same value,
    // is the clean separation: the passphrase enters memory under
    // the caller's control and is zeroized via `Zeroizing` on drop.
    let cfg = security_config::load_system_defaults();
    let passphrase: Zeroizing<String> = gui::request_passphrase(false, &cfg)?;
    let vault = Vault::open_default_with_passphrase(&passphrase)?;

    enroll_fido2_on_existing_master(&mk_path, vault.master_key(), &passphrase, authenticator)?;

    // Re-read the file to recover the credential id we just wrote
    // — the enrollment function itself doesn't return it.
    if let Fido2Status::Enrolled { credential_id } = fido2_status_at(&mk_path)? {
        let _ = audit::log_required(&audit::AuditEvent::Fido2Enrolled {
            credential_id_hash: credential_id_hash(&credential_id),
        });
    }
    Ok(())
}

/// Disable FIDO2 enrollment. Requires both the passphrase AND a
/// fresh assertion from the currently-enrolled authenticator —
/// without the assertion we cannot decrypt the master key, and
/// without the passphrase the re-wrap as v1 is impossible.
///
/// # Errors
///
/// Same as [`enroll`] plus a clear error when the vault is not
/// currently FIDO2-enrolled (returns [`Error::Fido2NotEnrolled`]).
pub fn disable<A: Fido2Authenticator>(authenticator: &mut A) -> Result<(), Error> {
    let root = crate::ops::vault_root()?;
    let mk_path = master_key_path(&root);

    let pre_status = fido2_status_at(&mk_path)?;
    let credential_id = match pre_status {
        Fido2Status::Enrolled { ref credential_id } => credential_id.clone(),
        _ => return Err(Error::Fido2NotEnrolled),
    };

    let cfg = security_config::load_system_defaults();
    let passphrase = gui::request_passphrase(false, &cfg)?;

    let master = unlock_master_key_with_fido2(&mk_path, &passphrase, authenticator)?;

    disable_fido2_keep_master(&mk_path, &master, &passphrase)?;

    let _ = audit::log_required(&audit::AuditEvent::Fido2Disabled {
        credential_id_hash: credential_id_hash(&credential_id),
    });
    Ok(())
}

/// Hex-encoded SHA-256 of a credential id. Re-exported here so the
/// CLI can format the same value the audit log records, without
/// reaching into the keychain module from the binary.
fn credential_id_hash_local(credential_id: &[u8]) -> String {
    fido2_unlock::credential_id_hash(credential_id)
}

/// Convenience entry point for CLI / desktop callers: discover a
/// hardware authenticator and run [`enroll`]. Only available when
/// the `fido2-hardware` feature is enabled at build time.
///
/// # Errors
///
/// Mirrors [`enroll`]; additionally surfaces hardware discovery
/// failures via [`Error::Fido2AssertionFailed`].
#[cfg(feature = "fido2-hardware")]
pub fn enroll_with_hardware() -> Result<(), Error> {
    let mut auth = crate::vault::fido2_hardware::HwAuthenticator::discover()?;
    enroll(&mut auth)
}

/// Convenience entry point: discover a hardware authenticator and
/// run [`disable`]. Only available when `fido2-hardware` is
/// enabled.
///
/// # Errors
///
/// Mirrors [`disable`].
#[cfg(feature = "fido2-hardware")]
pub fn disable_with_hardware() -> Result<(), Error> {
    let mut auth = crate::vault::fido2_hardware::HwAuthenticator::discover()?;
    disable(&mut auth)
}

/// Build the unique error returned when CLI callers ask for an
/// enroll / disable on a build that lacks the hardware feature.
/// Centralized so the message stays consistent across surfaces.
#[cfg(not(feature = "fido2-hardware"))]
pub fn enroll_with_hardware() -> Result<(), Error> {
    Err(Error::CryptoFailure(
        "this build of envseal does not include the FIDO2 hardware backend. \
         rebuild with `cargo install envseal-cli --features fido2-hardware` \
         to enable security-key support."
            .to_string(),
    ))
}

#[cfg(not(feature = "fido2-hardware"))]
pub fn disable_with_hardware() -> Result<(), Error> {
    Err(Error::CryptoFailure(
        "this build of envseal does not include the FIDO2 hardware backend. \
         rebuild with `cargo install envseal-cli --features fido2-hardware` \
         to enable security-key support."
            .to_string(),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn status_report_shape_serializes_cleanly() {
        // Smoke: Fido2StatusReport struct has the fields callers
        // expect and they're constructible without surprise.
        let r = Fido2StatusReport {
            vault_initialized: true,
            enrolled: false,
            credential_id_hash: None,
            hardware_backend_available: false,
        };
        assert!(r.vault_initialized);
        assert!(!r.enrolled);
        assert!(r.credential_id_hash.is_none());
    }

    #[test]
    fn credential_id_hash_local_matches_keychain_helper() {
        let id = b"some-opaque-credential-id-xyz";
        let a = credential_id_hash_local(id);
        let b = fido2_unlock::credential_id_hash(id);
        assert_eq!(a, b);
        assert_eq!(a.len(), 64);
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
    }
}