use argon2::{Algorithm, Argon2, Block, Params, Version}; use chacha20poly1305::{ChaCha20Poly1305, KeyInit as _}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use crate::{error::Error, normalize}; /// BLAKE3 domain-separation context for deriving the ChaCha20-Poly1305 /// encryption subkey from a key's master secret. const ENCRYPTION_KEY_CONTEXT: &str = "cipherbox 2026-07-07 chacha20-poly1305 encryption subkey"; /// BLAKE3 domain-separation context for deriving a key's commitment value from /// its master secret. const COMMITMENT_CONTEXT: &str = "cipherbox 2026-07-07 key commitment"; /// Argon2id cost parameters used to derive a master secret from a password. /// /// The parameters are stored alongside every ciphertext, so the values used /// for new data can be changed without affecting the readability of existing /// data. Parameters read from storage are bounded by the `MAX_*` limits so /// that a crafted header cannot demand unbounded memory or time. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct KdfParams { memory_kib: u32, iterations: u32, parallelism: u32, } impl KdfParams { /// The parameters used when writing new data: 19 MiB, 2 iterations, /// 1 lane, matching the OWASP password storage recommendation for Argon2id. pub(crate) const DEFAULT: KdfParams = KdfParams { memory_kib: 19 * 1024, iterations: 2, parallelism: 1, }; /// The largest memory cost accepted when reading: 4 GiB. pub(crate) const MAX_MEMORY_KIB: u32 = 4 * 1024 * 1024; /// The largest iteration count accepted when reading. pub(crate) const MAX_ITERATIONS: u32 = 64; /// The largest degree of parallelism accepted when reading. pub(crate) const MAX_PARALLELISM: u32 = 64; /// Creates a set of parameters, rejecting any that Argon2 considers /// invalid or that exceed the `MAX_*` limits. pub(crate) fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Result { let params = KdfParams { memory_kib, iterations, parallelism, }; if memory_kib > Self::MAX_MEMORY_KIB || iterations > Self::MAX_ITERATIONS || parallelism > Self::MAX_PARALLELISM { return Err(Error::UnsupportedKdfParameters); } params .argon2_params() .map_err(|_| Error::UnsupportedKdfParameters)?; Ok(params) } /// Argon2 memory cost in KiB. pub(crate) fn memory_kib(&self) -> u32 { self.memory_kib } /// Argon2 iteration count. pub(crate) fn iterations(&self) -> u32 { self.iterations } /// Argon2 degree of parallelism. pub(crate) fn parallelism(&self) -> u32 { self.parallelism } fn argon2_params(&self) -> Result { Params::new( self.memory_kib, self.iterations, self.parallelism, Some(Key::LEN), ) } } /// A 32-byte master secret from which the encryption subkey and the key /// commitment are derived. /// /// Keys are automatically zeroized when dropped for security. #[derive(Zeroize, ZeroizeOnDrop)] pub struct Key { master: [u8; Key::LEN], } impl Key { /// Length of the master secret in bytes. pub(crate) const LEN: usize = 32; /// Creates a key directly from a 32-byte master secret. #[allow(dead_code)] pub fn from_bytes(master: [u8; Key::LEN]) -> Key { Key { master } } /// Derives a key from a password and salt using Argon2id (version 0x13). /// /// The password is first normalized to Unicode Normalization Form C, as /// recommended for passwords by RFC 8265, so that visually identical /// passwords entered on systems that compose characters differently derive /// the same key. Argon2's working memory is wiped before it is freed. pub fn from_password( password: impl AsRef, salt: &[u8; 16], params: &KdfParams, ) -> Result { let password = normalize::nfc(password.as_ref()); let argon2_params = params .argon2_params() .map_err(|_| Error::UnsupportedKdfParameters)?; let mut memory = Zeroizing::new(Vec::new()); memory .try_reserve_exact(argon2_params.block_count()) .map_err(|e| Error::KeyDerivation(e.into()))?; memory.resize(argon2_params.block_count(), Block::new()); let mut key = Key { master: [0u8; Key::LEN], }; Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params) .hash_password_into_with_memory( password.as_bytes(), salt, &mut key.master, memory.as_mut_slice(), ) .map_err(|e| Error::KeyDerivation(e.into()))?; Ok(key) } /// Creates the ChaCha20-Poly1305 cipher keyed with this key's encryption /// subkey. /// /// The subkey is derived from the master secret through a BLAKE3 /// domain-separation context distinct from the commitment's, so publishing /// the commitment reveals nothing about it. pub fn cipher(&self) -> ChaCha20Poly1305 { let subkey = Zeroizing::new(blake3::derive_key(ENCRYPTION_KEY_CONTEXT, &self.master)); ChaCha20Poly1305::new(&(*subkey).into()) } /// Derives the 32-byte key commitment published alongside the ciphertext. /// /// On decryption a mismatching commitment proves the supplied password is /// wrong (or the data was tampered with), giving the construction the /// key-commitment property that the Poly1305 tag alone cannot provide: /// finding a second key that produces the same commitment would require a /// BLAKE3 collision. The value is not secret and is stored in the clear. pub fn commitment(&self) -> [u8; 32] { blake3::derive_key(COMMITMENT_CONTEXT, &self.master) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_params_are_accepted() { let params = KdfParams::DEFAULT; assert_eq!( KdfParams::new(params.memory_kib, params.iterations, params.parallelism).unwrap(), params ); } #[test] fn params_beyond_limits_are_rejected() { let d = KdfParams::DEFAULT; for (m, t, p) in [ (KdfParams::MAX_MEMORY_KIB + 1, d.iterations, d.parallelism), (d.memory_kib, KdfParams::MAX_ITERATIONS + 1, d.parallelism), (d.memory_kib, d.iterations, KdfParams::MAX_PARALLELISM + 1), (d.memory_kib, 0, d.parallelism), (4, d.iterations, d.parallelism), ] { assert!(matches!( KdfParams::new(m, t, p), Err(Error::UnsupportedKdfParameters) )); } } #[test] fn equivalent_passwords_derive_the_same_key() { let salt = [7u8; 16]; let composed = Key::from_password("caf\u{e9}", &salt, &KdfParams::DEFAULT).unwrap(); let decomposed = Key::from_password("cafe\u{301}", &salt, &KdfParams::DEFAULT).unwrap(); assert_eq!(composed.commitment(), decomposed.commitment()); } }