Skip to main content

ppoppo_token/
error.rs

1//! Verification errors for the JWT engine.
2//!
3//! Variants map 1:1 to mitigation IDs in
4//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §4. The mapping lets
5//! audit logs translate a runtime rejection back to the standards row that
6//! authorized it. Adding a mitigation row that has no corresponding variant
7//! here is a drift signal.
8//!
9//! Phase 1 covers M01-M16a (algorithm + header). Subsequent phases append
10//! variants — never reorder or rename existing ones.
11
12#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
13pub enum AuthError {
14    // ── A. Algorithm (M01–M06) ────────────────────────────────────────────
15    /// M01: token header carries `alg: none` (or a value the library cannot
16    /// parse to a known `Algorithm`).
17    #[error("M01: alg=none rejected")]
18    AlgNone,
19
20    /// M02: header `alg` is parseable but not in the per-request whitelist.
21    #[error("M02: algorithm outside whitelist")]
22    AlgNotWhitelisted,
23
24    /// M03: HMAC family (`HS256`/`HS384`/`HS512`) rejected — confusion attack
25    /// against asymmetric public keys.
26    #[error("M03: HMAC algorithm rejected")]
27    AlgHmacRejected,
28
29    /// M04: RSA family (`RS*`/`PS*`) rejected.
30    #[error("M04: RSA algorithm rejected")]
31    AlgRsaRejected,
32
33    /// M05: ECDSA family (`ES*`) rejected.
34    #[error("M05: ECDSA algorithm rejected")]
35    AlgEcdsaRejected,
36
37    // M06 (alg pinned per request, not header) collapses into
38    // `AlgNotWhitelisted` at runtime — the enforcement *is* "compare header
39    // alg against cfg.algorithms", so a header-trusting verifier and a
40    // misconfigured cfg surface the same condition. Audit logs distinguish
41    // via the cfg snapshot, not the variant.
42
43    // ── B. Header (M07–M16a) ──────────────────────────────────────────────
44    /// M07: header carries `jku` (URL-loaded JWK Set).
45    #[error("M07: jku header rejected")]
46    HeaderJku,
47
48    /// M08: header carries `x5u` (URL-loaded X.509 chain).
49    #[error("M08: x5u header rejected")]
50    HeaderX5u,
51
52    /// M09: header carries inline `jwk`.
53    #[error("M09: jwk header rejected")]
54    HeaderJwk,
55
56    /// M10: header carries `x5c` (inline X.509 chain).
57    #[error("M10: x5c header rejected")]
58    HeaderX5c,
59
60    /// M11: header carries `crit` with unknown extensions.
61    #[error("M11: crit header rejected")]
62    HeaderCrit,
63
64    /// M12: `kid` missing or unknown to the server-pinned `KeySet`.
65    #[error("M12: kid missing or unknown")]
66    KidUnknown,
67
68    /// M13/M13a: `typ` does not equal the configured value (default
69    /// `at+jwt`). Strict equality — `JWT` is also rejected.
70    #[error("M13: typ mismatch")]
71    TypMismatch,
72
73    /// M14: nested JWS (payload is itself a JWT) — defended via `cty` header
74    /// inspection.
75    #[error("M14: nested JWS rejected")]
76    NestedJws,
77
78    /// M15: token is JWE (5-part) — encryption forbidden in this profile.
79    #[error("M15: JWE rejected")]
80    JwePayload,
81
82    /// M16: header contains parameters outside the whitelist
83    /// (`typ`, `alg`, `kid`).
84    #[error("M16: extra header params")]
85    HeaderExtraParam,
86
87    /// M16a: header carries `b64=false` (RFC 7797 unencoded payload).
88    #[error("M16a: b64=false rejected")]
89    HeaderB64False,
90
91    // ── C. Claims (M17–M30) ───────────────────────────────────────────────
92    /// M17: `exp` claim absent. RFC 8725 §3.10 — a token without an expiry
93    /// contract has no admissibility window; reject before any value check.
94    #[error("M17: exp claim missing")]
95    ExpMissing,
96
97    /// M18: `exp` is in the past. RFC 8725 §3.10 — leeway = 0; the engine
98    /// refuses any token whose expiry timestamp precedes the current
99    /// instant. Distinct from `ExpMissing`: M17 fires when the claim is
100    /// absent, M18 fires when it's present but stale.
101    #[error("M18: token expired")]
102    Expired,
103
104    /// M19: `exp` exceeds the per-profile upper bound (24h for access,
105    /// 200d for refresh — Phase 2 is access-only; refresh issuance lands
106    /// Phase 4). Bounds the blast radius of a leaked token: a malicious
107    /// issuer cannot mint near-immortal credentials.
108    #[error("M19: exp exceeds upper bound")]
109    ExpUpperBound,
110
111    /// M20: `aud` claim absent. Without an audience binding the engine
112    /// cannot enforce the verifier-specific match (M21/M22) — refuse
113    /// before any value check.
114    #[error("M20: aud claim missing")]
115    AudMissing,
116
117    /// M21 + M22: `aud` value does not match `cfg.audience`. Phase 1's
118    /// M06 documented-collapse pattern applies — M21 covers the string
119    /// form, M22 covers the array form, both surface the same audit
120    /// signal. The variant carries the M-ID via the `#[error]` string;
121    /// audit logs disambiguate via the cfg+token state.
122    #[error("M21/M22: aud value does not match expected audience")]
123    AudMismatch,
124
125    /// M23: `iss` is missing OR does not match the pinned issuer
126    /// (`cfg.issuer`). Both cases collapse into one variant — the audit
127    /// signal is identical: this token did not come from the trusted
128    /// issuer. Distinguishing missing-vs-wrong adds no useful diagnostic
129    /// (an attacker who omits iss and one who forges a wrong iss are
130    /// both probing for issuer trust confusion).
131    #[error("M23: iss missing or does not match pinned issuer")]
132    IssMismatch,
133
134    /// M24 (first clause): `iat` claim absent. Without an issuance
135    /// timestamp the engine can neither bound the token's age (M19) nor
136    /// enforce M24's "must be in past" rule. M24's future-iat clause
137    /// surfaces as `IatFuture`.
138    #[error("M24: iat claim missing")]
139    IatMissing,
140
141    /// M24 (second clause) + M25: `iat` is in the future beyond the 60s
142    /// clock-skew leeway. Phase 1's M06 documented-collapse pattern
143    /// applies — M24's must-be-in-past predicate and M25's far-future
144    /// ceiling enforce the same condition (`iat > now + 60s`); they
145    /// share the variant. Audit logs disambiguate via the iat value
146    /// itself (60s vs hours-in-the-future).
147    #[error("M24/M25: iat is in the future beyond 60s leeway")]
148    IatFuture,
149
150    /// M26: `nbf` (not-before) is present and in the future — the token
151    /// has not yet entered its admissibility window. Distinct from
152    /// `IatFuture`: nbf is the issuer's explicit "valid from" boundary,
153    /// where iat is the issuance instant. nbf is optional; absence is
154    /// not an error.
155    #[error("M26: nbf is in the future — token not yet valid")]
156    NotYetValid,
157
158    /// M27: `jti` claim absent. RFC 7519 §4.1.7 — the unique token
159    /// identifier is the replay-cache key (M35). Without it the engine
160    /// cannot enforce one-shot semantics on per-token operations.
161    #[error("M27: jti claim missing")]
162    JtiMissing,
163
164    /// M28: `sub` claim absent. RFC 7519 §4.1.2 — the subject identifies
165    /// the principal the token is about; no useful authorization
166    /// decision can follow when it's missing.
167    #[error("M28: sub claim missing")]
168    SubMissing,
169
170    /// M28a: `client_id` claim absent. RFC 9068 §2.2 mandates this for
171    /// access JWTs so the resource server can identify the originating
172    /// OAuth client (audit, per-client rate limits).
173    #[error("M28a: client_id claim missing")]
174    ClientIdMissing,
175
176    /// M29: `cat` (token category) is missing or not the expected value.
177    /// RFC 9068 §2.2 + ppoppo extension — `cat ∈ {access, refresh}` is a
178    /// payload-level discriminator that lets a single verifier refuse
179    /// type-confusion attempts (refresh token at an access endpoint).
180    /// Phase 2 verifies access tokens only; Phase 4 (refresh issuance)
181    /// generalizes via `cfg.expected_cat`. Missing-cat collapses into
182    /// the same variant — audit signal is identical (untrusted token
183    /// type).
184    #[error("M29: cat does not match expected token type")]
185    TokenTypeMismatch,
186
187    /// M30: a numeric claim (`exp`/`iat`/`nbf`) is present but not a
188    /// JSON integer. RFC 8725 §2.4 — string-coerced numerics are a
189    /// classic substitution vector (`"exp": "9999"` parsed as a "valid"
190    /// future expiry). Engine refuses any non-integer numeric claim
191    /// before the value-violation rules can fire.
192    #[error("M30: numeric claim is not a JSON integer")]
193    InvalidNumericType,
194
195    // ── D. Serialization (M31–M34) ────────────────────────────────────────
196    /// M31: input is JWS JSON serialization (or any non-compact form).
197    /// RFC 8725 §2.4 — the profile accepts JWS Compact only. JSON-form
198    /// JWS expands the implementation surface and has historically
199    /// carried polyglot-payload attacks; refuse before any segment
200    /// parser runs.
201    #[error("M31: JWS JSON serialization rejected — Compact only")]
202    JwsJsonRejected,
203
204    /// M32: header or payload JSON contains duplicate top-level keys.
205    /// RFC 7515 §3 mandates rejection, but serde_json silently keeps the
206    /// last occurrence by default — making the smuggling case (a forger
207    /// duplicates a claim hoping the verifier reads one value while a
208    /// downstream consumer reads another) invisible. Engine pre-validates
209    /// every JSON object via a key-uniqueness Visitor before parsing.
210    #[error("M32: JSON object contains duplicate keys")]
211    DuplicateJsonKeys,
212
213    /// M33: a segment contains characters from the standard base64
214    /// alphabet (`+`, `/`, `=`) — RFC 8725 §2.4 requires strict
215    /// `base64url` (URL_SAFE_NO_PAD: only `A-Z a-z 0-9 - _`). Standard
216    /// b64 chars are rejected with their own variant so audit logs
217    /// distinguish "intentional + injection" from generic decode
218    /// failures (which surface as `HeaderUnparseable` /
219    /// `PayloadUnparseable`).
220    #[error("M33: segment contains non-URL-safe base64 characters")]
221    LaxBase64,
222
223    /// M34: total token length exceeds `cfg.max_token_size` (8 KB
224    /// default for the access-token profile). A large token is either
225    /// a misconfigured issuer (extras bloating beyond a reasonable
226    /// claim set) or a denial-of-service vector (parser amplification).
227    /// Engine refuses oversized input before any segment parsing runs.
228    #[error("M34: token exceeds maximum size")]
229    OversizedToken,
230
231    // ── F. Domain (M39–M45) ───────────────────────────────────────────────
232    /// M39: `sub` is present but not a 26-character Crockford-base32 ULID.
233    /// PAS-issued tokens carry `ppnum_id` (Human ULID) or an AI-agent ULID
234    /// in `sub`; any other shape is either an issuer drift or a forgery
235    /// attempt. Distinct variant so audit logs distinguish "sub missing"
236    /// (M28) from "sub ill-formed" (M39).
237    #[error("M39: sub is not a valid ULID")]
238    SubFormatInvalid,
239
240    /// M40: `account_type` is present but not in the whitelist
241    /// `{"human", "ai_agent"}`. The claim is optional (legacy tokens
242    /// minted before the field existed are admitted), but a present-
243    /// but-unknown value is a forgery signal — the issuer never emits
244    /// arbitrary strings here. Renamed from the matrix's earlier
245    /// `actor` to avoid collision with RFC 8693 token-exchange.
246    #[error("M40: account_type outside whitelist")]
247    AccountTypeInvalid,
248
249    /// M41: `caps` is present but the wire shape is wrong (not a JSON
250    /// array of strings). The default-deny invariant lives in the
251    /// *interpretation*: absent/empty both mean "no capabilities", and
252    /// any non-empty value MUST be an array of strings. A string-typed
253    /// `caps: "admin"` is the canonical confusion — a forger hoping the
254    /// verifier reads it as a one-element list.
255    #[error("M41: caps is not a JSON array of strings")]
256    CapsShapeInvalid,
257
258    /// M42 (shape): `scopes` is present but not a JSON array of strings.
259    /// Mirrors `CapsShapeInvalid` — collapsed into its own variant
260    /// because the audit signal is meaningfully different (a scope
261    /// confusion attack reads a different threat model than a
262    /// capability shape attack).
263    #[error("M42: scopes is not a JSON array of strings")]
264    ScopesShapeInvalid,
265
266    /// M42 (length): `scopes` has more than 256 entries. Bounds the
267    /// per-token audit surface and stops a misconfigured issuer (or a
268    /// forger who got hold of a signing key) from minting a token whose
269    /// authorization vector is itself a DoS — a 10k-entry scopes array
270    /// pessimizes every per-request scope check.
271    #[error("M42: scopes exceeds 256-entry cap")]
272    ScopesTooLong,
273
274    /// M43: `dlg_depth` is present but exceeds the 4-step delegation
275    /// chain bound (or is the wrong wire shape — non-integer, negative).
276    /// Bounds the audit-trail explosion of arbitrarily deep Token
277    /// Exchange chains; `dlg_depth = 4` is the inclusive bound, matching
278    /// RFC §6.5. Single variant covers both shape and bound errors —
279    /// audit signal is "delegation chain rejected", and the value
280    /// itself surfaces in structured logging at the rejection site.
281    #[error("M43: dlg_depth invalid (non-integer, negative, or > 4)")]
282    DlgDepthInvalid,
283
284    /// M44 (band gate): token claims `admin: true` but the supporting
285    /// `active_ppnum` is either absent or its first 3 digits don't fall
286    /// in the admin allocation band. PAS issues admin tokens only on
287    /// ppnums minted from the admin band, so a token outside the band is
288    /// either a forgery (with a stolen signing key) or an issuer drift.
289    /// **This is defense-in-depth on top of `is_admin` DB lookup**
290    /// (STANDARDS_AUTH_PPOPPO §3.2 — DB is the source of truth); it
291    /// turns a stolen-key forgery into an "active_ppnum needs to be
292    /// banded" forgery, narrowing the attack window meaningfully.
293    #[error("M44: admin claim requires active_ppnum in admin band")]
294    AdminBandRejected,
295
296    /// M45: payload contains a claim outside the engine's strict
297    /// allowlist. The PAS issuance pipeline only emits claims listed
298    /// in `engine::check_domain::ALLOWED_CLAIMS`; anything else is a
299    /// forgery / misconfiguration / PII smuggling attempt (M45 is the
300    /// "no PII in payload" defense — `email` / `phone` / `name` get
301    /// rejected by name). The variant carries the offending claim
302    /// name for audit logs so operators can see at a glance which
303    /// claim triggered the rejection.
304    #[error("M45: unknown claim '{0}'")]
305    UnknownClaim(String),
306
307    // ── E. Replay / revocation (M35–M38) — Phase 5 ────────────────────────
308    /// M35: jti has been seen within the replay-cache TTL — replayed
309    /// token. Engine refuses on the second sighting; implementations of
310    /// `ReplayDefense` MUST treat the cache check and record as a single
311    /// atomic primitive (KVRocks `SET NX EX`, equivalent) to avoid a
312    /// TOCTOU window between check and record.
313    #[error("M35: jti replayed within TTL")]
314    JtiReplayed,
315
316    /// M35 (substrate transient): replay-cache substrate is unreachable.
317    /// Engine fails closed — admitting on substrate failure would let a
318    /// replayer slip through during the outage. Audit logs surface this
319    /// as a SEPARATE signal from `JtiReplayed` so ops can distinguish
320    /// "active attack" (replay) from "infrastructure issue" (cache down).
321    #[error("M35: replay cache substrate unavailable")]
322    ReplayCacheUnavailable,
323
324    /// M36: `(sub, sid)` row absent from `user_sessions` — the session
325    /// was revoked. STANDARDS_JWT_DETAILS_MITIGATION §E "row deletion =
326    /// revocation" — this is the textbook stateful-revocation gate that
327    /// makes the system "stateful by design" (OVERVIEW §6: `stateless
328    /// 환상 폐기`). Distinct from `SessionVersionStale` (account-wide
329    /// epoch) because this axis kicks one device while leaving the
330    /// account's other sessions alive.
331    #[error("M36: session revoked (user_sessions row absent)")]
332    SessionRevoked,
333
334    /// M36 (substrate transient): session-row lookup substrate is
335    /// unreachable. Engine fails closed.
336    #[error("M36: session lookup substrate unavailable")]
337    SessionLookupUnavailable,
338
339    /// sv-port (Phase 5): token's `sv` claim is strictly less than the
340    /// current per-account session_version. Break-glass / `LogoutAll`
341    /// bump `current_sv` inside the substrate; tokens minted before the
342    /// bump fail this gate within the cache TTL (60s default — see
343    /// `SV_CACHE_TTL`). PAS-internal callers preemptively flip
344    /// `sv:{ppnum_id}`; remote consumers (PCS chat-auth, pas-external
345    /// SDK) converge via the cache TTL.
346    #[error("session_version stale: token < current epoch")]
347    SessionVersionStale,
348
349    /// sv-port (substrate transient): `EpochRevocation::current` failed.
350    /// Engine fails closed.
351    #[error("session_version lookup substrate unavailable")]
352    SessionVersionLookupUnavailable,
353
354    // ── Parse / structural ────────────────────────────────────────────────
355    /// Token cannot be split into a JWS Compact form (3 segments).
356    #[error("token is not a JWS Compact serialization")]
357    NotJwsCompact,
358
359    /// Header segment cannot be base64url-decoded or is not valid JSON.
360    #[error("header is not valid JSON")]
361    HeaderUnparseable,
362
363    /// Payload segment cannot be base64url-decoded or is not valid JSON.
364    /// Mirrors `HeaderUnparseable` for the second segment — structural,
365    /// not an M-row enforcement.
366    #[error("payload is not valid JSON")]
367    PayloadUnparseable,
368}