Skip to main content

authentication_verifier/
lib.rs

1//! Offline PASETO v4.public token verification against the
2//! authentication-service's published Ed25519 keys.
3//!
4//! # The offline verification model
5//!
6//! The [`authentication-service`] is the federation's single auth
7//! provider. A user session lives server-side (a Postgres-backed cookie
8//! session); from that session the service mints short-lived **PASETO
9//! v4.public** access tokens and publishes its **Ed25519 public keys** at
10//! `/.well-known/paseto-keys`. Every other service verifies those tokens
11//! *offline*: fetch the key set once at boot, build a [`Verifier`], then
12//! call [`Verifier::verify`] per request. There is no shared secret and
13//! no per-request introspection call.
14//!
15//! "Offline" is the key property and the reason this crate exists.
16//! Because v4.public is *asymmetric* (Ed25519), the signer (auth-service)
17//! holds the private key and the verifiers (every peer service) hold only
18//! the public key. A peer can therefore confirm a token's authenticity
19//! with no network round-trip on the hot path, no shared symmetric secret
20//! to distribute, and no introspection endpoint to depend on for
21//! availability. The only network interaction is the one-time (or rare)
22//! key fetch, available out of band or via the optional
23//! [`fetch`](crate#features) feature.
24//!
25//! This replaces the crate's previous RS256-JWT + JWKS design (≤ 0.1.x):
26//! PASETO is a versioned, misuse-resistant token format with no algorithm
27//! agility, so the "alg confusion" / `alg=none` class of JWT attacks does
28//! not exist. See [`agents/share/authentication-sessions.md`] in the
29//! monorepo for the family-wide design.
30//!
31//! # Authorization (ABAC)
32//!
33//! Since 0.3 the crate is also the family's shared **authorization**
34//! foundation: verified [`Claims`] carry the subject's attributes in the
35//! [`attrs`](Claims::attrs) claim, and the [`abac`] module provides the
36//! pure policy engine ([`Policy`], [`Rule`], [`Action`],
37//! [`Policy::evaluate`] → [`Decision`]) that the nine entity services
38//! call from their blanket `/api/*` guards. See
39//! [`agents/share/authorization-attributes.md`] for the design.
40//!
41//! # Security properties
42//!
43//! - **Asymmetric trust.** Verifiers never possess signing material, so a
44//!   compromised peer cannot mint tokens — it can only verify them.
45//! - **No algorithm agility.** The token header is the literal string
46//!   `v4.public`; there is no `alg` field to downgrade and no `none`.
47//! - **Key selection by `kid`.** The verifying key is chosen by the
48//!   token's (authenticated) footer `kid`; a forged or stale `kid` simply
49//!   matches no known key.
50//! - **Issuer / audience / expiry enforcement.** Beyond a valid
51//!   signature, every token must carry the expected `iss`, the expected
52//!   `aud`, an unexpired `exp`, and (if present) a satisfied `nbf`.
53//!
54//! # Features
55//!
56//! - `fetch` (off by default) — adds [`Verifier::from_paseto_keys_url`],
57//!   which pulls the key set over HTTPS via `reqwest` (rustls). With the
58//!   feature off the crate does no I/O and the caller supplies the key set
59//!   as a [`serde_json::Value`].
60//!
61//! # Example
62//!
63//! ```no_run
64//! # use authentication_verifier::Verifier;
65//! // Normally the keys come from the auth-service; an empty set here
66//! // keeps the doctest offline and dependency-free.
67//! let keys: serde_json::Value = serde_json::json!({ "keys": [] });
68//! let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
69//! let claims = verifier.verify("v4.public...")?;
70//! println!("authenticated subject: {}", claims.sub);
71//! # Ok::<(), authentication_verifier::VerifyError>(())
72//! ```
73//!
74//! [`authentication-service`]: https://github.com/sixarm/authentication-service-with-loco
75//! [`agents/share/authentication-sessions.md`]: https://github.com/sixarm/main-x-service
76//! [`agents/share/authorization-attributes.md`]: https://github.com/sixarm/main-x-service
77
78// Reject `unsafe` outright: this crate touches only safe, allocation-light
79// code paths and a security library has no business reaching for `unsafe`.
80#![forbid(unsafe_code)]
81// Opt into clippy's pedantic lints to keep the public-facing library tidy.
82#![warn(clippy::pedantic)]
83// A published library must document every public item; fail the build if
84// any item lacks a doc comment.
85#![deny(missing_docs)]
86
87use std::collections::{BTreeMap, HashMap};
88use std::time::{SystemTime, UNIX_EPOCH};
89
90// base64url (no padding) decodes the published key's `x` component.
91use base64::Engine;
92use base64::engine::general_purpose::URL_SAFE_NO_PAD;
93// The PASETO v4.public verify primitive plus the key / footer wrapper
94// types. `UntrustedToken` lets us read the (authenticated) footer to
95// select a key *before* verifying the signature.
96use rusty_paseto::core::{
97    Footer, ImplicitAssertion, Key, Paseto, PasetoAsymmetricPublicKey, Public, UntrustedToken, V4,
98};
99// `serde` derives let `Claims` deserialize from the token payload (and
100// serialize again, which the tests rely on to mint tokens).
101use serde::{Deserialize, Serialize};
102
103pub mod abac;
104
105// Re-export the ABAC engine types at the crate root so callers can use
106// `authentication_verifier::{Policy, Action, ...}` alongside `Verifier`
107// and `Claims` without spelling the module path.
108pub use abac::{Action, ActionPattern, Decision, Effect, Policy, ReloadablePolicy, Rule};
109
110/// Verified token claims. Mirrors the auth-service `Claims` exactly so a
111/// token signed there round-trips here. `sub` carries the user `pid`.
112///
113/// The field set is a contract with the auth-service: the service defines
114/// an identical struct, and changing one without the other breaks token
115/// round-tripping. `exp` / `iat` / `nbf` are unix seconds.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Claims {
118    /// Subject — the user `pid` (UUID string); the stable identifier a
119    /// peer service keys its authorization on.
120    pub sub: String,
121    /// User email, surfaced for convenience at the edge; not used for
122    /// authorization decisions.
123    pub email: String,
124    /// Human-readable display name carried alongside the subject.
125    pub name: String,
126    /// Issuer (`iss`) — the auth-service that minted the token. Checked
127    /// against the verifier's configured issuer.
128    pub iss: String,
129    /// Audience (`aud`) — the intended recipient service. Checked against
130    /// the verifier's configured audience so a token issued for one peer
131    /// cannot be replayed against another.
132    pub aud: String,
133    /// Expiry (`exp`), unix seconds. Tokens at or past this instant are
134    /// rejected. Issued ~5 minutes out (the session is the durable thing).
135    pub exp: i64,
136    /// Issued-at (`iat`), unix seconds — when the token was minted.
137    pub iat: i64,
138    /// Not-before (`nbf`), unix seconds. When present, tokens before this
139    /// instant are rejected. Omitted from the wire form when `None`.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub nbf: Option<i64>,
142    /// Session id (`sid`) — the originating server-side session, so a
143    /// token can be correlated back to (and revoked with) its session.
144    pub sid: String,
145    /// Granted scopes, if any. Empty when the token carries none.
146    ///
147    /// **Deprecated for authorization** (kept on the wire for
148    /// compatibility; removal is a future major): the ABAC guard ignores
149    /// `scope` and decides from [`attrs`](Self::attrs) instead. See
150    /// `agents/share/authorization-attributes.md` §3.
151    #[serde(default)]
152    pub scope: Vec<String>,
153    /// Granted roles, if any. Empty when the token carries none.
154    ///
155    /// **Deprecated for authorization** (kept on the wire for
156    /// compatibility; removal is a future major): the ABAC guard ignores
157    /// `roles` and decides from [`attrs`](Self::attrs) instead — a role,
158    /// where one is wanted, is just another attribute (`role=editor`).
159    /// See `agents/share/authorization-attributes.md` §3.
160    #[serde(default)]
161    pub roles: Vec<String>,
162    /// Subject attributes for ABAC authorization — a string→strings map
163    /// minted by the auth-service from the user's assigned attributes
164    /// (e.g. `access: ["write"]`, `dept: ["cardiology"]`,
165    /// `svc: ["true"]` for machine peers). Multi-valued keys mean "has
166    /// each of these values"; policies match set-membership; unknown
167    /// attributes are inert (forward-compatible). Absent on the wire
168    /// (old tokens) ⇒ empty map — no re-issue needed. Evaluated by the
169    /// [`abac`] engine per `agents/share/authorization-attributes.md`
170    /// §2–§3, alongside the pseudo-attributes `sub` and `email`.
171    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172    pub attrs: BTreeMap<String, Vec<String>>,
173}
174
175/// Failure modes for key-set loading and token verification.
176///
177/// Every fallible entry point returns this type, and every variant is a
178/// *handled* outcome — the crate never panics on bad input, so a malformed
179/// key set and a forged token are both ordinary `Err` values.
180#[derive(Debug, thiserror::Error)]
181pub enum VerifyError {
182    /// The key-set document was missing or structurally invalid (no `keys`
183    /// array, a key missing `kid` / `x`, or an `x` that is not a 32-byte
184    /// base64url Ed25519 public key). Raised at load time, not per token.
185    #[error("malformed key set: {0}")]
186    Keys(String),
187    /// The token was not a structurally valid `v4.public` token, or its
188    /// footer could not be decoded as `{ "kid": ... }`. Distinct from a
189    /// signature failure ([`Paseto`](Self::Paseto)).
190    #[error("malformed token: {0}")]
191    Malformed(String),
192    /// The token footer carried no `kid`, so no key could be selected.
193    /// All auth-service tokens stamp a footer `kid`, so this indicates a
194    /// hand-built or non-conforming token.
195    #[error("token footer has no kid")]
196    MissingKid,
197    /// No verification key matched the token's footer `kid` (stale cache,
198    /// wrong issuer, or forgery). The wrapped `String` is the unmatched
199    /// `kid`; on a legitimate stale cache a caller may refetch and retry.
200    #[error("no verification key for kid {0:?}")]
201    UnknownKid(String),
202    /// PASETO parsing or Ed25519 signature verification failed. Carries the
203    /// stringified underlying `rusty_paseto` error.
204    #[error("token verification failed: {0}")]
205    Paseto(String),
206    /// The signature was valid but a registered claim did not satisfy the
207    /// policy: wrong `iss`, wrong `aud`, expired `exp`, or unmet `nbf`.
208    #[error("claim rejected: {0}")]
209    Claim(String),
210    /// The token's `kid` selected a key whose algorithm this build does
211    /// not implement.
212    ///
213    /// Distinct from [`UnknownKid`](VerifyError::UnknownKid) on purpose.
214    /// Both reject the token, but they mean different things to whoever
215    /// is on call: `UnknownKid` says "I hold no key for this signer" and
216    /// invites a key-set refetch; this says "I hold the key and cannot
217    /// use it", which a refetch will never fix. It is the expected error
218    /// during a partial algorithm rollout — the issuer has moved ahead of
219    /// this verifier, and this binary needs upgrading.
220    #[error("key {kid:?} uses unsupported algorithm {algorithm:?}")]
221    UnsupportedAlgorithm {
222        /// The `kid` that selected the key.
223        kid: String,
224        /// The algorithm label as advertised in the key set.
225        algorithm: String,
226    },
227
228    /// Fetching the key set over HTTP failed (only with the `fetch`
229    /// feature): transport error, non-2xx status, or undecodable body.
230    #[cfg(feature = "fetch")]
231    #[error("key set fetch failed: {0}")]
232    Fetch(String),
233}
234
235/// One published verification key, tagged with the algorithm it is for.
236///
237/// Modelled as an enum rather than a byte string plus an algorithm field
238/// so that **verification cannot fall through to a default**. Adding a
239/// variant forces every match to be revisited; an unrecognised key can
240/// only ever land in [`Unsupported`](VerificationKey::Unsupported), which
241/// has no key material and therefore no path to an accept.
242#[derive(Debug, Clone)]
243enum VerificationKey {
244    /// Ed25519 raw public key — the algorithm PASETO `v4.public` uses.
245    Ed25519(Box<[u8; 32]>),
246    /// A key this build does not implement, retained only so the verifier
247    /// can say *why* it is refusing rather than reporting the `kid` as
248    /// unknown. Carries no key material.
249    Unsupported {
250        /// Algorithm label from the key set, for the error message.
251        label: String,
252    },
253}
254
255impl VerificationKey {
256    /// Parse one JWK-shaped entry.
257    ///
258    /// Returns `None` when the entry cannot be indexed at all (no `kid`),
259    /// since such a key could never be selected.
260    fn from_jwk(jwk: &serde_json::Value) -> Result<Option<(String, Self)>, VerifyError> {
261        let Some(kid) = jwk.get("kid").and_then(serde_json::Value::as_str) else {
262            // An entry with no `kid` is unselectable. For a supported
263            // algorithm that is a malformed key set; for one we do not
264            // implement it is simply not our business.
265            if is_ed25519(jwk) {
266                return Err(VerifyError::Keys("ed25519 jwk missing \"kid\"".to_string()));
267            }
268            return Ok(None);
269        };
270        if !is_ed25519(jwk) {
271            return Ok(Some((
272                kid.to_string(),
273                Self::Unsupported {
274                    label: algorithm_label(jwk),
275                },
276            )));
277        }
278        let x = jwk
279            .get("x")
280            .and_then(serde_json::Value::as_str)
281            .ok_or_else(|| VerifyError::Keys(format!("jwk {kid} missing \"x\"")))?;
282        let bytes = URL_SAFE_NO_PAD
283            .decode(x)
284            .map_err(|err| VerifyError::Keys(format!("jwk {kid}: bad base64url x: {err}")))?;
285        let key: [u8; 32] = bytes
286            .as_slice()
287            .try_into()
288            .map_err(|_| VerifyError::Keys(format!("jwk {kid}: x is not 32 bytes")))?;
289        Ok(Some((kid.to_string(), Self::Ed25519(Box::new(key)))))
290    }
291}
292
293/// Whether a JWK entry declares the one algorithm this build implements.
294fn is_ed25519(jwk: &serde_json::Value) -> bool {
295    jwk.get("kty").and_then(serde_json::Value::as_str) == Some("OKP")
296        && jwk.get("crv").and_then(serde_json::Value::as_str) == Some("Ed25519")
297}
298
299/// A human-readable label for an algorithm this build does not implement.
300///
301/// Deliberately assembled from whatever the entry advertises rather than
302/// matched against a fixed list of future algorithms: the JOSE/COSE
303/// registrations for post-quantum signatures were still settling when
304/// this was written, and guessing at names here would age badly. The
305/// label exists to be read in a log line, not to be matched on.
306fn algorithm_label(jwk: &serde_json::Value) -> String {
307    let field = |name: &str| {
308        jwk.get(name)
309            .and_then(serde_json::Value::as_str)
310            .unwrap_or("?")
311            .to_string()
312    };
313    match jwk.get("alg").and_then(serde_json::Value::as_str) {
314        Some(alg) => format!("{}/{alg}", field("kty")),
315        None => format!("{}/{}", field("kty"), field("crv")),
316    }
317}
318
319/// A set of published verification keys (indexed by `kid`) plus the issuer /
320/// audience policy applied to every token. Construct once at boot, then
321/// share behind an `Arc` and call [`verify`](Verifier::verify) per
322/// request — verification is read-only and allocation-light.
323///
324/// `kid` is an opaque string assigned by the auth-service and carried in
325/// each token's footer; the verifier indexes its keys by exactly that
326/// `kid`, so key selection at verify time is a direct map lookup.
327pub struct Verifier {
328    /// Published keys by `kid`, each tagged with its algorithm. Populated
329    /// at construction; never mutated, so a `Verifier` is safe to share
330    /// immutably across threads.
331    ///
332    /// Keys for algorithms this build does not implement are **kept**
333    /// rather than dropped, so a token naming one is refused with a
334    /// diagnosis instead of being reported as an unknown `kid`.
335    keys: HashMap<String, VerificationKey>,
336    /// Expected issuer (`iss`) enforced on every token.
337    issuer: String,
338    /// Expected audience (`aud`) enforced on every token.
339    audience: String,
340}
341
342impl Verifier {
343    /// Build a verifier from an in-memory key-set document, validating
344    /// tokens against `issuer` (`iss`) and `audience` (`aud`).
345    ///
346    /// The document mirrors a JWK set restricted to Ed25519:
347    /// `{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "kid": "...",
348    /// "x": "<base64url 32-byte public key>" }, ... ] }`. Entries whose
349    /// `kty`/`crv` are not `OKP`/`Ed25519` are skipped. An empty key set is
350    /// permitted — it yields a verifier that rejects every token with
351    /// [`VerifyError::UnknownKid`], so a service can boot before its key
352    /// source is reachable without panicking.
353    ///
354    /// # Errors
355    ///
356    /// [`VerifyError::Keys`] when the document lacks a `keys` array, an
357    /// Ed25519 key is missing `kid` / `x`, or `x` is not a 32-byte
358    /// base64url value.
359    ///
360    /// # Examples
361    ///
362    /// ```
363    /// # use authentication_verifier::Verifier;
364    /// let keys = serde_json::json!({ "keys": [] });
365    /// let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
366    /// assert_eq!(verifier.key_count(), 0);
367    /// # Ok::<(), authentication_verifier::VerifyError>(())
368    /// ```
369    pub fn from_paseto_keys_value(
370        keys_doc: &serde_json::Value,
371        issuer: &str,
372        audience: &str,
373    ) -> Result<Self, VerifyError> {
374        let entries = keys_doc
375            .get("keys")
376            .and_then(serde_json::Value::as_array)
377            .ok_or_else(|| VerifyError::Keys("missing \"keys\" array".to_string()))?;
378
379        let mut keys: HashMap<String, VerificationKey> = HashMap::new();
380        for jwk in entries {
381            let Some((kid, key)) = VerificationKey::from_jwk(jwk)? else {
382                continue;
383            };
384            // A repeated `kid` is a malformed key set, not a last-wins
385            // merge. Silently overwriting would let a key set that
386            // advertises the same id twice — say, mid-rotation across two
387            // algorithms — resolve differently depending on array order,
388            // and a verifier whose answer depends on JSON ordering is not
389            // one anybody should trust.
390            if keys.insert(kid.clone(), key).is_some() {
391                return Err(VerifyError::Keys(format!(
392                    "duplicate kid {kid:?} in key set"
393                )));
394            }
395        }
396
397        Ok(Self {
398            keys,
399            issuer: issuer.to_string(),
400            audience: audience.to_string(),
401        })
402    }
403
404    /// Number of **usable** verification keys loaded.
405    ///
406    /// Counts only keys whose algorithm this build implements, so a
407    /// health check reading this cannot be reassured by a key set full of
408    /// keys it cannot verify with. A count of zero means no token can
409    /// verify, which usually signals a key set that failed to load — or,
410    /// now, an issuer that has moved entirely to an algorithm this binary
411    /// does not support.
412    #[must_use]
413    pub fn key_count(&self) -> usize {
414        self.keys
415            .values()
416            .filter(|k| matches!(k, VerificationKey::Ed25519(_)))
417            .count()
418    }
419
420    /// Number of loaded keys whose algorithm this build does **not**
421    /// implement.
422    ///
423    /// Non-zero means the issuer publishes keys this binary cannot use.
424    /// That is normal and expected mid-rollout — the issuer adds the new
425    /// algorithm before every verifier understands it — and is the signal
426    /// to upgrade verifiers before the old keys are withdrawn. Worth
427    /// exporting as a metric for exactly that reason.
428    #[must_use]
429    pub fn unsupported_key_count(&self) -> usize {
430        self.keys
431            .values()
432            .filter(|k| matches!(k, VerificationKey::Unsupported { .. }))
433            .count()
434    }
435
436    /// The algorithm labels this verifier holds keys for, usable or not,
437    /// sorted and deduplicated — for logging what a key set actually
438    /// advertises.
439    #[must_use]
440    pub fn algorithms(&self) -> Vec<String> {
441        let mut out: Vec<String> = self
442            .keys
443            .values()
444            .map(|k| match k {
445                VerificationKey::Ed25519(_) => "OKP/Ed25519".to_string(),
446                VerificationKey::Unsupported { label } => label.clone(),
447            })
448            .collect();
449        out.sort_unstable();
450        out.dedup();
451        out
452    }
453
454    /// Verify a PASETO `v4.public` bearer token: select the key by the
455    /// footer `kid`, check the Ed25519 signature, then enforce issuer,
456    /// audience, expiry, and not-before.
457    ///
458    /// Steps run cheapest-rejection-first: confirm the `v4.public` header,
459    /// read the (authenticated) footer for its `kid`, select the key, then
460    /// perform the signature check and finally the claim policy.
461    ///
462    /// # Errors
463    ///
464    /// - [`VerifyError::Malformed`] if the token is not a structurally
465    ///   valid `v4.public` token or its footer is not `{ "kid": ... }`.
466    /// - [`VerifyError::MissingKid`] if the footer carries no `kid`.
467    /// - [`VerifyError::UnknownKid`] if the `kid` matches no loaded key.
468    /// - [`VerifyError::Paseto`] if the Ed25519 signature check fails.
469    /// - [`VerifyError::Claim`] if `iss` / `aud` / `exp` / `nbf` do not
470    ///   satisfy the policy.
471    ///
472    /// # Examples
473    ///
474    /// ```
475    /// # use authentication_verifier::{Verifier, VerifyError};
476    /// let keys = serde_json::json!({ "keys": [] });
477    /// let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
478    /// assert!(verifier.verify("not.a.paseto").is_err());
479    /// # Ok::<(), VerifyError>(())
480    /// ```
481    pub fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
482        // 1. Pin the version + purpose by the literal header. PASETO has no
483        //    algorithm field, so this is the whole "alg" decision.
484        if !token.starts_with("v4.public.") {
485            return Err(VerifyError::Malformed("not a v4.public token".to_string()));
486        }
487        // 2. Parse the token without trusting it, and read its footer. The
488        //    footer is authenticated (covered by the signature), so reading
489        //    the `kid` here and feeding the same footer back to `try_verify`
490        //    is safe: tampering with it fails the signature check in step 4.
491        let untrusted =
492            UntrustedToken::try_parse(token).map_err(|err| VerifyError::Paseto(err.to_string()))?;
493        let footer = untrusted
494            .footer_str()
495            .map_err(|err| VerifyError::Paseto(err.to_string()))?
496            .ok_or(VerifyError::MissingKid)?;
497        let footer_json: serde_json::Value = serde_json::from_str(&footer)
498            .map_err(|err| VerifyError::Malformed(format!("footer is not json: {err}")))?;
499        let kid = footer_json
500            .get("kid")
501            .and_then(serde_json::Value::as_str)
502            .ok_or(VerifyError::MissingKid)?;
503        // 3. Select the published key for this `kid`. A miss means we hold
504        //    no key for this signer (stale cache, wrong issuer, or forgery).
505        let selected = self
506            .keys
507            .get(kid)
508            .ok_or_else(|| VerifyError::UnknownKid(kid.to_string()))?;
509        // Dispatch on the key's declared algorithm. The match is
510        // exhaustive over `VerificationKey`, so a future algorithm cannot
511        // silently reach the Ed25519 path: adding a variant breaks this
512        // compile until it is handled deliberately.
513        let key_bytes = match selected {
514            VerificationKey::Ed25519(bytes) => bytes,
515            VerificationKey::Unsupported { label } => {
516                return Err(VerifyError::UnsupportedAlgorithm {
517                    kid: kid.to_string(),
518                    algorithm: label.clone(),
519                });
520            }
521        };
522        let key = Key::<32>::from(key_bytes.as_ref());
523        let public_key = PasetoAsymmetricPublicKey::<V4, Public>::from(&key);
524        // 4. Verify the Ed25519 signature over (header, payload, footer).
525        let payload = Paseto::<V4, Public>::try_verify(
526            token,
527            &public_key,
528            Footer::from(footer.as_str()),
529            Option::<ImplicitAssertion>::None,
530        )
531        .map_err(|err| VerifyError::Paseto(err.to_string()))?;
532        // 5. Reconstruct claims and apply the issuer/audience/expiry policy.
533        let claims: Claims = serde_json::from_str(&payload)
534            .map_err(|err| VerifyError::Malformed(format!("payload is not claims json: {err}")))?;
535        self.check_claims(&claims)?;
536        Ok(claims)
537    }
538
539    /// Apply the registered-claim policy: `iss`, `aud`, `exp`, and `nbf`.
540    fn check_claims(&self, claims: &Claims) -> Result<(), VerifyError> {
541        if claims.iss != self.issuer {
542            return Err(VerifyError::Claim(format!(
543                "issuer mismatch: expected {:?}, got {:?}",
544                self.issuer, claims.iss
545            )));
546        }
547        if claims.aud != self.audience {
548            return Err(VerifyError::Claim(format!(
549                "audience mismatch: expected {:?}, got {:?}",
550                self.audience, claims.aud
551            )));
552        }
553        let now = now_unix();
554        if let Some(nbf) = claims.nbf
555            && now < nbf
556        {
557            return Err(VerifyError::Claim("token not yet valid (nbf)".to_string()));
558        }
559        if now >= claims.exp {
560            return Err(VerifyError::Claim("token expired (exp)".to_string()));
561        }
562        Ok(())
563    }
564}
565
566/// Current unix time in seconds, saturating to `i64::MAX` if the clock is
567/// before the epoch (which would make every token "expired").
568fn now_unix() -> i64 {
569    SystemTime::now()
570        .duration_since(UNIX_EPOCH)
571        .ok()
572        .and_then(|d| i64::try_from(d.as_secs()).ok())
573        .unwrap_or(i64::MAX)
574}
575
576/// HTTP-loading constructor, available only with the `fetch` feature.
577///
578/// Kept in its own `cfg`-gated `impl` so the default build pulls in no
579/// HTTP stack and does no I/O whatsoever.
580#[cfg(feature = "fetch")]
581impl Verifier {
582    /// Fetch the key set from `url` over HTTPS and build a verifier. Call
583    /// once at boot; the auth-service rotates keys rarely, so a process can
584    /// cache the result for its lifetime (or refetch on
585    /// [`VerifyError::UnknownKid`] to pick up a rotation).
586    ///
587    /// # Errors
588    ///
589    /// [`VerifyError::Fetch`] on any transport / non-2xx / decode error, or
590    /// [`VerifyError::Keys`] when the fetched body is not a valid key set.
591    ///
592    /// # Examples
593    ///
594    /// ```no_run
595    /// # use authentication_verifier::Verifier;
596    /// # async fn run() -> Result<(), authentication_verifier::VerifyError> {
597    /// let verifier = Verifier::from_paseto_keys_url(
598    ///     "https://auth.example.com/.well-known/paseto-keys",
599    ///     "authentication-service",
600    ///     "main-x-service",
601    /// )
602    /// .await?;
603    /// # let _ = verifier;
604    /// # Ok(())
605    /// # }
606    /// ```
607    pub async fn from_paseto_keys_url(
608        url: &str,
609        issuer: &str,
610        audience: &str,
611    ) -> Result<Self, VerifyError> {
612        // SEC-V1: hard cap on the key-set body so a hostile endpoint can't
613        // OOM the peer. A published key set is a few hundred bytes.
614        const MAX_KEYS_BYTES: usize = 64 * 1024;
615        // SEC-V1: only fetch the key set over TLS — a plaintext (`http://`)
616        // or silently-downgraded fetch lets a network attacker inject their
617        // own Ed25519 public key, which is full **token forgery**. The one
618        // exception is a **loopback** host (`127.0.0.1` / `::1` / `localhost`),
619        // which is not reachable by a network attacker and is where dev/CI
620        // key servers run. Redirects are forbidden below so an `https` URL
621        // can't be bounced to plaintext.
622        if !url_scheme_is_permitted(url) {
623            return Err(VerifyError::Fetch(format!(
624                "key-set URL must be https:// (or http:// on loopback); refusing to fetch {url}"
625            )));
626        }
627        let client = reqwest::Client::builder()
628            // SEC-V1: bound boot time so a hung/slow key endpoint can't stall
629            // startup indefinitely.
630            .timeout(std::time::Duration::from_secs(10))
631            // SEC-V1: no redirects, so an https→http (or cross-host) bounce
632            // can't defeat the scheme check above.
633            .redirect(reqwest::redirect::Policy::none())
634            .build()
635            .map_err(|e| VerifyError::Fetch(e.to_string()))?;
636        let mut response = client
637            .get(url)
638            .send()
639            .await
640            .map_err(|e| VerifyError::Fetch(e.to_string()))?
641            .error_for_status()
642            .map_err(|e| VerifyError::Fetch(e.to_string()))?;
643
644        // SEC-V1: read the body with the hard size cap (above) so a hostile
645        // endpoint can't OOM the peer with an unbounded response.
646        let mut buf: Vec<u8> = Vec::new();
647        while let Some(chunk) = response
648            .chunk()
649            .await
650            .map_err(|e| VerifyError::Fetch(e.to_string()))?
651        {
652            if buf.len() + chunk.len() > MAX_KEYS_BYTES {
653                return Err(VerifyError::Fetch(format!(
654                    "key set exceeds the {MAX_KEYS_BYTES}-byte limit"
655                )));
656            }
657            buf.extend_from_slice(&chunk);
658        }
659        let body: serde_json::Value =
660            serde_json::from_slice(&buf).map_err(|e| VerifyError::Fetch(e.to_string()))?;
661        Self::from_paseto_keys_value(&body, issuer, audience)
662    }
663}
664
665/// A **hot-reloadable** [`Verifier`] holder for **key rotation**: the
666/// active verifier (its published Ed25519 key set) can be swapped at
667/// runtime — e.g. by a periodic re-fetch of `/.well-known/paseto-keys` —
668/// **without a restart**, while the per-request verify path stays
669/// lock-light.
670///
671/// It wraps an `Arc<Verifier>` behind an `RwLock` (the same shape as
672/// [`ReloadablePolicy`](crate::ReloadablePolicy)). Per request a guard
673/// calls [`current`](Self::current) — a brief read-lock returning a cheap
674/// `Arc` clone it verifies against; a refresh calls
675/// [`store`](Self::store) — a brief write-lock swapping the `Arc`. A
676/// verification in flight during a refresh finishes against its snapshot.
677/// Poison-safe: a panic elsewhere never makes `current`/`store` panic.
678///
679/// The **refresh trigger** (a periodic timer, a signal) is the service's
680/// concern — this type only holds and swaps the value. A refresh should
681/// keep the current verifier on a fetch failure (never swap to an empty
682/// key set), so a transient auth-service outage cannot lock everyone out.
683///
684/// (No `Debug` — [`Verifier`] deliberately does not derive it, so its key
685/// material never lands in a debug log.)
686pub struct ReloadableVerifier {
687    inner: std::sync::RwLock<std::sync::Arc<Verifier>>,
688}
689
690impl ReloadableVerifier {
691    /// Wrap an initial verifier (e.g. the one built/fetched at boot).
692    #[must_use]
693    pub fn new(verifier: Verifier) -> Self {
694        Self {
695            inner: std::sync::RwLock::new(std::sync::Arc::new(verifier)),
696        }
697    }
698
699    /// The currently active verifier — a cheap `Arc` clone taken under a
700    /// brief read-lock. Verify against the returned snapshot; a
701    /// concurrent [`store`](Self::store) does not affect it.
702    #[must_use]
703    pub fn current(&self) -> std::sync::Arc<Verifier> {
704        self.inner
705            .read()
706            .unwrap_or_else(std::sync::PoisonError::into_inner)
707            .clone()
708    }
709
710    /// Atomically replace the active verifier (a brief write-lock) — e.g.
711    /// after re-fetching a rotated key set. New requests verify against
712    /// the new key set; in-flight ones finish against their snapshot.
713    pub fn store(&self, verifier: Verifier) {
714        *self
715            .inner
716            .write()
717            .unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(verifier);
718    }
719}
720
721/// Whether a key-set URL may be fetched (SEC-V1): `https` to any host, or
722/// `http` only to a **loopback** host (`127.0.0.1` / `::1` / `localhost`),
723/// which a network attacker cannot intercept and where dev/CI key servers
724/// run. Any other scheme, a non-loopback `http` host, or an unparseable URL
725/// is refused. Pure, so it is unit-tested without network access.
726#[cfg(feature = "fetch")]
727fn url_scheme_is_permitted(url: &str) -> bool {
728    let Ok(parsed) = reqwest::Url::parse(url.trim()) else {
729        return false;
730    };
731    match parsed.scheme() {
732        "https" => true,
733        "http" => matches!(
734            parsed.host_str(),
735            Some("127.0.0.1" | "::1" | "[::1]" | "localhost")
736        ),
737        _ => false,
738    }
739}
740
741/// Offline unit tests.
742///
743/// The whole suite runs without network access: a fixed Ed25519 keypair
744/// plays the auth-service's signing role, a key set is derived from its
745/// public half exactly as the service would publish it, and tokens are
746/// minted locally so each verification path is exercised deterministically.
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use ed25519_dalek::SigningKey;
751    use rusty_paseto::core::{PasetoAsymmetricPrivateKey, Payload};
752
753    // A fixed 32-byte Ed25519 seed → deterministic keypair, used only to
754    // exercise the verifier offline. Not used anywhere in production.
755    const TEST_SEED: [u8; 32] = [
756        7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
757        7, 7,
758    ];
759    const ISSUER: &str = "authentication-service";
760    const AUDIENCE: &str = "main-x-service";
761    const KID: &str = "test-key-1";
762
763    fn signing_key() -> SigningKey {
764        SigningKey::from_bytes(&TEST_SEED)
765    }
766
767    // Build a key-set document from the test public key, mirroring exactly
768    // how the auth-service publishes (kty, crv, kid, x).
769    fn test_keys() -> serde_json::Value {
770        let public = signing_key().verifying_key().to_bytes();
771        let x = URL_SAFE_NO_PAD.encode(public);
772        serde_json::json!({
773            "keys": [{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x }]
774        })
775    }
776
777    // Mint a v4.public token with the given footer kid and claims, using
778    // the test private key — the inverse of what the verifier does.
779    fn sign(kid: &str, claims: &Claims) -> String {
780        let payload = serde_json::to_string(claims).expect("serialize claims");
781        sign_payload(kid, &payload)
782    }
783
784    // Mint a v4.public token from a raw JSON payload string, so tests can
785    // exercise wire forms `Claims` itself would not serialize (e.g. a
786    // pre-0.3 token with no `attrs` member at all).
787    fn sign_payload(kid: &str, payload: &str) -> String {
788        let keypair = signing_key().to_keypair_bytes(); // [u8; 64]
789        let key = Key::<64>::from(keypair);
790        let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
791        let footer = format!(r#"{{"kid":"{kid}"}}"#);
792        let mut builder = Paseto::<V4, Public>::builder();
793        builder.set_payload(Payload::from(payload));
794        builder.set_footer(Footer::from(footer.as_str()));
795        builder.try_sign(&private).expect("sign")
796    }
797
798    // Claims whose `exp` is `exp_offset` seconds from a fixed reference
799    // "now" comfortably in the future, so well-formed tokens are unexpired
800    // regardless of the real clock; large negative offsets expire them.
801    fn claims(exp_offset: i64) -> Claims {
802        let now = 1_900_000_000; // year 2030
803        Claims {
804            sub: "11111111-1111-1111-1111-111111111111".to_string(),
805            email: "alice@example.com".to_string(),
806            name: "Alice".to_string(),
807            iss: ISSUER.to_string(),
808            aud: AUDIENCE.to_string(),
809            exp: now + exp_offset,
810            iat: now,
811            nbf: None,
812            sid: "22222222-2222-2222-2222-222222222222".to_string(),
813            scope: vec![],
814            roles: vec![],
815            attrs: BTreeMap::new(),
816        }
817    }
818
819    #[test]
820    fn valid_token_round_trips_claims() {
821        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
822        assert_eq!(verifier.key_count(), 1);
823        let token = sign(KID, &claims(3600));
824        let got = verifier.verify(&token).expect("verify");
825        assert_eq!(got.sub, "11111111-1111-1111-1111-111111111111");
826        assert_eq!(got.email, "alice@example.com");
827        assert_eq!(got.iss, ISSUER);
828        assert_eq!(got.aud, AUDIENCE);
829        assert_eq!(got.sid, "22222222-2222-2222-2222-222222222222");
830    }
831
832    #[test]
833    fn attrs_claim_round_trips_mint_to_verify() {
834        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
835        let mut c = claims(3600);
836        c.attrs.insert(
837            "access".to_string(),
838            vec!["write".to_string(), "admin".to_string()],
839        );
840        c.attrs
841            .insert("dept".to_string(), vec!["cardiology".to_string()]);
842        let token = sign(KID, &c);
843        let got = verifier.verify(&token).expect("verify");
844        assert_eq!(got.attrs, c.attrs);
845    }
846
847    #[test]
848    fn absent_attrs_claim_verifies_to_empty_map() {
849        // A pre-0.3 token carries no `attrs` member at all; it must verify
850        // and land as an empty map — no re-issue needed.
851        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
852        let now = 1_900_000_000_i64;
853        let payload = serde_json::json!({
854            "sub": "11111111-1111-1111-1111-111111111111",
855            "email": "alice@example.com",
856            "name": "Alice",
857            "iss": ISSUER,
858            "aud": AUDIENCE,
859            "exp": now + 3600,
860            "iat": now,
861            "sid": "22222222-2222-2222-2222-222222222222",
862        })
863        .to_string();
864        let token = sign_payload(KID, &payload);
865        let got = verifier.verify(&token).expect("verify");
866        assert!(got.attrs.is_empty());
867    }
868
869    #[test]
870    fn expired_token_is_rejected() {
871        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
872        let token = sign(KID, &claims(-10_000_000_000));
873        assert!(matches!(
874            verifier.verify(&token),
875            Err(VerifyError::Claim(_))
876        ));
877    }
878
879    #[test]
880    fn not_yet_valid_token_is_rejected() {
881        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
882        let mut c = claims(3600);
883        c.nbf = Some(1_900_000_000); // year 2030, after the real clock
884        let token = sign(KID, &c);
885        assert!(matches!(
886            verifier.verify(&token),
887            Err(VerifyError::Claim(_))
888        ));
889    }
890
891    #[test]
892    fn wrong_audience_is_rejected() {
893        let verifier =
894            Verifier::from_paseto_keys_value(&test_keys(), ISSUER, "some-other-service").unwrap();
895        let token = sign(KID, &claims(3600));
896        assert!(matches!(
897            verifier.verify(&token),
898            Err(VerifyError::Claim(_))
899        ));
900    }
901
902    #[test]
903    fn wrong_issuer_is_rejected() {
904        let verifier =
905            Verifier::from_paseto_keys_value(&test_keys(), "some-other-issuer", AUDIENCE).unwrap();
906        let token = sign(KID, &claims(3600));
907        assert!(matches!(
908            verifier.verify(&token),
909            Err(VerifyError::Claim(_))
910        ));
911    }
912
913    #[test]
914    fn unknown_kid_is_rejected() {
915        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
916        let token = sign("not-a-known-kid", &claims(3600));
917        assert!(matches!(
918            verifier.verify(&token),
919            Err(VerifyError::UnknownKid(_))
920        ));
921    }
922
923    #[test]
924    fn tampered_payload_is_rejected() {
925        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
926        let token = sign(KID, &claims(3600));
927        // Flip a character in the payload segment (index 2 of v4.public.X.Y).
928        let mut parts: Vec<&str> = token.split('.').collect();
929        let mut payload = parts[2].to_string();
930        let last = payload.len() - 1;
931        let swapped = if &payload[last..] == "A" { "B" } else { "A" };
932        payload.replace_range(last.., swapped);
933        parts[2] = &payload;
934        let tampered = parts.join(".");
935        assert!(verifier.verify(&tampered).is_err());
936    }
937
938    #[test]
939    fn garbage_token_is_rejected() {
940        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
941        assert!(verifier.verify("not.a.paseto").is_err());
942        assert!(verifier.verify("").is_err());
943        // A wrong-version token is rejected on the header check.
944        assert!(matches!(
945            verifier.verify("v2.public.aaaa"),
946            Err(VerifyError::Malformed(_))
947        ));
948    }
949
950    #[test]
951    fn empty_key_set_builds_but_rejects_everything() {
952        let keys = serde_json::json!({ "keys": [] });
953        let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
954        assert_eq!(verifier.key_count(), 0);
955        let token = sign(KID, &claims(3600));
956        assert!(matches!(
957            verifier.verify(&token),
958            Err(VerifyError::UnknownKid(_))
959        ));
960    }
961
962    #[test]
963    fn key_set_without_keys_array_errors() {
964        let keys = serde_json::json!({ "not_keys": [] });
965        assert!(matches!(
966            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
967            Err(VerifyError::Keys(_))
968        ));
969    }
970
971    #[test]
972    fn non_ed25519_keys_are_skipped() {
973        let keys = serde_json::json!({
974            "keys": [{ "kty": "RSA", "kid": "rsa-1", "n": "a", "e": "b" }]
975        });
976        let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
977        assert_eq!(verifier.key_count(), 0);
978    }
979
980    #[test]
981    fn ed25519_key_missing_kid_errors() {
982        let public = signing_key().verifying_key().to_bytes();
983        let x = URL_SAFE_NO_PAD.encode(public);
984        let keys = serde_json::json!({
985            "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x }]
986        });
987        assert!(matches!(
988            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
989            Err(VerifyError::Keys(_))
990        ));
991    }
992
993    #[test]
994    fn ed25519_key_with_bad_x_errors() {
995        let keys = serde_json::json!({
996            "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "bad-1", "x": "!!!not-base64!!!" }]
997        });
998        assert!(matches!(
999            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
1000            Err(VerifyError::Keys(_))
1001        ));
1002    }
1003
1004    #[test]
1005    fn ed25519_key_with_wrong_length_x_errors() {
1006        // Valid base64url, but only 3 bytes — not a 32-byte Ed25519 key.
1007        let keys = serde_json::json!({
1008            "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "short-1", "x": URL_SAFE_NO_PAD.encode([1, 2, 3]) }]
1009        });
1010        assert!(matches!(
1011            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
1012            Err(VerifyError::Keys(_))
1013        ));
1014    }
1015
1016    #[cfg(feature = "fetch")]
1017    #[tokio::test]
1018    async fn from_paseto_keys_url_maps_transport_error_to_fetch() {
1019        let result = Verifier::from_paseto_keys_url("not-a-url://nowhere", ISSUER, AUDIENCE).await;
1020        assert!(matches!(result, Err(VerifyError::Fetch(_))));
1021    }
1022
1023    #[test]
1024    fn reloadable_verifier_swaps_the_key_set_for_rotation() {
1025        // Start with an empty key set (rejects every token), then
1026        // hot-swap to the real published key set — simulating a key
1027        // rotation picked up by a periodic re-fetch.
1028        let empty = serde_json::json!({ "keys": [] });
1029        let holder = ReloadableVerifier::new(
1030            Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap(),
1031        );
1032        let token = sign(KID, &claims(3600));
1033        assert!(
1034            holder.current().verify(&token).is_err(),
1035            "before rotation: the empty key set rejects the token"
1036        );
1037
1038        holder.store(Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap());
1039        assert!(
1040            holder.current().verify(&token).is_ok(),
1041            "after rotation: the token verifies against the new key set"
1042        );
1043
1044        // A snapshot taken before a swap keeps the key set it captured.
1045        let snapshot = holder.current();
1046        holder.store(Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap());
1047        assert!(
1048            snapshot.verify(&token).is_ok(),
1049            "an in-flight verification keeps the key set it snapshotted"
1050        );
1051        assert!(
1052            holder.current().verify(&token).is_err(),
1053            "new requests see the latest key set"
1054        );
1055    }
1056
1057    // A DIFFERENT (attacker) seed → a keypair the published key set does NOT
1058    // contain. Signing with it while stamping the honest `kid` is the forgery
1059    // attempt the SEC-V4 test below must reject.
1060    const ATTACKER_SEED: [u8; 32] = [
1061        9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
1062        9, 9,
1063    ];
1064
1065    fn attacker_sign_payload(kid: &str, payload: &str) -> String {
1066        let keypair = SigningKey::from_bytes(&ATTACKER_SEED).to_keypair_bytes();
1067        let key = Key::<64>::from(keypair);
1068        let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
1069        let footer = format!(r#"{{"kid":"{kid}"}}"#);
1070        let mut builder = Paseto::<V4, Public>::builder();
1071        builder.set_payload(Payload::from(payload));
1072        builder.set_footer(Footer::from(footer.as_str()));
1073        builder.try_sign(&private).expect("attacker sign")
1074    }
1075
1076    /// SEC-V4 (the previously-missing forgery path): a token **validly signed
1077    /// by an attacker key** but stamped with the *honest* published `kid`
1078    /// must be rejected. The verifier selects the honest public key by `kid`,
1079    /// then the Ed25519 signature check fails — proving `kid` selection can't
1080    /// be abused to verify a token the honest key never signed.
1081    #[test]
1082    fn cross_key_forgery_with_honest_kid_is_rejected() {
1083        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
1084        let payload = serde_json::to_string(&claims(3600)).unwrap();
1085        let forged = attacker_sign_payload(KID, &payload);
1086        assert!(matches!(
1087            verifier.verify(&forged),
1088            Err(VerifyError::Paseto(_))
1089        ));
1090    }
1091
1092    /// SEC-V4: a token whose payload omits the required `exp` claim must be
1093    /// rejected (not treated as never-expiring) — `exp` is a non-`Option`
1094    /// field, so deserialization fails after the signature verifies.
1095    #[test]
1096    fn token_missing_exp_is_rejected() {
1097        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
1098        let now = 1_900_000_000_i64;
1099        let payload = serde_json::json!({
1100            "sub": "11111111-1111-1111-1111-111111111111",
1101            "email": "alice@example.com",
1102            "name": "Alice",
1103            "iss": ISSUER,
1104            "aud": AUDIENCE,
1105            // no "exp"
1106            "iat": now,
1107            "sid": "22222222-2222-2222-2222-222222222222",
1108        })
1109        .to_string();
1110        let token = sign_payload(KID, &payload);
1111        assert!(verifier.verify(&token).is_err(), "missing exp must reject");
1112    }
1113
1114    /// SEC-V4 (parser robustness / fuzz-lite): the verifier must only ever
1115    /// return `Err` — never panic — on arbitrary / malformed / truncated
1116    /// input. Pairs with `#![forbid(unsafe_code)]`.
1117    #[test]
1118    fn malformed_tokens_never_panic() {
1119        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
1120        let valid = sign(KID, &claims(3600));
1121        let mut cases: Vec<String> = vec![
1122            String::new(),
1123            ".".into(),
1124            "....".into(),
1125            "v4".into(),
1126            "v4.public".into(),
1127            "v4.public.".into(),
1128            "v4.public.!!!!".into(),
1129            "v4.local.deadbeef".into(),
1130            "v3.public.deadbeef".into(),
1131            "v4.public.YWJj.YWJj".into(),
1132            format!("v4.public.{}", "A".repeat(10_000)),
1133            format!("{valid}.extrasegment"),
1134            valid[..valid.len() / 2].to_string(),
1135            "🔥.🔥.🔥".into(),
1136        ];
1137        // A valid token with a wildly oversized footer.
1138        cases.push(sign_payload(
1139            &"k".repeat(5_000),
1140            &serde_json::to_string(&claims(3600)).unwrap(),
1141        ));
1142        for c in cases {
1143            // The contract: an `Err`, and above all no panic / no unwind.
1144            let _ = verifier.verify(&c);
1145        }
1146    }
1147
1148    /// SEC-V1: the `fetch` path refuses a non-`https` key-set URL outright
1149    /// (before any network I/O), so a plaintext / downgraded fetch can't
1150    /// inject attacker keys. No network needed — the scheme check fails fast.
1151    #[cfg(feature = "fetch")]
1152    #[tokio::test]
1153    async fn non_https_keys_url_is_refused() {
1154        for url in [
1155            "http://auth.example.com/.well-known/paseto-keys",
1156            "ftp://x",
1157            "//x",
1158            "auth",
1159        ] {
1160            let r = Verifier::from_paseto_keys_url(url, ISSUER, AUDIENCE).await;
1161            assert!(
1162                matches!(r, Err(VerifyError::Fetch(_))),
1163                "non-https URL {url:?} must be refused"
1164            );
1165        }
1166    }
1167
1168    /// SEC-V1 scheme policy (pure): `https` anywhere is permitted; `http` is
1169    /// permitted only to a loopback host (where dev/CI key servers run);
1170    /// everything else — non-loopback `http`, other schemes, garbage — is
1171    /// refused. This loopback exception is why the services' own
1172    /// `http://127.0.0.1` key-fetch tests keep working.
1173    #[cfg(feature = "fetch")]
1174    #[test]
1175    fn url_scheme_policy_allows_https_and_loopback_http_only() {
1176        assert!(url_scheme_is_permitted("https://auth.example.com/keys"));
1177        assert!(url_scheme_is_permitted("http://127.0.0.1:8080/keys"));
1178        assert!(url_scheme_is_permitted("http://localhost:3000/keys"));
1179        assert!(url_scheme_is_permitted("http://[::1]:9000/keys"));
1180        assert!(!url_scheme_is_permitted("http://auth.example.com/keys"));
1181        assert!(!url_scheme_is_permitted("http://10.0.0.5/keys"));
1182        assert!(!url_scheme_is_permitted("ftp://x"));
1183        assert!(!url_scheme_is_permitted("not a url"));
1184    }
1185
1186    // ─── Algorithm agility ──────────────────────────────────────────────
1187    //
1188    // The system's only Shor-vulnerable component is this signature: the
1189    // audit digests are hash-based, and sessions are opaque ids. When the
1190    // issuer eventually adds a post-quantum algorithm it will publish both
1191    // key types for a while, so a verifier must (a) keep working off the
1192    // Ed25519 keys, and (b) refuse a token naming the new algorithm with
1193    // an error that tells an operator to upgrade rather than to refetch.
1194
1195    // A key set advertising a post-quantum key alongside the Ed25519 one.
1196    // The exact `kty` / `alg` spelling is invented: the JOSE registrations
1197    // were still settling when this was written, and the verifier is built
1198    // not to care — it matches only what it supports and labels the rest.
1199    fn mixed_keys() -> serde_json::Value {
1200        let public = signing_key().verifying_key().to_bytes();
1201        let x = URL_SAFE_NO_PAD.encode(public);
1202        serde_json::json!({
1203            "keys": [
1204                { "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x },
1205                { "kty": "AKP", "alg": "ML-DSA-44", "use": "sig", "kid": "pq-1",
1206                  "pub": "irrelevant-to-this-build" }
1207            ]
1208        })
1209    }
1210
1211    /// A key set carrying an algorithm this build does not implement still
1212    /// verifies tokens signed with the one it does. This is the property
1213    /// that lets an issuer roll a new algorithm out ahead of its verifiers.
1214    #[test]
1215    fn unknown_algorithm_in_the_key_set_does_not_break_ed25519() {
1216        let verifier =
1217            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
1218        assert_eq!(verifier.key_count(), 1, "one usable key");
1219        assert_eq!(verifier.unsupported_key_count(), 1);
1220        let claims = verifier.verify(&sign(KID, &claims(3600))).expect("verify");
1221        assert_eq!(claims.iss, ISSUER);
1222    }
1223
1224    /// A token naming a key this build cannot use is refused **as such** —
1225    /// not as an unknown `kid`.
1226    ///
1227    /// Both reject, so this is not a security fix; it is a diagnosis fix,
1228    /// and the distinction is the point. `UnknownKid` invites an operator
1229    /// to refetch the key set, which during an algorithm rollout will
1230    /// cheerfully return the same key and the same failure forever. The
1231    /// error has to say "upgrade this binary" instead.
1232    #[test]
1233    fn token_naming_an_unsupported_algorithm_reports_the_algorithm() {
1234        let verifier =
1235            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
1236        // Signed with the Ed25519 test key but footered with the PQ kid:
1237        // enough to select the key, which is all this test needs.
1238        let err = verifier
1239            .verify(&sign("pq-1", &claims(3600)))
1240            .expect_err("must refuse");
1241        match err {
1242            VerifyError::UnsupportedAlgorithm { kid, algorithm } => {
1243                assert_eq!(kid, "pq-1");
1244                assert_eq!(algorithm, "AKP/ML-DSA-44", "the label must be actionable");
1245            }
1246            other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
1247        }
1248    }
1249
1250    /// **Fail closed.** An unsupported key carries no material and cannot
1251    /// reach the signature check, so a token cannot be accepted by
1252    /// selecting it — even when signed with a key the verifier does hold.
1253    ///
1254    /// Without the enum this is exactly the bug that would appear: store a
1255    /// byte string plus an algorithm tag, forget one branch, and a
1256    /// "post-quantum" key verifies as Ed25519.
1257    #[test]
1258    fn an_unsupported_key_can_never_produce_an_accept() {
1259        let verifier =
1260            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
1261        for offset in [3600, -3600] {
1262            assert!(
1263                verifier.verify(&sign("pq-1", &claims(offset))).is_err(),
1264                "no token selecting an unsupported key may verify"
1265            );
1266        }
1267    }
1268
1269    /// A repeated `kid` is a malformed key set, not a last-wins merge.
1270    ///
1271    /// Silently overwriting would make the verifier's answer depend on
1272    /// JSON array order — the failure would surface as intermittent auth
1273    /// errors after a rotation, which is close to undiagnosable.
1274    #[test]
1275    fn duplicate_kid_is_rejected_rather_than_resolved_by_order() {
1276        let public = signing_key().verifying_key().to_bytes();
1277        let x = URL_SAFE_NO_PAD.encode(public);
1278        let doc = serde_json::json!({
1279            "keys": [
1280                { "kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x },
1281                { "kty": "AKP", "alg": "ML-DSA-44", "kid": KID }
1282            ]
1283        });
1284        let result = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE);
1285        let Err(err) = result else {
1286            panic!("duplicate kid must fail");
1287        };
1288        assert!(matches!(err, VerifyError::Keys(m) if m.contains("duplicate kid")));
1289    }
1290
1291    /// An unsupported entry with no `kid` is skipped rather than fatal: it
1292    /// could never be selected, so it is not this verifier's problem. A
1293    /// *supported* entry missing its `kid` is still a malformed key set.
1294    #[test]
1295    fn unselectable_unsupported_entry_is_skipped_not_fatal() {
1296        let doc = serde_json::json!({
1297            "keys": [{ "kty": "AKP", "alg": "ML-DSA-44" }]
1298        });
1299        let verifier = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE).expect("keys");
1300        assert_eq!(verifier.key_count(), 0);
1301        assert_eq!(verifier.unsupported_key_count(), 0);
1302
1303        let bad = serde_json::json!({
1304            "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "AAAA" }]
1305        });
1306        assert!(Verifier::from_paseto_keys_value(&bad, ISSUER, AUDIENCE).is_err());
1307    }
1308
1309    /// `algorithms()` reports what the key set actually advertises, so a
1310    /// service can log it at boot and an operator can see a rollout
1311    /// arriving before it breaks anything.
1312    #[test]
1313    fn algorithms_reports_what_is_published() {
1314        let verifier =
1315            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
1316        assert_eq!(verifier.algorithms(), vec!["AKP/ML-DSA-44", "OKP/Ed25519"]);
1317    }
1318}