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}