1use 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#[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#[derive(Debug, Clone)]
76pub struct KeySetVerifier {
77 keys: Keys,
78}
79
80#[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 pub fn from_key_set(keys: KeySet) -> Self {
101 Self {
102 keys: Keys::Static(Arc::new(keys)),
103 }
104 }
105
106 pub fn from_cache(cache: Arc<JwksCache>) -> Self {
108 Self {
109 keys: Keys::Cached(cache),
110 }
111 }
112
113 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 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 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 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 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
234fn 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
245fn 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}