arcature 0.1.1

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
//! PKCE (RFC 7636) and the CSRF `state` parameter.
//!
//! The two values an Authorization Code flow has to carry across the redirect
//! are both handled here, and both are handled as secrets:
//!
//! * The **code verifier** is the proof that the client which started the
//!   flow is the client redeeming the code. Anyone who reads it out of a log
//!   file can redeem an intercepted authorization code.
//! * The **state** is the CSRF defence. It is not confidential in the way the
//!   verifier is -- it travels in a query string -- but it must be
//!   unguessable, and comparing it must not leak how much of a guess was
//!   right.
//!
//! Neither type implements `Display`, and both redact under `Debug`.

use std::fmt;

use crate::oauth::error::OauthError;

/// The number of random bytes behind a generated [`OauthState`].
///
/// 32 bytes is 256 bits of entropy -- far past what a CSRF token needs, and
/// cheap enough that there is no reason to economise.
const STATE_BYTES: usize = 32;

// ---------------------------------------------------------------------------
// PkceVerifier
// ---------------------------------------------------------------------------

/// The PKCE code verifier: the secret half of the challenge/verifier pair.
///
/// Generated with the authorization URL, stored by the application for the
/// duration of the redirect (a session is the usual place), and handed back
/// to [`OauthClient::exchange`](crate::oauth::OauthClient::exchange).
///
/// `Debug` prints `PkceVerifier([redacted])` and there is no `Display`, so
/// the verifier cannot reach a log line through ordinary formatting. Reading
/// it out requires calling [`PkceVerifier::secret`], which is the point where
/// a reviewer can see the decision.
pub struct PkceVerifier(oauth2::PkceCodeVerifier);

impl PkceVerifier {
    /// Rebuild a verifier from the string an application stored across the
    /// redirect.
    #[must_use]
    pub fn from_secret(secret: String) -> Self {
        Self(oauth2::PkceCodeVerifier::new(secret))
    }

    /// The verifier itself, for storing across the redirect.
    ///
    /// # Security
    ///
    /// Do not log this. Store it where the session data is stored and drop it
    /// once the code has been exchanged.
    #[must_use]
    pub fn secret(&self) -> &str {
        self.0.secret()
    }

    pub(crate) fn into_inner(self) -> oauth2::PkceCodeVerifier {
        self.0
    }
}

impl fmt::Debug for PkceVerifier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("PkceVerifier([redacted])")
    }
}

// ---------------------------------------------------------------------------
// OauthState
// ---------------------------------------------------------------------------

/// The `state` parameter: an unguessable value round-tripped through the
/// provider to prove that the callback belongs to a flow this application
/// started.
///
/// Generated by [`OauthState::generate`], stored across the redirect, and
/// checked with [`OauthState::verify`] when the callback arrives.
pub struct OauthState(String);

impl OauthState {
    /// Generate a fresh state from operating-system entropy.
    ///
    /// # Errors
    ///
    /// Returns [`OauthError::Entropy`] if the OS randomness source is
    /// unavailable. This is not recoverable by retrying and must never be
    /// papered over with a fallback -- a predictable state is no state.
    pub fn generate() -> Result<Self, OauthError> {
        let mut bytes = [0u8; STATE_BYTES];
        getrandom::fill(&mut bytes).map_err(|_| OauthError::Entropy)?;
        Ok(Self(hex_encode(&bytes)))
    }

    /// Rebuild a state from the string an application stored across the
    /// redirect.
    #[must_use]
    pub fn from_stored(state: String) -> Self {
        Self(state)
    }

    /// The state as it travels in the query string, for storing across the
    /// redirect.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Whether `returned` -- the `state` query parameter from the callback --
    /// matches this state.
    ///
    /// The comparison is constant time with respect to the *contents* of the
    /// two values: a candidate that shares a long prefix takes exactly as
    /// long to reject as one that differs in its first byte. Only the length
    /// short-circuits, and the length of a state is public.
    #[must_use]
    pub fn verify(&self, returned: &str) -> bool {
        constant_time_eq(self.0.as_bytes(), returned.as_bytes())
    }
}

impl fmt::Debug for OauthState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("OauthState([redacted])")
    }
}

// ---------------------------------------------------------------------------
// Constant-time comparison
// ---------------------------------------------------------------------------

/// Compare two byte strings without an early exit on the first difference.
///
/// The accumulator is passed through [`std::hint::black_box`] before the
/// final test. Without it a compiler is entitled to notice that `diff` can
/// only grow and to break out of the loop once it is non-zero, which would
/// reintroduce exactly the timing signal this function exists to remove.
///
/// Length is compared up front and short-circuits. That is the standard
/// trade: the length of an OAuth state is visible in the query string
/// already, so hiding it buys nothing.
#[must_use]
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    std::hint::black_box(diff) == 0
}

/// Lowercase hex, so a state is safe in a query string without escaping.
fn hex_encode(bytes: &[u8]) -> String {
    const DIGITS: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(DIGITS[usize::from(byte >> 4)] as char);
        out.push(DIGITS[usize::from(byte & 0x0f)] as char);
    }
    out
}

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

    #[test]
    fn a_generated_state_is_hex_and_full_length() {
        let state = OauthState::generate().expect("entropy");
        assert_eq!(state.as_str().len(), STATE_BYTES * 2);
        assert!(state.as_str().bytes().all(|b| b.is_ascii_hexdigit()));
    }

    #[test]
    fn two_generated_states_differ() {
        let a = OauthState::generate().expect("entropy");
        let b = OauthState::generate().expect("entropy");
        assert_ne!(a.as_str(), b.as_str());
    }

    #[test]
    fn a_state_verifies_against_itself_and_nothing_else() {
        let state = OauthState::generate().expect("entropy");
        let echoed = state.as_str().to_string();
        assert!(state.verify(&echoed));
        assert!(!state.verify(""));
        assert!(!state.verify(&format!("{echoed}x")));
        let mut tampered = echoed.clone();
        tampered.replace_range(0..1, "z");
        assert!(!state.verify(&tampered));
    }

    #[test]
    fn constant_time_eq_agrees_with_equality_wherever_the_difference_sits() {
        let base = vec![7u8; 64];
        assert!(constant_time_eq(&base, &base));
        for index in [0usize, 1, 31, 63] {
            let mut other = base.clone();
            other[index] ^= 0x01;
            assert!(!constant_time_eq(&base, &other), "missed byte {index}");
        }
        assert!(!constant_time_eq(&base, &base[..63]));
    }
}