Skip to main content

envvault/crypto/
keyfile.rs

1//! Keyfile-based authentication for EnvVault.
2//!
3//! A keyfile is a 32-byte random file that acts as a second factor.
4//! When a vault is created with a keyfile, both the password and the
5//! keyfile are required to derive the master key.
6//!
7//! The combination is: `HMAC-SHA256(keyfile_bytes, password_bytes)`.
8//! This combined value is then fed into Argon2id as the "password".
9
10use std::fs;
11use std::path::Path;
12
13use hmac::{Hmac, Mac};
14use rand::RngCore;
15use sha2::Sha256;
16
17use crate::errors::{EnvVaultError, Result};
18
19/// Expected length of a keyfile in bytes (256 bits).
20const KEYFILE_LEN: usize = 32;
21
22/// Generate a new random keyfile and write it to `path`.
23///
24/// The file is written with restrictive permissions (owner-only read).
25/// Returns the raw keyfile bytes so the caller can use them immediately.
26pub 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    // Generate 32 cryptographically random bytes.
35    let mut keyfile = vec![0u8; KEYFILE_LEN];
36    rand::rngs::OsRng.fill_bytes(&mut keyfile);
37
38    // Ensure the parent directory exists.
39    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    // On Unix, restrict permissions to owner-only read/write.
51    #[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
63/// Load a keyfile from disk and validate its length.
64pub 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
86/// Combine a password and keyfile into a single effective password.
87///
88/// Uses HMAC-SHA256 with the keyfile as the key and the password as
89/// the message: `HMAC-SHA256(keyfile, password)`.
90///
91/// The result is fed into Argon2id instead of the raw password.
92pub 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
101/// Compute the SHA-256 hash of a keyfile for storage in the vault header.
102///
103/// This hash lets us verify the correct keyfile is being used without
104/// storing the keyfile itself in the vault.
105pub 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
113/// Verify that a keyfile matches the expected hash stored in the header.
114pub 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    // Use constant-time comparison to avoid timing side channels.
120    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}