entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! SHA-1 hash function (FIPS 180-4).
//!
//! **SHA-1 is cryptographically broken.** Collision attacks are practical
//! (`SHAttered`, 2017). This implementation exists **solely** for the
//! Have I Been Pwned (HIBP) k-anonymity API, which requires SHA-1
//! password hashes. **Do not use SHA-1 for any other purpose.**
//!
//! For new work, use [`Sha256`](super::Sha256) or
//! [`Sha512`](super::Sha512).

use core::fmt;

// ---------------------------------------------------------------------------
// SHA-1 constants
// ---------------------------------------------------------------------------

/// Initial hash values for SHA-1 (FIPS 180-4 §5.3.1).
#[allow(clippy::unreadable_literal)]
const H1: [u32; 5] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];

/// Round constants for SHA-1 (FIPS 180-4 §4.2.1).
#[allow(clippy::unreadable_literal)]
const K1: [u32; 4] = [0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6];

// ---------------------------------------------------------------------------
// SHA-1
// ---------------------------------------------------------------------------

/// SHA-1 hasher (FIPS 180-4).
///
/// Produces a 160-bit (20-byte) digest. Supports incremental feeding via
/// [`update`](Sha1::update) or one-shot hashing via [`digest`](Sha1::digest).
///
/// # Security
///
/// **SHA-1 is cryptographically broken.** This implementation exists only
/// for the HIBP k-anonymity API. For all other uses, prefer
/// [`Sha256`](super::Sha256) or [`Sha512`](super::Sha512).
///
/// # Examples
///
/// ```
/// use entropy_auth::crypto::Sha1;
///
/// let hash = Sha1::digest(b"hello");
/// assert_eq!(hash.len(), 20);
/// ```
#[derive(Clone)]
pub struct Sha1 {
    /// Running hash state — five 32-bit words (H0..H4).
    state: [u32; 5],
    /// Partial block buffer. Data is accumulated here until a full 64-byte
    /// block is available for compression.
    buffer: [u8; 64],
    /// Number of valid bytes currently in `buffer`.
    buf_len: usize,
    /// Total number of bytes fed into the hasher, used to compute the
    /// padding length field.
    total_len: u64,
}

impl Sha1 {
    /// Creates a new SHA-1 hasher initialised with the standard IV.
    #[must_use]
    pub fn new() -> Self {
        Self {
            state: H1,
            buffer: [0u8; 64],
            buf_len: 0,
            total_len: 0,
        }
    }

    /// Feeds `data` into the hasher.
    ///
    /// Can be called any number of times with arbitrarily sized slices. The
    /// final digest is the same regardless of how the input is partitioned.
    #[allow(clippy::missing_panics_doc)]
    pub fn update(&mut self, data: &[u8]) {
        self.total_len = self.total_len.wrapping_add(data.len() as u64);
        let mut offset = 0;

        // If the buffer has leftover bytes, try to fill it to a full block.
        if self.buf_len > 0 {
            let need = 64 - self.buf_len;
            let take = need.min(data.len());
            self.buffer[self.buf_len..self.buf_len + take].copy_from_slice(&data[..take]);
            self.buf_len += take;
            offset = take;

            if self.buf_len == 64 {
                // Buffer is full — compress it and reset.
                let block: [u8; 64] = self.buffer;
                Self::compress(&mut self.state, &block);
                self.buf_len = 0;
            } else {
                // Still not enough data for a full block; nothing more to do.
                return;
            }
        }

        // Process as many full 64-byte blocks as possible directly from `data`.
        while offset + 64 <= data.len() {
            let block: [u8; 64] = data[offset..offset + 64].try_into().unwrap();
            Self::compress(&mut self.state, &block);
            offset += 64;
        }

        // Buffer any remaining bytes (less than a full block).
        let remaining = data.len() - offset;
        if remaining > 0 {
            self.buffer[..remaining].copy_from_slice(&data[offset..]);
            self.buf_len = remaining;
        }
    }

    /// Consumes the hasher and returns the 20-byte digest.
    ///
    /// Applies the FIPS 180-4 padding (0x80, zero-fill, 64-bit big-endian
    /// bit length) before the final compression.
    #[must_use]
    pub fn finalize(mut self) -> [u8; 20] {
        // Padding: append a 1-bit (0x80 byte), then enough zero bytes so
        // that the total padded message length ≡ 56 (mod 64), followed by
        // the 64-bit big-endian bit count.
        let bit_len = self.total_len.wrapping_mul(8);

        // Append 0x80.
        self.buffer[self.buf_len] = 0x80;
        self.buf_len += 1;

        // If there isn't room for the 8-byte length field in this block,
        // pad the current block with zeros, compress it, and start a fresh
        // padding block.
        if self.buf_len > 56 {
            // Zero-fill the rest of this block and compress.
            self.buffer[self.buf_len..64].fill(0);
            let block: [u8; 64] = self.buffer;
            Self::compress(&mut self.state, &block);
            self.buf_len = 0;
        }

        // Zero-fill up to byte 56, then write the 64-bit bit length.
        self.buffer[self.buf_len..56].fill(0);
        self.buffer[56..64].copy_from_slice(&bit_len.to_be_bytes());
        let block: [u8; 64] = self.buffer;
        Self::compress(&mut self.state, &block);

        // Serialize state to big-endian bytes.
        let mut out = [0u8; 20];
        for (i, word) in self.state.iter().enumerate() {
            out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
        }
        out
    }

    /// Convenience: hashes `data` in a single call.
    #[must_use]
    #[inline]
    pub fn digest(data: &[u8]) -> [u8; 20] {
        let mut hasher = Self::new();
        hasher.update(data);
        hasher.finalize()
    }

    /// SHA-1 compression function.
    ///
    /// Processes one 64-byte block: expands the 16-word message schedule to
    /// 80 words, then runs 80 rounds of the compression function, updating
    /// `state` in place.
    #[allow(clippy::many_single_char_names, clippy::needless_range_loop)]
    fn compress(state: &mut [u32; 5], block: &[u8; 64]) {
        // Parse the block into 16 big-endian 32-bit words, then expand to 80.
        let mut w = [0u32; 80];
        for i in 0..16 {
            w[i] = u32::from_be_bytes(block[i * 4..(i + 1) * 4].try_into().unwrap());
        }
        for i in 16..80 {
            w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
        }

        // Initialize working variables from current hash state.
        let [mut a, mut b, mut c, mut d, mut e] = *state;

        // 80 compression rounds.
        for i in 0..80 {
            let (f, k) = match i {
                0..20 => ((b & c) | (!b & d), K1[0]),
                20..40 => (b ^ c ^ d, K1[1]),
                40..60 => ((b & c) | (b & d) | (c & d), K1[2]),
                _ => (b ^ c ^ d, K1[3]),
            };

            let temp = a
                .rotate_left(5)
                .wrapping_add(f)
                .wrapping_add(e)
                .wrapping_add(k)
                .wrapping_add(w[i]);

            e = d;
            d = c;
            c = b.rotate_left(30);
            b = a;
            a = temp;
        }

        // Add the compressed chunk's hash to the running state.
        state[0] = state[0].wrapping_add(a);
        state[1] = state[1].wrapping_add(b);
        state[2] = state[2].wrapping_add(c);
        state[3] = state[3].wrapping_add(d);
        state[4] = state[4].wrapping_add(e);
    }
}

impl fmt::Debug for Sha1 {
    /// SECURITY: Redact internal hash state to prevent accidental exposure
    /// of in-progress digests in logs or error messages.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Sha1")
            .field("total_len", &self.total_len)
            .finish_non_exhaustive()
    }
}

impl Drop for Sha1 {
    #[allow(unsafe_code)]
    fn drop(&mut self) {
        // SECURITY: Zeroize internal state and buffer to prevent secret
        // material from lingering in memory after the hasher is dropped.
        for word in &mut self.state {
            // SAFETY: `word` is a valid, aligned, dereferenceable pointer.
            // `write_volatile` guarantees the store is not optimised away.
            unsafe { core::ptr::write_volatile(word, 0) };
        }
        crate::crypto::zeroize::zeroize(&mut self.buffer);
    }
}

impl Default for Sha1 {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // --- SHA-1 Test Vectors ---
    //
    // Vectors from NIST CAVP / FIPS 180-4 examples.

    #[test]
    fn sha1_empty() {
        let digest = Sha1::digest(b"");
        assert_eq!(
            hex_encode(&digest),
            "da39a3ee5e6b4b0d3255bfef95601890afd80709",
        );
    }

    #[test]
    fn sha1_abc() {
        let digest = Sha1::digest(b"abc");
        assert_eq!(
            hex_encode(&digest),
            "a9993e364706816aba3e25717850c26c9cd0d89d",
        );
    }

    #[test]
    fn sha1_448_bits() {
        // 448-bit message (56 bytes) — exactly one block minus the padding.
        let msg = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
        assert_eq!(msg.len(), 56);
        let digest = Sha1::digest(msg);
        assert_eq!(
            hex_encode(&digest),
            "84983e441c3bd26ebaae4aa1f95129e5e54670f1",
        );
    }

    #[test]
    fn sha1_one_million_a() {
        // NIST test: SHA-1 of 1,000,000 repetitions of 'a'.
        let digest = Sha1::digest(&vec![b'a'; 1_000_000]);
        assert_eq!(
            hex_encode(&digest),
            "34aa973cd4c4daa4f61eeb2bdbad27316534016f",
        );
    }

    // --- SHA-1 Incremental Update ---

    #[test]
    fn sha1_incremental_split() {
        // Split "abc" across two update() calls — must produce the same
        // digest as a single-shot hash.
        let mut hasher = Sha1::new();
        hasher.update(b"a");
        hasher.update(b"bc");
        assert_eq!(
            hex_encode(&hasher.finalize()),
            "a9993e364706816aba3e25717850c26c9cd0d89d",
        );
    }

    #[test]
    fn sha1_single_byte_updates() {
        // Feed "abc" one byte at a time.
        let mut hasher = Sha1::new();
        for &byte in b"abc" {
            hasher.update(&[byte]);
        }
        assert_eq!(
            hex_encode(&hasher.finalize()),
            "a9993e364706816aba3e25717850c26c9cd0d89d",
        );
    }

    #[test]
    fn sha1_default_trait() {
        // Ensure `Default` produces the same initial state as `new()`.
        let a = Sha1::new().finalize();
        let b = Sha1::default().finalize();
        assert_eq!(a, b);
    }

    // --- Debug redaction -------------------------------------------------

    #[test]
    fn sha1_debug_does_not_leak_state() {
        // SECURITY: The Debug output must not contain internal hash state.
        let mut hasher = Sha1::new();
        hasher.update(b"secret data that must not appear");
        let debug = format!("{hasher:?}");
        assert!(debug.contains("Sha1"), "should contain the type name");
        assert!(
            debug.contains("total_len"),
            "should contain total_len field"
        );
        // The internal state words must not leak.
        assert!(!debug.contains("state"), "must not expose state field");
        assert!(!debug.contains("buffer"), "must not expose buffer field");
        assert!(!debug.contains("buf_len"), "must not expose buf_len field");
        assert!(
            !debug.contains("67452301"),
            "must not expose SHA-1 IV words"
        );
    }
}