libsoliton 0.1.3

Core cryptographic library for the LO protocol — hybrid post-quantum key exchange, signatures, ratchet, and storage encryption
Documentation
//! Argon2id password-based key derivation (RFC 9106).
//!
//! Provides a thin, safe wrapper over the `argon2` crate (RustCrypto).
//! Pure Rust — no C FFI, no system library dependencies.

use argon2::{Algorithm, Argon2, Params, Version};
use zeroize::Zeroize;

use crate::error::{Error, Result};

/// Cost parameters for Argon2id key derivation.
///
/// Controls the computational cost of key derivation. Higher values are more
/// resistant to brute-force attacks but take longer to compute.
#[derive(Debug, Clone, Copy)]
pub struct Argon2Params {
    /// Memory cost in KiB (minimum 8; OWASP minimum: 19456 = 19 MiB).
    pub m_cost: u32,
    /// Time cost — number of passes over memory (minimum 1).
    pub t_cost: u32,
    /// Parallelism — number of independent memory lanes (minimum 1).
    pub p_cost: u32,
}

impl Argon2Params {
    /// OWASP recommended minimum: 19 MiB, 2 passes, 1 lane.
    ///
    /// Suitable for interactive authentication with a latency budget below 1 s.
    /// Use [`RECOMMENDED`](Self::RECOMMENDED) for stored key material where
    /// latency is less critical.
    pub const OWASP_MIN: Self = Self {
        m_cost: 19 * 1024,
        t_cost: 2,
        p_cost: 1,
    };

    /// Conservative default for locally-stored keypair protection: 64 MiB, 3 passes, 4 lanes.
    ///
    /// Appropriate for passphrase-protected identity keypairs stored on-device.
    /// Runs in roughly 0.1-1 s on modern hardware.
    ///
    /// **Not suitable for WASM targets** — 64 MiB allocation will OOM-abort on
    /// most WASM runtimes (default linear memory ~256 MiB). Use
    /// [`WASM_DEFAULT`](Self::WASM_DEFAULT) instead.
    pub const RECOMMENDED: Self = Self {
        m_cost: 64 * 1024,
        t_cost: 3,
        p_cost: 4,
    };

    /// WASM-safe default: 16 MiB, 3 passes, 1 lane.
    ///
    /// WASM linear memory defaults to ~256 MiB maximum. The 64 MiB
    /// [`RECOMMENDED`](Self::RECOMMENDED) parameters risk OOM on constrained
    /// runtimes. This preset reduces memory to 16 MiB while compensating with
    /// the same pass count and single-lane execution (WASM is single-threaded).
    pub const WASM_DEFAULT: Self = Self {
        m_cost: 16 * 1024,
        t_cost: 3,
        p_cost: 1,
    };
}

/// Derive key material from a passphrase using Argon2id (RFC 9106).
///
/// # Arguments
///
/// * `password` — passphrase bytes (UTF-8 recommended; any byte sequence accepted).
///   May be empty.
/// * `salt` — random salt. Must be at least 8 bytes; 16 or 32 random bytes recommended.
///   Use [`crate::primitives::random::random_array`] to generate.
/// * `params` — Argon2id cost parameters. Use [`Argon2Params::RECOMMENDED`] for
///   keypair protection.
/// * `out` — caller-allocated output buffer; receives derived key material on success.
///   Must be at least 1 byte and at most 4096 bytes. Typically 32 bytes for a 256-bit symmetric key.
///
/// # Security
///
/// The caller is responsible for zeroizing `out` when the derived key material is
/// no longer needed — use `zeroize::Zeroize::zeroize(&mut out)` or wrap `out` in
/// `zeroize::Zeroizing`. The `argon2` crate's internal working memory blocks are
/// zeroized on drop (enabled via the `zeroize` feature). The `password` slice is
/// never copied or retained after this call returns.
///
/// # Errors
///
/// Returns [`Error::InvalidLength`] if `salt` is shorter than 8 bytes, or `out` is
/// empty or exceeds 4096 bytes.
/// Returns [`Error::InvalidData`] if cost params exceed upper bounds (`m_cost > 4_194_304`,
/// `t_cost > 256`, `p_cost > 256`) or violate argon2 library minimums (e.g. `m_cost`
/// below the library minimum of 8 KiB, or `t_cost` / `p_cost` below 1).
pub fn argon2id(password: &[u8], salt: &[u8], params: Argon2Params, out: &mut [u8]) -> Result<()> {
    const SALT_MIN: usize = 8;
    // Output cap: 4096 bytes (32768 bits). No realistic use case exceeds a few
    // hundred bytes (typical: 32 for a symmetric key). Prevents multi-GiB
    // allocation from untrusted output length. Matches the CAPI cap.
    const OUTPUT_MAX: usize = 4096;
    // Upper bounds prevent DoS from untrusted parameters. Matches the CAPI caps.
    // m_cost: 4 GiB (4_194_304 KiB) — no realistic use case exceeds single-digit GiB.
    // t_cost: 256 passes — far beyond any recommendation (OWASP: 2-3).
    // p_cost: 256 lanes — far beyond any recommendation (typical: 1-8).
    const M_COST_MAX: u32 = 4_194_304;
    const T_COST_MAX: u32 = 256;
    const P_COST_MAX: u32 = 256;

    if salt.len() < SALT_MIN {
        return Err(Error::InvalidLength {
            expected: SALT_MIN,
            got: salt.len(),
        });
    }
    // Empty output violates the minimum (1 byte) — `expected` reflects the minimum bound
    // violated, not the maximum. Oversized output violates the maximum (4096 bytes) —
    // `expected` reflects the maximum. Two separate branches so `expected` always names
    // the specific bound that was crossed. (§10.6: "expected reflects the bound violated.")
    if out.is_empty() {
        return Err(Error::InvalidLength {
            expected: 1,
            got: 0,
        });
    }
    if out.len() > OUTPUT_MAX {
        return Err(Error::InvalidLength {
            expected: OUTPUT_MAX,
            got: out.len(),
        });
    }
    // Cost parameters are semantic values, not lengths — InvalidData is correct
    // per the error taxonomy (InvalidLength = wrong-size parameter).
    if params.m_cost > M_COST_MAX || params.t_cost > T_COST_MAX || params.p_cost > P_COST_MAX {
        return Err(Error::InvalidData);
    }

    let argon2_params = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(out.len()))
        .map_err(|_| Error::InvalidData)?;

    let result = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params)
        .hash_password_into(password, salt, out);
    // Zeroize on failure: hash_password_into may have written partial key material.
    if result.is_err() {
        out.zeroize();
    }
    result.map_err(|_| Error::Internal)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;

    /// Tiny parameters for fast unit tests.
    ///
    /// m=8 KiB (minimum), t=1, p=1 — completes in microseconds.
    /// **Not** for production use.
    const FAST: Argon2Params = Argon2Params {
        m_cost: 8,
        t_cost: 1,
        p_cost: 1,
    };

    #[test]
    fn deterministic() {
        let mut out1 = [0u8; 32];
        let mut out2 = [0u8; 32];
        argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
        argon2id(b"password", b"saltsalt", FAST, &mut out2).unwrap();
        assert_eq!(out1, out2);
    }

    #[test]
    fn sensitive_to_password() {
        let mut out1 = [0u8; 32];
        let mut out2 = [0u8; 32];
        argon2id(b"password1", b"saltsalt", FAST, &mut out1).unwrap();
        argon2id(b"password2", b"saltsalt", FAST, &mut out2).unwrap();
        assert_ne!(out1, out2);
    }

    #[test]
    fn sensitive_to_salt() {
        let mut out1 = [0u8; 32];
        let mut out2 = [0u8; 32];
        argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
        argon2id(b"password", b"saltXXXX", FAST, &mut out2).unwrap();
        assert_ne!(out1, out2);
    }

    #[test]
    fn sensitive_to_t_cost() {
        let params2 = Argon2Params {
            m_cost: 8,
            t_cost: 2,
            p_cost: 1,
        };
        let mut out1 = [0u8; 32];
        let mut out2 = [0u8; 32];
        argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
        argon2id(b"password", b"saltsalt", params2, &mut out2).unwrap();
        assert_ne!(out1, out2);
    }

    #[test]
    fn output_length_affects_result() {
        // Argon2 output is not extendable: different output lengths produce different
        // initial bytes (unlike HKDF). This tests that the output-length parameter
        // is correctly threaded through to the argon2 crate.
        let mut out32 = [0u8; 32];
        let mut out64 = [0u8; 64];
        argon2id(b"password", b"saltsalt", FAST, &mut out32).unwrap();
        argon2id(b"password", b"saltsalt", FAST, &mut out64).unwrap();
        assert_ne!(out32, out64[..32]);
    }

    #[test]
    fn output_16_bytes() {
        let mut out = [0u8; 16];
        argon2id(b"password", b"saltsalt", FAST, &mut out).unwrap();
        assert!(out.iter().any(|&b| b != 0));
    }

    #[test]
    fn empty_password_accepted() {
        let mut out = [0u8; 32];
        argon2id(b"", b"saltsalt", FAST, &mut out).unwrap();
        assert!(out.iter().any(|&b| b != 0));
    }

    #[test]
    fn salt_too_short_rejected() {
        let mut out = [0u8; 32];
        let err = argon2id(b"password", b"short", FAST, &mut out).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidLength {
                    expected: 8,
                    got: 5
                }
            ),
            "expected InvalidLength(8, 5), got {err:?}"
        );
    }

    #[test]
    fn empty_salt_rejected() {
        let mut out = [0u8; 32];
        let err = argon2id(b"password", b"", FAST, &mut out).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidLength {
                    expected: 8,
                    got: 0
                }
            ),
            "expected InvalidLength(8, 0), got {err:?}"
        );
    }

    #[test]
    fn empty_output_rejected() {
        let mut out: [u8; 0] = [];
        let err = argon2id(b"password", b"saltsalt", FAST, &mut out).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidLength {
                    expected: 1,
                    got: 0
                }
            ),
            "expected InvalidLength(1, 0), got {err:?}"
        );
    }

    #[test]
    fn zero_m_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 0,
            t_cost: 1,
            p_cost: 1,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn zero_t_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 8,
            t_cost: 0,
            p_cost: 1,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn zero_p_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 8,
            t_cost: 1,
            p_cost: 0,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn excessive_m_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 4_194_305,
            t_cost: 1,
            p_cost: 1,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn excessive_t_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 8,
            t_cost: 257,
            p_cost: 1,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn excessive_p_cost_returns_invalid_data() {
        let bad = Argon2Params {
            m_cost: 8,
            t_cost: 1,
            p_cost: 257,
        };
        let mut out = [0u8; 32];
        assert!(matches!(
            argon2id(b"pw", b"saltsalt", bad, &mut out),
            Err(Error::InvalidData)
        ));
    }

    #[test]
    fn output_too_large_rejected() {
        let mut out = vec![0u8; 4097];
        let err = argon2id(b"pw", b"saltsalt", FAST, &mut out).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidLength {
                expected: 4096,
                got: 4097
            }
        ));
    }

    #[test]
    fn boundary_t_cost_accepted() {
        let params = Argon2Params {
            m_cost: 8,
            t_cost: 256,
            p_cost: 1,
        };
        let mut out = [0u8; 32];
        assert!(argon2id(b"pw", b"saltsalt", params, &mut out).is_ok());
    }

    #[test]
    fn boundary_p_cost_accepted() {
        // m_cost must be >= 8 * p_cost per the argon2 spec, so m_cost = 2048
        // is the minimum that satisfies the constraint for p_cost = 256.
        let params = Argon2Params {
            m_cost: 2048,
            t_cost: 1,
            p_cost: 256,
        };
        let mut out = [0u8; 32];
        assert!(argon2id(b"pw", b"saltsalt", params, &mut out).is_ok());
    }

    #[test]
    fn boundary_output_size_accepted() {
        let mut out = vec![0u8; 4096];
        assert!(argon2id(b"pw", b"saltsalt", FAST, &mut out).is_ok());
    }
}