1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! `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}': "))
}