compact_argon2 0.2.0

Thin wrapper around the argon2 crate to improve ergonomics and provide a smaller serialization format
Documentation
#![cfg_attr(test, feature(test))]

#[cfg(feature = "base64")] mod base64;
#[cfg(feature = "serde")] pub mod serde;
#[cfg(feature = "postgres")] mod sqlx;

use std::num::{NonZero, NonZeroU32};

use argon2::Algorithm::Argon2id;
use argon2::password_hash::rand_core::{OsRng, RngCore};
use argon2::{Argon2, Block, Version};
use bytemuck::{Pod, Zeroable};

const DEFAULT_PARAMS: argon2::Params = argon2::Params::DEFAULT;
const DEFAULT_VERSION: Version = Version::V0x13;
/// [`argon2::RECOMMENDED_SALT_LEN`]
const SALT_LEN: usize = 16;
/// [`argon2::Params::DEFAULT_OUTPUT_LEN`]
const OUTPUT_LEN: usize = 32;

/// Serialized size of the [`Hash`] struct.
pub const OUTPUT_SIZE: usize = size_of::<SerializedHash>();
/// Size of the [`Hash`] struct when encoded as base64.
#[cfg(feature = "base64")]
pub const BASE64_OUTPUT_SIZE: usize = (OUTPUT_SIZE * 4).div_ceil(3);

#[derive(Debug, thiserror::Error)]
pub enum ParseError {
    #[error("unrecognized version {0:#02x?}")]
    InvalidVersion(u8),
    #[error("memory too low, expected at least {expected}, got {got}")]
    MemoryTooLow { expected: u32, got: u32 },
    #[error("parameters out of range")]
    InvalidParameters,
    #[error("input is the wrong length, expected exactly {OUTPUT_SIZE} bytes")]
    SliceLength,
}

/// Parameters for the argon2 hash function. Subset of [`argon2::Params`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Params {
    /// Equivalent to `t_cost`
    iterations: NonZeroU32,
    /// Equivalent to `p_cost`
    parallelism: NonZeroU32,
    /// Equivalent to `m_cost`
    memory: NonZeroU32,
}

impl Params {
    /// Iterations (`t_cost`)
    pub fn iterations(&self) -> u32 { self.iterations.get() }

    /// Parallelism (`p_cost`)
    pub fn parallelism(&self) -> u32 { self.parallelism.get() }

    /// Memory (`m_cost`)
    pub fn memory(&self) -> u32 { self.memory.get() }

    /// Minimum amount of memory required to construct a hash with these
    /// parameters, in KiB. Intended to be used with the
    /// [`hash_with_memory`] and [`verify_with_memory`] functions.
    ///
    /// ## Example usage
    ///
    /// ```rs
    /// let mut blocks = vec![Block::default(); DEFAULT_PARAMS.block_count()];
    /// let hash = compact_argon2::hash_with_memory(b"hunter2", blocks.as_mut_slice()).unwrap();
    /// assert!(compact_argon2::verify_with_memory(b"hunter2", &hash, blocks.as_mut_slice()).unwrap());
    /// ```
    pub fn block_count(&self) -> usize { argon2::Params::from(*self).block_count() }

    // private impl as not all features of argon2::Params are supported
    // in this crate's Params such as AD.
    #[inline]
    fn from_argon2(params: &argon2::Params) -> Self {
        Self {
            iterations: NonZeroU32::new(params.t_cost()).unwrap(),
            parallelism: NonZeroU32::new(params.p_cost()).unwrap(),
            memory: NonZeroU32::new(params.m_cost()).unwrap(),
        }
    }
}

impl Default for Params {
    fn default() -> Self { Self::from_argon2(&DEFAULT_PARAMS) }
}

impl From<Params> for argon2::Params {
    fn from(value: Params) -> Self {
        Self::new(
            value.memory(),
            value.iterations(),
            value.parallelism(),
            None,
        )
        .unwrap()
    }
}

/// An `argon2id` hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hash {
    params: Params,
    version: Version,
    salt: [u8; SALT_LEN],
    out: [u8; OUTPUT_LEN],
}

#[derive(Pod, Zeroable, Clone, Copy)]
#[repr(C, packed)]
struct SerializedHash {
    iterations: [u8; 3],
    parallelism: [u8; 3],
    memory: [u8; 4],
    version: u8,
    salt: [u8; 16],
    out: [u8; 32],
}

fn get_u24be(v: [u8; 3]) -> u32 { u32::from_be_bytes([0, v[0], v[1], v[2]]) }

impl Hash {
    #[inline]
    fn serialized(&self) -> SerializedHash {
        // unwraps should be optimized out
        SerializedHash {
            iterations: self.params.iterations().to_be_bytes()[1..]
                .try_into()
                .unwrap(),
            parallelism: self.params.parallelism().to_be_bytes()[1..]
                .try_into()
                .unwrap(),
            memory: self.params.memory().to_be_bytes(),
            version: self.version as u8,
            salt: self.salt,
            out: self.out,
        }
    }

    /// Get the parameters used to construct this hash.
    pub fn params(&self) -> &Params { &self.params }

    /// Get the version of argon2 used
    pub fn version(&self) -> Version { self.version }

    /// Verify a password against this hash. See [`crate::verify`] for more
    /// information.
    pub fn verify(&self, password: &[u8]) -> Result<bool, argon2::Error> { verify(password, self) }

    /// Serialize this hash into an existing slice.
    ///
    /// ## Panics
    ///
    /// Panics if `slice` isn't of length [`OUTPUT_SIZE`].
    pub fn write_to_slice(&self, slice: &mut [u8]) {
        let serialized = self.serialized();

        slice.copy_from_slice(bytemuck::bytes_of(&serialized));
    }

    /// Serialize this hash into an array.
    pub fn to_bytes(&self) -> [u8; OUTPUT_SIZE] {
        let mut slice = [0u8; _];
        self.write_to_slice(&mut slice);
        slice
    }

    ///
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
        if bytes.len() != OUTPUT_SIZE {
            return Err(ParseError::SliceLength);
        }

        let serialized: &SerializedHash = bytemuck::from_bytes(bytes);

        let iterations =
            NonZero::new(get_u24be(serialized.iterations)).ok_or(ParseError::InvalidParameters)?;
        let parallelism =
            NonZero::new(get_u24be(serialized.parallelism)).ok_or(ParseError::InvalidParameters)?;
        let memory = NonZero::new(u32::from_be_bytes(serialized.memory))
            .ok_or(ParseError::InvalidParameters)?;

        let required_memory = parallelism.get() * 8;
        if memory.get() < required_memory {
            return Err(ParseError::MemoryTooLow {
                expected: required_memory,
                got: memory.get(),
            });
        }

        let version = Version::try_from(serialized.version as u32)
            .map_err(|_| ParseError::InvalidVersion(serialized.version))?;

        Ok(Self {
            params: Params {
                iterations,
                parallelism,
                memory,
            },
            version,
            salt: serialized.salt,
            out: serialized.out,
        })
    }
}

impl TryFrom<&[u8; 59]> for Hash {
    type Error = ParseError;

    fn try_from(value: &[u8; 59]) -> Result<Self, Self::Error> { Self::from_bytes(&*value) }
}

impl TryFrom<&[u8]> for Hash {
    type Error = ParseError;

    fn try_from(value: &[u8]) -> Result<Self, Self::Error> { Self::from_bytes(value) }
}

impl From<Hash> for [u8; OUTPUT_SIZE] {
    fn from(value: Hash) -> Self { value.to_bytes() }
}

impl std::hash::Hash for Hash {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.params.hash(state);
        (self.version as u32).hash(state);
        self.salt.hash(state);
        self.out.hash(state);
    }
}

/// Hash a byte array with default argon2id params.
pub fn hash(password: &[u8]) -> Result<Hash, argon2::Error> {
    let mut blocks = vec![Block::default(); DEFAULT_PARAMS.block_count()];
    hash_with_memory(password, blocks.as_mut_slice())
}

/// Hash a byte array with default argon2id params, and a specified allocation.
/// See [`argon2::Argon2::hash_password_into_with_memory`] for implementation.
pub fn hash_with_memory(
    password: &[u8],
    memory: impl AsMut<[Block]>,
) -> Result<Hash, argon2::Error> {
    let version = DEFAULT_VERSION;
    let hasher = Argon2::new(Argon2id, version, DEFAULT_PARAMS);
    let params = Params::from_argon2(hasher.params());

    let mut hash = Hash {
        params,
        version,
        salt: [0; _],
        out: [0; _],
    };
    OsRng.fill_bytes(&mut hash.salt);
    hasher.hash_password_into_with_memory(password, &hash.salt, &mut hash.out, memory)?;
    Ok(hash)
}

/// Verify the given password against a [`Hash`] with the included argon2id
/// params.
pub fn verify(password: &[u8], hash: &Hash) -> Result<bool, argon2::Error> {
    let mut blocks = vec![Block::default(); DEFAULT_PARAMS.block_count()];
    verify_with_memory(password, hash, blocks.as_mut_slice())
}

/// Verify the given password against a [`Hash`] with the included argon2id
/// params, and a specified allocation. See
/// [`argon2::Argon2::hash_password_into_with_memory`] for implementation.
pub fn verify_with_memory(
    password: &[u8],
    hash: &Hash,
    memory: impl AsMut<[Block]>,
) -> Result<bool, argon2::Error> {
    let mut out = [0; _];
    let hasher = Argon2::new(Argon2id, hash.version, hash.params.into());
    hasher.hash_password_into_with_memory(password, &hash.salt, &mut out, memory)?;
    Ok(out.eq(&hash.out))
}

#[cfg(test)]
mod tests {
    extern crate test;

    use std::hint::black_box;
    use std::sync::LazyLock;

    use argon2::password_hash::{PasswordHashString, SaltString};
    use argon2::{PasswordHash, PasswordHasher};
    use test::Bencher;

    use super::*;

    pub(crate) static HASH: LazyLock<Hash> = LazyLock::new(|| hash(b"hunter2").unwrap());

    static PHC_HASH: LazyLock<PasswordHash> = LazyLock::new(|| {
        static SALT: LazyLock<SaltString> = LazyLock::new(|| SaltString::generate(&mut OsRng));
        let argon2 = argon2::Argon2::default();
        argon2.hash_password(b"hunter2", &*SALT).unwrap()
    });

    #[test]
    fn it_works() {
        let password = b"hunter2";

        let hash = hash(password).unwrap();

        assert!(hash.verify(password).unwrap());
    }

    #[test]
    fn round_trip() {
        let bytes = HASH.to_bytes();
        let reconstructed = Hash::from_bytes(&bytes).unwrap();
        assert_eq!(*HASH, reconstructed);
    }

    #[bench]
    fn to_bytes(bencher: &mut Bencher) {
        let hash = *HASH;

        bencher.iter(|| black_box(hash).to_bytes());
    }

    #[bench]
    fn from_bytes(bencher: &mut Bencher) {
        let bytes = HASH.to_bytes();

        bencher.iter(|| Hash::from_bytes(black_box(&bytes)));
    }

    #[bench]
    fn from_phc_string(bencher: &mut Bencher) {
        let phc_string = PasswordHashString::from(&*PHC_HASH);

        bencher.iter(|| black_box(&phc_string).password_hash());
    }

    #[bench]
    fn to_phc_string(bencher: &mut Bencher) {
        let phc_hash = &*PHC_HASH;

        bencher.iter(|| PasswordHashString::from(black_box(phc_hash)));
    }
}