Skip to main content

chamber_vault/
crypto.rs

1use argon2::{Algorithm, Argon2, Params, Version};
2use chacha20poly1305::aead::{Aead, KeyInit};
3use chacha20poly1305::{XChaCha20Poly1305, XNonce};
4use color_eyre::Result;
5use color_eyre::eyre::eyre;
6use hmac::{Hmac, Mac};
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use zeroize::Zeroize;
10
11pub type HmacSha256 = Hmac<Sha256>;
12
13#[derive(Clone, Debug)]
14pub struct KeyMaterial(pub [u8; 32]);
15impl KeyMaterial {
16    /// Generates a new instance of `Self` with random bytes.
17    ///
18    /// # Attributes
19    /// * `#[allow(clippy::expect_used)]` - Suppresses the Clippy lint warning
20    ///   for using `.expect()`.
21    /// * `#[must_use]` - Indicates that the result of this function must be used
22    ///   by the caller, preventing accidental omission.
23    ///
24    /// # Returns
25    /// A new instance of `Self` initialized with 32 cryptographically secure
26    /// random bytes.
27    ///
28    /// # Panics
29    /// This function will panic if the system fails to generate random bytes.
30    ///
31    /// The panic occurs at the `expect` call if `getrandom::fill` does not succeed
32    /// in generating the random numbers.
33    #[allow(clippy::expect_used)]
34    #[must_use]
35    pub fn random() -> Self {
36        let mut k = [0u8; 32];
37        getrandom::fill(&mut k).expect("Failed to get random bytes");
38
39        Self(k)
40    }
41}
42impl Drop for KeyMaterial {
43    fn drop(&mut self) {
44        self.0.zeroize();
45    }
46}
47
48#[derive(Clone, Serialize, Deserialize)]
49pub struct KdfParams {
50    pub salt: Vec<u8>,
51    pub m_cost_kib: u32,
52    pub t_cost: u32,
53    pub p_cost: u32,
54}
55impl KdfParams {
56    /// Generates a default secure configuration for the given struct.
57    ///
58    /// This function initializes a secure default configuration with randomly generated salt
59    /// and preset computational parameters. It leverages the `getrandom` crate to fill the salt
60    /// with cryptographically secure random bytes. The computational parameters (`m_cost_kib`,
61    /// `t_cost`, and `p_cost`) are chosen to provide a balance between security and practical resource
62    /// usage:
63    ///
64    /// - `salt`: A 16-byte cryptographic salt used in hashing or encryption processes.
65    /// - `m_cost_kib`: Memory cost in kibibytes, set to 19,456 (~19MB) to mitigate against brute-force attacks.
66    /// - `t_cost`: Time cost or iterations, set to 3, determining the number of hashing passes.
67    /// - `p_cost`: Parallelism factor, set to 1, determining the number of threads or lanes.
68    ///
69    /// # Returns
70    ///
71    /// A new instance of `Self` with a secure default configuration.
72    ///
73    /// # Panics
74    ///
75    /// This function will panic if the underlying system fails to generate random bytes
76    /// using the `getrandom` crate. The error message will be `"Failed to get random bytes"`.
77    ///
78    /// # Attributes
79    ///
80    /// - `#[allow(clippy::expect_used)]`: Allows the `expect` function to be used without Clippy lint warnings.
81    /// - `#[must_use]`: Indicates that the result of this function must be used; otherwise,
82    ///   the compiler will issue a warning.
83    #[allow(clippy::expect_used)]
84    #[must_use]
85    pub fn default_secure() -> Self {
86        let mut salt = vec![0u8; 16];
87        getrandom::fill(&mut salt).expect("Failed to get random bytes");
88        Self {
89            salt,
90            m_cost_kib: 19456,
91            t_cost: 3,
92            p_cost: 1,
93        } // ~19MB memory
94    }
95}
96
97/// Derives a key from the given master password and key derivation function (KDF) parameters.
98///
99/// # Arguments
100///
101/// * `master` - A string slice representing the master password or secret from which the key
102///   will be derived.
103/// * `kdf` - A reference to `KdfParams` struct containing the parameters for the Argon2 key
104///   derivation function, such as memory cost, time cost, parallelism, and salt.
105///
106/// # Returns
107///
108/// Returns a `Result` containing:
109/// * `KeyMaterial` - On success, the derived key material.
110/// * `anyhow::Error` - On failure, an error that indicates what went wrong.
111///
112/// # KDF Parameters
113///
114/// The function uses the Argon2 key derivation algorithm with the following:
115/// * `Algorithm::Argon2id` - A hybrid of Argon2i and Argon2d.
116/// * `Version::V0x13` - The Argon2 version 0x13 (current recommended version).
117/// * `Params` - Configurable parameters (memory cost in KiB, time cost, parallelism, and output size, which is fixed to 32 bytes here).
118///
119/// # Errors
120///
121/// This function will return an error in the following scenarios:
122/// * If the KDF parameters cannot be created (`Params::new` fails).
123/// * If the hash computation with Argon2 fails (`hash_password_into` fails).
124pub fn derive_key(master: &str, kdf: &KdfParams) -> Result<KeyMaterial> {
125    let argon2 = Argon2::new(
126        Algorithm::Argon2id,
127        Version::V0x13,
128        Params::new(kdf.m_cost_kib, kdf.t_cost, kdf.p_cost, Some(32)).map_err(|e| eyre!("{e}"))?,
129    );
130    let mut out = [0u8; 32];
131    argon2
132        .hash_password_into(master.as_bytes(), &kdf.salt, &mut out)
133        .map_err(|e| eyre!("{e}"))?;
134    Ok(KeyMaterial(out))
135}
136
137// We implement a simple key wrap: derive an AEAD from the master-derived key,
138// generate random nonce and encrypt the vault key; store nonce+ciphertext.
139// Add a verifier: HMAC(master_derived, "chamber-verifier")
140#[derive(Serialize, Deserialize)]
141pub struct WrappedVaultKey {
142    pub nonce: Vec<u8>,
143    pub ciphertext: Vec<u8>,
144}
145
146/// Encrypts a vault key using a master derived key and returns the wrapped vault key along with a verification tag.
147///
148/// # Parameters
149/// - `master_derived`: A reference to the master derived `KeyMaterial` used to encrypt the vault key and generate a MAC.
150/// - `vault_key`: A reference to the `KeyMaterial` representing the vault key to be encrypted.
151///
152/// # Returns
153/// - `Ok((WrappedVaultKey, Vec<u8>))`: On success, returns a tuple containing:
154///   - `WrappedVaultKey`: A struct containing the encrypted vault key (ciphertext) and the nonce used for encryption.
155///   - `Vec<u8>`: A verification tag generated using HMAC-SHA256 to ensure integrity.
156/// - `Err(anyhow::Error)`: If encryption or random nonce generation fails.
157///
158/// # Errors
159/// - Returns an error if:
160///   - The random nonce generation (`getrandom::fill`) fails.
161///   - The AEAD encryption with `XChaCha20Poly1305` fails.
162///   - The HMAC-SHA256 initialization or computation fails.
163///
164/// # Implementation Details
165/// - A nonce of 24 bytes is generated using the `getrandom` library to ensure randomization for the AEAD encryption.
166/// - `XChaCha20Poly1305` is used for authenticated encryption, which requires:
167///   - A 256-bit encryption key derived from `master_derived`.
168///   - A nonce of 24 bytes.
169/// - The vault key is encrypted using the AEAD scheme, producing a ciphertext.
170/// - Alongside encryption, an HMAC-SHA256 tag is computed for message authentication using the `master_derived` key and the predefined string `chamber-verifier`.
171///
172/// # Dependencies
173/// - `XChaCha20Poly1305` for the encryption process.
174/// - `HmacSha256` from the `Mac` trait for generating the authentication tag.
175/// - `getrandom` for generating a secure random nonce.
176pub fn wrap_vault_key(master_derived: &KeyMaterial, vault_key: &KeyMaterial) -> Result<(WrappedVaultKey, Vec<u8>)> {
177    let aead = XChaCha20Poly1305::new((&master_derived.0).into());
178    let mut nonce = [0u8; 24];
179    getrandom::fill(&mut nonce)?;
180    let ct = aead
181        .encrypt(XNonce::from_slice(&nonce), vault_key.0.as_ref())
182        .map_err(|_| eyre!("AEAD encrypt failed"))?;
183    let wrapped = WrappedVaultKey {
184        nonce: nonce.to_vec(),
185        ciphertext: ct,
186    };
187
188    let mut mac = <HmacSha256 as Mac>::new_from_slice(&master_derived.0)?;
189    mac.update(b"chamber-verifier");
190    let tag = mac.finalize().into_bytes().to_vec();
191
192    Ok((wrapped, tag))
193}
194
195/// Unwraps a wrapped vault key using a master derived key, optionally verifying the unwrapping process
196/// with an additional verifier.
197///
198/// # Parameters
199/// - `master_derived`: A reference to the master derived key (`KeyMaterial`) that is used to unwrap
200///   the encrypted vault key.
201/// - `wrapped`: A reference to a `WrappedVaultKey` structure containing the nonce and ciphertext of
202///   the encrypted vault key.
203/// - `verifier`: An optional byte slice containing a verifier. If provided, this verifies that the
204///   unwrapping is being performed with the expected master derived key.
205///
206/// # Returns
207/// - `Ok(KeyMaterial)`: Returns the unwrapped vault key as a `KeyMaterial` object if successful.
208/// - `Err(anyhow::Error)`: Returns an error if the verification or decryption fails.
209///
210/// # Errors
211/// - Returns an error if the verifier is provided and does not match the expected value. The mismatch
212///   is reported as a "Verifier mismatch".
213/// - Returns an error if the AEAD (Authenticated Encryption with Associated Data) decryption fails,
214///   reported as "AEAD decrypt failed".
215///
216/// # Verification Process
217/// If a verifier is provided:
218/// 1. Uses HMAC-SHA256, initialized with the `master_derived` key, to compute a message authentication
219///    code (MAC) over a constant string, `"chamber-verifier"`.
220/// 2. Compares the computed MAC with the provided verifier. If they do not match, an error is returned.
221///
222/// # Decryption Process
223/// 1. Initializes an AEAD cipher (`XChaCha20Poly1305`) using the `master_derived` key.
224/// 2. Constructs a nonce using the `wrapped` data.
225/// 3. Uses the AEAD cipher to decrypt the provided ciphertext into plaintext.
226/// 4. Extracts 32 bytes from the plaintext to construct the unwrapped key.
227pub fn unwrap_vault_key(
228    master_derived: &KeyMaterial,
229    wrapped: &WrappedVaultKey,
230    verifier: Option<&[u8]>,
231) -> Result<KeyMaterial> {
232    if let Some(v) = verifier {
233        let mut mac = <HmacSha256 as Mac>::new_from_slice(&master_derived.0)?;
234        mac.update(b"chamber-verifier");
235        mac.verify_slice(v).map_err(|_| eyre!("Verifier mismatch"))?;
236    }
237    let aead = XChaCha20Poly1305::new((&master_derived.0).into());
238    let nonce = XNonce::from_slice(&wrapped.nonce);
239    let pt = aead
240        .decrypt(nonce, wrapped.ciphertext.as_ref())
241        .map_err(|_| eyre!("AEAD decrypt failed"))?;
242    let mut key = [0u8; 32];
243    key.copy_from_slice(&pt);
244    Ok(KeyMaterial(key))
245}
246
247/// Encrypts plaintext data using the AEAD (Authenticated Encryption with Associated Data) construction
248/// provided by the `XChaCha20Poly1305` algorithm, ensuring confidentiality, integrity, and authenticity.
249///
250/// # Arguments
251///
252/// - `vault_key`: A reference to a `KeyMaterial` that serves as the encryption key.
253/// - `plaintext`: A slice of bytes representing the data to encrypt.
254/// - `ad`: A slice of bytes representing the associated data (AD) to include in the encryption.
255///   The AD is authenticated but not encrypted, allowing external validation during decryption.
256///
257/// # Returns
258///
259/// Returns a `Result` containing:
260/// - On success: A tuple `(nonce, ciphertext)`:
261///   - `nonce`: A 24-byte vector representing the randomly generated nonce used during encryption.
262///   - `ciphertext`: A vector of bytes representing the encrypted data.
263/// - On failure: An error with a descriptive message.
264///
265/// # Errors
266///
267/// - Returns an error if random nonce generation fails using the `getrandom` crate.
268/// - Returns an error if the encryption process fails (e.g., due to an internal library failure).
269///
270/// # Security Considerations
271///
272/// - Ensure that the `vault_key` is securely managed and never reused across key contexts.
273/// - Nonces must be unique for each encryption operation. This function generates random nonces
274///   automatically to prevent reuse.
275/// - The associated data (`ad`) must be consistent during encryption and decryption for the integrity
276///   check to succeed.
277///
278/// # Dependencies
279///
280/// This function relies on the `chacha20poly1305` crate for encryption and the `getrandom` crate
281/// for secure random number generation.
282pub fn aead_encrypt(vault_key: &KeyMaterial, plaintext: &[u8], ad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
283    let aead = XChaCha20Poly1305::new((&vault_key.0).into());
284    let mut nonce = [0u8; 24];
285    getrandom::fill(&mut nonce)?;
286    let ct = aead
287        .encrypt(
288            XNonce::from_slice(&nonce),
289            chacha20poly1305::aead::Payload {
290                msg: plaintext,
291                aad: ad,
292            },
293        )
294        .map_err(|_| eyre!("encrypt failed"))?;
295    Ok((nonce.to_vec(), ct))
296}
297
298/// Decrypts a ciphertext using the AEAD (Authenticated Encryption with Associated Data)
299/// encryption scheme with the `XChaCha20Poly1305` algorithm.
300///
301/// # Parameters
302/// - `vault_key`: A reference to the key material (`KeyMaterial`) used for decryption. The key is used to initialize
303///   the `XChaCha20Poly1305` encryption algorithm.
304/// - `nonce`: A byte slice representing the unique nonce required for decryption. The nonce must match the one
305///   used during encryption.
306/// - `ciphertext`: A byte slice representing the encrypted data to be decrypted.
307/// - `ad`: A byte slice containing the associated data (AD) that was provided during encryption.
308///   The AD is authenticated but not encrypted, and must match exactly during decryption.
309///
310/// # Returns
311/// - `Result<Vec<u8>>`: On success, returns the decrypted plaintext as a vector of bytes. On failure, returns an error wrapped
312///   in a `Result`. An error could occur if the decryption fails due to a mismatch in the key, nonce, ciphertext, or associated data.
313///
314/// # Errors
315/// - Returns an error if the decryption fails, such as in cases of an invalid key, mismatched nonce or associated data, or corrupted ciphertext.
316///
317/// # Notes
318/// - The `XChaCha20Poly1305` cipher ensures both confidentiality and authenticity of the ciphertext and associated data.
319/// - The caller must ensure the `nonce`, `ciphertext`, and `ad` provided match exactly with those used during encryption.
320/// - Incorrect inputs will result in a decryption failure.
321pub fn aead_decrypt(vault_key: &KeyMaterial, nonce: &[u8], ciphertext: &[u8], ad: &[u8]) -> Result<Vec<u8>> {
322    let aead = XChaCha20Poly1305::new((&vault_key.0).into());
323    let pt = aead
324        .decrypt(
325            XNonce::from_slice(nonce),
326            chacha20poly1305::aead::Payload {
327                msg: ciphertext,
328                aad: ad,
329            },
330        )
331        .map_err(|_| eyre!("decrypt failed"))?;
332    Ok(pt)
333}
334
335// Rust
336#[cfg(test)]
337mod tests {
338    #![allow(clippy::unwrap_used)]
339    use super::*;
340    use hex::encode as hex_encode;
341
342    // Use a reduced-cost KDF for fast tests
343    fn small_kdf(salt: &[u8]) -> KdfParams {
344        let mut s = salt.to_vec();
345        if s.len() < 8 {
346            s.resize(8, 0); // pad with zeros to meet Argon2 salt requirement
347        }
348        KdfParams {
349            salt: s,
350            m_cost_kib: 8, // very small memory for test speed
351            t_cost: 1,
352            p_cost: 1,
353        }
354    }
355
356    #[test]
357    fn test_keymaterial_random_and_length() {
358        let k1 = KeyMaterial::random();
359        let k2 = KeyMaterial::random();
360        assert_eq!(k1.0.len(), 32);
361        assert_eq!(k2.0.len(), 32);
362        // Very likely different
363        assert_ne!(hex_encode(k1.0), hex_encode(k2.0));
364    }
365
366    #[test]
367    fn test_derive_key_deterministic_and_salt_sensitive() {
368        let kdf1 = small_kdf(b"salt-1");
369        let kdf2 = small_kdf(b"salt-2");
370        let master = "correct horse battery staple";
371
372        let a = derive_key(master, &kdf1).unwrap();
373        let b = derive_key(master, &kdf1).unwrap();
374        let c = derive_key(master, &kdf2).unwrap();
375
376        // Deterministic with same params
377        assert_eq!(hex_encode(a.0), hex_encode(b.0));
378        // Different salt -> different key
379        assert_ne!(hex_encode(a.0), hex_encode(c.0));
380    }
381
382    #[test]
383    fn test_aead_encrypt_decrypt_roundtrip_with_ad() {
384        let key = KeyMaterial::random();
385        let msg = b"secret message";
386        let ad = b"associated-data";
387
388        let (nonce, ct) = aead_encrypt(&key, msg, ad).unwrap();
389        let pt = aead_decrypt(&key, &nonce, &ct, ad).unwrap();
390        assert_eq!(pt, msg);
391    }
392
393    #[test]
394    fn test_aead_decrypt_wrong_ad_fails() {
395        let key = KeyMaterial::random();
396        let msg = b"message";
397        let ad_ok = b"ad-ok";
398        let ad_bad = b"ad-bad";
399
400        let (nonce, ct) = aead_encrypt(&key, msg, ad_ok).unwrap();
401        let err = aead_decrypt(&key, &nonce, &ct, ad_bad).unwrap_err();
402        assert!(err.to_string().to_lowercase().contains("decrypt"));
403    }
404
405    #[test]
406    fn test_aead_decrypt_wrong_key_fails() {
407        let key1 = KeyMaterial::random();
408        let key2 = KeyMaterial::random();
409        let (nonce, ct) = aead_encrypt(&key1, b"data", b"ad").unwrap();
410
411        let err = aead_decrypt(&key2, &nonce, &ct, b"ad").unwrap_err();
412        assert!(err.to_string().to_lowercase().contains("decrypt"));
413    }
414
415    #[test]
416    fn test_aead_tamper_detection() {
417        let key = KeyMaterial::random();
418        let (nonce, mut ct) = aead_encrypt(&key, b"payload", b"ad").unwrap();
419
420        // Flip one bit in ciphertext
421        if let Some(byte) = ct.get_mut(0) {
422            *byte ^= 0x01;
423        }
424        let err = aead_decrypt(&key, &nonce, &ct, b"ad").unwrap_err();
425        assert!(err.to_string().to_lowercase().contains("decrypt"));
426    }
427
428    #[test]
429    fn test_wrap_unwrap_vault_key_roundtrip_and_verifier() {
430        let master = "test-master";
431        let kdf = small_kdf(b"wrapsalt");
432        let master_derived = derive_key(master, &kdf).unwrap();
433
434        let vk = KeyMaterial::random();
435        let (wrapped, verifier) = wrap_vault_key(&master_derived, &vk).unwrap();
436
437        // Unwrap with verifier ok
438        let unwrapped = unwrap_vault_key(&master_derived, &wrapped, Some(&verifier)).unwrap();
439        assert_eq!(hex_encode(vk.0), hex_encode(unwrapped.0));
440
441        // Unwrap without verifier also ok
442        let unwrapped2 = unwrap_vault_key(&master_derived, &wrapped, None).unwrap();
443        assert_eq!(hex_encode(vk.0), hex_encode(unwrapped2.0));
444    }
445
446    #[test]
447    fn test_unwrap_verifier_mismatch_fails() {
448        let master_ok = "master-ok";
449        let master_bad = "master-bad";
450        let kdf = small_kdf(b"v-salt");
451
452        let md_ok = derive_key(master_ok, &kdf).unwrap();
453        let md_bad = derive_key(master_bad, &kdf).unwrap();
454
455        let vk = KeyMaterial::random();
456        let (wrapped, verifier) = wrap_vault_key(&md_ok, &vk).unwrap();
457
458        // Using wrong master-derived key with correct verifier should fail verification
459        let err = unwrap_vault_key(&md_bad, &wrapped, Some(&verifier)).unwrap_err();
460        assert!(err.to_string().to_lowercase().contains("verifier"));
461    }
462
463    #[test]
464    fn test_unwrap_with_tampered_ciphertext_fails() {
465        let master = "master";
466        let kdf = small_kdf(b"salt-x");
467        let md = derive_key(master, &kdf).unwrap();
468
469        let vk = KeyMaterial::random();
470        let (mut wrapped, verifier) = wrap_vault_key(&md, &vk).unwrap();
471
472        // Tamper with ciphertext
473        if let Some(byte) = wrapped.ciphertext.get_mut(0) {
474            *byte ^= 0x80;
475        }
476
477        let err = unwrap_vault_key(&md, &wrapped, Some(&verifier)).unwrap_err();
478        assert!(err.to_string().to_lowercase().contains("aead"));
479    }
480
481    #[test]
482    fn test_kdfparams_default_secure_has_expected_shape() {
483        let kdf = KdfParams::default_secure();
484        // Basic invariants; we don't assert exact costs, but ensure they are non-trivial
485        assert_eq!(kdf.salt.len(), 16);
486        assert!(kdf.m_cost_kib >= 1024);
487        assert!(kdf.t_cost >= 1);
488        assert!(kdf.p_cost >= 1);
489        // Derive works
490        let km = derive_key("pw", &kdf).unwrap();
491        assert_eq!(km.0.len(), 32);
492    }
493
494    #[test]
495    fn test_hmac_verifier_stable_for_same_key() {
496        let master = "verifier-master";
497        let kdf = small_kdf(b"vsalt");
498        let md = derive_key(master, &kdf).unwrap();
499        let vk = KeyMaterial::random();
500
501        let (_, tag1) = wrap_vault_key(&md, &vk).unwrap();
502        let (_, tag2) = wrap_vault_key(&md, &vk).unwrap();
503
504        // HMAC is deterministic for the same key and data
505        assert_eq!(hex_encode(tag1), hex_encode(tag2));
506    }
507
508    #[test]
509    fn test_hmac_verifier_differs_for_different_master_keys() {
510        let kdf = small_kdf(b"vsalt");
511        let md1 = derive_key("m1", &kdf).unwrap();
512        let md2 = derive_key("m2", &kdf).unwrap();
513        let vk = KeyMaterial::random();
514
515        let (_, tag1) = wrap_vault_key(&md1, &vk).unwrap();
516        let (_, tag2) = wrap_vault_key(&md2, &vk).unwrap();
517
518        assert_ne!(hex_encode(tag1), hex_encode(tag2));
519    }
520
521    // Helper: ensure hex codec available in tests
522    mod hex {
523        #[allow(clippy::format_collect)]
524        pub fn encode<T: AsRef<[u8]>>(data: T) -> String {
525            data.as_ref().iter().map(|b| format!("{b:02x}")).collect()
526        }
527    }
528}