Skip to main content

envvault/cli/commands/
rotate.rs

1//! `envvault rotate-key` — change the vault master password.
2//!
3//! Decrypts all secrets with the old password, generates a new salt,
4//! re-derives the master key from the new password, re-encrypts all
5//! secrets, and writes the vault atomically.
6//!
7//! Optionally changes the keyfile with `--new-keyfile <path>` or removes
8//! the keyfile requirement with `--new-keyfile none`.
9
10use std::path::Path;
11
12use zeroize::Zeroize;
13
14use crate::cli::output;
15use crate::cli::{load_keyfile, prompt_new_password, prompt_password_for_vault, vault_path, Cli};
16use crate::config::Settings;
17use crate::crypto::kdf::generate_salt;
18use crate::crypto::keyfile;
19use crate::crypto::keys::MasterKey;
20use crate::errors::Result;
21use crate::vault::format::{StoredArgon2Params, VaultHeader, CURRENT_VERSION};
22use crate::vault::VaultStore;
23
24/// Execute the `rotate-key` command.
25///
26/// `new_keyfile_arg`: `None` = keep existing keyfile, `Some("none")` = remove
27/// keyfile requirement, `Some(path)` = switch to a different keyfile.
28pub fn execute(cli: &Cli, new_keyfile_arg: Option<&str>) -> Result<()> {
29    let path = vault_path(cli)?;
30
31    // 1. Open the vault with the current password.
32    output::info("Enter your current vault password.");
33    let keyfile_data = load_keyfile(cli)?;
34    let vault_id = path.to_string_lossy();
35    let old_password = prompt_password_for_vault(Some(&vault_id))?;
36    let store = VaultStore::open(&path, old_password.as_bytes(), keyfile_data.as_deref())?;
37
38    // 2. Decrypt all secrets into memory.
39    let mut secrets = store.get_all_secrets()?;
40
41    // 3. Prompt for the new password.
42    output::info("Choose your new vault password.");
43    let new_password = prompt_new_password()?;
44
45    // 4. Load settings for Argon2 params.
46    let cwd = std::env::current_dir()?;
47    let settings = Settings::load(&cwd)?;
48    let params = settings.argon2_params();
49
50    // 5. Resolve keyfile for the new vault.
51    let (new_keyfile_bytes, new_keyfile_hash) =
52        resolve_new_keyfile(new_keyfile_arg, keyfile_data.as_deref(), &store)?;
53
54    // 6. Generate a new salt and derive a new master key.
55    let new_salt = generate_salt();
56    let mut effective_password = match &new_keyfile_bytes {
57        Some(kf) => keyfile::combine_password_keyfile(new_password.as_bytes(), kf)?,
58        None => new_password.as_bytes().to_vec(),
59    };
60    let mut master_bytes =
61        crate::crypto::kdf::derive_master_key_with_params(&effective_password, &new_salt, &params)?;
62    effective_password.zeroize();
63    let new_master_key = MasterKey::new(master_bytes);
64    master_bytes.zeroize();
65
66    // 7. Build a new header with the new salt and params.
67    let new_header = VaultHeader {
68        version: CURRENT_VERSION,
69        salt: new_salt.to_vec(),
70        created_at: store.created_at(),
71        environment: store.environment().to_string(),
72        argon2_params: Some(StoredArgon2Params {
73            memory_kib: params.memory_kib,
74            iterations: params.iterations,
75            parallelism: params.parallelism,
76        }),
77        keyfile_hash: new_keyfile_hash,
78    };
79
80    // 8. Create a new vault store with the new key and re-encrypt secrets.
81    let mut new_store = VaultStore::from_parts(path, new_header, new_master_key);
82
83    for (name, value) in &secrets {
84        new_store.set_secret(name, value)?;
85    }
86
87    // 9. Zeroize plaintext secrets from memory.
88    for value in secrets.values_mut() {
89        value.zeroize();
90    }
91
92    // 10. Save atomically.
93    new_store.save()?;
94
95    crate::audit::log_audit(
96        cli,
97        "rotate-key",
98        None,
99        Some(&format!(
100            "{} secrets re-encrypted",
101            new_store.secret_count()
102        )),
103    );
104
105    // Print a message indicating what changed.
106    let keyfile_msg = match new_keyfile_arg {
107        Some("none") => " (keyfile requirement removed)",
108        Some(_) => " (keyfile changed)",
109        None => "",
110    };
111
112    output::success(&format!(
113        "Password rotated for '{}' vault ({} secrets re-encrypted){}",
114        new_store.environment(),
115        new_store.secret_count(),
116        keyfile_msg,
117    ));
118
119    Ok(())
120}
121
122/// Resolve the keyfile configuration for the new vault.
123///
124/// Returns `(keyfile_bytes, keyfile_hash)` for the new header.
125fn resolve_new_keyfile(
126    new_keyfile_arg: Option<&str>,
127    existing_keyfile: Option<&[u8]>,
128    store: &VaultStore,
129) -> Result<(Option<Vec<u8>>, Option<String>)> {
130    match new_keyfile_arg {
131        // Explicit "none" removes keyfile requirement.
132        Some("none") => {
133            output::info("Removing keyfile requirement from vault.");
134            Ok((None, None))
135        }
136        // New keyfile path provided.
137        Some(path) => {
138            output::info(&format!("Switching to new keyfile: {path}"));
139            let bytes = keyfile::load_keyfile(Path::new(path))?;
140            let hash = keyfile::hash_keyfile(&bytes);
141            Ok((Some(bytes), Some(hash)))
142        }
143        // No flag: preserve existing keyfile configuration.
144        None => Ok((
145            existing_keyfile.map(|b| b.to_vec()),
146            store.header().keyfile_hash.clone(),
147        )),
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn resolve_new_keyfile_none_removes_requirement() {
157        let tmp = tempfile::TempDir::new().unwrap();
158        let vault_path = tmp.path().join(".envvault").join("dev.vault");
159        std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
160
161        // Create a vault with a keyfile.
162        let kf_bytes = [0xABu8; 32];
163        let store = VaultStore::create(
164            &vault_path,
165            b"test-password-long",
166            "dev",
167            None,
168            Some(&kf_bytes),
169        )
170        .unwrap();
171
172        let (bytes, hash) = resolve_new_keyfile(Some("none"), Some(&kf_bytes), &store).unwrap();
173        assert!(bytes.is_none());
174        assert!(hash.is_none());
175    }
176
177    #[test]
178    fn resolve_new_keyfile_with_path_changes_hash() {
179        let tmp = tempfile::TempDir::new().unwrap();
180        let vault_path = tmp.path().join(".envvault").join("dev.vault");
181        std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
182
183        // Create a vault without a keyfile.
184        let store =
185            VaultStore::create(&vault_path, b"test-password-long", "dev", None, None).unwrap();
186
187        // Generate a keyfile.
188        let kf_path = tmp.path().join("new.keyfile");
189        let kf_bytes = crate::crypto::keyfile::generate_keyfile(&kf_path).unwrap();
190
191        let (bytes, hash) =
192            resolve_new_keyfile(Some(kf_path.to_str().unwrap()), None, &store).unwrap();
193        assert!(bytes.is_some());
194        assert!(hash.is_some());
195        assert_eq!(bytes.unwrap(), kf_bytes);
196    }
197
198    #[test]
199    fn resolve_new_keyfile_preserves_existing() {
200        let tmp = tempfile::TempDir::new().unwrap();
201        let vault_path = tmp.path().join(".envvault").join("dev.vault");
202        std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
203
204        let kf_bytes = [0xCDu8; 32];
205        let store = VaultStore::create(
206            &vault_path,
207            b"test-password-long",
208            "dev",
209            None,
210            Some(&kf_bytes),
211        )
212        .unwrap();
213
214        let original_hash = store.header().keyfile_hash.clone();
215
216        let (bytes, hash) = resolve_new_keyfile(None, Some(&kf_bytes), &store).unwrap();
217        assert_eq!(bytes.unwrap(), kf_bytes);
218        assert_eq!(hash, original_hash);
219    }
220}