use std::sync::Arc;
use pasetors::keys::AsymmetricPublicKey;
use pasetors::token::UntrustedToken;
use pasetors::version4::{PublicToken, V4};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use cheers_core::{KeyRole, McpClaims, ServiceCeiling, SignedArtifact};
use crate::jwks::{JwkKey, JwksCache, JwksDoc, KeyEntry, KeySet};
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("malformed token: {0}")]
Malformed(String),
#[error("token footer missing or unreadable kid")]
MissingKid,
#[error("unknown kid {0:?} after refresh")]
UnknownKid(String),
#[error("kid {kid:?} is a {role:?} key and may not sign this token: {reason}")]
KeyRoleRejected {
kid: String,
role: KeyRole,
reason: String,
},
#[error("signature verification failed")]
SignatureMismatch,
#[error("token expired at {exp}, now {now}")]
Expired { exp: i64, now: i64 },
#[error("issuer mismatch: expected {expected:?}, got {got:?}")]
BadIssuer { expected: String, got: String },
#[error("audience mismatch: expected {expected:?}, got {got:?}")]
BadAudience { expected: String, got: String },
#[error("claim shape violation: {0}")]
BadClaims(String),
}
#[derive(Debug, Clone)]
enum Keys {
Static(Arc<KeySet>),
Cached(Arc<JwksCache>),
}
#[derive(Debug, Clone)]
pub struct KeySetVerifier {
keys: Keys,
}
#[derive(Deserialize)]
struct RoleView {
iss: String,
aud: String,
sub: String,
exp: i64,
#[serde(default)]
scope: Vec<String>,
#[serde(default)]
ceiling: Option<String>,
}
#[derive(Deserialize)]
struct Footer {
kid: String,
}
impl KeySetVerifier {
pub fn from_key_set(keys: KeySet) -> Self {
Self {
keys: Keys::Static(Arc::new(keys)),
}
}
pub fn from_cache(cache: Arc<JwksCache>) -> Self {
Self {
keys: Keys::Cached(cache),
}
}
pub fn from_issuer_key(kid: impl Into<String>, public_key: &[u8; 32], issuer: impl Into<String>) -> Self {
let doc = JwksDoc {
keys: vec![JwkKey::ed25519(kid, public_key, issuer, KeyRole::Issuer)],
};
Self::from_key_set(KeySet::from_doc(doc).expect("one Ed25519 key decodes"))
}
pub async fn verify<T: DeserializeOwned>(
&self,
token: &str,
now: i64,
expected_iss: &str,
expected_aud: Option<&str>,
) -> Result<T, VerifyError> {
let untrusted = parse(token)?;
let kid = footer_kid(&untrusted)?;
let set = self.keys_for(&kid).await;
let entry = set
.get(&kid)
.ok_or_else(|| VerifyError::UnknownKid(kid.clone()))?;
if entry.role == KeyRole::Assertion {
return Err(rejected(&kid, entry, "assertion keys sign only client assertions presented to /token"));
}
let payload = verify_sig(entry, &untrusted, None)?;
let view: RoleView =
serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))?;
if view.iss != expected_iss {
return Err(VerifyError::BadIssuer {
expected: expected_iss.into(),
got: view.iss,
});
}
if let Some(aud) = expected_aud {
if view.aud != aud {
return Err(VerifyError::BadAudience {
expected: aud.into(),
got: view.aud,
});
}
}
if view.exp <= now {
return Err(VerifyError::Expired { exp: view.exp, now });
}
if entry.role == KeyRole::SelfSigner {
check_self_signed(&set, &kid, entry, &view, now)?;
}
serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))
}
pub async fn verify_mcp(
&self,
token: &str,
now: i64,
expected_iss: &str,
expected_aud: Option<&str>,
) -> Result<McpClaims, VerifyError> {
self.verify(token, now, expected_iss, expected_aud).await
}
pub async fn verify_artifact<T: SignedArtifact>(&self, token: &str) -> Result<T, VerifyError> {
let untrusted = parse(token)?;
let kid = footer_kid(&untrusted)?;
let set = self.keys_for(&kid).await;
let entry = set
.get(&kid)
.ok_or_else(|| VerifyError::UnknownKid(kid.clone()))?;
if entry.role != KeyRole::Issuer {
return Err(rejected(&kid, entry, "only issuer keys sign artifacts"));
}
let payload = verify_sig(entry, &untrusted, Some(T::IMPLICIT_ASSERTION))?;
let artifact: T =
serde_json::from_str(&payload).map_err(|e| VerifyError::BadClaims(e.to_string()))?;
if artifact.issuer() != entry.principal {
return Err(VerifyError::BadIssuer {
expected: entry.principal.clone(),
got: artifact.issuer().to_owned(),
});
}
Ok(artifact)
}
pub fn key_set(&self) -> Arc<KeySet> {
match &self.keys {
Keys::Static(s) => s.clone(),
Keys::Cached(c) => c.keys(),
}
}
async fn keys_for(&self, kid: &str) -> Arc<KeySet> {
match &self.keys {
Keys::Static(s) => s.clone(),
Keys::Cached(c) => c.keys_for(kid).await,
}
}
}
fn parse(token: &str) -> Result<UntrustedToken<pasetors::token::Public, V4>, VerifyError> {
UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
.map_err(|e| VerifyError::Malformed(format!("{e:?}")))
}
fn footer_kid(t: &UntrustedToken<pasetors::token::Public, V4>) -> Result<String, VerifyError> {
let bytes = t.untrusted_footer();
if bytes.is_empty() {
return Err(VerifyError::MissingKid);
}
let footer: Footer =
serde_json::from_slice(bytes).map_err(|e| VerifyError::Malformed(format!("footer: {e}")))?;
Ok(footer.kid)
}
fn verify_sig(
entry: &KeyEntry,
t: &UntrustedToken<pasetors::token::Public, V4>,
implicit_assertion: Option<&[u8]>,
) -> Result<String, VerifyError> {
let key = AsymmetricPublicKey::<V4>::from(&entry.public_key)
.map_err(|e| VerifyError::Malformed(format!("pubkey: {e:?}")))?;
let trusted = PublicToken::verify(&key, t, None, implicit_assertion).map_err(|e| match e {
pasetors::errors::Error::TokenValidation => VerifyError::SignatureMismatch,
other => VerifyError::Malformed(format!("{other:?}")),
})?;
Ok(trusted.payload().to_string())
}
fn rejected(kid: &str, entry: &KeyEntry, reason: impl Into<String>) -> VerifyError {
VerifyError::KeyRoleRejected {
kid: kid.into(),
role: entry.role,
reason: reason.into(),
}
}
fn check_self_signed(
set: &KeySet,
kid: &str,
entry: &KeyEntry,
view: &RoleView,
now: i64,
) -> Result<(), VerifyError> {
let deny = |reason: &str| rejected(kid, entry, reason);
if view.sub != entry.principal {
return Err(deny("self-signed sub is not the key's principal"));
}
let raw = view.ceiling.as_deref().ok_or_else(|| deny("self-signed token carries no ceiling"))?;
let ct = parse(raw).map_err(|_| deny("ceiling is not a PASETO v4.public"))?;
let ckid = footer_kid(&ct).map_err(|_| deny("ceiling footer has no kid"))?;
let issuer = set.get(&ckid).ok_or_else(|| deny("ceiling signed by an unknown kid"))?;
if issuer.role != KeyRole::Issuer {
return Err(deny("ceiling not signed by an issuer key"));
}
let payload = verify_sig(issuer, &ct, None).map_err(|_| deny("ceiling signature invalid"))?;
let ceiling: ServiceCeiling =
serde_json::from_str(&payload).map_err(|_| deny("ceiling payload malformed"))?;
if ceiling.principal.to_string() != view.sub {
return Err(deny("ceiling names a different principal"));
}
if ceiling.is_expired_at(now) {
return Err(deny("ceiling expired"));
}
if !ceiling.allows_audience(&view.aud) {
return Err(deny("token aud outside ceiling"));
}
if !ceiling.covers(view.scope.iter().map(String::as_str)) {
return Err(deny("token scope exceeds ceiling"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::{yah_scopes, PrincipalId};
use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
const ISS: &str = "https://cheers.test";
const AUD: &str = "https://inference.test";
const SVC: &str = "svc:issues";
fn kp() -> AsymmetricKeyPair<V4> {
AsymmetricKeyPair::<V4>::generate().unwrap()
}
fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
k.public.as_bytes().try_into().unwrap()
}
fn sign(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl serde::Serialize) -> String {
let body = serde_json::to_vec(payload).unwrap();
let footer = format!(r#"{{"kid":"{kid}"}}"#);
PublicToken::sign(secret, &body, Some(footer.as_bytes()), None).unwrap()
}
struct Rig {
issuer: AsymmetricKeyPair<V4>,
assertion: AsymmetricKeyPair<V4>,
selfsig: AsymmetricKeyPair<V4>,
v: KeySetVerifier,
}
fn rig() -> Rig {
let (issuer, assertion, selfsig) = (kp(), kp(), kp());
let doc = JwksDoc {
keys: vec![
JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer),
JwkKey::ed25519("asr-1", &pub_bytes(&assertion), SVC, KeyRole::Assertion),
JwkKey::ed25519("self-1", &pub_bytes(&selfsig), SVC, KeyRole::SelfSigner),
],
};
Rig {
v: KeySetVerifier::from_key_set(KeySet::from_doc(doc).unwrap()),
issuer,
assertion,
selfsig,
}
}
fn claims(sub: PrincipalId, scope: Vec<cheers_core::Scope>) -> McpClaims {
McpClaims::new(ISS, AUD, sub, 1_000, 2_000, "jti", scope)
}
fn ceiling(r: &Rig, scopes: Vec<cheers_core::Scope>) -> String {
let c = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], scopes, 900, 3_000)
.unwrap();
sign(&r.issuer.secret, "iss-1", &c)
}
fn verify(r: &Rig, token: &str) -> Result<McpClaims, VerifyError> {
pollster::block_on(r.v.verify_mcp(token, 1_500, ISS, Some(AUD)))
}
fn role_rejected(res: Result<McpClaims, VerifyError>) -> String {
match res {
Err(VerifyError::KeyRoleRejected { reason, .. }) => reason,
other => panic!("expected KeyRoleRejected, got {other:?}"),
}
}
#[test]
fn issuer_key_signs_any_sub() {
let r = rig();
let c = claims(PrincipalId::user("alice"), vec![yah_scopes::CLOUD_READ]);
assert_eq!(verify(&r, &sign(&r.issuer.secret, "iss-1", &c)).unwrap(), c);
}
#[test]
fn assertion_key_is_always_rejected() {
let r = rig();
let c = claims(PrincipalId::service("issues"), vec![]);
role_rejected(verify(&r, &sign(&r.assertion.secret, "asr-1", &c)));
}
#[test]
fn self_signer_without_ceiling_is_rejected() {
let r = rig();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ]);
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("no ceiling"));
}
#[test]
fn self_signer_for_another_sub_is_rejected() {
let r = rig();
let c = claims(PrincipalId::service("other"), vec![yah_scopes::CLOUD_READ])
.with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ]));
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("principal"));
}
#[test]
fn self_signer_exceeding_ceiling_is_rejected() {
let r = rig();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY])
.with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ]));
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("exceeds"));
}
#[test]
fn self_signer_within_ceiling_is_accepted() {
let r = rig();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
.with_ceiling(ceiling(&r, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]));
assert_eq!(verify(&r, &sign(&r.selfsig.secret, "self-1", &c)).unwrap(), c);
}
#[test]
fn ceiling_signed_by_a_non_issuer_key_is_rejected() {
let r = rig();
let forged = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], vec![yah_scopes::CLOUD_DEPLOY], 900, 3_000)
.unwrap();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_DEPLOY])
.with_ceiling(sign(&r.selfsig.secret, "self-1", &forged));
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("issuer key"));
}
#[test]
fn expired_ceiling_and_foreign_audience_are_rejected() {
let r = rig();
let stale = ServiceCeiling::new(PrincipalId::service("issues"), vec![AUD.into()], vec![yah_scopes::CLOUD_READ], 100, 1_200)
.unwrap();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
.with_ceiling(sign(&r.issuer.secret, "iss-1", &stale));
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("expired"));
let other = ServiceCeiling::new(PrincipalId::service("issues"), vec!["https://x.test".into()], vec![yah_scopes::CLOUD_READ], 900, 3_000)
.unwrap();
let c = claims(PrincipalId::service("issues"), vec![yah_scopes::CLOUD_READ])
.with_ceiling(sign(&r.issuer.secret, "iss-1", &other));
assert!(role_rejected(verify(&r, &sign(&r.selfsig.secret, "self-1", &c))).contains("aud"));
}
#[test]
fn standard_claim_checks() {
let r = rig();
let c = claims(PrincipalId::user("alice"), vec![]);
let t = sign(&r.issuer.secret, "iss-1", &c);
assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 2_000, ISS, Some(AUD))), Err(VerifyError::Expired { .. })));
assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 1_500, "https://evil", Some(AUD))), Err(VerifyError::BadIssuer { .. })));
assert!(matches!(pollster::block_on(r.v.verify_mcp(&t, 1_500, ISS, Some("https://x"))), Err(VerifyError::BadAudience { .. })));
assert!(pollster::block_on(r.v.verify_mcp(&t, 1_500, ISS, None)).is_ok());
let unknown = sign(&r.issuer.secret, "nope", &c);
assert!(matches!(verify(&r, &unknown), Err(VerifyError::UnknownKid(_))));
let wrong_key = sign(&r.selfsig.secret, "iss-1", &c);
assert!(matches!(verify(&r, &wrong_key), Err(VerifyError::SignatureMismatch)));
let no_footer = PublicToken::sign(&r.issuer.secret, &serde_json::to_vec(&c).unwrap(), None, None).unwrap();
assert!(matches!(verify(&r, &no_footer), Err(VerifyError::MissingKid)));
}
}