tsafe-cli 1.0.23

Local-first developer secret vault CLI — encrypted storage, process injection via exec, cloud sync, audit trail
Documentation
//! `tsafe rotate-key` — atomic vault re-encryption with biometric credential update.
//!
//! Re-encrypts all vault secrets under a new master password, replaces the vault file
//! atomically via a temp-file rename, and updates the OS credential store entry so
//! biometric/quick-unlock continues to work with the new password.
//!
//! Failure safety contract:
//!
//! - The temp file is written next to the vault file.  If the write fails the
//!   original vault file is untouched.
//! - If the atomic rename fails the original vault file is untouched.
//! - If the rename succeeds but the biometric re-store fails, a stale-credential
//!   warning is emitted; the vault is still accessible with the new password via
//!   interactive prompt or `TSAFE_PASSWORD`.

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, events::emit_event, keyring_store};

use crate::helpers::{audit, open_vault, prompt_password, prompt_password_confirmed};

/// Re-encrypt `profile`'s vault under a new master password.
///
/// Steps:
/// 1. Open the vault with the current password (env var, biometric, or prompt).
/// 2. Prompt for a new password (or read from `TSAFE_NEW_MASTER_PASSWORD`).
/// 3. Re-encrypt via [`tsafe_core::vault::Vault::rotate`] which handles the
///    temp-file + atomic rename internally.
/// 4. Re-store the biometric/keyring credential if one was present.
/// 5. Write a `"rotate-key"` audit entry.
pub(crate) fn cmd_rotate_key(profile: &str) -> Result<()> {
    let mut vault = open_vault(profile)?;

    let new_pw = if let Ok(pw) = std::env::var("TSAFE_NEW_MASTER_PASSWORD") {
        if pw.is_empty() {
            anyhow::bail!(
                "TSAFE_NEW_MASTER_PASSWORD is set but empty — unset it or provide a non-empty password"
            );
        }
        tracing::debug!("rotate-key: using TSAFE_NEW_MASTER_PASSWORD (non-interactive)");
        pw
    } else {
        prompt_password_confirmed()?
    };

    // ── Step 3: re-encrypt ────────────────────────────────────────────────────
    //
    // Vault::rotate() decrypts all secrets under the current key, re-encrypts
    // them under the new key, and writes the result atomically (temp file +
    // rename).  The original file is untouched if anything fails before the
    // rename.
    vault
        .rotate(new_pw.as_bytes())
        .context("vault re-encryption failed")?;

    tracing::info!(profile, "vault re-encrypted successfully");

    // ── Step 4: biometric credential update ───────────────────────────────────
    //
    // If the keyring currently holds a credential for this profile, update it
    // to the new password so biometric / quick-unlock continues to work.
    //
    // If the retrieve succeeds with Some(_) the credential existed before rotation
    // and we must replace it.  If the re-store fails the vault is still correct
    // (the rename already happened) but the biometric entry is now stale — emit
    // actionable guidance matching the StaleBiometricCredential error message.
    match keyring_store::retrieve_password(profile) {
        Ok(Some(_)) => {
            // A credential existed — update it to the new password.
            match keyring_store::store_password(profile, &new_pw) {
                Ok(()) => {
                    tracing::debug!(profile, "biometric credential updated to new password");
                }
                Err(e) => {
                    tracing::warn!(
                        profile,
                        error = %e,
                        "failed to update biometric credential after re-key"
                    );
                    eprintln!(
                        "{} Vault re-encrypted but the OS credential store update failed: {e}",
                        "warn:".yellow().bold()
                    );
                    eprintln!(
                        "  The stored credential is now stale — biometric / quick-unlock will fail."
                    );
                    eprintln!(
                        "  Run `tsafe --profile {profile} biometric re-enroll` to restore password-free unlock."
                    );
                }
            }
        }
        Ok(None) => {
            // No credential was stored — nothing to update.
            tracing::debug!(profile, "no biometric credential stored; skipping update");
        }
        Err(e) => {
            // Keyring is unavailable in this environment (e.g. headless CI).
            tracing::debug!(profile, error = %e, "keyring unavailable; skipping biometric update");
        }
    }

    // ── Step 5: audit ─────────────────────────────────────────────────────────
    audit(profile)
        .append(&AuditEntry::success(profile, "rotate-key", None))
        .ok();
    emit_event(profile, "rotate-key", None);

    println!(
        "{} Vault re-encrypted successfully. If biometric is enabled, the stored credential has been updated.",
        "".green()
    );

    Ok(())
}

// ── Prompt helper (new-password path) ────────────────────────────────────────

/// Read the current vault password for `rotate-key` without triggering any of
/// the first-run auto-create side effects.  Delegates to [`open_vault`] which
/// handles TSAFE_PASSWORD, agent, biometric, and interactive prompt in order.
///
/// This is an internal re-export that keeps cmd_rotate.rs self-contained.
#[allow(dead_code)]
fn _read_current_password(profile: &str) -> Result<String> {
    prompt_password(&format!("Current password for profile '{profile}': "))
}