entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Opaque token generation for sessions, API keys, and CSRF protection.
//!
//! Provides cryptographically secure random token generation with optional
//! SHA-256 hashing for server-side storage. The generated tokens are hex-encoded
//! for safe embedding in HTTP headers, cookies, and query parameters.
//!
//! # Design
//!
//! Tokens are generated from the platform CSPRNG via [`fill_random`] and encoded
//! as lowercase hex strings. When a token must be stored server-side (e.g. session
//! tokens, API keys), [`generate_token_with_hash`] returns both the hex token
//! (sent to the client) and its SHA-256 hash (stored in the database). This
//! ensures that a database compromise does not directly expose valid tokens.

use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{RandomError, Sha256, fill_random};
use crate::encoding::{hex_decode, hex_encode};

/// Generates a cryptographically secure opaque token as a hex string.
///
/// The returned string contains `byte_len * 2` lowercase hex characters,
/// representing `byte_len` random bytes from the platform CSPRNG.
///
/// # Errors
///
/// Returns [`RandomError`] if the platform CSPRNG is unavailable.
#[must_use = "this returns a Result that may report a CSPRNG failure"]
pub fn generate_token(byte_len: usize) -> Result<Zeroizing<String>, RandomError> {
    let mut buf = vec![0u8; byte_len];
    fill_random(&mut buf)?;
    let token = hex_encode(&buf);
    // SECURITY: Zeroize the raw random bytes after encoding.
    crate::crypto::zeroize::zeroize(&mut buf);
    Ok(Zeroizing::new(token))
}

/// Generates a token and its SHA-256 hash for server-side storage.
///
/// Returns `(hex_token, sha256_hash_bytes)` where:
/// - `hex_token` is the client-facing opaque token (hex-encoded).
/// - `sha256_hash_bytes` is the 32-byte SHA-256 digest of the raw random
///   bytes, suitable for database storage.
///
/// # Security
///
/// SECURITY: The server stores only the hash. Comparing a presented token
/// against the stored hash requires re-hashing the presented token's raw
/// bytes with SHA-256. A database breach exposes hashes, not tokens.
///
/// # Errors
///
/// Returns [`RandomError`] if the platform CSPRNG is unavailable.
#[must_use = "this returns a Result that may report a CSPRNG failure"]
pub fn generate_token_with_hash(
    byte_len: usize,
) -> Result<(Zeroizing<String>, [u8; 32]), RandomError> {
    let mut buf = vec![0u8; byte_len];
    fill_random(&mut buf)?;
    let token = hex_encode(&buf);
    let hash = Sha256::digest(&buf);
    // SECURITY: Zeroize the raw random bytes after hashing. The caller
    // receives only the hex-encoded token and the hash.
    crate::crypto::zeroize::zeroize(&mut buf);
    Ok((Zeroizing::new(token), hash))
}

/// Re-derives the storage hash of a token for server-side lookup.
///
/// This is the verifying inverse of [`generate_token_with_hash`]: given the
/// hex token handed to the client, it reproduces the exact 32-byte SHA-256
/// digest that was stored at mint time, so a `WHERE token_hash = ?` lookup
/// matches. A well-formed token is the hex encoding of the raw random bytes,
/// so the canonical path decodes the hex and hashes the raw bytes (matching
/// the mint side). A token that is not valid hex cannot correspond to any
/// minted token; it is hashed as UTF-8 bytes so the result is well-defined
/// and deterministic but can never collide with a real row.
///
/// Keeping this beside [`generate_token_with_hash`] means the mint and lookup
/// schemes are one matched pair in one place — changing the mint hashing
/// without changing the lookup (or vice versa) is no longer possible across
/// two hand-copied server modules.
#[must_use]
pub fn hash_token_for_lookup(token: &str) -> [u8; 32] {
    match hex_decode(token) {
        Ok(raw) => Sha256::digest(&raw),
        Err(_) => Sha256::digest(token.as_bytes()),
    }
}

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

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

    // --- Token Generation ---

    #[test]
    fn generate_token_correct_length() {
        for byte_len in [0, 1, 8, 16, 32, 64] {
            let token = generate_token(byte_len).unwrap();
            assert_eq!(
                token.len(),
                byte_len * 2,
                "hex token length should be byte_len * 2 for byte_len={byte_len}",
            );
        }
    }

    #[test]
    fn generate_token_different_each_call() {
        let a = generate_token(32).unwrap();
        let b = generate_token(32).unwrap();
        assert_ne!(&*a, &*b, "two generated tokens should differ");
    }

    #[test]
    fn generate_token_hex_characters_only() {
        let token = generate_token(32).unwrap();
        assert!(
            token.chars().all(|c| c.is_ascii_hexdigit()),
            "token should contain only hex characters: {}",
            &*token,
        );
    }

    // --- Token with Hash ---

    #[test]
    fn generate_token_with_hash_produces_verifiable_hash() {
        let (token, hash) = generate_token_with_hash(32).unwrap();

        // Decode the hex token back to raw bytes and hash them independently.
        let raw_bytes = crate::encoding::hex_decode(&token).unwrap();
        let expected_hash = Sha256::digest(&raw_bytes);
        assert_eq!(
            hash, expected_hash,
            "hash should match SHA-256 of raw bytes"
        );
    }

    #[test]
    fn generate_token_with_hash_different_each_call() {
        let (token_a, hash_a) = generate_token_with_hash(32).unwrap();
        let (token_b, hash_b) = generate_token_with_hash(32).unwrap();
        assert_ne!(&*token_a, &*token_b, "two generated tokens should differ");
        assert_ne!(hash_a, hash_b, "two generated hashes should differ");
    }

    #[test]
    fn generate_token_with_hash_correct_lengths() {
        let (token, hash) = generate_token_with_hash(16).unwrap();
        assert_eq!(token.len(), 32, "16-byte token should produce 32 hex chars");
        assert_eq!(hash.len(), 32, "SHA-256 hash should be 32 bytes");
    }

    // --- Lookup hashing (verifying inverse of the mint side) ---

    #[test]
    fn hash_token_for_lookup_matches_mint_hash() {
        // The lookup hash of a freshly minted token must equal the hash the
        // mint side stored — otherwise server lookups silently miss.
        let (token, mint_hash) = generate_token_with_hash(32).unwrap();
        assert_eq!(
            hash_token_for_lookup(&token),
            mint_hash,
            "lookup hash must match the stored mint hash for the same token",
        );
    }

    #[test]
    fn hash_token_for_lookup_non_hex_is_deterministic_and_distinct() {
        // A non-hex token can never be a real minted token; it must still
        // hash deterministically (UTF-8 fallback) and not match the hex path.
        assert_eq!(
            hash_token_for_lookup("not-a-hex-token"),
            hash_token_for_lookup("not-a-hex-token"),
        );
        assert_ne!(
            hash_token_for_lookup("not-a-hex-token"),
            hash_token_for_lookup("00"),
        );
    }
}