Skip to main content

termivar_scanner/
jwt_policy_review.rs

1//! Bounded, transport-free review of one explicitly supplied compact JWT.
2//!
3//! This module deliberately supports only compact JWS objects signed with
4//! ES256 and one explicitly supplied local P-256 public JWK. It never follows
5//! key URLs or trusts token-carried key material. Parsing, local policy
6//! consistency, local signature verification, and target acceptance are four
7//! separate states; this module performs no target request.
8
9use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
10use ring::{
11    rand::{SecureRandom, SystemRandom},
12    signature::{UnparsedPublicKey, ECDSA_P256_SHA256_FIXED},
13};
14use serde::{
15    de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor},
16    Deserialize, Deserializer, Serialize,
17};
18use sha2::{Digest, Sha256};
19use std::fmt;
20use thiserror::Error;
21use zeroize::Zeroizing;
22
23#[cfg(feature = "jwt-target-acceptance-review")]
24use crate::jwt_target_acceptance::{
25    JwtTargetAcceptanceBinding, JwtTargetAcceptanceNotEligibleReason,
26};
27
28/// Stable identifier for the initial local JWT policy evaluator.
29pub const JWT_POLICY_REVIEW_POLICY_ID: &str = "termivar.jwt-local-policy/es256-v1";
30/// Stable schema identifier for the value-free audit outcome.
31pub const JWT_POLICY_REVIEW_AUDIT_SCHEMA: &str = "security.jwt-policy-review-audit/v1";
32/// Maximum accepted compact JWT bytes.
33pub const MAX_COMPACT_JWT_BYTES: usize = 4 * 1024;
34/// Maximum accepted local public JWK bytes.
35pub const MAX_LOCAL_PUBLIC_JWK_BYTES: usize = 2 * 1024;
36/// Maximum decoded protected-header bytes.
37pub const MAX_JWT_HEADER_BYTES: usize = 1024;
38/// Maximum decoded claims bytes.
39pub const MAX_JWT_CLAIMS_BYTES: usize = 3 * 1024;
40/// ES256 uses a fixed-width `R || S` signature.
41pub const ES256_SIGNATURE_BYTES: usize = 64;
42/// Maximum required claim names in one policy.
43pub const MAX_REQUIRED_CLAIMS: usize = 16;
44/// Maximum allowed clock-skew declaration.
45pub const MAX_CLOCK_SKEW_SECONDS: u32 = 3600;
46
47const MAX_EXPECTED_TYPE_BYTES: usize = 128;
48const MAX_EXPECTED_STRING_BYTES: usize = 512;
49const MAX_CLAIM_NAME_BYTES: usize = 128;
50const MAX_POLICY_REFERENCE_BYTES: usize = 64;
51const MAX_JSON_DEPTH: usize = 8;
52const MAX_JSON_NODES: usize = 256;
53const MAX_JSON_MEMBERS: usize = 128;
54const MAX_JSON_ARRAY_ITEMS: usize = 64;
55const MAX_JSON_STRING_BYTES: usize = 1024;
56const JSON_DUPLICATE_SENTINEL: &str = "termivar-jwt-json-duplicate";
57const JSON_LIMIT_SENTINEL: &str = "termivar-jwt-json-limit";
58
59/// Move-only compact JWT bytes. The original token is wiped on drop and is
60/// never serializable or printable through `Debug`.
61pub struct SecretCompactJwt {
62    bytes: Zeroizing<Vec<u8>>,
63}
64
65impl SecretCompactJwt {
66    /// Takes ownership of one bounded compact JWT.
67    pub fn new(bytes: Vec<u8>) -> Result<Self, JwtInputError> {
68        let bytes = Zeroizing::new(bytes);
69        if bytes.is_empty() {
70            return Err(JwtInputError::EmptyToken);
71        }
72        if bytes.len() > MAX_COMPACT_JWT_BYTES {
73            return Err(JwtInputError::TokenTooLarge);
74        }
75        Ok(Self { bytes })
76    }
77
78    fn as_bytes(&self) -> &[u8] {
79        self.bytes.as_slice()
80    }
81}
82
83/// Move-only valid/invalid token pair prepared only after the supplied token
84/// has passed the complete local JWT review.
85///
86/// The invalid control preserves the exact protected header and claims bytes
87/// while changing one bit of the fixed-width ES256 signature. Neither token is
88/// printable, cloneable, or serializable. The target runtime may borrow the
89/// bytes only through this crate-private seam while constructing one sensitive
90/// `Authorization` header per isolated request leg.
91#[cfg(feature = "jwt-target-acceptance-review")]
92pub(crate) struct JwtTargetAcceptanceCredentials {
93    valid: SecretCompactJwt,
94    invalid_signature_control: SecretCompactJwt,
95    preparation_binding: JwtTargetAcceptanceBinding,
96}
97
98#[cfg(feature = "jwt-target-acceptance-review")]
99impl JwtTargetAcceptanceCredentials {
100    pub(crate) fn valid_bytes(&self) -> &[u8] {
101        self.valid.as_bytes()
102    }
103
104    pub(crate) fn invalid_signature_control_bytes(&self) -> &[u8] {
105        self.invalid_signature_control.as_bytes()
106    }
107
108    pub(crate) const fn preparation_binding(&self) -> &JwtTargetAcceptanceBinding {
109        &self.preparation_binding
110    }
111
112    #[cfg(test)]
113    pub(crate) fn synthetic_for_test() -> Self {
114        Self {
115            valid: SecretCompactJwt::new(b"eyJhbGciOiJFUzI1NiJ9.e30.AA".to_vec()).unwrap(),
116            invalid_signature_control: SecretCompactJwt::new(
117                b"eyJhbGciOiJFUzI1NiJ9.e30.AQ".to_vec(),
118            )
119            .unwrap(),
120            preparation_binding: JwtTargetAcceptanceBinding::synthetic_for_test(0xA5),
121        }
122    }
123}
124
125/// Move-only sealed handoff from local JWT preparation into the sole target
126/// runtime. Callers can carry this value alongside the public target policy,
127/// but cannot separate credentials, the local ineligibility reason, or the
128/// opaque per-preparation binding.
129#[cfg(feature = "jwt-target-acceptance-review")]
130pub struct JwtTargetAcceptanceRuntimeInput {
131    credentials: Option<JwtTargetAcceptanceCredentials>,
132    not_eligible_reason: Option<JwtTargetAcceptanceNotEligibleReason>,
133    preparation_binding: JwtTargetAcceptanceBinding,
134}
135
136#[cfg(feature = "jwt-target-acceptance-review")]
137impl JwtTargetAcceptanceRuntimeInput {
138    pub(crate) fn into_parts(
139        self,
140    ) -> (
141        Option<JwtTargetAcceptanceCredentials>,
142        Option<JwtTargetAcceptanceNotEligibleReason>,
143        JwtTargetAcceptanceBinding,
144    ) {
145        (
146            self.credentials,
147            self.not_eligible_reason,
148            self.preparation_binding,
149        )
150    }
151
152    #[cfg(test)]
153    pub(crate) fn synthetic_eligible_for_test() -> Self {
154        let credentials = JwtTargetAcceptanceCredentials::synthetic_for_test();
155        let preparation_binding = credentials.preparation_binding().clone();
156        Self {
157            credentials: Some(credentials),
158            not_eligible_reason: None,
159            preparation_binding,
160        }
161    }
162}
163
164#[cfg(feature = "jwt-target-acceptance-review")]
165impl fmt::Debug for JwtTargetAcceptanceRuntimeInput {
166    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        formatter
168            .debug_struct("JwtTargetAcceptanceRuntimeInput")
169            .field(
170                "credentials",
171                &self.credentials.as_ref().map(|_| "<redacted>"),
172            )
173            .field("not_eligible_reason", &self.not_eligible_reason)
174            .field("preparation_binding", &"<opaque-per-preparation>")
175            .finish()
176    }
177}
178
179/// Local review plus an optional sealed credential handoff for the target
180/// acceptance child. A selected but locally ineligible token produces the
181/// local audit and no credential handoff, so it can never reach transport.
182#[cfg(feature = "jwt-target-acceptance-review")]
183pub struct PreparedJwtTargetAcceptance {
184    local_audit: JwtPolicyReviewAudit,
185    runtime_input: JwtTargetAcceptanceRuntimeInput,
186}
187
188#[cfg(feature = "jwt-target-acceptance-review")]
189impl PreparedJwtTargetAcceptance {
190    pub fn local_audit(&self) -> &JwtPolicyReviewAudit {
191        &self.local_audit
192    }
193
194    pub fn into_parts(self) -> (JwtPolicyReviewAudit, JwtTargetAcceptanceRuntimeInput) {
195        (self.local_audit, self.runtime_input)
196    }
197}
198
199/// Redaction-safe invariant failure while deriving the one invalid-signature
200/// control. This is a local implementation error, not a target observation.
201#[cfg(feature = "jwt-target-acceptance-review")]
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
203pub enum JwtTargetControlPreparationError {
204    /// The operating system could not provide one opaque per-preparation
205    /// binding. No token bytes are exposed by this failure.
206    #[error("JWT target control preparation could not mint its private binding")]
207    PreparationBindingUnavailable,
208    /// The already reviewed token could not be reproduced as one strict
209    /// compact ES256 object.
210    #[error("JWT target control preparation violated the compact-token contract")]
211    InvalidCompactToken,
212    /// The derived token did not retain the expected locally invalid signature
213    /// relationship.
214    #[error("JWT target control preparation violated the invalid-signature contract")]
215    InvalidControlInvariant,
216}
217
218/// Redaction-safe compact-token input failure.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
220#[non_exhaustive]
221pub enum JwtInputError {
222    /// No compact token bytes were supplied.
223    #[error("JWT input is empty")]
224    EmptyToken,
225    /// The compact token exceeded the fixed source ceiling.
226    #[error("JWT input exceeds its compiled byte limit")]
227    TokenTooLarge,
228}
229
230/// One explicitly supplied local ES256 verification key.
231///
232/// The retained bytes are only the uncompressed public P-256 point. The raw
233/// JWK, key identifier, and any source path are not retained or reported.
234pub struct Es256LocalPublicKey {
235    sec1_public_point: [u8; 65],
236    public_key_sha256: String,
237}
238
239impl Es256LocalPublicKey {
240    /// Parses the strict public-JWK subset accepted by this evaluator.
241    ///
242    /// Exactly `kty`, `crv`, `alg`, `x`, and `y` are accepted. Private
243    /// material and remote or embedded key-selection metadata are rejected.
244    /// This intake validates the closed JWK shape and canonical fixed-width
245    /// coordinate encodings. Curve membership is deliberately not inferred
246    /// from those declarations; the Ring ES256 verifier checks the encoded
247    /// point together with the signature and reports one coarse invalid local
248    /// verification outcome on either failure.
249    pub fn from_jwk_json(bytes: Vec<u8>) -> Result<Self, Es256PublicJwkError> {
250        // A purportedly public JWK can still contain an accidentally supplied
251        // private `d` member. Own and wipe the entire source on every path,
252        // including the empty and oversized rejection paths.
253        let bytes = Zeroizing::new(bytes);
254        if bytes.is_empty() {
255            return Err(Es256PublicJwkError::Malformed);
256        }
257        if bytes.len() > MAX_LOCAL_PUBLIC_JWK_BYTES {
258            return Err(Es256PublicJwkError::TooLarge);
259        }
260
261        let document = parse_private_json(bytes.as_slice()).map_err(|failure| match failure {
262            JsonFailure::DuplicateKey => Es256PublicJwkError::DuplicateKey,
263            JsonFailure::LimitExceeded => Es256PublicJwkError::LimitExceeded,
264            JsonFailure::Malformed => Es256PublicJwkError::Malformed,
265        })?;
266        let object = document.as_object().ok_or(Es256PublicJwkError::Malformed)?;
267
268        for (name, _) in object {
269            match name.as_str() {
270                "kty" | "crv" | "alg" | "x" | "y" => {},
271                "d" => return Err(Es256PublicJwkError::PrivateMaterialProhibited),
272                "jku" | "x5u" | "jwk" | "x5c" => {
273                    return Err(Es256PublicJwkError::KeyMetadataProhibited)
274                },
275                _ => return Err(Es256PublicJwkError::UnsupportedMember),
276            }
277        }
278        if object.len() != 5 {
279            return Err(Es256PublicJwkError::MissingMember);
280        }
281        if document.string_member("kty") != Some("EC")
282            || document.string_member("crv") != Some("P-256")
283            || document.string_member("alg") != Some("ES256")
284        {
285            return Err(Es256PublicJwkError::UnsupportedKey);
286        }
287
288        let x = decode_canonical_coordinate(
289            document
290                .string_member("x")
291                .ok_or(Es256PublicJwkError::InvalidCoordinate)?,
292        )?;
293        let y = decode_canonical_coordinate(
294            document
295                .string_member("y")
296                .ok_or(Es256PublicJwkError::InvalidCoordinate)?,
297        )?;
298        let mut sec1_public_point = [0_u8; 65];
299        sec1_public_point[0] = 0x04;
300        sec1_public_point[1..33].copy_from_slice(&x);
301        sec1_public_point[33..].copy_from_slice(&y);
302        let public_key_sha256 = sha256_reference(&sec1_public_point);
303        Ok(Self {
304            sec1_public_point,
305            public_key_sha256,
306        })
307    }
308
309    fn as_sec1_bytes(&self) -> &[u8] {
310        &self.sec1_public_point
311    }
312
313    fn public_key_sha256(&self) -> &str {
314        &self.public_key_sha256
315    }
316}
317
318fn sha256_reference(bytes: &[u8]) -> String {
319    const HEX: &[u8; 16] = b"0123456789abcdef";
320    let digest = Sha256::digest(bytes);
321    let mut output = String::with_capacity("sha256:".len() + digest.len() * 2);
322    output.push_str("sha256:");
323    for byte in digest {
324        output.push(char::from(HEX[usize::from(byte >> 4)]));
325        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
326    }
327    output
328}
329
330fn decode_canonical_coordinate(value: &str) -> Result<[u8; 32], Es256PublicJwkError> {
331    if !is_unpadded_base64url(value.as_bytes()) {
332        return Err(Es256PublicJwkError::InvalidCoordinate);
333    }
334    let decoded = Zeroizing::new(
335        URL_SAFE_NO_PAD
336            .decode(value.as_bytes())
337            .map_err(|_| Es256PublicJwkError::InvalidCoordinate)?,
338    );
339    let coordinate: [u8; 32] = decoded
340        .as_slice()
341        .try_into()
342        .map_err(|_| Es256PublicJwkError::InvalidCoordinate)?;
343    if URL_SAFE_NO_PAD.encode(coordinate) != value {
344        return Err(Es256PublicJwkError::InvalidCoordinate);
345    }
346    Ok(coordinate)
347}
348
349/// Redaction-safe local public-JWK rejection.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
351#[non_exhaustive]
352pub enum Es256PublicJwkError {
353    /// The local JWK exceeded its fixed source ceiling.
354    #[error("local ES256 public JWK exceeds its compiled byte limit")]
355    TooLarge,
356    /// The JWK was not one bounded JSON object.
357    #[error("local ES256 public JWK is malformed")]
358    Malformed,
359    /// A JSON object contained a duplicate key.
360    #[error("local ES256 public JWK contains a duplicate JSON key")]
361    DuplicateKey,
362    /// JSON structural limits were exceeded.
363    #[error("local ES256 public JWK exceeds JSON structural limits")]
364    LimitExceeded,
365    /// One of the five required public members was absent.
366    #[error("local ES256 public JWK is missing a required member")]
367    MissingMember,
368    /// A member outside the closed V1 public subset was supplied.
369    #[error("local ES256 public JWK contains an unsupported member")]
370    UnsupportedMember,
371    /// A private EC scalar was supplied.
372    #[error("local ES256 public JWK must not contain private key material")]
373    PrivateMaterialProhibited,
374    /// Remote or embedded key-selection metadata was supplied.
375    #[error("local ES256 public JWK must not contain key-selection metadata")]
376    KeyMetadataProhibited,
377    /// The declared key type, curve, or algorithm is outside V1.
378    #[error("local public JWK is not an ES256 P-256 verification key")]
379    UnsupportedKey,
380    /// A coordinate was padded, non-canonical, malformed, or not 32 bytes.
381    #[error("local ES256 public JWK coordinate is invalid")]
382    InvalidCoordinate,
383}
384
385/// Non-secret local policy. Expected values are kept private, wiped on drop,
386/// and never included in the audit outcome or `Debug` output.
387pub struct JwtLocalPolicy {
388    operator_policy_reference: String,
389    operator_policy_revision: String,
390    expected_type: Zeroizing<String>,
391    expected_issuer: Zeroizing<String>,
392    expected_audience: Zeroizing<String>,
393    required_claims: Vec<Zeroizing<String>>,
394    require_expiration: bool,
395    allowed_clock_skew_seconds: u32,
396}
397
398impl JwtLocalPolicy {
399    /// Constructs one bounded policy. String comparisons are exact and
400    /// case-sensitive; no URI or version normalization is performed.
401    pub fn new(
402        operator_policy_identity: (&str, &str),
403        expected_type: &str,
404        expected_issuer: &str,
405        expected_audience: &str,
406        required_claims: &[&str],
407        require_expiration: bool,
408        allowed_clock_skew_seconds: u32,
409    ) -> Result<Self, JwtPolicyError> {
410        let (operator_policy_reference, operator_policy_revision) = operator_policy_identity;
411        if !valid_policy_reference(operator_policy_reference) {
412            return Err(JwtPolicyError::InvalidPolicyReference);
413        }
414        if !valid_policy_reference(operator_policy_revision) {
415            return Err(JwtPolicyError::InvalidPolicyRevision);
416        }
417        if allowed_clock_skew_seconds > MAX_CLOCK_SKEW_SECONDS {
418            return Err(JwtPolicyError::ClockSkewTooLarge);
419        }
420        let expected_type = copy_expected(expected_type, MAX_EXPECTED_TYPE_BYTES)?;
421        let expected_issuer = copy_expected(expected_issuer, MAX_EXPECTED_STRING_BYTES)?;
422        let expected_audience = copy_expected(expected_audience, MAX_EXPECTED_STRING_BYTES)?;
423        if required_claims.len() > MAX_REQUIRED_CLAIMS {
424            return Err(JwtPolicyError::TooManyRequiredClaims);
425        }
426        let mut copied_claims: Vec<Zeroizing<String>> = Vec::with_capacity(required_claims.len());
427        for claim in required_claims {
428            if !valid_claim_name(claim) {
429                return Err(JwtPolicyError::InvalidRequiredClaim);
430            }
431            if copied_claims.iter().any(|known| known.as_str() == *claim) {
432                return Err(JwtPolicyError::DuplicateRequiredClaim);
433            }
434            copied_claims.push(Zeroizing::new((*claim).to_owned()));
435        }
436        Ok(Self {
437            operator_policy_reference: operator_policy_reference.to_owned(),
438            operator_policy_revision: operator_policy_revision.to_owned(),
439            expected_type,
440            expected_issuer,
441            expected_audience,
442            required_claims: copied_claims,
443            require_expiration,
444            allowed_clock_skew_seconds,
445        })
446    }
447}
448
449fn valid_policy_reference(value: &str) -> bool {
450    !value.is_empty()
451        && value.len() <= MAX_POLICY_REFERENCE_BYTES
452        && value.bytes().enumerate().all(|(index, byte)| {
453            byte.is_ascii_lowercase()
454                || byte.is_ascii_digit()
455                || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
456        })
457}
458
459fn copy_expected(value: &str, maximum: usize) -> Result<Zeroizing<String>, JwtPolicyError> {
460    if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
461        return Err(JwtPolicyError::InvalidExpectedValue);
462    }
463    Ok(Zeroizing::new(value.to_owned()))
464}
465
466fn valid_claim_name(value: &str) -> bool {
467    !value.is_empty()
468        && value.len() <= MAX_CLAIM_NAME_BYTES
469        && value.bytes().all(|byte| {
470            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'/')
471        })
472}
473
474/// Redaction-safe local policy construction failure.
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
476#[non_exhaustive]
477pub enum JwtPolicyError {
478    /// The safe non-secret operator policy slug was absent or malformed.
479    #[error("JWT operator policy reference is invalid")]
480    InvalidPolicyReference,
481    /// The safe non-secret operator revision slug was absent or malformed.
482    #[error("JWT operator policy revision is invalid")]
483    InvalidPolicyRevision,
484    /// An expected type, issuer, or audience was empty, too long, or contained controls.
485    #[error("JWT policy expected value is invalid")]
486    InvalidExpectedValue,
487    /// More required claims were supplied than the fixed V1 ceiling.
488    #[error("JWT policy has too many required claims")]
489    TooManyRequiredClaims,
490    /// A required claim name violated the bounded ASCII-name contract.
491    #[error("JWT policy required claim name is invalid")]
492    InvalidRequiredClaim,
493    /// A required claim name was repeated.
494    #[error("JWT policy required claim name is duplicated")]
495    DuplicateRequiredClaim,
496    /// The permitted time skew exceeded the V1 ceiling.
497    #[error("JWT policy clock skew exceeds its compiled limit")]
498    ClockSkewTooLarge,
499}
500
501/// Caller-supplied local evaluation time. It is evidence about this process's
502/// clock, not an observed target decision.
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub struct JwtEvaluationTime {
505    /// Unix time in whole seconds.
506    pub unix_seconds: i64,
507}
508
509/// Value-free outcome of one bounded local review.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct JwtPolicyReviewAudit {
512    /// Compact syntax and supported-profile status.
513    parsing: JwtParsingStatus,
514    /// Local expected-claim and time-policy status.
515    policy_status: JwtPolicyStatus,
516    /// Signature state against the explicitly supplied local public key.
517    local_signature: JwtLocalSignatureStatus,
518    /// Target-server acceptance is outside this transport-free evaluator.
519    target_acceptance: JwtTargetAcceptanceStatus,
520    /// Safe, value-free policy selection and local-clock facts.
521    methodology: JwtPolicyMethodology,
522    /// Explicit proof that this local evaluator did not perform network work.
523    external_activity: JwtExternalActivity,
524    /// Bounded value-free reasons for a policy-inconsistent result.
525    policy_violations: Vec<JwtPolicyViolation>,
526    #[cfg(feature = "jwt-target-acceptance-review")]
527    target_preparation_binding: Option<JwtTargetAcceptanceBinding>,
528}
529
530/// Value-free local methodology facts needed by strict saved reports and
531/// semantic comparison.
532#[derive(Debug, Clone, PartialEq, Eq)]
533pub struct JwtPolicyMethodology {
534    /// Bounded non-secret ASCII slug supplied by the operator policy.
535    operator_policy_reference: String,
536    /// Bounded non-secret revision slug that the operator must change whenever
537    /// the private expected-value policy semantics change.
538    operator_policy_revision: String,
539    /// SHA-256 over the exact uncompressed public P-256 point. This identifies
540    /// supplied public key bytes; it is not a signature or source attestation.
541    local_public_key_sha256: String,
542    /// Whether exact protected-token type comparison was selected.
543    expected_type_selected: bool,
544    /// Whether exact issuer comparison was selected.
545    expected_issuer_selected: bool,
546    /// Whether exact audience membership comparison was selected.
547    expected_audience_selected: bool,
548    /// Number of additionally required claim names; names are not reported.
549    required_claim_count: u8,
550    /// Whether absence of `exp` is policy-inconsistent.
551    require_expiration: bool,
552    /// Caller-selected allowance applied to `exp` and `nbf`.
553    allowed_clock_skew_seconds: u32,
554    /// Caller-supplied local evaluation instant.
555    evaluation_time_unix_seconds: i64,
556    /// Assurance limitation for the supplied clock value.
557    clock_assurance: JwtClockAssurance,
558}
559
560/// Assurance attached to the local evaluation clock.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum JwtClockAssurance {
564    /// The process used a caller-supplied local-system time that this module
565    /// did not independently validate against an external clock.
566    LocalSystemClockNotIndependentlyVerified,
567}
568
569/// Network activity facts for the transport-free review.
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub struct JwtExternalActivity {
572    /// Target requests made by this evaluator; fixed at zero.
573    target_request_count: u8,
574    /// Retrieval of `jku`, `x5u`, or any other remote key source.
575    remote_key_retrieval: JwtExternalOperationStatus,
576    /// Forwarding or replaying the supplied token to a target.
577    token_forwarding: JwtExternalOperationStatus,
578}
579
580/// Closed status for network operations excluded from this local slice.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(rename_all = "snake_case")]
583pub enum JwtExternalOperationStatus {
584    /// The operation was not performed.
585    NotPerformed,
586}
587
588/// Compact syntax/profile status.
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(rename_all = "snake_case")]
591pub enum JwtParsingStatus {
592    /// The token is a supported compact ES256 JWS with bounded JSON claims.
593    Parsed,
594    /// The compact input was malformed or outside the closed local profile.
595    Rejected(JwtParseRejection),
596}
597
598/// Static reason a compact input was not parsed into the supported profile.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum JwtParseRejection {
602    /// A five-segment JWE compact object is outside this JWS-only profile.
603    EncryptedTokenUnsupported,
604    /// The compact input did not contain exactly three segments.
605    InvalidSegmentCount,
606    /// One required compact segment was empty.
607    EmptySegment,
608    /// Compact segments must use unpadded base64url only.
609    InvalidBase64Url,
610    /// A decoded header or claims object exceeded its byte ceiling.
611    DecodedSegmentTooLarge,
612    /// Protected header or claims JSON was malformed or not an object.
613    MalformedJson,
614    /// Any duplicate JSON object key is rejected, including in nested values.
615    DuplicateJsonKey,
616    /// JSON depth, nodes, members, arrays, or strings exceeded a bound.
617    JsonLimitExceeded,
618    /// The protected header omitted or mistyped a required parameter.
619    MalformedProtectedHeader,
620    /// `alg=none` is never accepted by this policy.
621    UnsecuredAlgorithmUnsupported,
622    /// Only exact `ES256` is supported by V1.
623    AlgorithmUnsupported,
624    /// The closed V1 profile does not process any `crit` member.
625    CriticalHeaderUnsupported,
626    /// The closed V1 profile does not process a `b64` extension member.
627    UnencodedPayloadUnsupported,
628    /// Compressed JOSE content is outside this profile.
629    CompressionUnsupported,
630    /// Nested JWT/JWS content is outside this profile.
631    NestedTokenUnsupported,
632    /// Token-carried remote or embedded key selection is prohibited.
633    KeyMetadataUnsupported,
634    /// The ES256 signature was not exactly 64 decoded bytes.
635    InvalidSignatureLength,
636}
637
638/// Local policy relationship, independent of signature and target behavior.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641pub enum JwtPolicyStatus {
642    /// Parsing failed, so claim policy was not evaluated.
643    NotEvaluated,
644    /// Every selected local claim and time condition was satisfied.
645    Consistent,
646    /// One or more selected local conditions was not satisfied.
647    Inconsistent,
648}
649
650/// Signature relationship to the one explicitly supplied local public key.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum JwtLocalSignatureStatus {
654    /// Parsing failed before a supported signing input was available.
655    NotEvaluated,
656    /// ES256 verification succeeded against the supplied public key.
657    Verified,
658    /// Verification failed against the supplied public key.
659    Invalid,
660}
661
662/// Target behavior state for this transport-free slice.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(rename_all = "snake_case")]
665pub enum JwtTargetAcceptanceStatus {
666    /// No target request or server acceptance check was performed.
667    NotPerformed,
668}
669
670/// Value-free local policy inconsistency.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(rename_all = "snake_case")]
673pub enum JwtPolicyViolation {
674    /// A protected `typ` value required by policy was absent.
675    MissingType,
676    /// The protected `typ` value differed from policy.
677    TypeMismatch,
678    /// A required issuer was absent.
679    MissingIssuer,
680    /// The issuer claim was not a string.
681    InvalidIssuerType,
682    /// The issuer differed from policy.
683    IssuerMismatch,
684    /// A required audience was absent.
685    MissingAudience,
686    /// Audience was neither a string nor an array of strings.
687    InvalidAudienceType,
688    /// No declared audience exactly matched policy.
689    AudienceMismatch,
690    /// One required claim name was absent. The ordinal refers only to the
691    /// caller's policy order and does not expose the name or value.
692    MissingRequiredClaim { ordinal: u8 },
693    /// Expiration was required but absent.
694    MissingExpiration,
695    /// `exp` was not an integral NumericDate representable as `i64`.
696    InvalidExpiration,
697    /// The token was expired at the caller-supplied time and skew.
698    Expired,
699    /// `nbf` was not an integral NumericDate representable as `i64`.
700    InvalidNotBefore,
701    /// The token was not yet valid at the caller-supplied time and skew.
702    NotYetValid,
703}
704
705impl JwtPolicyReviewAudit {
706    /// Stable audit schema used by the report projection.
707    pub const fn schema(&self) -> &'static str {
708        JWT_POLICY_REVIEW_AUDIT_SCHEMA
709    }
710
711    /// Stable local interpretation policy identifier.
712    pub const fn policy(&self) -> &'static str {
713        JWT_POLICY_REVIEW_POLICY_ID
714    }
715
716    /// An audit value exists only after explicit operator selection.
717    pub const fn selected(&self) -> bool {
718        true
719    }
720
721    /// Compact syntax and supported-profile status.
722    pub const fn parsing_status(&self) -> JwtParsingStatus {
723        self.parsing
724    }
725
726    /// Local expected-claim and time-policy status.
727    pub const fn policy_status(&self) -> JwtPolicyStatus {
728        self.policy_status
729    }
730
731    /// Signature relationship to the explicitly supplied local public key.
732    pub const fn local_signature_status(&self) -> JwtLocalSignatureStatus {
733        self.local_signature
734    }
735
736    /// Target acceptance is deliberately outside this transport-free slice.
737    pub const fn target_acceptance_status(&self) -> JwtTargetAcceptanceStatus {
738        self.target_acceptance
739    }
740
741    /// Value-free policy-selection and local-clock facts.
742    pub const fn methodology(&self) -> &JwtPolicyMethodology {
743        &self.methodology
744    }
745
746    /// Explicit external-activity accounting for this evaluator.
747    pub const fn external_activity(&self) -> JwtExternalActivity {
748        self.external_activity
749    }
750
751    /// Bounded value-free local policy violations.
752    pub fn policy_violations(&self) -> &[JwtPolicyViolation] {
753        &self.policy_violations
754    }
755
756    fn rejected(
757        reason: JwtParseRejection,
758        key: &Es256LocalPublicKey,
759        policy: &JwtLocalPolicy,
760        evaluation_time: JwtEvaluationTime,
761    ) -> Self {
762        Self {
763            parsing: JwtParsingStatus::Rejected(reason),
764            policy_status: JwtPolicyStatus::NotEvaluated,
765            local_signature: JwtLocalSignatureStatus::NotEvaluated,
766            target_acceptance: JwtTargetAcceptanceStatus::NotPerformed,
767            methodology: methodology(key, policy, evaluation_time),
768            external_activity: no_external_activity(),
769            policy_violations: Vec::new(),
770            #[cfg(feature = "jwt-target-acceptance-review")]
771            target_preparation_binding: None,
772        }
773    }
774
775    #[cfg(feature = "jwt-target-acceptance-review")]
776    pub(crate) const fn target_preparation_binding(&self) -> Option<&JwtTargetAcceptanceBinding> {
777        self.target_preparation_binding.as_ref()
778    }
779}
780
781impl JwtPolicyMethodology {
782    pub fn operator_policy_reference(&self) -> &str {
783        &self.operator_policy_reference
784    }
785
786    pub fn operator_policy_revision(&self) -> &str {
787        &self.operator_policy_revision
788    }
789
790    pub fn local_public_key_sha256(&self) -> &str {
791        &self.local_public_key_sha256
792    }
793
794    pub const fn expected_type_selected(&self) -> bool {
795        self.expected_type_selected
796    }
797
798    pub const fn expected_issuer_selected(&self) -> bool {
799        self.expected_issuer_selected
800    }
801
802    pub const fn expected_audience_selected(&self) -> bool {
803        self.expected_audience_selected
804    }
805
806    pub const fn required_claim_count(&self) -> u8 {
807        self.required_claim_count
808    }
809
810    pub const fn require_expiration(&self) -> bool {
811        self.require_expiration
812    }
813
814    pub const fn allowed_clock_skew_seconds(&self) -> u32 {
815        self.allowed_clock_skew_seconds
816    }
817
818    pub const fn evaluation_time_unix_seconds(&self) -> i64 {
819        self.evaluation_time_unix_seconds
820    }
821
822    pub const fn clock_assurance(&self) -> JwtClockAssurance {
823        self.clock_assurance
824    }
825}
826
827impl JwtExternalActivity {
828    pub const fn target_request_count(self) -> u8 {
829        self.target_request_count
830    }
831
832    pub const fn remote_key_retrieval(self) -> JwtExternalOperationStatus {
833        self.remote_key_retrieval
834    }
835
836    pub const fn token_forwarding(self) -> JwtExternalOperationStatus {
837        self.token_forwarding
838    }
839}
840
841/// Reviews one compact JWS without any network or filesystem activity.
842pub fn review_compact_jwt(
843    token: &SecretCompactJwt,
844    key: &Es256LocalPublicKey,
845    policy: &JwtLocalPolicy,
846    evaluation_time: JwtEvaluationTime,
847) -> JwtPolicyReviewAudit {
848    match review_parsed(token, key, policy, evaluation_time) {
849        Ok((local_signature, policy_violations)) => JwtPolicyReviewAudit {
850            parsing: JwtParsingStatus::Parsed,
851            policy_status: if policy_violations.is_empty() {
852                JwtPolicyStatus::Consistent
853            } else {
854                JwtPolicyStatus::Inconsistent
855            },
856            local_signature,
857            target_acceptance: JwtTargetAcceptanceStatus::NotPerformed,
858            methodology: methodology(key, policy, evaluation_time),
859            external_activity: no_external_activity(),
860            policy_violations,
861            #[cfg(feature = "jwt-target-acceptance-review")]
862            target_preparation_binding: None,
863        },
864        Err(reason) => JwtPolicyReviewAudit::rejected(reason, key, policy, evaluation_time),
865    }
866}
867
868/// Reviews one compact token locally and, only when every selected local gate
869/// succeeds, derives a sealed invalid-signature control for the explicitly
870/// selected target-acceptance child.
871///
872/// This function performs no I/O. In particular, a malformed, policy-
873/// inconsistent, expired, or locally unverified token can never be forwarded
874/// to the target runtime.
875#[cfg(feature = "jwt-target-acceptance-review")]
876pub fn review_and_prepare_target_acceptance(
877    token: SecretCompactJwt,
878    key: &Es256LocalPublicKey,
879    policy: &JwtLocalPolicy,
880    evaluation_time: JwtEvaluationTime,
881) -> Result<PreparedJwtTargetAcceptance, JwtTargetControlPreparationError> {
882    let mut binding_bytes = [0_u8; 32];
883    SystemRandom::new()
884        .fill(&mut binding_bytes)
885        .map_err(|_| JwtTargetControlPreparationError::PreparationBindingUnavailable)?;
886    let preparation_binding = JwtTargetAcceptanceBinding::from_random_bytes(binding_bytes);
887    let mut local_audit = review_compact_jwt(&token, key, policy, evaluation_time);
888    local_audit.target_preparation_binding = Some(preparation_binding.clone());
889    let eligible = local_audit.parsing_status() == JwtParsingStatus::Parsed
890        && local_audit.policy_status() == JwtPolicyStatus::Consistent
891        && local_audit.local_signature_status() == JwtLocalSignatureStatus::Verified;
892    if !eligible {
893        let not_eligible_reason = match local_audit.parsing_status() {
894            JwtParsingStatus::Rejected(_) => {
895                JwtTargetAcceptanceNotEligibleReason::LocalParsingNotEstablished
896            },
897            JwtParsingStatus::Parsed
898                if local_audit.policy_status() != JwtPolicyStatus::Consistent =>
899            {
900                JwtTargetAcceptanceNotEligibleReason::LocalPolicyNotEstablished
901            },
902            _ => JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished,
903        };
904        return Ok(PreparedJwtTargetAcceptance {
905            local_audit,
906            runtime_input: JwtTargetAcceptanceRuntimeInput {
907                credentials: None,
908                not_eligible_reason: Some(not_eligible_reason),
909                preparation_binding,
910            },
911        });
912    }
913
914    let invalid_signature_control = derive_invalid_signature_control(&token)?;
915    let control_audit =
916        review_compact_jwt(&invalid_signature_control, key, policy, evaluation_time);
917    if control_audit.parsing_status() != JwtParsingStatus::Parsed
918        || control_audit.policy_status() != JwtPolicyStatus::Consistent
919        || control_audit.local_signature_status() != JwtLocalSignatureStatus::Invalid
920    {
921        return Err(JwtTargetControlPreparationError::InvalidControlInvariant);
922    }
923
924    Ok(PreparedJwtTargetAcceptance {
925        local_audit,
926        runtime_input: JwtTargetAcceptanceRuntimeInput {
927            credentials: Some(JwtTargetAcceptanceCredentials {
928                valid: token,
929                invalid_signature_control,
930                preparation_binding: preparation_binding.clone(),
931            }),
932            not_eligible_reason: None,
933            preparation_binding,
934        },
935    })
936}
937
938#[cfg(feature = "jwt-target-acceptance-review")]
939fn derive_invalid_signature_control(
940    token: &SecretCompactJwt,
941) -> Result<SecretCompactJwt, JwtTargetControlPreparationError> {
942    let bytes = token.as_bytes();
943    let segments = bytes.split(|byte| *byte == b'.').collect::<Vec<_>>();
944    if segments.len() != 3
945        || segments.iter().any(|segment| segment.is_empty())
946        || segments
947            .iter()
948            .any(|segment| !is_unpadded_base64url(segment))
949    {
950        return Err(JwtTargetControlPreparationError::InvalidCompactToken);
951    }
952    let mut signature = Zeroizing::new(Vec::new());
953    URL_SAFE_NO_PAD
954        .decode_vec(segments[2], &mut signature)
955        .map_err(|_| JwtTargetControlPreparationError::InvalidCompactToken)?;
956    if signature.len() != ES256_SIGNATURE_BYTES {
957        return Err(JwtTargetControlPreparationError::InvalidCompactToken);
958    }
959    signature[0] ^= 1;
960
961    // The unpadded encoding of 64 bytes is exactly 86 bytes. Encode directly
962    // into guarded storage instead of creating an intermediate `String`.
963    const ENCODED_ES256_SIGNATURE_BYTES: usize = 86;
964    let mut encoded_signature = Zeroizing::new(vec![0_u8; ENCODED_ES256_SIGNATURE_BYTES]);
965    let encoded = URL_SAFE_NO_PAD
966        .encode_slice(signature.as_slice(), encoded_signature.as_mut_slice())
967        .map_err(|_| JwtTargetControlPreparationError::InvalidControlInvariant)?;
968    if encoded != ENCODED_ES256_SIGNATURE_BYTES {
969        return Err(JwtTargetControlPreparationError::InvalidControlInvariant);
970    }
971
972    let length = segments[0]
973        .len()
974        .checked_add(1)
975        .and_then(|value| value.checked_add(segments[1].len()))
976        .and_then(|value| value.checked_add(1))
977        .and_then(|value| value.checked_add(encoded))
978        .ok_or(JwtTargetControlPreparationError::InvalidControlInvariant)?;
979    if length > MAX_COMPACT_JWT_BYTES {
980        return Err(JwtTargetControlPreparationError::InvalidControlInvariant);
981    }
982    let mut control = Zeroizing::new(Vec::with_capacity(length));
983    control.extend_from_slice(segments[0]);
984    control.push(b'.');
985    control.extend_from_slice(segments[1]);
986    control.push(b'.');
987    control.extend_from_slice(&encoded_signature[..encoded]);
988    let control = std::mem::take(&mut *control);
989    SecretCompactJwt::new(control)
990        .map_err(|_| JwtTargetControlPreparationError::InvalidControlInvariant)
991}
992
993fn methodology(
994    key: &Es256LocalPublicKey,
995    policy: &JwtLocalPolicy,
996    evaluation_time: JwtEvaluationTime,
997) -> JwtPolicyMethodology {
998    JwtPolicyMethodology {
999        operator_policy_reference: policy.operator_policy_reference.clone(),
1000        operator_policy_revision: policy.operator_policy_revision.clone(),
1001        local_public_key_sha256: key.public_key_sha256().to_owned(),
1002        expected_type_selected: true,
1003        expected_issuer_selected: true,
1004        expected_audience_selected: true,
1005        required_claim_count: u8::try_from(policy.required_claims.len())
1006            .expect("required-claim ceiling fits u8"),
1007        require_expiration: policy.require_expiration,
1008        allowed_clock_skew_seconds: policy.allowed_clock_skew_seconds,
1009        evaluation_time_unix_seconds: evaluation_time.unix_seconds,
1010        clock_assurance: JwtClockAssurance::LocalSystemClockNotIndependentlyVerified,
1011    }
1012}
1013
1014const fn no_external_activity() -> JwtExternalActivity {
1015    JwtExternalActivity {
1016        target_request_count: 0,
1017        remote_key_retrieval: JwtExternalOperationStatus::NotPerformed,
1018        token_forwarding: JwtExternalOperationStatus::NotPerformed,
1019    }
1020}
1021
1022fn review_parsed(
1023    token: &SecretCompactJwt,
1024    key: &Es256LocalPublicKey,
1025    policy: &JwtLocalPolicy,
1026    evaluation_time: JwtEvaluationTime,
1027) -> Result<(JwtLocalSignatureStatus, Vec<JwtPolicyViolation>), JwtParseRejection> {
1028    let bytes = token.as_bytes();
1029    let segments: Vec<&[u8]> = bytes.split(|byte| *byte == b'.').collect();
1030    if segments.len() == 5 {
1031        return Err(JwtParseRejection::EncryptedTokenUnsupported);
1032    }
1033    if segments.len() != 3 {
1034        return Err(JwtParseRejection::InvalidSegmentCount);
1035    }
1036    if segments.iter().any(|segment| segment.is_empty()) {
1037        return Err(JwtParseRejection::EmptySegment);
1038    }
1039    if segments
1040        .iter()
1041        .any(|segment| !is_unpadded_base64url(segment))
1042    {
1043        return Err(JwtParseRejection::InvalidBase64Url);
1044    }
1045
1046    let header_bytes = decode_segment(segments[0], MAX_JWT_HEADER_BYTES)?;
1047    let claims_bytes = decode_segment(segments[1], MAX_JWT_CLAIMS_BYTES)?;
1048    let signature = decode_segment(segments[2], ES256_SIGNATURE_BYTES)?;
1049    if signature.len() != ES256_SIGNATURE_BYTES {
1050        return Err(JwtParseRejection::InvalidSignatureLength);
1051    }
1052
1053    let header = parse_private_json(header_bytes.as_slice()).map_err(map_json_failure)?;
1054    let claims = parse_private_json(claims_bytes.as_slice()).map_err(map_json_failure)?;
1055    if header.as_object().is_none() || claims.as_object().is_none() {
1056        return Err(JwtParseRejection::MalformedJson);
1057    }
1058    validate_protected_header(&header)?;
1059
1060    let signing_input_length = segments[0]
1061        .len()
1062        .checked_add(1)
1063        .and_then(|length| length.checked_add(segments[1].len()))
1064        .ok_or(JwtParseRejection::InvalidSegmentCount)?;
1065    let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, key.as_sec1_bytes());
1066    let local_signature = if verifier
1067        .verify(&bytes[..signing_input_length], signature.as_slice())
1068        .is_ok()
1069    {
1070        JwtLocalSignatureStatus::Verified
1071    } else {
1072        JwtLocalSignatureStatus::Invalid
1073    };
1074    let violations = evaluate_policy(&header, &claims, policy, evaluation_time);
1075    Ok((local_signature, violations))
1076}
1077
1078fn is_unpadded_base64url(value: &[u8]) -> bool {
1079    !value.is_empty()
1080        && value
1081            .iter()
1082            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1083}
1084
1085fn decode_segment(segment: &[u8], maximum: usize) -> Result<Zeroizing<Vec<u8>>, JwtParseRejection> {
1086    // `decode_vec` writes into an already guarded owner, so malformed or
1087    // non-canonical input cannot drop a partially decoded secret allocation
1088    // outside `Zeroizing` on the error path.
1089    let mut decoded = Zeroizing::new(Vec::new());
1090    URL_SAFE_NO_PAD
1091        .decode_vec(segment, &mut decoded)
1092        .map_err(|_| JwtParseRejection::InvalidBase64Url)?;
1093    if decoded.len() > maximum {
1094        return Err(JwtParseRejection::DecodedSegmentTooLarge);
1095    }
1096    let canonical = Zeroizing::new(URL_SAFE_NO_PAD.encode(decoded.as_slice()));
1097    if canonical.as_bytes() != segment {
1098        return Err(JwtParseRejection::InvalidBase64Url);
1099    }
1100    Ok(decoded)
1101}
1102
1103fn map_json_failure(failure: JsonFailure) -> JwtParseRejection {
1104    match failure {
1105        JsonFailure::Malformed => JwtParseRejection::MalformedJson,
1106        JsonFailure::DuplicateKey => JwtParseRejection::DuplicateJsonKey,
1107        JsonFailure::LimitExceeded => JwtParseRejection::JsonLimitExceeded,
1108    }
1109}
1110
1111fn validate_protected_header(header: &PrivateJsonValue) -> Result<(), JwtParseRejection> {
1112    match header.string_member("alg") {
1113        Some("ES256") => {},
1114        Some("none") => return Err(JwtParseRejection::UnsecuredAlgorithmUnsupported),
1115        Some(_) => return Err(JwtParseRejection::AlgorithmUnsupported),
1116        None => return Err(JwtParseRejection::MalformedProtectedHeader),
1117    }
1118    if header.object_member("enc").is_some() {
1119        return Err(JwtParseRejection::EncryptedTokenUnsupported);
1120    }
1121    if header.object_member("zip").is_some() {
1122        return Err(JwtParseRejection::CompressionUnsupported);
1123    }
1124    if ["jku", "x5u", "jwk", "x5c"]
1125        .iter()
1126        .any(|name| header.object_member(name).is_some())
1127    {
1128        return Err(JwtParseRejection::KeyMetadataUnsupported);
1129    }
1130    if let Some(content_type) = header.object_member("cty") {
1131        let content_type = content_type
1132            .as_str()
1133            .ok_or(JwtParseRejection::MalformedProtectedHeader)?;
1134        if content_type.eq_ignore_ascii_case("jwt")
1135            || content_type.eq_ignore_ascii_case("application/jwt")
1136        {
1137            return Err(JwtParseRejection::NestedTokenUnsupported);
1138        }
1139    }
1140    if header.object_member("b64").is_some() {
1141        return Err(JwtParseRejection::UnencodedPayloadUnsupported);
1142    }
1143    if header.object_member("crit").is_some() {
1144        return Err(JwtParseRejection::CriticalHeaderUnsupported);
1145    }
1146    if let Some(token_type) = header.object_member("typ") {
1147        if token_type.as_str().is_none() {
1148            return Err(JwtParseRejection::MalformedProtectedHeader);
1149        }
1150    }
1151    Ok(())
1152}
1153
1154fn evaluate_policy(
1155    header: &PrivateJsonValue,
1156    claims: &PrivateJsonValue,
1157    policy: &JwtLocalPolicy,
1158    evaluation_time: JwtEvaluationTime,
1159) -> Vec<JwtPolicyViolation> {
1160    let mut violations = Vec::new();
1161    match header.string_member("typ") {
1162        None => violations.push(JwtPolicyViolation::MissingType),
1163        Some(actual) if actual != policy.expected_type.as_str() => {
1164            violations.push(JwtPolicyViolation::TypeMismatch)
1165        },
1166        Some(_) => {},
1167    }
1168    match claims.object_member("iss") {
1169        None => violations.push(JwtPolicyViolation::MissingIssuer),
1170        Some(PrivateJsonValue::String(actual))
1171            if actual.as_str() == policy.expected_issuer.as_str() => {},
1172        Some(PrivateJsonValue::String(_)) => violations.push(JwtPolicyViolation::IssuerMismatch),
1173        Some(_) => violations.push(JwtPolicyViolation::InvalidIssuerType),
1174    }
1175    match claims.object_member("aud") {
1176        None => violations.push(JwtPolicyViolation::MissingAudience),
1177        Some(PrivateJsonValue::String(actual))
1178            if actual.as_str() == policy.expected_audience.as_str() => {},
1179        Some(PrivateJsonValue::String(_)) => {
1180            violations.push(JwtPolicyViolation::AudienceMismatch)
1181        }
1182        Some(PrivateJsonValue::Array(values)) => {
1183            if values.iter().any(|value| {
1184                matches!(value, PrivateJsonValue::String(actual) if actual.as_str() == policy.expected_audience.as_str())
1185            }) {
1186                if values
1187                    .iter()
1188                    .any(|value| !matches!(value, PrivateJsonValue::String(_)))
1189                {
1190                    violations.push(JwtPolicyViolation::InvalidAudienceType);
1191                }
1192            } else if values
1193                .iter()
1194                .any(|value| !matches!(value, PrivateJsonValue::String(_)))
1195            {
1196                violations.push(JwtPolicyViolation::InvalidAudienceType);
1197            } else {
1198                violations.push(JwtPolicyViolation::AudienceMismatch);
1199            }
1200        }
1201        Some(_) => violations.push(JwtPolicyViolation::InvalidAudienceType),
1202    }
1203    for (ordinal, required) in policy.required_claims.iter().enumerate() {
1204        if claims.object_member(required.as_str()).is_none() {
1205            violations.push(JwtPolicyViolation::MissingRequiredClaim {
1206                ordinal: u8::try_from(ordinal).expect("required-claim ceiling fits u8"),
1207            });
1208        }
1209    }
1210
1211    match claims.object_member("exp") {
1212        None if policy.require_expiration => violations.push(JwtPolicyViolation::MissingExpiration),
1213        None => {},
1214        Some(value) => match value.numeric_date() {
1215            Some(expiration)
1216                if evaluation_time.unix_seconds
1217                    >= expiration.saturating_add(i64::from(policy.allowed_clock_skew_seconds)) =>
1218            {
1219                violations.push(JwtPolicyViolation::Expired);
1220            },
1221            Some(_) => {},
1222            None => violations.push(JwtPolicyViolation::InvalidExpiration),
1223        },
1224    }
1225    if let Some(value) = claims.object_member("nbf") {
1226        match value.numeric_date() {
1227            Some(not_before)
1228                if evaluation_time
1229                    .unix_seconds
1230                    .saturating_add(i64::from(policy.allowed_clock_skew_seconds))
1231                    < not_before =>
1232            {
1233                violations.push(JwtPolicyViolation::NotYetValid);
1234            },
1235            Some(_) => {},
1236            None => violations.push(JwtPolicyViolation::InvalidNotBefore),
1237        }
1238    }
1239    violations
1240}
1241
1242enum PrivateJsonValue {
1243    Null,
1244    Bool,
1245    I64(i64),
1246    U64(u64),
1247    F64,
1248    String(Zeroizing<String>),
1249    Array(Vec<Self>),
1250    Object(Vec<(Zeroizing<String>, Self)>),
1251}
1252
1253impl PrivateJsonValue {
1254    fn as_object(&self) -> Option<&[(Zeroizing<String>, Self)]> {
1255        match self {
1256            Self::Object(entries) => Some(entries),
1257            _ => None,
1258        }
1259    }
1260
1261    fn as_str(&self) -> Option<&str> {
1262        match self {
1263            Self::String(value) => Some(value.as_str()),
1264            _ => None,
1265        }
1266    }
1267
1268    fn object_member(&self, name: &str) -> Option<&Self> {
1269        self.as_object()?.iter().find_map(|(key, value)| {
1270            if key.as_str() == name {
1271                Some(value)
1272            } else {
1273                None
1274            }
1275        })
1276    }
1277
1278    fn string_member(&self, name: &str) -> Option<&str> {
1279        self.object_member(name)?.as_str()
1280    }
1281
1282    fn numeric_date(&self) -> Option<i64> {
1283        match self {
1284            Self::I64(value) => Some(*value),
1285            Self::U64(value) => i64::try_from(*value).ok(),
1286            _ => None,
1287        }
1288    }
1289}
1290
1291#[derive(Default)]
1292struct JsonBudget {
1293    nodes: usize,
1294    members: usize,
1295}
1296
1297impl JsonBudget {
1298    fn enter<E: serde::de::Error>(&mut self, depth: usize) -> Result<(), E> {
1299        if depth > MAX_JSON_DEPTH || self.nodes >= MAX_JSON_NODES {
1300            return Err(E::custom(JSON_LIMIT_SENTINEL));
1301        }
1302        self.nodes += 1;
1303        Ok(())
1304    }
1305
1306    fn member<E: serde::de::Error>(&mut self) -> Result<(), E> {
1307        if self.members >= MAX_JSON_MEMBERS {
1308            return Err(E::custom(JSON_LIMIT_SENTINEL));
1309        }
1310        self.members += 1;
1311        Ok(())
1312    }
1313}
1314
1315struct PrivateJsonSeed<'a> {
1316    budget: &'a mut JsonBudget,
1317    depth: usize,
1318}
1319
1320impl<'de> DeserializeSeed<'de> for PrivateJsonSeed<'_> {
1321    type Value = PrivateJsonValue;
1322
1323    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1324    where
1325        D: Deserializer<'de>,
1326    {
1327        self.budget.enter::<D::Error>(self.depth)?;
1328        deserializer.deserialize_any(PrivateJsonVisitor {
1329            budget: self.budget,
1330            depth: self.depth,
1331        })
1332    }
1333}
1334
1335struct PrivateJsonVisitor<'a> {
1336    budget: &'a mut JsonBudget,
1337    depth: usize,
1338}
1339
1340impl<'de> Visitor<'de> for PrivateJsonVisitor<'_> {
1341    type Value = PrivateJsonValue;
1342
1343    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1344        formatter.write_str("bounded JSON")
1345    }
1346
1347    fn visit_unit<E>(self) -> Result<Self::Value, E> {
1348        Ok(PrivateJsonValue::Null)
1349    }
1350
1351    fn visit_none<E>(self) -> Result<Self::Value, E> {
1352        Ok(PrivateJsonValue::Null)
1353    }
1354
1355    fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
1356        Ok(PrivateJsonValue::Bool)
1357    }
1358
1359    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1360        Ok(PrivateJsonValue::I64(value))
1361    }
1362
1363    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1364        Ok(PrivateJsonValue::U64(value))
1365    }
1366
1367    fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
1368        Ok(PrivateJsonValue::F64)
1369    }
1370
1371    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1372    where
1373        E: serde::de::Error,
1374    {
1375        if value.len() > MAX_JSON_STRING_BYTES {
1376            return Err(E::custom(JSON_LIMIT_SENTINEL));
1377        }
1378        Ok(PrivateJsonValue::String(Zeroizing::new(value.to_owned())))
1379    }
1380
1381    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
1382    where
1383        E: serde::de::Error,
1384    {
1385        let value = Zeroizing::new(value);
1386        if value.len() > MAX_JSON_STRING_BYTES {
1387            return Err(E::custom(JSON_LIMIT_SENTINEL));
1388        }
1389        Ok(PrivateJsonValue::String(value))
1390    }
1391
1392    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1393    where
1394        A: SeqAccess<'de>,
1395    {
1396        let mut values = Vec::new();
1397        while let Some(value) = sequence.next_element_seed(PrivateJsonSeed {
1398            budget: self.budget,
1399            depth: self.depth + 1,
1400        })? {
1401            if values.len() >= MAX_JSON_ARRAY_ITEMS {
1402                return Err(A::Error::custom(JSON_LIMIT_SENTINEL));
1403            }
1404            values.push(value);
1405        }
1406        Ok(PrivateJsonValue::Array(values))
1407    }
1408
1409    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1410    where
1411        A: MapAccess<'de>,
1412    {
1413        let mut entries: Vec<(Zeroizing<String>, PrivateJsonValue)> = Vec::new();
1414        while let Some(key) = map.next_key::<String>()? {
1415            let key = Zeroizing::new(key);
1416            if key.len() > MAX_JSON_STRING_BYTES {
1417                return Err(A::Error::custom(JSON_LIMIT_SENTINEL));
1418            }
1419            if entries
1420                .iter()
1421                .any(|(known, _)| known.as_str() == key.as_str())
1422            {
1423                return Err(A::Error::custom(JSON_DUPLICATE_SENTINEL));
1424            }
1425            self.budget.member::<A::Error>()?;
1426            let value = map.next_value_seed(PrivateJsonSeed {
1427                budget: self.budget,
1428                depth: self.depth + 1,
1429            })?;
1430            entries.push((key, value));
1431        }
1432        Ok(PrivateJsonValue::Object(entries))
1433    }
1434}
1435
1436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1437enum JsonFailure {
1438    Malformed,
1439    DuplicateKey,
1440    LimitExceeded,
1441}
1442
1443fn parse_private_json(bytes: &[u8]) -> Result<PrivateJsonValue, JsonFailure> {
1444    let mut budget = JsonBudget::default();
1445    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
1446    let parsed = PrivateJsonSeed {
1447        budget: &mut budget,
1448        depth: 0,
1449    }
1450    .deserialize(&mut deserializer)
1451    .map_err(classify_json_error)?;
1452    deserializer.end().map_err(classify_json_error)?;
1453    Ok(parsed)
1454}
1455
1456fn classify_json_error(error: serde_json::Error) -> JsonFailure {
1457    let message = error.to_string();
1458    if message.contains(JSON_DUPLICATE_SENTINEL) {
1459        JsonFailure::DuplicateKey
1460    } else if message.contains(JSON_LIMIT_SENTINEL) {
1461        JsonFailure::LimitExceeded
1462    } else {
1463        JsonFailure::Malformed
1464    }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470
1471    const RFC7515_PUBLIC_JWK: &[u8] = br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}"#;
1472    const RFC7515_ES256_JWS: &str = concat!(
1473        "eyJhbGciOiJFUzI1NiJ9.",
1474        "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt",
1475        "cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.",
1476        "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSA",
1477        "pmWQxfKTUJqPP3-Kg6NU1Q"
1478    );
1479
1480    fn key() -> Es256LocalPublicKey {
1481        Es256LocalPublicKey::from_jwk_json(RFC7515_PUBLIC_JWK.to_vec()).expect("RFC public key")
1482    }
1483
1484    fn rfc_policy() -> JwtLocalPolicy {
1485        JwtLocalPolicy::new(
1486            ("rfc7515-example", "rfc7515-example-v1"),
1487            "JWT",
1488            "joe",
1489            "termivar-fixture",
1490            &["http://example.com/is_root"],
1491            true,
1492            0,
1493        )
1494        .expect("policy")
1495    }
1496
1497    fn review_literal(token: &str, policy: &JwtLocalPolicy, now: i64) -> JwtPolicyReviewAudit {
1498        let token = SecretCompactJwt::new(token.as_bytes().to_vec()).expect("bounded token");
1499        review_compact_jwt(
1500            &token,
1501            &key(),
1502            policy,
1503            JwtEvaluationTime { unix_seconds: now },
1504        )
1505    }
1506
1507    fn unsigned_test_token(header: &str, claims: &str) -> String {
1508        let signature = [0_u8; ES256_SIGNATURE_BYTES];
1509        format!(
1510            "{}.{}.{}",
1511            URL_SAFE_NO_PAD.encode(header.as_bytes()),
1512            URL_SAFE_NO_PAD.encode(claims.as_bytes()),
1513            URL_SAFE_NO_PAD.encode(signature)
1514        )
1515    }
1516
1517    #[cfg(feature = "jwt-target-acceptance-review")]
1518    fn locally_verified_target_fixture() -> (SecretCompactJwt, Es256LocalPublicKey, JwtLocalPolicy)
1519    {
1520        use ring::{
1521            rand::SystemRandom,
1522            signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING},
1523        };
1524
1525        let rng = SystemRandom::new();
1526        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1527            .expect("generate task-owned key");
1528        let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1529            .expect("parse task-owned key");
1530        let point = pair.public_key().as_ref();
1531        let public_jwk = format!(
1532            concat!(
1533                "{{\"kty\":\"EC\",\"crv\":\"P-256\",\"alg\":\"ES256\",",
1534                "\"x\":\"{}\",\"y\":\"{}\"}}"
1535            ),
1536            URL_SAFE_NO_PAD.encode(&point[1..33]),
1537            URL_SAFE_NO_PAD.encode(&point[33..65]),
1538        );
1539        let key = Es256LocalPublicKey::from_jwk_json(public_jwk.into_bytes())
1540            .expect("task-owned public key");
1541        let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT"}"#);
1542        let claims = URL_SAFE_NO_PAD
1543            .encode(br#"{"iss":"owned-issuer","aud":"owned-api","scope":"read","exp":4000000000}"#);
1544        let signing_input = format!("{header}.{claims}");
1545        let signature = pair
1546            .sign(&rng, signing_input.as_bytes())
1547            .expect("sign task-owned token");
1548        let token = SecretCompactJwt::new(
1549            format!(
1550                "{signing_input}.{}",
1551                URL_SAFE_NO_PAD.encode(signature.as_ref())
1552            )
1553            .into_bytes(),
1554        )
1555        .expect("bounded token");
1556        let policy = JwtLocalPolicy::new(
1557            ("owned-target-policy", "owned-target-policy-v1"),
1558            "JWT",
1559            "owned-issuer",
1560            "owned-api",
1561            &["scope"],
1562            true,
1563            0,
1564        )
1565        .expect("policy");
1566        (token, key, policy)
1567    }
1568
1569    #[cfg(feature = "jwt-target-acceptance-review")]
1570    #[test]
1571    fn target_handoff_is_sealed_until_all_local_gates_pass() {
1572        let (token, key, policy) = locally_verified_target_fixture();
1573        let prepared = review_and_prepare_target_acceptance(
1574            token,
1575            &key,
1576            &policy,
1577            JwtEvaluationTime {
1578                unix_seconds: 1_900_000_000,
1579            },
1580        )
1581        .expect("prepare control");
1582        let (audit, runtime_input) = prepared.into_parts();
1583        let (credentials, not_eligible_reason, preparation_binding) = runtime_input.into_parts();
1584        assert_eq!(audit.parsing_status(), JwtParsingStatus::Parsed);
1585        assert_eq!(audit.policy_status(), JwtPolicyStatus::Consistent);
1586        assert_eq!(
1587            audit.local_signature_status(),
1588            JwtLocalSignatureStatus::Verified
1589        );
1590        let credentials = credentials.expect("eligible handoff");
1591        assert_eq!(not_eligible_reason, None);
1592        assert_eq!(credentials.preparation_binding(), &preparation_binding);
1593        assert_eq!(
1594            audit.target_preparation_binding(),
1595            Some(&preparation_binding)
1596        );
1597        assert_ne!(
1598            credentials.valid_bytes(),
1599            credentials.invalid_signature_control_bytes()
1600        );
1601        let valid_parts = credentials
1602            .valid_bytes()
1603            .split(|byte| *byte == b'.')
1604            .collect::<Vec<_>>();
1605        let invalid_parts = credentials
1606            .invalid_signature_control_bytes()
1607            .split(|byte| *byte == b'.')
1608            .collect::<Vec<_>>();
1609        assert_eq!(valid_parts.len(), 3);
1610        assert_eq!(invalid_parts.len(), 3);
1611        assert_eq!(valid_parts[0], invalid_parts[0]);
1612        assert_eq!(valid_parts[1], invalid_parts[1]);
1613        let valid_signature = URL_SAFE_NO_PAD
1614            .decode(valid_parts[2])
1615            .expect("decode valid signature independently");
1616        let invalid_signature = URL_SAFE_NO_PAD
1617            .decode(invalid_parts[2])
1618            .expect("decode invalid signature independently");
1619        assert_eq!(valid_signature.len(), ES256_SIGNATURE_BYTES);
1620        assert_eq!(invalid_signature.len(), ES256_SIGNATURE_BYTES);
1621        assert_eq!(
1622            valid_signature
1623                .iter()
1624                .zip(&invalid_signature)
1625                .map(|(valid, invalid)| (valid ^ invalid).count_ones())
1626                .sum::<u32>(),
1627            1,
1628            "the control changes exactly one signature bit"
1629        );
1630        let mut signing_input = Vec::new();
1631        signing_input.extend_from_slice(valid_parts[0]);
1632        signing_input.push(b'.');
1633        signing_input.extend_from_slice(valid_parts[1]);
1634        let verifier =
1635            UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, key.sec1_public_point.as_slice());
1636        assert!(verifier.verify(&signing_input, &valid_signature).is_ok());
1637        assert!(verifier.verify(&signing_input, &invalid_signature).is_err());
1638        let invalid = SecretCompactJwt::new(credentials.invalid_signature_control_bytes().to_vec())
1639            .expect("bounded invalid control");
1640        let control = review_compact_jwt(
1641            &invalid,
1642            &key,
1643            &policy,
1644            JwtEvaluationTime {
1645                unix_seconds: 1_900_000_000,
1646            },
1647        );
1648        assert_eq!(control.parsing_status(), JwtParsingStatus::Parsed);
1649        assert_eq!(control.policy_status(), JwtPolicyStatus::Consistent);
1650        assert_eq!(
1651            control.local_signature_status(),
1652            JwtLocalSignatureStatus::Invalid
1653        );
1654
1655        let (token, key, _) = locally_verified_target_fixture();
1656        let mismatched = JwtLocalPolicy::new(
1657            ("owned-target-policy", "owned-target-policy-v2"),
1658            "JWT",
1659            "other-issuer",
1660            "owned-api",
1661            &["scope"],
1662            true,
1663            0,
1664        )
1665        .expect("mismatched policy");
1666        let prepared = review_and_prepare_target_acceptance(
1667            token,
1668            &key,
1669            &mismatched,
1670            JwtEvaluationTime {
1671                unix_seconds: 1_900_000_000,
1672            },
1673        )
1674        .expect("local mismatch remains an audit outcome");
1675        let (audit, runtime_input) = prepared.into_parts();
1676        let (credentials, not_eligible_reason, preparation_binding) = runtime_input.into_parts();
1677        assert_eq!(audit.policy_status(), JwtPolicyStatus::Inconsistent);
1678        assert!(credentials.is_none());
1679        assert_eq!(
1680            not_eligible_reason,
1681            Some(JwtTargetAcceptanceNotEligibleReason::LocalPolicyNotEstablished)
1682        );
1683        assert_eq!(
1684            audit.target_preparation_binding(),
1685            Some(&preparation_binding)
1686        );
1687    }
1688
1689    #[test]
1690    fn rfc7515_es256_vector_separates_all_four_states() {
1691        let audit = review_literal(RFC7515_ES256_JWS, &rfc_policy(), 1_300_819_300);
1692        assert_eq!(audit.parsing, JwtParsingStatus::Parsed);
1693        assert_eq!(audit.policy_status, JwtPolicyStatus::Inconsistent);
1694        assert_eq!(audit.local_signature, JwtLocalSignatureStatus::Verified);
1695        assert_eq!(
1696            audit.target_acceptance,
1697            JwtTargetAcceptanceStatus::NotPerformed
1698        );
1699        assert_eq!(
1700            audit.methodology,
1701            JwtPolicyMethodology {
1702                operator_policy_reference: "rfc7515-example".to_owned(),
1703                operator_policy_revision: "rfc7515-example-v1".to_owned(),
1704                local_public_key_sha256:
1705                    "sha256:dcd2446ca98830c843c493a672364ba971d674fbef5ce87d697165b31e90300a"
1706                        .to_owned(),
1707                expected_type_selected: true,
1708                expected_issuer_selected: true,
1709                expected_audience_selected: true,
1710                required_claim_count: 1,
1711                require_expiration: true,
1712                allowed_clock_skew_seconds: 0,
1713                evaluation_time_unix_seconds: 1_300_819_300,
1714                clock_assurance: JwtClockAssurance::LocalSystemClockNotIndependentlyVerified,
1715            }
1716        );
1717        assert_eq!(audit.external_activity, no_external_activity());
1718        assert_eq!(
1719            audit.methodology.local_public_key_sha256,
1720            "sha256:dcd2446ca98830c843c493a672364ba971d674fbef5ce87d697165b31e90300a"
1721        );
1722        assert_eq!(
1723            audit.policy_violations,
1724            vec![
1725                JwtPolicyViolation::MissingType,
1726                JwtPolicyViolation::MissingAudience,
1727            ]
1728        );
1729    }
1730
1731    #[test]
1732    fn wrong_public_key_does_not_change_parsing_or_policy() {
1733        let wrong_jwk = br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEQ","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}"#;
1734        let wrong_key =
1735            Es256LocalPublicKey::from_jwk_json(wrong_jwk.to_vec()).expect("bounded public JWK");
1736        let token = SecretCompactJwt::new(RFC7515_ES256_JWS.as_bytes().to_vec()).expect("token");
1737        let audit = review_compact_jwt(
1738            &token,
1739            &wrong_key,
1740            &rfc_policy(),
1741            JwtEvaluationTime {
1742                unix_seconds: 1_300_819_300,
1743            },
1744        );
1745        assert_eq!(audit.parsing, JwtParsingStatus::Parsed);
1746        assert_eq!(audit.policy_status, JwtPolicyStatus::Inconsistent);
1747        assert_eq!(audit.local_signature, JwtLocalSignatureStatus::Invalid);
1748    }
1749
1750    #[test]
1751    fn malformed_duplicate_and_wrong_compact_shapes_are_rejected() {
1752        let policy = rfc_policy();
1753        let malformed = unsigned_test_token("not-json", r#"{"iss":"joe"}"#);
1754        assert_eq!(
1755            review_literal(&malformed, &policy, 0).parsing,
1756            JwtParsingStatus::Rejected(JwtParseRejection::MalformedJson)
1757        );
1758        let duplicate_header = unsigned_test_token(
1759            r#"{"alg":"ES256","alg":"ES256"}"#,
1760            r#"{"iss":"joe","exp":2000000000,"http://example.com/is_root":true}"#,
1761        );
1762        assert_eq!(
1763            review_literal(&duplicate_header, &policy, 0).parsing,
1764            JwtParsingStatus::Rejected(JwtParseRejection::DuplicateJsonKey)
1765        );
1766        let duplicate_claim = unsigned_test_token(
1767            r#"{"alg":"ES256"}"#,
1768            r#"{"iss":"joe","iss":"joe","exp":2000000000}"#,
1769        );
1770        assert_eq!(
1771            review_literal(&duplicate_claim, &policy, 0).parsing,
1772            JwtParsingStatus::Rejected(JwtParseRejection::DuplicateJsonKey)
1773        );
1774        assert_eq!(
1775            review_literal("a.b.c.d.e", &policy, 0).parsing,
1776            JwtParsingStatus::Rejected(JwtParseRejection::EncryptedTokenUnsupported)
1777        );
1778        assert_eq!(
1779            review_literal("a.b", &policy, 0).parsing,
1780            JwtParsingStatus::Rejected(JwtParseRejection::InvalidSegmentCount)
1781        );
1782    }
1783
1784    #[test]
1785    fn issuer_audience_and_type_checks_use_exact_values() {
1786        let token = unsigned_test_token(
1787            r#"{"alg":"ES256","typ":"JWT"}"#,
1788            r#"{"iss":"issuer-b","aud":["api-b","api-c"],"exp":2000000000,"scope":"read"}"#,
1789        );
1790        let policy = JwtLocalPolicy::new(
1791            ("exact-claim-policy", "exact-claim-policy-v1"),
1792            "at+jwt",
1793            "issuer-a",
1794            "api-a",
1795            &["scope", "tenant"],
1796            true,
1797            0,
1798        )
1799        .expect("policy");
1800        let audit = review_literal(&token, &policy, 1_900_000_000);
1801        assert_eq!(audit.parsing, JwtParsingStatus::Parsed);
1802        assert_eq!(audit.policy_status, JwtPolicyStatus::Inconsistent);
1803        assert_eq!(audit.local_signature, JwtLocalSignatureStatus::Invalid);
1804        assert_eq!(
1805            audit.policy_violations,
1806            vec![
1807                JwtPolicyViolation::TypeMismatch,
1808                JwtPolicyViolation::IssuerMismatch,
1809                JwtPolicyViolation::AudienceMismatch,
1810                JwtPolicyViolation::MissingRequiredClaim { ordinal: 1 },
1811            ]
1812        );
1813    }
1814
1815    #[test]
1816    fn expiration_not_before_and_skew_are_integral_and_bounded() {
1817        let token = unsigned_test_token(
1818            r#"{"alg":"ES256","typ":"JWT"}"#,
1819            r#"{"iss":"issuer","aud":"audience","exp":100,"nbf":110}"#,
1820        );
1821        let no_skew = JwtLocalPolicy::new(
1822            ("no-skew", "no-skew-v1"),
1823            "JWT",
1824            "issuer",
1825            "audience",
1826            &[],
1827            true,
1828            0,
1829        )
1830        .expect("policy");
1831        assert_eq!(
1832            review_literal(&token, &no_skew, 100).policy_violations,
1833            vec![JwtPolicyViolation::Expired, JwtPolicyViolation::NotYetValid]
1834        );
1835        let with_skew = JwtLocalPolicy::new(
1836            ("with-skew", "with-skew-v1"),
1837            "JWT",
1838            "issuer",
1839            "audience",
1840            &[],
1841            true,
1842            11,
1843        )
1844        .expect("policy");
1845        assert_eq!(
1846            review_literal(&token, &with_skew, 100).policy_status,
1847            JwtPolicyStatus::Consistent
1848        );
1849        let invalid = unsigned_test_token(
1850            r#"{"alg":"ES256","typ":"JWT"}"#,
1851            r#"{"iss":"issuer","aud":"audience","exp":100.5,"nbf":true}"#,
1852        );
1853        assert_eq!(
1854            review_literal(&invalid, &no_skew, 0).policy_violations,
1855            vec![
1856                JwtPolicyViolation::InvalidExpiration,
1857                JwtPolicyViolation::InvalidNotBefore,
1858            ]
1859        );
1860    }
1861
1862    #[test]
1863    fn unsupported_jose_forms_are_rejected_without_key_authority() {
1864        let cases = [
1865            (
1866                r#"{"alg":"none"}"#,
1867                JwtParseRejection::UnsecuredAlgorithmUnsupported,
1868            ),
1869            (
1870                r#"{"alg":"HS256"}"#,
1871                JwtParseRejection::AlgorithmUnsupported,
1872            ),
1873            (
1874                r#"{"alg":"ES256","crit":["unknown"]}"#,
1875                JwtParseRejection::CriticalHeaderUnsupported,
1876            ),
1877            (
1878                r#"{"alg":"ES256","b64":false,"crit":["b64"]}"#,
1879                JwtParseRejection::UnencodedPayloadUnsupported,
1880            ),
1881            (
1882                r#"{"alg":"ES256","b64":true}"#,
1883                JwtParseRejection::UnencodedPayloadUnsupported,
1884            ),
1885            (
1886                r#"{"alg":"ES256","zip":"DEF"}"#,
1887                JwtParseRejection::CompressionUnsupported,
1888            ),
1889            (
1890                r#"{"alg":"ES256","cty":"JWT"}"#,
1891                JwtParseRejection::NestedTokenUnsupported,
1892            ),
1893            (
1894                r#"{"alg":"ES256","jku":"https://keys.invalid/jwks"}"#,
1895                JwtParseRejection::KeyMetadataUnsupported,
1896            ),
1897            (
1898                r#"{"alg":"ES256","x5u":"https://keys.invalid/cert"}"#,
1899                JwtParseRejection::KeyMetadataUnsupported,
1900            ),
1901            (
1902                r#"{"alg":"ES256","jwk":{"kty":"EC"}}"#,
1903                JwtParseRejection::KeyMetadataUnsupported,
1904            ),
1905            (
1906                r#"{"alg":"ES256","x5c":[]}"#,
1907                JwtParseRejection::KeyMetadataUnsupported,
1908            ),
1909        ];
1910        for (header, expected) in cases {
1911            let token = unsigned_test_token(header, r#"{}"#);
1912            assert_eq!(
1913                review_literal(&token, &rfc_policy(), 0).parsing,
1914                JwtParsingStatus::Rejected(expected)
1915            );
1916        }
1917    }
1918
1919    #[test]
1920    fn token_key_id_is_inert_under_the_single_explicit_local_key_policy() {
1921        let without_key_id = unsigned_test_token(r#"{"alg":"ES256"}"#, r#"{}"#);
1922        let with_hostile_key_id = unsigned_test_token(
1923            r#"{"alg":"ES256","kid":"../../untrusted-key?source=remote"}"#,
1924            r#"{}"#,
1925        );
1926        let baseline = review_literal(&without_key_id, &rfc_policy(), 0);
1927        let key_id = review_literal(&with_hostile_key_id, &rfc_policy(), 0);
1928        assert_eq!(baseline.parsing, JwtParsingStatus::Parsed);
1929        assert_eq!(key_id.parsing, JwtParsingStatus::Parsed);
1930        assert_eq!(key_id.policy_status, baseline.policy_status);
1931        assert_eq!(key_id.local_signature, baseline.local_signature);
1932        assert_eq!(key_id.external_activity, no_external_activity());
1933        assert_eq!(
1934            key_id.target_acceptance,
1935            JwtTargetAcceptanceStatus::NotPerformed
1936        );
1937    }
1938
1939    #[test]
1940    fn local_jwk_is_a_strict_public_es256_subset() {
1941        let x = "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU";
1942        let y = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0";
1943        for (document, expected) in [
1944            (
1945                format!(r#"{{"kty":"RSA","crv":"P-256","alg":"ES256","x":"{x}","y":"{y}"}}"#),
1946                Es256PublicJwkError::UnsupportedKey,
1947            ),
1948            (
1949                format!(r#"{{"kty":"EC","crv":"P-384","alg":"ES256","x":"{x}","y":"{y}"}}"#),
1950                Es256PublicJwkError::UnsupportedKey,
1951            ),
1952            (
1953                format!(r#"{{"kty":"EC","crv":"P-256","x":"{x}","y":"{y}"}}"#),
1954                Es256PublicJwkError::MissingMember,
1955            ),
1956            (
1957                format!(
1958                    r#"{{"kty":"EC","crv":"P-256","alg":"ES256","x":"{x}","x":"{x}","y":"{y}"}}"#
1959                ),
1960                Es256PublicJwkError::DuplicateKey,
1961            ),
1962            (
1963                format!(
1964                    r#"{{"kty":"EC","crv":"P-256","alg":"ES256","x":"{x}","y":"{y}","kid":"ignored-nowhere"}}"#
1965                ),
1966                Es256PublicJwkError::UnsupportedMember,
1967            ),
1968        ] {
1969            assert_eq!(
1970                Es256LocalPublicKey::from_jwk_json(document.into_bytes()).err(),
1971                Some(expected)
1972            );
1973        }
1974        assert_eq!(
1975            Es256LocalPublicKey::from_jwk_json(
1976                br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","d":"private"}"#.to_vec()
1977            )
1978            .err(),
1979            Some(Es256PublicJwkError::PrivateMaterialProhibited)
1980        );
1981        assert_eq!(
1982            Es256LocalPublicKey::from_jwk_json(
1983                br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","jku":"https://invalid"}"#.to_vec()
1984            )
1985            .err(),
1986            Some(Es256PublicJwkError::KeyMetadataProhibited)
1987        );
1988        assert_eq!(
1989            Es256LocalPublicKey::from_jwk_json(
1990                br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU=","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}"#.to_vec()
1991            )
1992            .err(),
1993            Some(Es256PublicJwkError::InvalidCoordinate)
1994        );
1995    }
1996
1997    #[test]
1998    fn canonical_coordinates_do_not_claim_curve_membership_before_verification() {
1999        let zero = URL_SAFE_NO_PAD.encode([0_u8; 32]);
2000        let document =
2001            format!(r#"{{"kty":"EC","crv":"P-256","alg":"ES256","x":"{zero}","y":"{zero}"}}"#);
2002        let structurally_bounded = Es256LocalPublicKey::from_jwk_json(document.into_bytes())
2003            .expect("closed JWK shape and canonical coordinates");
2004        let token = SecretCompactJwt::new(RFC7515_ES256_JWS.as_bytes().to_vec()).expect("token");
2005        let audit = review_compact_jwt(
2006            &token,
2007            &structurally_bounded,
2008            &rfc_policy(),
2009            JwtEvaluationTime {
2010                unix_seconds: 1_300_819_300,
2011            },
2012        );
2013        assert_eq!(audit.parsing, JwtParsingStatus::Parsed);
2014        assert_eq!(audit.policy_status, JwtPolicyStatus::Inconsistent);
2015        assert_eq!(audit.local_signature, JwtLocalSignatureStatus::Invalid);
2016        assert_eq!(audit.external_activity, no_external_activity());
2017    }
2018
2019    #[test]
2020    fn oversize_and_structural_limits_fail_closed() {
2021        assert_eq!(
2022            SecretCompactJwt::new(vec![b'a'; MAX_COMPACT_JWT_BYTES + 1]).err(),
2023            Some(JwtInputError::TokenTooLarge)
2024        );
2025        let deep_claims = r#"{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":1}}}}}}}}}"#;
2026        let token = unsigned_test_token(r#"{"alg":"ES256"}"#, deep_claims);
2027        assert_eq!(
2028            review_literal(&token, &rfc_policy(), 0).parsing,
2029            JwtParsingStatus::Rejected(JwtParseRejection::JsonLimitExceeded)
2030        );
2031    }
2032
2033    #[test]
2034    fn policy_reference_and_compact_base64url_are_canonical() {
2035        assert_eq!(
2036            JwtLocalPolicy::new(
2037                ("Uppercase", "uppercase-v1"),
2038                "JWT",
2039                "issuer",
2040                "audience",
2041                &[],
2042                false,
2043                0,
2044            )
2045            .err(),
2046            Some(JwtPolicyError::InvalidPolicyReference)
2047        );
2048        assert_eq!(
2049            JwtLocalPolicy::new(
2050                ("valid-policy", "Invalid Revision"),
2051                "JWT",
2052                "issuer",
2053                "audience",
2054                &[],
2055                false,
2056                0,
2057            )
2058            .err(),
2059            Some(JwtPolicyError::InvalidPolicyRevision)
2060        );
2061        for (expected_type, expected_issuer, expected_audience) in [
2062            ("", "issuer", "audience"),
2063            ("JWT", "", "audience"),
2064            ("JWT", "issuer", ""),
2065        ] {
2066            assert_eq!(
2067                JwtLocalPolicy::new(
2068                    ("required-bindings", "required-bindings-v1"),
2069                    expected_type,
2070                    expected_issuer,
2071                    expected_audience,
2072                    &[],
2073                    true,
2074                    0,
2075                )
2076                .err(),
2077                Some(JwtPolicyError::InvalidExpectedValue)
2078            );
2079        }
2080        let mut token = unsigned_test_token(r#"{"alg":"ES256"}"#, r#"{}"#);
2081        let final_character = token.pop().expect("signature segment");
2082        assert_eq!(final_character, 'A');
2083        token.push('B');
2084        assert_eq!(
2085            review_literal(&token, &rfc_policy(), 0).parsing,
2086            JwtParsingStatus::Rejected(JwtParseRejection::InvalidBase64Url)
2087        );
2088    }
2089
2090    #[test]
2091    fn audit_and_errors_never_retain_token_key_or_claim_canaries() {
2092        const CANARY: &str = "TERMIVAR-JWT-SECRET-CANARY-7d21";
2093        let token = unsigned_test_token(
2094            r#"{"alg":"ES256","typ":"wrong"}"#,
2095            &format!(r#"{{"iss":"{CANARY}","exp":0}}"#),
2096        );
2097        let policy = JwtLocalPolicy::new(
2098            ("canary-redaction", "canary-redaction-v1"),
2099            CANARY,
2100            "expected-issuer",
2101            "expected-audience",
2102            &[],
2103            true,
2104            0,
2105        )
2106        .expect("policy");
2107        let audit = review_literal(&token, &policy, 1);
2108        // The authoritative audit is intentionally not serializable. Public
2109        // report bytes are produced only by the bounded reporting projection;
2110        // this core check locks the remaining diagnostic surface.
2111        let encoded = format!("{audit:?}");
2112        assert!(!encoded.contains(CANARY));
2113        assert!(!encoded.contains("expected-issuer"));
2114        assert!(!encoded.contains(&token));
2115        assert_eq!(
2116            audit.target_acceptance,
2117            JwtTargetAcceptanceStatus::NotPerformed
2118        );
2119    }
2120}