Skip to main content

cheers_verify/
key_set.rs

1//! [`KeySetVerifier`] — PASETO v4.public verification against a JWKS key set,
2//! with key-role enforcement (product-scopes-and-authorization.md §D5).
3//!
4//! The footer `kid` selects a [`KeyEntry`]; its role decides what it may sign:
5//!
6//! | Role | Accepted as an access-token signer when |
7//! |---|---|
8//! | `issuer` | always (any `sub`) |
9//! | `assertion` | never — assertions go only to `/token`, never to a resource server |
10//! | `self-signer` | `sub` == the key's principal AND the token carries a valid [`ServiceCeiling`] |
11//!
12//! A ceiling is itself a PASETO v4.public, signed by an **issuer**-role key in
13//! the same set, riding in the token's `ceiling` claim. It must name the same
14//! principal as `sub`, list the token's `aud`, cover every token scope, and be
15//! unexpired.
16//!
17//! Keys come from a static [`KeySet`] (in-process / tests / edge) or a live
18//! [`JwksCache`] (remote; kid-miss triggers its rate-limited refetch).
19
20use std::sync::Arc;
21
22use pasetors::keys::AsymmetricPublicKey;
23use pasetors::token::UntrustedToken;
24use pasetors::version4::{PublicToken, V4};
25use serde::de::DeserializeOwned;
26use serde::Deserialize;
27
28use cheers_core::{KeyRole, McpClaims, ServiceCeiling, SignedArtifact};
29
30use crate::jwks::{JwkKey, JwksCache, JwksDoc, KeyEntry, KeySet};
31
32/// Why a token was refused. Callers on an HTTP boundary should collapse these
33/// into one 401; the variants exist for logs and audit.
34#[derive(Debug, thiserror::Error)]
35pub enum VerifyError {
36    #[error("malformed token: {0}")]
37    Malformed(String),
38
39    #[error("token footer missing or unreadable kid")]
40    MissingKid,
41
42    #[error("unknown kid {0:?} after refresh")]
43    UnknownKid(String),
44
45    #[error("kid {kid:?} is a {role:?} key and may not sign this token: {reason}")]
46    KeyRoleRejected {
47        kid: String,
48        role: KeyRole,
49        reason: String,
50    },
51
52    #[error("signature verification failed")]
53    SignatureMismatch,
54
55    #[error("token expired at {exp}, now {now}")]
56    Expired { exp: i64, now: i64 },
57
58    #[error("issuer mismatch: expected {expected:?}, got {got:?}")]
59    BadIssuer { expected: String, got: String },
60
61    #[error("audience mismatch: expected {expected:?}, got {got:?}")]
62    BadAudience { expected: String, got: String },
63
64    #[error("claim shape violation: {0}")]
65    BadClaims(String),
66}
67
68#[derive(Debug, Clone)]
69enum Keys {
70    Static(Arc<KeySet>),
71    Cached(Arc<JwksCache>),
72}
73
74/// Verifies access tokens against a key set. Cheap to clone.
75#[derive(Debug, Clone)]
76pub struct KeySetVerifier {
77    keys: Keys,
78}
79
80/// The claims every role check needs, whatever the caller's claim type.
81#[derive(Deserialize)]
82struct RoleView {
83    iss: String,
84    aud: String,
85    sub: String,
86    exp: i64,
87    #[serde(default)]
88    scope: Vec<String>,
89    #[serde(default)]
90    ceiling: Option<String>,
91}
92
93#[derive(Deserialize)]
94struct Footer {
95    kid: String,
96}
97
98impl KeySetVerifier {
99    /// A fixed key set (in-process, tests, an edge with keys baked in).
100    pub fn from_key_set(keys: KeySet) -> Self {
101        Self {
102            keys: Keys::Static(Arc::new(keys)),
103        }
104    }
105
106    /// A live W159 cache; a kid-miss triggers its rate-limited refetch.
107    pub fn from_cache(cache: Arc<JwksCache>) -> Self {
108        Self {
109            keys: Keys::Cached(cache),
110        }
111    }
112
113    /// One issuer-role key, owned by `issuer`. The static set an in-process
114    /// cheers surface verifies its own platform key with.
115    pub fn from_issuer_key(kid: impl Into<String>, public_key: &[u8; 32], issuer: impl Into<String>) -> Self {
116        let doc = JwksDoc {
117            keys: vec![JwkKey::ed25519(kid, public_key, issuer, KeyRole::Issuer)],
118        };
119        Self::from_key_set(KeySet::from_doc(doc).expect("one Ed25519 key decodes"))
120    }
121
122    /// Verify `token` at `now` and return its payload as `T`.
123    ///
124    /// Checks, in order: footer kid, key lookup, role (assertion keys refused
125    /// before any crypto), signature, `iss == expected_iss`, `aud ==
126    /// expected_aud` when `Some`, expiry, then the self-signer rules.
127    pub async fn verify<T: DeserializeOwned>(
128        &self,
129        token: &str,
130        now: i64,
131        expected_iss: &str,
132        expected_aud: Option<&str>,
133    ) -> Result<T, VerifyError> {
134        let untrusted = parse(token)?;
135        let kid = footer_kid(&untrusted)?;
136        let set = self.keys_for(&kid).await;
137        let entry = set
138            .get(&kid)
139            .ok_or_else(|| VerifyError::UnknownKid(kid.clone()))?;
140        if entry.role == KeyRole::Assertion {
141            return Err(rejected(&kid, entry, "assertion keys sign only client assertions presented to /token"));
142        }
143        let payload = verify_sig(entry, &untrusted, None)?;
144        let view: RoleView =
145            serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))?;
146        if view.iss != expected_iss {
147            return Err(VerifyError::BadIssuer {
148                expected: expected_iss.into(),
149                got: view.iss,
150            });
151        }
152        if let Some(aud) = expected_aud {
153            if view.aud != aud {
154                return Err(VerifyError::BadAudience {
155                    expected: aud.into(),
156                    got: view.aud,
157                });
158            }
159        }
160        if view.exp <= now {
161            return Err(VerifyError::Expired { exp: view.exp, now });
162        }
163        if entry.role == KeyRole::SelfSigner {
164            check_self_signed(&set, &kid, entry, &view, now)?;
165        }
166        serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))
167    }
168
169    /// [`verify`](Self::verify) into cheers's own [`McpClaims`].
170    pub async fn verify_mcp(
171        &self,
172        token: &str,
173        now: i64,
174        expected_iss: &str,
175        expected_aud: Option<&str>,
176    ) -> Result<McpClaims, VerifyError> {
177        self.verify(token, now, expected_iss, expected_aud).await
178    }
179
180    /// Verify a [`SignedArtifact`] (R732-F6) and return its payload.
181    ///
182    /// The footer kid must name an **issuer**-role key (assertion and
183    /// self-signer keys are refused before any crypto), the signature must
184    /// carry `T::IMPLICIT_ASSERTION` — so an access token never verifies here
185    /// and an artifact never verifies in [`verify`](Self::verify) — and the
186    /// payload's issuer must be the key's owner. There is no clock check:
187    /// artifacts have no edge-enforced expiry (noisetable W235 §0.1).
188    pub async fn verify_artifact<T: SignedArtifact>(&self, token: &str) -> Result<T, VerifyError> {
189        let untrusted = parse(token)?;
190        let kid = footer_kid(&untrusted)?;
191        let set = self.keys_for(&kid).await;
192        let entry = set
193            .get(&kid)
194            .ok_or_else(|| VerifyError::UnknownKid(kid.clone()))?;
195        if entry.role != KeyRole::Issuer {
196            return Err(rejected(&kid, entry, "only issuer keys sign artifacts"));
197        }
198        let payload = verify_sig(entry, &untrusted, Some(T::IMPLICIT_ASSERTION))?;
199        let artifact: T =
200            serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))?;
201        if artifact.issuer() != entry.principal {
202            return Err(VerifyError::BadIssuer {
203                expected: entry.principal.clone(),
204                got: artifact.issuer().to_owned(),
205            });
206        }
207        Ok(artifact)
208    }
209
210    /// The keys this verifier trusts right now — the static set, or the
211    /// cache's current one. Persist [`KeySet::doc`] and rebuild with
212    /// [`KeySet::from_doc`] + [`from_key_set`](Self::from_key_set) to verify
213    /// offline against exactly these keys (R732-F5; see `IssuerTrust::export`).
214    pub fn key_set(&self) -> Arc<KeySet> {
215        match &self.keys {
216            Keys::Static(s) => s.clone(),
217            Keys::Cached(c) => c.keys(),
218        }
219    }
220
221    async fn keys_for(&self, kid: &str) -> Arc<KeySet> {
222        match &self.keys {
223            Keys::Static(s) => s.clone(),
224            Keys::Cached(c) => c.keys_for(kid).await,
225        }
226    }
227}
228
229fn parse(token: &str) -> Result<UntrustedToken<pasetors::token::Public, V4>, VerifyError> {
230    UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
231        .map_err(|e| VerifyError::Malformed(format!("{e:?}")))
232}
233
234/// The footer is bound into the signature, so reading it first weakens nothing.
235fn footer_kid(t: &UntrustedToken<pasetors::token::Public, V4>) -> Result<String, VerifyError> {
236    let bytes = t.untrusted_footer();
237    if bytes.is_empty() {
238        return Err(VerifyError::MissingKid);
239    }
240    let footer: Footer =
241        serde_json::from_slice(bytes).map_err(|e| VerifyError::Malformed(format!("footer: {e}")))?;
242    Ok(footer.kid)
243}
244
245/// Low-level `PublicToken::verify`, never `pasetors::public::verify`: the
246/// high-level path rejects the i64 `exp`/`iat` these payloads carry (R592-B7).
247/// `implicit_assertion` is `None` for access tokens and ceilings, and the
248/// artifact kind's own assertion for a [`SignedArtifact`].
249fn verify_sig(
250    entry: &KeyEntry,
251    t: &UntrustedToken<pasetors::token::Public, V4>,
252    implicit_assertion: Option<&[u8]>,
253) -> Result<String, VerifyError> {
254    let key = AsymmetricPublicKey::<V4>::from(&entry.public_key)
255        .map_err(|e| VerifyError::Malformed(format!("pubkey: {e:?}")))?;
256    let trusted = PublicToken::verify(&key, t, None, implicit_assertion).map_err(|e| match e {
257        pasetors::errors::Error::TokenValidation => VerifyError::SignatureMismatch,
258        other => VerifyError::Malformed(format!("{other:?}")),
259    })?;
260    Ok(trusted.payload().to_string())
261}
262
263fn rejected(kid: &str, entry: &KeyEntry, reason: impl Into<String>) -> VerifyError {
264    VerifyError::KeyRoleRejected {
265        kid: kid.into(),
266        role: entry.role,
267        reason: reason.into(),
268    }
269}
270
271fn check_self_signed(
272    set: &KeySet,
273    kid: &str,
274    entry: &KeyEntry,
275    view: &RoleView,
276    now: i64,
277) -> Result<(), VerifyError> {
278    let deny = |reason: &str| rejected(kid, entry, reason);
279    if view.sub != entry.principal {
280        return Err(deny("self-signed sub is not the key's principal"));
281    }
282    let raw = view.ceiling.as_deref().ok_or_else(|| deny("self-signed token carries no ceiling"))?;
283    let ct = parse(raw).map_err(|_| deny("ceiling is not a PASETO v4.public"))?;
284    let ckid = footer_kid(&ct).map_err(|_| deny("ceiling footer has no kid"))?;
285    let issuer = set.get(&ckid).ok_or_else(|| deny("ceiling signed by an unknown kid"))?;
286    if issuer.role != KeyRole::Issuer {
287        return Err(deny("ceiling not signed by an issuer key"));
288    }
289    let payload = verify_sig(issuer, &ct, None).map_err(|_| deny("ceiling signature invalid"))?;
290    let ceiling: ServiceCeiling =
291        serde_json::from_str(&payload).map_err(|_| deny("ceiling payload malformed"))?;
292    if ceiling.principal.to_string() != view.sub {
293        return Err(deny("ceiling names a different principal"));
294    }
295    if ceiling.is_expired_at(now) {
296        return Err(deny("ceiling expired"));
297    }
298    if !ceiling.allows_audience(&view.aud) {
299        return Err(deny("token aud outside ceiling"));
300    }
301    if !ceiling.covers(view.scope.iter().map(String::as_str)) {
302        return Err(deny("token scope exceeds ceiling"));
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use cheers_core::{yah_scopes, PrincipalId};
311    use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
312
313    const ISS: &str = "https://cheers.test";
314    const AUD: &str = "https://inference.test";
315    const SVC: &str = "svc:issues";
316
317    fn kp() -> AsymmetricKeyPair<V4> {
318        AsymmetricKeyPair::<V4>::generate().unwrap()
319    }
320
321    fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
322        k.public.as_bytes().try_into().unwrap()
323    }
324
325    fn sign(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl serde::Serialize) -> String {
326        let body = serde_json::to_vec(payload).unwrap();
327        let footer = format!(r#"{{"kid":"{kid}"}}"#);
328        PublicToken::sign(secret, &body, Some(footer.as_bytes()), None).unwrap()
329    }
330
331    struct Rig {
332        issuer: AsymmetricKeyPair<V4>,
333        assertion: AsymmetricKeyPair<V4>,
334        selfsig: AsymmetricKeyPair<V4>,
335        v: KeySetVerifier,
336    }
337
338    fn rig() -> Rig {
339        let (issuer, assertion, selfsig) = (kp(), kp(), kp());
340        let doc = JwksDoc {
341            keys: vec![
342                JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer),
343                JwkKey::ed25519("asr-1", &pub_bytes(&assertion), SVC, KeyRole::Assertion),
344                JwkKey::ed25519("self-1", &pub_bytes(&selfsig), SVC, KeyRole::SelfSigner),
345            ],
346        };
347        Rig {
348            v: KeySetVerifier::from_key_set(KeySet::from_doc(doc).unwrap()),
349            issuer,
350            assertion,
351            selfsig,
352        }
353    }
354
355    fn claims(sub: PrincipalId, scope: Vec<cheers_core::Scope>) -> McpClaims {
356        McpClaims::new(ISS, AUD, sub, 1_000, 2_000, "jti", scope)
357    }
358
359    fn ceiling(r: &Rig, scopes: Vec<cheers_core::Scope>) -> String {
360        let c = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], scopes, 900, 3_000)
361            .unwrap();
362        sign(&r.issuer.secret, "iss-1", &c)
363    }
364
365    fn verify(r: &Rig, token: &str) -> Result<McpClaims, VerifyError> {
366        pollster::block_on(r.v.verify_mcp(token, 1_500, ISS, Some(AUD)))
367    }
368
369    fn role_rejected(res: Result<McpClaims, VerifyError>) -> String {
370        match res {
371            Err(VerifyError::KeyRoleRejected { reason, .. }) => reason,
372            other => panic!("expected KeyRoleRejected, got {other:?}"),
373        }
374    }
375
376    #[test]
377    fn issuer_key_signs_any_sub() {
378        let r = rig();
379        let c = claims(PrincipalId::user("alice"), vec![yah_scopes::CLOUD_READ]);
380        assert_eq!(verify(&r, &sign(&r.issuer.secret, "iss-1", &c)).unwrap(), c);
381    }
382
383    #[test]
384    fn assertion_key_is_always_rejected() {
385        let r = rig();
386        let c = claims(PrincipalId::service("issues"), vec![]);
387        role_rejected(verify(&r, &sign(&r.assertion.secret, "asr-1", &c)));
388    }
389
390    #[test]
391    fn self_signer_without_ceiling_is_rejected() {
392        let r = rig();
393        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ]);
394        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("no ceiling"));
395    }
396
397    #[test]
398    fn self_signer_for_another_sub_is_rejected() {
399        let r = rig();
400        let c = claims(PrincipalId::service("other"), vec![yah_scopes::CLOUD_READ])
401            .with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ]));
402        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("principal"));
403    }
404
405    #[test]
406    fn self_signer_exceeding_ceiling_is_rejected() {
407        let r = rig();
408        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY])
409            .with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ]));
410        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("exceeds"));
411    }
412
413    #[test]
414    fn self_signer_within_ceiling_is_accepted() {
415        let r = rig();
416        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
417            .with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]));
418        assert_eq!(verify(&r, &sign(&r.selfsig.secret, "self-1", &c)).unwrap(), c);
419    }
420
421    #[test]
422    fn ceiling_signed_by_a_non_issuer_key_is_rejected() {
423        let r = rig();
424        let forged = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], vec![yah_scopes::CLOUD_DEPLOY], 900, 3_000)
425            .unwrap();
426        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_DEPLOY])
427            .with_ceiling(sign(&r.selfsig.secret, "self-1", &forged));
428        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("issuer key"));
429    }
430
431    #[test]
432    fn expired_ceiling_and_foreign_audience_are_rejected() {
433        let r = rig();
434        let stale = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], vec![yah_scopes::CLOUD_READ], 100, 1_200)
435            .unwrap();
436        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
437            .with_ceiling(sign(&r.issuer.secret, "iss-1", &stale));
438        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("expired"));
439
440        let other = ServiceCeiling::new(PrincipalId::service("issues"), vec!["https://x.test".into()], vec![yah_scopes::CLOUD_READ], 900, 3_000)
441            .unwrap();
442        let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
443            .with_ceiling(sign(&r.issuer.secret, "iss-1", &other));
444        assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("aud"));
445    }
446
447    #[test]
448    fn standard_claim_checks() {
449        let r = rig();
450        let c = claims(PrincipalId::user("alice"), vec![]);
451        let t = sign(&r.issuer.secret, "iss-1", &c);
452        assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 2_000, ISS, Some(AUD))), Err(VerifyError::Expired { .. })));
453        assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 1_500, "https://evil", Some(AUD))), Err(VerifyError::BadIssuer { .. })));
454        assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 1_500, ISS, Some("https://x"))), Err(VerifyError::BadAudience { .. })));
455        assert!(pollster::block_on(r.v.verify_mcp(&t, 1_500, ISS, None)).is_ok());
456        let unknown = sign(&r.issuer.secret, "nope", &c);
457        assert!(matches!(verify(&r, &unknown), Err(VerifyError::UnknownKid(_))));
458        let wrong_key = sign(&r.selfsig.secret, "iss-1", &c);
459        assert!(matches!(verify(&r, &wrong_key), Err(VerifyError::SignatureMismatch)));
460        let no_footer = PublicToken::sign(&r.issuer.secret, &serde_json::to_vec(&c).unwrap(), None, None).unwrap();
461        assert!(matches!(verify(&r, &no_footer), Err(VerifyError::MissingKid)));
462    }
463}