ppoppo-token 0.2.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! M67 + M68 — id_token hash binding (OIDC Core 1.0 §3.1.3.8 + §3.3.2.11).
//!
//! Provides the `at_hash` (id_token ↔ access_token) and `c_hash` (id_token
//! ↔ authorization_code) substitution-defense primitive. Both claims share
//! identical cryptography: SHA-256 over the ASCII octets of the bound
//! subject, take the leftmost 128 bits, base64url-encode without padding.
//!
//! Owned by `engine::*` (not `id_token::*`) so the issuance side
//! (Phase 10.10) reuses `compute` without crossing a profile boundary.
//!
//! ── Hash family is locked to SHA-256 ───────────────────────────────────
//!
//! PAS issues Ed25519-signed tokens only. The crate-root `Algorithm` enum
//! has exactly one variant (`EdDSA`), the JWKS admission filter at
//! `Jwks::into_key_set` silently skips non-Ed25519 entries, and
//! `engine::check_algorithm` rejects HS/RS/PS/ES families before this
//! module ever runs. By the time a token reaches `verify_match`, the
//! signing curve is *structurally* Ed25519.
//!
//! Hardcoding SHA-256 here mirrors PASETO v4.public's no-cryptographic-
//! agility stance — the security property is "no negotiation surface for
//! attackers", not "support every hash family the spec allows". When a
//! future phase introduces a second signature curve, that change is a
//! multi-file PR (`Algorithm` enum gains a variant, surfacing every
//! callsite as a compile error, plus `check_algorithm` family rules,
//! `SigningKey` constructor, JWKS admission, and this module's `compute`
//! signature). The diff cost of that PR is itself the structural gate; no
//! runtime curve-dispatch branch is needed today.
//!
//! ── Reusability ─────────────────────────────────────────────────────────
//!
//! `compute` is consumed by `engine::check_at_hash` + `engine::check_c_hash`
//! this session, and by `id_token::issue::<S>` (Phase 10.10) once that
//! lands. `verify_match` is verifier-side only; `compute` is the shared
//! primitive both sides agree on.

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
use sha2::{Digest, Sha256};

/// Hash-binding errors emitted by `verify_match`. Profile-specific
/// callers (`check_at_hash`, `check_c_hash`) translate these into their
/// `id_token::AuthError` variants — the engine layer stays
/// profile-agnostic so a future DPoP / RFC-9449 consumer could reuse it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HashBindingError {
    /// The expected claim (e.g. `at_hash`, `c_hash`) is absent from the
    /// payload, but the verifier expected one.
    ClaimMissing,
    /// Claim is present but its value does not match the SHA-256
    /// leftmost-128b base64url-no-pad of the expected subject. Canonical
    /// substitution-attack signal.
    Mismatch,
}

/// Compute the hash-binding string for an arbitrary subject.
///
/// Contract (OIDC Core §3.1.3.6 / §3.3.2.11): `BASE64URL(SHA-256(subject)[..16])`.
/// Subject bytes are the **ASCII octets** of the bound value — for M67
/// the access_token JWS Compact string, for M68 the authorization code.
/// The OIDC spec is unambiguous that the input is the wire string itself,
/// not its claims or a normalized form.
#[must_use]
pub(crate) fn compute(subject: &[u8]) -> String {
    let digest = Sha256::digest(subject);
    B64.encode(&digest[..16])
}

/// Verify that `payload[claim_name]` equals `compute(expected_subject)`.
///
/// Returns `ClaimMissing` when the claim is absent or non-string-valued
/// (an `at_hash` of an integer is a wire-shape attack signal, surfaced
/// the same as missing — neither shape is a valid OIDC binding). Returns
/// `Mismatch` when both sides are well-formed strings but disagree.
pub(crate) fn verify_match(
    payload: &serde_json::Value,
    claim_name: &str,
    expected_subject: &[u8],
) -> Result<(), HashBindingError> {
    let claim = payload
        .get(claim_name)
        .and_then(|v| v.as_str())
        .ok_or(HashBindingError::ClaimMissing)?;
    let expected = compute(expected_subject);
    if claim != expected {
        return Err(HashBindingError::Mismatch);
    }
    Ok(())
}

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

    /// RFC 6234 known-answer test for SHA-256 over the empty string.
    ///
    /// `SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
    /// Leftmost 128 bits = `e3b0c44298fc1c149afbf4c8996fb924`.
    /// base64url-no-pad of those 16 bytes = `"47DEQpj8HBSa-_TImW-5JA"` (22 chars).
    ///
    /// Locking this into a test guarantees the engine's encoding agrees
    /// with every other OIDC-compliant implementation — a drift here would
    /// silently break interop with all federated RPs.
    #[test]
    fn compute_empty_input_matches_rfc_6234_leftmost_128b_b64url() {
        let got = compute(b"");
        assert_eq!(
            got, "47DEQpj8HBSa-_TImW-5JA",
            "SHA-256(\"\") leftmost 128 bits base64url no-pad must match RFC 6234"
        );
    }

    /// The 22-character length is a structural property: 16 bytes encoded
    /// base64url no-pad always lands on 22 chars (16 * 8 / 6 = 21.33,
    /// rounded up to 22, no `=` padding because 16 mod 3 == 1 needs the
    /// dangling-pair handling).
    #[test]
    fn compute_output_is_always_22_chars() {
        for input in [b"".as_slice(), b"a", b"hello", b"a long subject string with lots of bytes"] {
            let h = compute(input);
            assert_eq!(h.len(), 22, "leftmost-128-bit base64url-no-pad is 22 chars");
            assert!(!h.contains('='), "no padding allowed");
            assert!(!h.contains('+') && !h.contains('/'), "URL-safe alphabet only");
        }
    }

    /// Determinism: same subject always yields same hash. Catches
    /// accidental introduction of nondeterminism (e.g. salting).
    #[test]
    fn compute_is_deterministic() {
        let a = compute(b"some-access-token-string.with.three.dots");
        let b = compute(b"some-access-token-string.with.three.dots");
        assert_eq!(a, b);
    }

    /// Canonical happy path for `verify_match`.
    #[test]
    fn verify_match_passes_when_claim_equals_compute() {
        let subject = b"forged-access-token-jws-compact";
        let expected = compute(subject);
        let payload = json!({ "at_hash": expected });
        assert_eq!(verify_match(&payload, "at_hash", subject), Ok(()));
    }

    #[test]
    fn verify_match_returns_claim_missing_when_absent() {
        let payload = json!({ "iss": "x" });
        assert_eq!(
            verify_match(&payload, "at_hash", b"any"),
            Err(HashBindingError::ClaimMissing)
        );
    }

    /// A non-string claim value (e.g. number, object) is treated as
    /// `ClaimMissing` rather than `Mismatch` — the wire-shape error is
    /// distinct from a substitution attempt; both are rejected, but the
    /// audit signal differs.
    #[test]
    fn verify_match_returns_claim_missing_when_non_string() {
        let payload = json!({ "at_hash": 42 });
        assert_eq!(
            verify_match(&payload, "at_hash", b"any"),
            Err(HashBindingError::ClaimMissing)
        );
    }

    #[test]
    fn verify_match_returns_mismatch_when_disagree() {
        let payload = json!({ "at_hash": compute(b"token-A") });
        assert_eq!(
            verify_match(&payload, "at_hash", b"token-B"),
            Err(HashBindingError::Mismatch)
        );
    }

    /// Symmetry: the same primitive serves M67 (`at_hash`) and M68
    /// (`c_hash`). Test both claim names against the same subject so a
    /// future regression where someone hardcodes "at_hash" inside
    /// verify_match would surface immediately.
    #[test]
    fn verify_match_works_for_both_at_hash_and_c_hash_claims() {
        let token = b"forged-access-token";
        let code = b"oauth2-authorization-code-xyz";
        let payload = json!({
            "at_hash": compute(token),
            "c_hash": compute(code),
        });
        assert_eq!(verify_match(&payload, "at_hash", token), Ok(()));
        assert_eq!(verify_match(&payload, "c_hash", code), Ok(()));
        // Cross-pollination: at_hash slot must not match the code, etc.
        assert_eq!(
            verify_match(&payload, "at_hash", code),
            Err(HashBindingError::Mismatch)
        );
    }
}