tsafe-cli 1.0.25

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
Documentation
//! Biometric/keyring unlock command handler.
//!
//! Implements `tsafe biometric enable|disable|status|re-enroll` — storing, removing, checking,
//! and recovering the OS credential-store entry used for password-free vault unlock.
//!
//! # Stale credential handling (ADR-016)
//!
//! A stored credential becomes stale when the vault password is rotated or (on macOS) a new
//! fingerprint is enrolled after `biometric enable` was run.  When a stale credential is
//! detected during vault open (decryption fails with the stored password), the caller should
//! propagate `SafeError::StaleBiometricCredential` rather than a generic decryption error.
//! This module provides `retrieve_biometric_password` and `classify_biometric_unlock_error`
//! for that path.
//!
//! Detection is intentionally explicit — tsafe NEVER silently falls through to another
//! unlock method when a stale credential is the cause.  The user is directed to
//! `tsafe biometric re-enroll` to restore password-free unlock.

use anyhow::Result;
use colored::Colorize;
use tsafe_cli::cli::BiometricAction;
use tsafe_core::{errors::SafeError, keyring_store, profile};
use zeroize::Zeroizing;

use crate::helpers::*;

/// Attempt to unlock the vault using the OS credential store.
///
/// Returns `Ok(Some(password))` if a credential exists and should be tried.
/// Returns `Ok(None)` if no credential is stored (caller falls back to password prompt).
///
/// This function does NOT open the vault itself; the caller is responsible for attempting
/// `Vault::open` with the returned password and calling `classify_biometric_unlock_error`
/// on failure to surface the correct `StaleBiometricCredential` error.
pub fn retrieve_biometric_password(
    profile_name: &str,
) -> Result<Option<Zeroizing<String>>, SafeError> {
    keyring_store::retrieve_password(profile_name).map(|opt| opt.map(Zeroizing::new))
}

/// Classify a vault-open error that occurred when using a biometric/keyring-sourced password.
///
/// If the error looks like a stale credential (wrong password from the keyring), returns
/// `SafeError::StaleBiometricCredential`.  Otherwise returns the original error unchanged.
///
/// Callers MUST NOT silently fall through — propagate `StaleBiometricCredential` to the
/// surface so the user sees the re-enroll instruction.
pub fn classify_biometric_unlock_error(e: SafeError) -> SafeError {
    let msg = e.to_string();
    if keyring_store::looks_like_stale_credential(&msg) {
        return SafeError::StaleBiometricCredential;
    }
    e
}

pub(crate) fn cmd_biometric(profile: &str, action: BiometricAction) -> Result<()> {
    match action {
        BiometricAction::Enable => biometric_enable(profile),
        BiometricAction::Disable => biometric_disable(profile),
        BiometricAction::Status => biometric_status(profile),
        BiometricAction::ReEnroll => biometric_re_enroll(profile),
    }
}

fn biometric_enable(profile: &str) -> Result<()> {
    let password = prompt_password(&format!("Password for profile '{profile}': "))?;
    let path = profile::vault_path(profile);
    // Verify the password is correct before storing.
    tsafe_core::vault::Vault::open(&path, password.as_bytes())
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    keyring_store::store_password(profile, &password).map_err(|e| anyhow::anyhow!("{e}"))?;
    println!(
        "{} Biometric/keyring unlock enabled for '{}'",
        "".green(),
        profile
    );
    println!("  Your vault password is now stored in the OS credential store.");
    Ok(())
}

fn biometric_disable(profile: &str) -> Result<()> {
    keyring_store::remove_password(profile).map_err(|e| anyhow::anyhow!("{e}"))?;
    println!(
        "{} Biometric/keyring unlock disabled for '{}'",
        "".green(),
        profile
    );
    Ok(())
}

fn biometric_status(profile: &str) -> Result<()> {
    if keyring_store::has_password(profile) {
        println!(
            "{} Biometric/keyring unlock is {} for '{}'",
            "".green(),
            "enabled".green(),
            profile
        );
        if let Some(note) = keyring_store::quick_unlock_storage_note(profile) {
            println!("  {note}");
        }
    } else {
        println!(
            "  Biometric/keyring unlock is {} for '{}'",
            "not configured".yellow(),
            profile
        );
        println!("  Run {} to enable.", "tsafe biometric enable".cyan());
    }
    Ok(())
}

/// Re-enroll biometric/keyring unlock after a stale-credential error.
///
/// This is the canonical recovery path for `SafeError::StaleBiometricCredential`.
/// It removes the stale stored credential and immediately prompts to store a fresh one,
/// verifying the password against the vault before committing it.
fn biometric_re_enroll(profile: &str) -> Result<()> {
    // Step 1: remove any stale credential — best-effort, ignore "not found".
    let _ = keyring_store::remove_password(profile);

    println!(
        "{} Stale biometric credential removed for '{}'.",
        "i".cyan(),
        profile
    );
    println!("  Enter the current vault password to re-enroll:");

    let password = prompt_password(&format!("Password for profile '{profile}': "))?;
    let path = profile::vault_path(profile);

    // Step 2: verify the password opens the vault before storing.
    tsafe_core::vault::Vault::open(&path, password.as_bytes()).map_err(|e| {
        anyhow::anyhow!(
            "password verification failed — the vault could not be opened: {e}\n\
             Hint: use the current master password, not the old one."
        )
    })?;

    // Step 3: store the fresh credential.
    keyring_store::store_password(profile, &password).map_err(|e| anyhow::anyhow!("{e}"))?;

    println!(
        "{} Biometric/keyring unlock re-enrolled for '{}'. Password-free unlock is restored.",
        "".green(),
        profile
    );
    Ok(())
}