cheers_verify/public_verifier.rs
1//! PASETO v4.public **verifier** — the edge-safe half of the asymmetric codec.
2//!
3//! The origin holds the secret-key minter (`cheers_server::PasetoV4SecretMinter`);
4//! the edge holds *only* the public-key verifier here, which can check a
5//! signature but is physically unable to forge one. Both reuse the v4.local
6//! payload convention — the cheers [`Claims`] ride under a single `"cheers"`
7//! additional claim, with PASETO's own `exp` left non-expiring so that
8//! `verify_at(now)` owns the expiry decision (parity with the symmetric impls).
9//!
10//! v4.public signs the payload in the clear, so anything minted this way is
11//! readable by whoever holds the token: only non-secret claims (identity +
12//! expiry + jti) belong in an access token.
13//!
14//! @yah:ticket(R731-F6, "JWKS-backed verifier with key roles in cheers-verify; McpAuthState holds a key set (D5)")
15//! @yah:status(review)
16//! @yah:assignee(agent:bundle-anthropic-ashguard)
17//! @yah:at(2026-10-06T23:20:54Z)
18//! @yah:phase(P2)
19//! @yah:parent(R731)
20//! @yah:next("Doc D5. Lift the W159 cache from kamaji (oss/kamaji/crates/kamaji-bin/src/auth/jwks.rs: first fetch, atomic refresh, rate-limited kid-miss refetch, restart from disk) into cheers-verify, then move kamaji onto it.")
21//! @yah:next("Enforce key roles: issuer signs any sub; assertion is never valid here; self-signer only for sub = its own svc and within a cheers-issued ceiling shaped like UserDelegation (cheers-core/src/delegation.rs). Build the enforcement now with zero self-signers.")
22//! @yah:next("cheers already caches Apple JWKS (crates/cheers/src/providers/apple/jwks_cache.rs); check for reuse before writing a third cache.")
23//! @yah:tier(Warrior)
24//! @yah:gotcha("Scheduling edge (leader, 2026-10-06): F6 edits cheers-verify, and F2 rewrites McpClaims.scope there, so F6 waits for F2 too.")
25//! @yah:depends_on(R731-B1)
26//! @yah:depends_on(R731-F2)
27//! @yah:handoff("cheers-core: new ceiling.rs — ServiceCeiling {principal (must be svc), audiences, scopes: Vec<Scope>, issued_at, expires_at} + CeilingError, validated on new() and on deserialize; helpers is_expired_at/allows_audience/covers. McpClaims gains `ceiling: Option<String>` (serde default + skip_serializing_if None, golden fixtures unchanged) and with_ceiling(). McpClaims is #[non_exhaustive], so the only cheers literal was McpClaims::new; kamaji's ~15 literals are kamaji's OWN McpClaims type and needed no field (the role view reads `ceiling` from the raw payload).")
28//! @yah:handoff("cheers-verify: new jwks.rs = kamaji's W159 cache MOVED (JwkKey, JwksDoc, KeyEntry{public_key,principal,role}, KeySet, JwksError, JwksSource trait, JwksCacheConfig, JwksCache::boot/refresh/keys/keys_for, load_from_disk/write_atomic with format-2 discard rule and per-writer staging names). HttpJwksSource behind new feature `jwks-http` (default off; reqwest optional). No tokio in cheers-verify: std locks, sync file I/O; the periodic tick stays with the caller (kamaji spawns it). New deps: base64, serde, thiserror, tracing; dev tempfile.")
29//! @yah:handoff("cheers-verify: new key_set.rs — KeySetVerifier (from_key_set / from_cache / from_issuer_key) with async verify<T>(token, now, expected_iss, expected_aud: Option) and verify_mcp. Footer kid -> entry; assertion role refused before crypto; signature; iss/aud/exp; self-signer: sub == key principal, ceiling present, ceiling kid is an issuer-role key in the same set, ceiling sig valid, principal == sub, unexpired, aud in audiences, scopes subset. VerifyError replaces kamaji's (NotIssuerKey -> KeyRoleRejected{kid,role,reason}).")
30//! @yah:handoff("cheers-axum: McpAuthState is {verifier: Arc<KeySetVerifier>, expected_iss, expected_aud}; new(PasetoV4PublicVerifier, kid, iss, aud) builds the static one-issuer-key set, from_key_set(KeySetVerifier, iss, aud) takes a JwksCache-backed one. authenticate_mcp and verify_mcp_bearer are now async (expected_kid param dropped); .await added at audit.rs x2, ownership.rs x3, camps.rs, tokens.rs. ApiTokenTrust likewise holds a KeySetVerifier (expected_kid field removed). cheers-axum gains a direct cheers-verify dep.")
31//! @yah:handoff("kamaji-bin: auth/jwks.rs DELETED; AuthVerifier now wraps cheers_verify::JwksCache (HttpJwksSource) + KeySetVerifier, keeping boot(AuthConfig)/verify/refresh/spawn_refresh_task; with_jwks -> jwks() returning Arc<KeySet>. error.rs re-exports cheers_verify::{JwksError, VerifyError}; AuthError renamed JwksError (config.rs, lib.rs). deny.rs/audit record.rs map KeyRoleRejected (audit tag now `key_role_rejected`, was `not_issuer_key`). Cargo: cheers-verify path dep with jwks-http; base64ct dropped; pasetors moved to dev-deps. claim/policy/deny layers unchanged.")
32//! @yah:handoff("Apple JWKS cache (crates/cheers/src/providers/apple/jwks_cache.rs, RS256) left alone: different key type and crate. Moving kamaji's cache keeps the count of JWKS caches at two, not three.")
33//! @yah:handoff("Tests: cheers workspace 683 passed / 0 failed / 3 ignored (baseline 652/0/3; +23 are mine: 3 ceiling, 11 jwks, 9 key_set incl. the six required role cases; remainder presumably peer F3). kamaji-bin auth 63 passed (baseline 72; the 9 jwks/kid-miss tests moved to cheers-verify, none lost). kamaji-bin --test mock_issuer_boot 2/2. No git writes (policy defer).")
34//! @yah:verify("cd oss/cheers && cargo test --workspace (683/0/3)")
35//! @yah:verify("cd oss/cheers && cargo test -p cheers-verify --all-features (key_set::tests: issuer ok, assertion rejected, self-signer no ceiling / wrong sub / over-ceiling / non-issuer ceiling / expired ceiling / foreign aud rejected, within ceiling accepted)")
36//! @yah:verify("cd oss/kamaji && cargo test -p kamaji-bin auth (63) && cargo test -p kamaji-bin --test mock_issuer_boot (2)")
37//! @yah:assumes("Self-signed tokens must carry iss == the verifier's expected cheers issuer (same rule as issuer-signed); the ceiling, not iss, carries cheers's authority. Revisit if F5 mints self-signed tokens with iss = svc.")
38//! @yah:verify("Leader re-verify 2026-10-06: cargo test -p kamaji-bin (full, not just auth) 242 pass / 0 fail; cheers workspace 686 pass / 0 fail / 3 ignored, no build skew.")
39
40use cheers_core::{Claims, CodecError, McpClaims, SignedArtifact, TokenVerifier};
41use pasetors::claims::ClaimsValidationRules;
42use pasetors::keys::AsymmetricPublicKey;
43use pasetors::public;
44use pasetors::token::UntrustedToken;
45use pasetors::version4::{PublicToken, V4};
46
47/// Map a `pasetors` crypto-library error into the shared [`CodecError`].
48///
49/// Lives here rather than as `impl From<pasetors::errors::Error> for CodecError`
50/// because the orphan rule would force that impl into `cheers-core`, dragging
51/// pasetors into the keyless contract crate. `cheers-server`'s symmetric codecs
52/// reuse this via `cheers_verify::codec_err`.
53pub fn codec_err(e: pasetors::errors::Error) -> CodecError {
54 use pasetors::errors::Error as P;
55 match e {
56 P::TokenValidation => CodecError::SignatureMismatch,
57 P::ClaimValidation(_) => CodecError::Expired,
58 other => CodecError::Crypto(format!("{other:?}")),
59 }
60}
61
62/// PASETO v4.public **verifier** — checks signatures with an Ed25519 public key.
63///
64/// The edge-safe half of v4.public: a public key can *verify* a token but is
65/// physically unable to *mint* one. It is the only [`TokenVerifier`] in the
66/// cheers tree that doesn't also carry minting power (the symmetric codecs in
67/// `cheers-server` are [`Codec`](cheers_core::Codec)s, so they do) — which is
68/// exactly what lets an edge (e.g. a CF Worker) authenticate sessions without the
69/// forge-any-session blast radius of holding a minting key. Pair with a
70/// `cheers_server::PasetoV4SecretMinter` at the origin.
71pub struct PasetoV4PublicVerifier {
72 public: AsymmetricPublicKey<V4>,
73}
74
75/// Derive the `kid` for a 32-byte Ed25519 public key: the first 16 bytes of its
76/// SHA-256, base64url-unpadded.
77///
78/// *Derived* rather than random, so the minting side and every verifier compute
79/// the same value from the key alone and a key rotation changes it for free. A
80/// hand-copied kid would silently go stale on rotation; compute it here.
81pub fn kid_for(public_key: &[u8; 32]) -> String {
82 use base64::Engine as _;
83 use sha2::{Digest, Sha256};
84 let digest = Sha256::digest(public_key);
85 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest[..16])
86}
87
88impl PasetoV4PublicVerifier {
89 /// Build from a 32-byte Ed25519 public key (the V4 public-key size).
90 pub fn from_public_key(bytes: &[u8; 32]) -> Result<Self, CodecError> {
91 let public = AsymmetricPublicKey::<V4>::from(bytes)
92 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
93 Ok(Self { public })
94 }
95
96 /// Wrap an already-parsed Ed25519 public key — e.g. the half a minter derives
97 /// from its secret key (`cheers_server::PasetoV4SecretMinter::verifier`).
98 pub fn from_key(public: AsymmetricPublicKey<V4>) -> Self {
99 Self { public }
100 }
101
102 /// The underlying public key, e.g. to serialize the 32 bytes for publishing
103 /// to an edge.
104 pub fn public_key(&self) -> &AsymmetricPublicKey<V4> {
105 &self.public
106 }
107
108 /// Verify an MCP-call token signed by
109 /// `cheers_server::PasetoV4SecretMinter::mint_mcp`, returning the
110 /// embedded [`McpClaims`].
111 ///
112 /// **Wire convention** (R592-B7) — the shape kamaji-bin's verifier,
113 /// `cheers-mock`, and yubaba's minter already share: `claims` is the
114 /// token's FLAT top-level JSON payload (no wrapping claim key), verified
115 /// via pasetors' LOW-LEVEL [`PublicToken::verify`] — never the high-level
116 /// `public::verify`, which hard-rejects the i64 `exp`/`iat` this shape
117 /// carries.
118 ///
119 /// `kid` is REQUIRED in the PASETO footer: an empty footer is
120 /// [`CodecError::MissingKid`]; a footer that doesn't parse or has no
121 /// `kid` field is [`CodecError::Malformed`]; a `kid` that doesn't match
122 /// `expected_kid` is [`CodecError::UnknownKid`] (this verifier holds a
123 /// single trusted `(kid, public key)` pair — it has no JWKS of its own,
124 /// so "does the footer's kid match the one caller I trust" is the whole
125 /// key-selection check here; a full JWKS lookup across many kids is a
126 /// consumer's job, e.g. kamaji-bin's `AuthVerifier`).
127 ///
128 /// Expiry is owned by [`McpClaims::is_expired_at`] (parity with the
129 /// session-claim path) — `exp <= now` is [`CodecError::Expired`].
130 /// `iss`/`aud` are NOT checked here — that policy varies per consumer
131 /// (which issuer/audience a caller expects), so it lives at the caller
132 /// (e.g. `cloud-admin`'s `viewer_from_claims`), not in this shared
133 /// primitive.
134 pub fn verify_mcp_at(
135 &self,
136 token: &str,
137 now: i64,
138 expected_kid: &str,
139 ) -> Result<McpClaims, CodecError> {
140 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
141 .map_err(|_| CodecError::Malformed)?;
142
143 // Footer/kid check BEFORE the crypto check — cheap, and it's the
144 // key-selection signal a real multi-kid verifier would need anyway.
145 // The footer bytes are bound into the signature regardless of when
146 // we read them, so this ordering doesn't weaken anything.
147 let footer_bytes = untrusted.untrusted_footer();
148 if footer_bytes.is_empty() {
149 return Err(CodecError::MissingKid);
150 }
151 let footer: serde_json::Value =
152 serde_json::from_slice(footer_bytes).map_err(|_| CodecError::Malformed)?;
153 let kid = footer
154 .get("kid")
155 .and_then(|v| v.as_str())
156 .ok_or(CodecError::Malformed)?;
157 if kid != expected_kid {
158 return Err(CodecError::UnknownKid(kid.to_string()));
159 }
160
161 let trusted = PublicToken::verify(&self.public, &untrusted, None, None).map_err(codec_err)?;
162 let out: McpClaims = serde_json::from_str(trusted.payload())?;
163 if out.is_expired_at(now) {
164 return Err(CodecError::Expired);
165 }
166 Ok(out)
167 }
168
169 /// Verify a [`SignedArtifact`] (R732-F6) under this pinned key and return
170 /// its payload.
171 ///
172 /// A pinned key is the issuer key by configuration, so there is no kid or
173 /// role to check; the signature must carry `T::IMPLICIT_ASSERTION`, which
174 /// is what keeps an access token from verifying here and an artifact from
175 /// verifying as one. The payload's issuer is the caller's to check (see
176 /// `IssuerTrust`). No clock: artifacts have no edge-enforced expiry.
177 pub fn verify_artifact<T: SignedArtifact>(&self, token: &str) -> Result<T, CodecError> {
178 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
179 .map_err(|_| CodecError::Malformed)?;
180 let trusted = PublicToken::verify(&self.public, &untrusted, None, Some(T::IMPLICIT_ASSERTION))
181 .map_err(codec_err)?;
182 Ok(serde_json::from_str(trusted.payload())?)
183 }
184}
185
186impl TokenVerifier for PasetoV4PublicVerifier {
187 fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
188 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
189 .map_err(|_| CodecError::Malformed)?;
190 // Skip pasetors's wall-clock validation; enforce `now` ourselves for
191 // testability and parity with the symmetric impls.
192 let mut rules = ClaimsValidationRules::new();
193 rules.allow_non_expiring();
194 let trusted = public::verify(&self.public, &untrusted, &rules, None, None)
195 .map_err(codec_err)?;
196 let pclaims = trusted.payload_claims().ok_or(CodecError::Malformed)?;
197 let v = pclaims
198 .get_claim("cheers")
199 .ok_or(CodecError::Malformed)?
200 .clone();
201 let out: Claims = serde_json::from_value(v)?;
202 if out.is_expired_at(now) {
203 return Err(CodecError::Expired);
204 }
205 Ok(out)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn kid_for_is_stable_distinct_and_22_chars() {
215 let k = [7u8; 32];
216 assert_eq!(kid_for(&k), kid_for(&k));
217 assert_ne!(kid_for(&k), kid_for(&[8u8; 32]));
218 assert_eq!(kid_for(&k).len(), 22);
219 }
220
221 #[test]
222 fn rejects_malformed() {
223 let verifier = PasetoV4PublicVerifier::from_public_key(&[0u8; 32]).unwrap();
224 assert!(matches!(
225 verifier.verify_at("not-a-paseto", 0).unwrap_err(),
226 CodecError::Malformed
227 ));
228 }
229
230 #[test]
231 fn codec_err_maps_token_validation_to_signature_mismatch() {
232 let e = codec_err(pasetors::errors::Error::TokenValidation);
233 assert!(matches!(e, CodecError::SignatureMismatch));
234 }
235
236 // The full mint -> verify -> expiry round-trip lives in cheers-server's
237 // tests: minting needs the secret key, which is origin-only and therefore
238 // not reachable from this verify-only crate (the property under test).
239}