entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Cryptographically secure random byte generation.
//!
//! Thin wrapper over the OS CSPRNG via [`rand_core::OsRng`], which routes to
//! the platform's syscall-based entropy source (`getrandom(2)` on Linux,
//! `getentropy` on the BSDs/macOS, `BCryptGenRandom` on Windows). Using the
//! syscall rather than opening `/dev/urandom` per call avoids a file
//! descriptor on every token/key/salt and so cannot fail under fd
//! exhaustion, a restrictive seccomp profile, an empty `/dev` in a minimal
//! container, or a chroot; on Linux it also blocks correctly until the
//! entropy pool is seeded at early boot. `OsRng` is the same source the
//! crate's `SecretBox` already uses, so all random material flows through
//! one vetted path.

use crate::crypto::zeroize::Zeroizing;
use crate::encoding::{base64url_encode, hex_encode};

use core::fmt;

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Error returned when the platform CSPRNG is unavailable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RandomError {
    message: String,
}

impl fmt::Display for RandomError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "random: {}", self.message)
    }
}

impl std::error::Error for RandomError {}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Fills a buffer with cryptographically secure random bytes.
///
/// Backed by [`rand_core::OsRng`] (the OS syscall CSPRNG). No file
/// descriptor is opened. An empty buffer is accepted and returns `Ok(())`.
///
/// # Errors
///
/// Returns `RandomError` if the OS CSPRNG is unavailable (the syscall
/// itself fails — e.g. blocked by a seccomp filter that denies
/// `getrandom`).
pub fn fill_random(buf: &mut [u8]) -> Result<(), RandomError> {
    use rand_core::RngCore as _;

    rand_core::OsRng
        .try_fill_bytes(buf)
        .map_err(|e| RandomError {
            message: format!("OS CSPRNG unavailable: {e}"),
        })
}

/// Generates a random token as a hex-encoded string.
///
/// The returned string contains `byte_len * 2` hex characters,
/// representing `byte_len` random bytes. The string is wrapped in
/// [`Zeroizing`] so that the token is cleared from memory on drop.
///
/// # Errors
///
/// Returns `RandomError` if the platform CSPRNG is unavailable.
#[must_use = "this returns the generated token"]
pub fn random_token_hex(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 random token as a base64url-encoded string (no padding).
///
/// The string is wrapped in [`Zeroizing`] so that the token is cleared
/// from memory on drop.
///
/// # Errors
///
/// Returns `RandomError` if the platform CSPRNG is unavailable.
#[must_use = "this returns the generated token"]
pub fn random_token_base64url(byte_len: usize) -> Result<Zeroizing<String>, RandomError> {
    let mut buf = vec![0u8; byte_len];
    fill_random(&mut buf)?;
    let token = base64url_encode(&buf);
    // SECURITY: Zeroize the raw random bytes after encoding.
    crate::crypto::zeroize::zeroize(&mut buf);
    Ok(Zeroizing::new(token))
}

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

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

    #[test]
    fn fill_random_produces_nonzero_bytes() {
        let mut buf = [0u8; 32];
        fill_random(&mut buf).unwrap();
        // A 32-byte buffer of all zeros has probability 2^{-256} — effectively impossible.
        assert!(
            buf.iter().any(|&b| b != 0),
            "buffer should not be all zeros"
        );
    }

    #[test]
    fn fill_random_different_each_call() {
        let mut a = [0u8; 32];
        let mut b = [0u8; 32];
        fill_random(&mut a).unwrap();
        fill_random(&mut b).unwrap();
        assert_ne!(a, b, "two random fills should produce different output");
    }

    #[test]
    fn fill_random_empty_buffer_succeeds() {
        let mut buf = [];
        fill_random(&mut buf).unwrap();
    }

    #[test]
    fn random_token_hex_correct_length() {
        for byte_len in [0, 1, 8, 16, 32, 64] {
            let token = random_token_hex(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 random_token_base64url_not_empty() {
        let token = random_token_base64url(32).unwrap();
        assert!(!token.is_empty(), "base64url token should not be empty");
        // Verify it contains only URL-safe base64 characters.
        assert!(
            token
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
            "base64url token should contain only URL-safe characters: {}",
            &*token,
        );
    }

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