Skip to main content

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}