key.rs raw

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<Self, Error> {
        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, argon2::Error> {
        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<str>,
        salt: &[u8; 16],
        params: &KdfParams,
    ) -> Result<Key, Error> {
        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());
    }
}