openlatch-client 0.6.18

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! PKCE (RFC 7636, S256) and `state` for the browser sign-in (PLT-7 C-1, C-2).
//!
//! One [`Pkce`] per sign-in. The verifier and `state` are 32 bytes each from the
//! OS CSPRNG (`getrandom`), base64url-encoded without padding — 43 characters,
//! 256 bits, the floor RFC 7636 §7.1 asks for. Two UUIDv4s would carry 244.
//!
//! None of these values is ever logged or printed: the verifier proves this
//! process started the sign-in, and `state` binds the loopback callback to it.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;

use crate::error::{OlError, ERR_AUTH_FLOW_FAILED};

/// The `code_challenge_method` the platform accepts; `plain` is refused.
pub const CHALLENGE_METHOD: &str = "S256";

/// One sign-in's PKCE pair and `state`.
///
/// `Debug` is deliberately not derived: every field is a secret of the sign-in.
#[derive(Clone)]
pub struct Pkce {
    /// Sent only to the platform, on the listing and the exchange.
    pub verifier: String,
    /// `BASE64URL(SHA256(verifier))`, sent in the browser URL.
    pub challenge: String,
    /// Sent in the browser URL and expected back on the loopback callback.
    pub state: String,
}

impl Pkce {
    /// Draw a fresh verifier and `state` from the OS CSPRNG.
    ///
    /// # Errors
    ///
    /// `OL-1606` when the OS refuses randomness — there is no safe fallback.
    pub fn new() -> Result<Self, OlError> {
        let verifier = random_token()?;
        let state = random_token()?;
        Ok(Self {
            challenge: challenge_for(&verifier),
            verifier,
            state,
        })
    }

    /// Does `candidate` equal this sign-in's `state`? Constant time, so the
    /// listener leaks nothing about how close a guess came.
    pub fn state_matches(&self, candidate: &str) -> bool {
        bool::from(self.state.as_bytes().ct_eq(candidate.as_bytes()))
    }
}

/// `BASE64URL(SHA256(ASCII(verifier)))`, RFC 7636 §4.2.
pub fn challenge_for(verifier: &str) -> String {
    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}

/// 32 CSPRNG bytes, base64url without padding.
fn random_token() -> Result<String, OlError> {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).map_err(|e| {
        OlError::new(
            ERR_AUTH_FLOW_FAILED,
            format!("The operating system refused secure randomness: {e}"),
        )
    })?;
    Ok(URL_SAFE_NO_PAD.encode(bytes))
}

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

    /// RFC 7636 Appendix B — the one published S256 vector.
    #[test]
    fn pkce_challenge_matches_rfc_7636_appendix_b() {
        assert_eq!(
            challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        );
    }

    #[test]
    fn pkce_verifier_and_state_carry_256_bits() {
        let p = Pkce::new().expect("CSPRNG available");
        // 32 bytes → 43 base64url characters, inside RFC 7636's 43–128 range.
        assert_eq!(p.verifier.len(), 43);
        assert_eq!(p.state.len(), 43);
        assert!(p
            .verifier
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
        assert_eq!(p.challenge, challenge_for(&p.verifier));
        assert_ne!(p.verifier, p.state, "two independent draws");
    }

    #[test]
    fn pkce_each_sign_in_draws_fresh_values() {
        let a = Pkce::new().unwrap();
        let b = Pkce::new().unwrap();
        assert_ne!(a.verifier, b.verifier);
        assert_ne!(a.state, b.state);
    }

    #[test]
    fn pkce_state_compare_is_exact() {
        let p = Pkce::new().unwrap();
        assert!(p.state_matches(&p.state.clone()));
        assert!(!p.state_matches(""));
        assert!(!p.state_matches(&p.state[..42]));
        assert!(!p.state_matches(&format!("{}x", p.state)));
        assert!(!p.state_matches(&p.verifier));
    }
}