envvault/cli/commands/
rotate.rs1use 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
24pub fn execute(cli: &Cli, new_keyfile_arg: Option<&str>) -> Result<()> {
29 let path = vault_path(cli)?;
30
31 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 let mut secrets = store.get_all_secrets()?;
40
41 output::info("Choose your new vault password.");
43 let new_password = prompt_new_password()?;
44
45 let cwd = std::env::current_dir()?;
47 let settings = Settings::load(&cwd)?;
48 let params = settings.argon2_params();
49
50 let (new_keyfile_bytes, new_keyfile_hash) =
52 resolve_new_keyfile(new_keyfile_arg, keyfile_data.as_deref(), &store)?;
53
54 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, ¶ms)?;
62 effective_password.zeroize();
63 let new_master_key = MasterKey::new(master_bytes);
64 master_bytes.zeroize();
65
66 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 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 for value in secrets.values_mut() {
89 value.zeroize();
90 }
91
92 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 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
122fn 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 Some("none") => {
133 output::info("Removing keyfile requirement from vault.");
134 Ok((None, None))
135 }
136 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 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 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 let store =
185 VaultStore::create(&vault_path, b"test-password-long", "dev", None, None).unwrap();
186
187 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}