entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! PKCE code verifier and challenge (RFC 7636).
//!
//! Only the S256 method is supported — the `plain` method is insecure and
//! rejected. The verifier is generated from 32 bytes of CSPRNG output,
//! base64url-encoded to produce a 43-character string (within the RFC 7636
//! requirement of 43-128 characters).
//!
//! # Security
//!
//! SECURITY: The verifier is stored in a [`Zeroizing`] wrapper so it is
//! cleared from memory on drop. The `Debug` implementation redacts the
//! verifier to prevent accidental logging.

use std::fmt;

use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{RandomError, Sha256, fill_random};
use crate::encoding::base64url_encode;
use crate::util::log::debug;

// ---------------------------------------------------------------------------
// PkceChallenge
// ---------------------------------------------------------------------------

/// A PKCE code verifier and its S256 challenge.
///
/// Generated via [`PkceChallenge::generate`]. The verifier is sent in the
/// token exchange request; the challenge is included in the authorization URL.
///
/// # Security
///
/// SECURITY: The verifier is stored in [`Zeroizing`] to clear memory on
/// drop. The `Debug` output redacts the verifier to prevent accidental
/// logging of secret material.
#[doc(alias = "pkce")]
pub struct PkceChallenge {
    // SECURITY: Verifier is secret material — cleared on drop.
    verifier: Zeroizing<String>,
    challenge: String,
}

impl PkceChallenge {
    /// Generates a new PKCE verifier and its S256 challenge.
    ///
    /// The verifier is 32 random bytes base64url-encoded (43 characters).
    /// The challenge is `base64url(SHA-256(verifier))`.
    ///
    /// # Errors
    ///
    /// Returns [`RandomError`] if the platform CSPRNG is unavailable.
    pub fn generate() -> Result<Self, RandomError> {
        // Generate 32 random bytes for the verifier.
        let mut buf = [0u8; 32];
        fill_random(&mut buf)?;
        let verifier = base64url_encode(&buf);

        // SECURITY: Zeroize the raw random bytes after encoding.
        crate::crypto::zeroize::zeroize(&mut buf);

        // S256: challenge = base64url(SHA-256(ASCII(verifier)))
        let hash = Sha256::digest(verifier.as_bytes());
        let challenge = base64url_encode(&hash);

        // SECURITY: Never log the verifier — it is secret material.
        debug!("oauth: PKCE challenge generated (method=S256)");

        Ok(Self {
            verifier: Zeroizing::new(verifier),
            challenge,
        })
    }

    /// Returns the code verifier string.
    ///
    /// This value is sent in the token exchange request body.
    ///
    /// # Security
    ///
    /// SECURITY: The verifier is secret material. Do not log or display it.
    #[must_use]
    #[inline]
    pub fn verifier(&self) -> &str {
        &self.verifier
    }

    /// Returns the code challenge string.
    ///
    /// This value is included in the authorization URL.
    #[must_use]
    #[inline]
    pub fn challenge(&self) -> &str {
        &self.challenge
    }

    /// Returns the code challenge method.
    ///
    /// Always `"S256"` — the `plain` method is insecure and not supported.
    #[must_use]
    #[inline]
    pub fn method(&self) -> &'static str {
        "S256"
    }
}

// SECURITY: Debug redacts the verifier to prevent accidental logging.
impl fmt::Debug for PkceChallenge {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PkceChallenge")
            .field("verifier", &"[REDACTED]")
            .field("challenge", &self.challenge)
            .field("method", &"S256")
            .finish()
    }
}

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

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

    // --- Generation ---

    #[test]
    fn generate_produces_valid_verifier_length() {
        let pkce = PkceChallenge::generate().unwrap();
        let verifier = pkce.verifier();
        // 32 bytes base64url-encoded = 43 characters (no padding).
        assert_eq!(verifier.len(), 43, "verifier should be 43 characters");
    }

    #[test]
    fn generate_verifier_is_base64url() {
        let pkce = PkceChallenge::generate().unwrap();
        let verifier = pkce.verifier();
        assert!(
            verifier
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
            "verifier should contain only base64url characters: {verifier}",
        );
    }

    #[test]
    fn generate_verifier_within_rfc7636_bounds() {
        let pkce = PkceChallenge::generate().unwrap();
        let len = pkce.verifier().len();
        assert!(
            (43..=128).contains(&len),
            "verifier length {len} should be between 43 and 128",
        );
    }

    // --- S256 verification ---

    #[test]
    fn challenge_is_s256_of_verifier() {
        let pkce = PkceChallenge::generate().unwrap();

        // Manually compute S256: base64url(SHA-256(ASCII(verifier)))
        let hash = Sha256::digest(pkce.verifier().as_bytes());
        let expected_challenge = base64url_encode(&hash);

        assert_eq!(
            pkce.challenge(),
            expected_challenge,
            "challenge should be base64url(SHA-256(verifier))",
        );
    }

    #[test]
    fn challenge_is_base64url() {
        let pkce = PkceChallenge::generate().unwrap();
        let challenge = pkce.challenge();
        assert!(
            challenge
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
            "challenge should contain only base64url characters: {challenge}",
        );
    }

    #[test]
    fn challenge_decodes_to_32_bytes() {
        let pkce = PkceChallenge::generate().unwrap();
        let decoded = base64url_decode(pkce.challenge()).unwrap();
        assert_eq!(decoded.len(), 32, "SHA-256 digest should be 32 bytes");
    }

    // --- Method ---

    #[test]
    fn method_is_s256() {
        let pkce = PkceChallenge::generate().unwrap();
        assert_eq!(pkce.method(), "S256");
    }

    // --- Uniqueness ---

    #[test]
    fn two_challenges_differ() {
        let a = PkceChallenge::generate().unwrap();
        let b = PkceChallenge::generate().unwrap();
        assert_ne!(a.verifier(), b.verifier(), "verifiers should differ");
        assert_ne!(a.challenge(), b.challenge(), "challenges should differ");
    }

    // --- Debug redaction ---

    #[test]
    fn debug_redacts_verifier() {
        let pkce = PkceChallenge::generate().unwrap();
        let debug_output = format!("{pkce:?}");
        assert!(
            debug_output.contains("[REDACTED]"),
            "debug should contain [REDACTED]: {debug_output}",
        );
        assert!(
            !debug_output.contains(pkce.verifier()),
            "debug must not contain the actual verifier",
        );
    }
}