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}