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