ppoppo-token 0.3.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
//! Header attack-surface checks — M07-M16a (RFC 8725 §3.5-§3.13, RFC 7797).
//!
//! Strategy: inspect the raw header JSON object (parsed once via
//! `engine::raw::parse_header_json`). Each mitigation maps to a specific
//! `SharedAuthError` variant — no generic "bad header" rejection. Order is
//! specificity-first so audit logs carry the precise attack signal even
//! when multiple violations co-occur.

use crate::engine::raw::parse_header_json;
use crate::engine::shared_config::SharedVerifyConfig;
use crate::engine::shared_error::SharedAuthError;
use crate::KeySet;

/// Allow-list of header parameters the profile recognises. Anything else
/// — including registered JOSE params we choose not to support — falls
/// through to `SharedAuthError::HeaderExtraParam`. Mutating this list is a
/// security decision: add a row to STANDARDS_JWT_DETAILS in the same PR.
const ALLOWED_HEADER_PARAMS: &[&str] = &["typ", "alg", "kid"];

pub(crate) fn run(
    token: &str,
    cfg: &SharedVerifyConfig,
    key_set: &KeySet,
) -> Result<(), SharedAuthError> {
    // M15 (JWE) is detected structurally in `verify` before any
    // per-segment parser runs.
    let header = parse_header_json(token)?;

    // M07: jku — URL-loaded JWK Set. RFC 8725 §3.5: "do not follow URLs".
    if header.get("jku").is_some() {
        return Err(SharedAuthError::HeaderJku);
    }

    // M08: x5u — URL-loaded X.509 chain. Same defense as M07.
    if header.get("x5u").is_some() {
        return Err(SharedAuthError::HeaderX5u);
    }

    // M09: jwk — inline public key. Trusting it would let any token
    // declare its own validation key (RFC 8725 §3.6).
    if header.get("jwk").is_some() {
        return Err(SharedAuthError::HeaderJwk);
    }

    // M10: x5c — inline X.509 chain. Same self-validating-key risk as M09.
    if header.get("x5c").is_some() {
        return Err(SharedAuthError::HeaderX5c);
    }

    // M11: crit — RFC 7515 says verifiers MUST reject any `crit` extension
    // they don't understand. Profile understands none, so any `crit` is a
    // rejection.
    if header.get("crit").is_some() {
        return Err(SharedAuthError::HeaderCrit);
    }

    // M14: nested JWS — `cty` (content type) of `JWT` or `JOSE` signals
    // the payload is itself a JWT/JWS. The profile is signing-only and
    // single-layer; nested signatures expand the audit surface and have
    // historically been the carrier for downgrade attacks (RFC 8725 §3.13).
    if let Some(cty) = header.get("cty").and_then(|v| v.as_str())
        && (cty.eq_ignore_ascii_case("jwt") || cty.eq_ignore_ascii_case("jose"))
    {
        return Err(SharedAuthError::NestedJws);
    }

    // M16a: RFC 7797 unencoded payload option (`b64: false`). The profile
    // requires base64url-encoded payloads; `b64=false` would make the
    // payload appear in the clear and break the signature input
    // construction the engine assumes.
    if header.get("b64") == Some(&serde_json::Value::Bool(false)) {
        return Err(SharedAuthError::HeaderB64False);
    }

    // M16: header parameter whitelist {typ, alg, kid}. Catches any
    // registered JOSE param the profile chose not to support (`cty` with
    // any non-JWT value, `enc`, etc.) and any custom param an attacker
    // sneaks in. Fires after the specific checks so audit logs carry the
    // precise variant when one applies.
    if let Some(obj) = header.as_object() {
        for key in obj.keys() {
            if !ALLOWED_HEADER_PARAMS.contains(&key.as_str()) {
                return Err(SharedAuthError::HeaderExtraParam);
            }
        }
    }

    // M13/M13a: typ strict-equal to cfg.expected_typ. The default is
    // `at+jwt` (RFC 9068) so a token bearing `typ: "JWT"` or `typ:
    // "id_token"` (or absent typ) is rejected — id-token-vs-access-token
    // confusion was the canonical cross-JWT substitution attack.
    let typ = header.get("typ").and_then(|v| v.as_str());
    if typ != Some(cfg.expected_typ) {
        return Err(SharedAuthError::TypMismatch);
    }

    // M12: kid — REQUIRED + must resolve against the server-pinned KeySet.
    // Missing kid and unknown kid collapse into one variant; the audit
    // surface doesn't need to distinguish (an attacker who omits kid and
    // one who guesses a wrong kid are both probing for key confusion).
    let kid = header
        .get("kid")
        .and_then(|v| v.as_str())
        .ok_or(SharedAuthError::KidUnknown)?;
    if key_set.get(kid).is_none() {
        return Err(SharedAuthError::KidUnknown);
    }

    Ok(())
}