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::TryRngCore;
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
37        .try_fill_bytes(&mut keyfile)
38        .expect("OS RNG failed");
39
40    // Ensure the parent directory exists.
41    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    // On Unix, restrict permissions to owner-only read/write.
53    #[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
65/// Load a keyfile from disk and validate its length.
66pub 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
88/// Combine a password and keyfile into a single effective password.
89///
90/// Uses HMAC-SHA256 with the keyfile as the key and the password as
91/// the message: `HMAC-SHA256(keyfile, password)`.
92///
93/// The result is fed into Argon2id instead of the raw password.
94pub 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
103/// Compute the SHA-256 hash of a keyfile for storage in the vault header.
104///
105/// This hash lets us verify the correct keyfile is being used without
106/// storing the keyfile itself in the vault.
107pub 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
115/// Verify that a keyfile matches the expected hash stored in the header.
116pub 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    // Use constant-time comparison to avoid timing side channels.
122    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}