envvault/crypto/
keyfile.rs1use std::fs;
11use std::path::Path;
12
13use hmac::{Hmac, Mac};
14use rand::TryRngCore;
15use sha2::Sha256;
16
17use crate::errors::{EnvVaultError, Result};
18
19const KEYFILE_LEN: usize = 32;
21
22pub fn generate_keyfile(path: &Path) -> Result<Vec<u8>> {
27 if path.exists() {
28 return Err(EnvVaultError::KeyfileError(format!(
29 "keyfile already exists at {}",
30 path.display()
31 )));
32 }
33
34 let mut keyfile = vec![0u8; KEYFILE_LEN];
36 rand::rngs::OsRng
37 .try_fill_bytes(&mut keyfile)
38 .expect("OS RNG failed");
39
40 if let Some(parent) = path.parent() {
42 if !parent.exists() {
43 fs::create_dir_all(parent).map_err(|e| {
44 EnvVaultError::KeyfileError(format!("cannot create keyfile directory: {e}"))
45 })?;
46 }
47 }
48
49 fs::write(path, &keyfile)
50 .map_err(|e| EnvVaultError::KeyfileError(format!("failed to write keyfile: {e}")))?;
51
52 #[cfg(unix)]
54 {
55 use std::os::unix::fs::PermissionsExt;
56 let perms = fs::Permissions::from_mode(0o600);
57 fs::set_permissions(path, perms).map_err(|e| {
58 EnvVaultError::KeyfileError(format!("failed to set keyfile permissions: {e}"))
59 })?;
60 }
61
62 Ok(keyfile)
63}
64
65pub fn load_keyfile(path: &Path) -> Result<Vec<u8>> {
67 if !path.exists() {
68 return Err(EnvVaultError::KeyfileError(format!(
69 "keyfile not found at {}",
70 path.display()
71 )));
72 }
73
74 let data = fs::read(path)
75 .map_err(|e| EnvVaultError::KeyfileError(format!("failed to read keyfile: {e}")))?;
76
77 if data.len() != KEYFILE_LEN {
78 return Err(EnvVaultError::KeyfileError(format!(
79 "keyfile must be exactly {} bytes, got {}",
80 KEYFILE_LEN,
81 data.len()
82 )));
83 }
84
85 Ok(data)
86}
87
88pub fn combine_password_keyfile(password: &[u8], keyfile_bytes: &[u8]) -> Result<Vec<u8>> {
95 let mut mac = Hmac::<Sha256>::new_from_slice(keyfile_bytes)
96 .map_err(|e| EnvVaultError::KeyfileError(format!("HMAC init failed: {e}")))?;
97
98 mac.update(password);
99
100 Ok(mac.finalize().into_bytes().to_vec())
101}
102
103pub fn hash_keyfile(keyfile_bytes: &[u8]) -> String {
108 use base64::engine::general_purpose::STANDARD as BASE64;
109 use base64::Engine;
110 use sha2::Digest;
111 let hash = Sha256::digest(keyfile_bytes);
112 BASE64.encode(hash)
113}
114
115pub fn verify_keyfile_hash(keyfile_bytes: &[u8], expected_hash: &str) -> Result<()> {
117 use subtle::ConstantTimeEq;
118
119 let actual_hash = hash_keyfile(keyfile_bytes);
120
121 if actual_hash
123 .as_bytes()
124 .ct_eq(expected_hash.as_bytes())
125 .into()
126 {
127 Ok(())
128 } else {
129 Err(EnvVaultError::KeyfileError(
130 "wrong keyfile — hash does not match the vault".into(),
131 ))
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use tempfile::TempDir;
139
140 #[test]
141 fn generate_and_load_keyfile_roundtrip() {
142 let dir = TempDir::new().unwrap();
143 let path = dir.path().join("test.keyfile");
144
145 let generated = generate_keyfile(&path).unwrap();
146 assert_eq!(generated.len(), KEYFILE_LEN);
147
148 let loaded = load_keyfile(&path).unwrap();
149 assert_eq!(generated, loaded);
150 }
151
152 #[test]
153 fn generate_keyfile_fails_if_exists() {
154 let dir = TempDir::new().unwrap();
155 let path = dir.path().join("test.keyfile");
156
157 generate_keyfile(&path).unwrap();
158 let result = generate_keyfile(&path);
159 assert!(result.is_err());
160 }
161
162 #[test]
163 fn load_keyfile_fails_if_missing() {
164 let dir = TempDir::new().unwrap();
165 let path = dir.path().join("nonexistent.keyfile");
166
167 let result = load_keyfile(&path);
168 assert!(result.is_err());
169 }
170
171 #[test]
172 fn load_keyfile_fails_on_wrong_length() {
173 let dir = TempDir::new().unwrap();
174 let path = dir.path().join("bad.keyfile");
175 fs::write(&path, [0u8; 16]).unwrap();
176
177 let result = load_keyfile(&path);
178 assert!(result.is_err());
179 }
180
181 #[test]
182 fn combine_password_keyfile_is_deterministic() {
183 let password = b"my-password";
184 let keyfile = [0xABu8; 32];
185
186 let result1 = combine_password_keyfile(password, &keyfile).unwrap();
187 let result2 = combine_password_keyfile(password, &keyfile).unwrap();
188 assert_eq!(result1, result2);
189 }
190
191 #[test]
192 fn combine_differs_with_different_keyfile() {
193 let password = b"my-password";
194 let keyfile1 = [0xABu8; 32];
195 let keyfile2 = [0xCDu8; 32];
196
197 let result1 = combine_password_keyfile(password, &keyfile1).unwrap();
198 let result2 = combine_password_keyfile(password, &keyfile2).unwrap();
199 assert_ne!(result1, result2);
200 }
201
202 #[test]
203 fn combine_differs_with_different_password() {
204 let keyfile = [0xABu8; 32];
205
206 let result1 = combine_password_keyfile(b"password1", &keyfile).unwrap();
207 let result2 = combine_password_keyfile(b"password2", &keyfile).unwrap();
208 assert_ne!(result1, result2);
209 }
210
211 #[test]
212 fn hash_keyfile_is_deterministic() {
213 let keyfile = [0x42u8; 32];
214 let hash1 = hash_keyfile(&keyfile);
215 let hash2 = hash_keyfile(&keyfile);
216 assert_eq!(hash1, hash2);
217 }
218
219 #[test]
220 fn verify_keyfile_hash_succeeds_for_correct_keyfile() {
221 let keyfile = [0x42u8; 32];
222 let hash = hash_keyfile(&keyfile);
223 assert!(verify_keyfile_hash(&keyfile, &hash).is_ok());
224 }
225
226 #[test]
227 fn verify_keyfile_hash_fails_for_wrong_keyfile() {
228 let keyfile = [0x42u8; 32];
229 let wrong_keyfile = [0x43u8; 32];
230 let hash = hash_keyfile(&keyfile);
231 assert!(verify_keyfile_hash(&wrong_keyfile, &hash).is_err());
232 }
233}