use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::{Hmac, Mac};
use pasetors::claims::{Claims as PasetoClaims, ClaimsValidationRules};
use pasetors::keys::{AsymmetricPublicKey, AsymmetricSecretKey, SymmetricKey};
use pasetors::token::UntrustedToken;
use pasetors::version4::{PublicToken, V4};
use pasetors::{local, public};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use cheers_core::{Claims, CodecError, McpClaims, TokenMinter, TokenVerifier};
use cheers_verify::{codec_err, PasetoV4PublicVerifier};
type HmacSha256 = Hmac<Sha256>;
pub struct PasetoV4Codec {
key: SymmetricKey<V4>,
}
impl PasetoV4Codec {
pub fn new(key_bytes: &[u8; 32]) -> Result<Self, CodecError> {
let key = SymmetricKey::<V4>::from(key_bytes)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
Ok(Self { key })
}
}
impl TokenMinter for PasetoV4Codec {
fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
let mut p = PasetoClaims::new_expires_in(&core::time::Duration::ZERO)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
p.non_expiring();
let cheers_value = serde_json::to_value(claims)?;
p.add_additional("cheers", cheers_value)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
local::encrypt(&self.key, &p, None, None).map_err(codec_err)
}
}
impl TokenVerifier for PasetoV4Codec {
fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
let untrusted = UntrustedToken::<pasetors::token::Local, V4>::try_from(token)
.map_err(|_| CodecError::Malformed)?;
let mut rules = ClaimsValidationRules::new();
rules.allow_non_expiring();
let trusted = local::decrypt(&self.key, &untrusted, &rules, None, None)
.map_err(codec_err)?;
let pclaims = trusted.payload_claims().ok_or(CodecError::Malformed)?;
let v = pclaims
.get_claim("cheers")
.ok_or(CodecError::Malformed)?
.clone();
let out: Claims = serde_json::from_value(v)?;
if out.is_expired_at(now) {
return Err(CodecError::Expired);
}
Ok(out)
}
}
pub struct PasetoV4SecretMinter {
secret: AsymmetricSecretKey<V4>,
}
impl PasetoV4SecretMinter {
pub fn from_secret_key(bytes: &[u8; 64]) -> Result<Self, CodecError> {
let secret = AsymmetricSecretKey::<V4>::from(bytes)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
Ok(Self { secret })
}
pub fn generate() -> Result<(Self, PasetoV4PublicVerifier), CodecError> {
use pasetors::keys::{AsymmetricKeyPair, Generate};
let kp = AsymmetricKeyPair::<V4>::generate()
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
Ok((
Self { secret: kp.secret },
PasetoV4PublicVerifier::from_key(kp.public),
))
}
pub fn verifier(&self) -> Result<PasetoV4PublicVerifier, CodecError> {
let public = AsymmetricPublicKey::<V4>::try_from(&self.secret)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
Ok(PasetoV4PublicVerifier::from_key(public))
}
pub fn secret_key_bytes(&self) -> &[u8] {
self.secret.as_bytes()
}
pub fn mint_mcp(&self, claims: &McpClaims, kid: &str) -> Result<String, CodecError> {
let value = serde_json::to_value(claims)?;
let payload = serde_json::to_vec(&value)?;
let footer = serde_json::to_vec(&serde_json::json!({ "kid": kid }))?;
PublicToken::sign(&self.secret, &payload, Some(&footer), None).map_err(codec_err)
}
}
impl TokenMinter for PasetoV4SecretMinter {
fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
let mut p = PasetoClaims::new_expires_in(&core::time::Duration::ZERO)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
p.non_expiring();
let cheers_value = serde_json::to_value(claims)?;
p.add_additional("cheers", cheers_value)
.map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
public::sign(&self.secret, &p, None, None).map_err(codec_err)
}
}
pub struct HmacBlobCodec {
key: [u8; 32],
}
impl HmacBlobCodec {
pub fn new(key: [u8; 32]) -> Self {
Self { key }
}
}
impl TokenMinter for HmacBlobCodec {
fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
let payload = serde_json::to_vec(claims)?;
let mut mac =
HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(&payload);
let tag = mac.finalize().into_bytes();
let mut out = String::with_capacity(payload.len() * 2 + 64);
out.push_str(&URL_SAFE_NO_PAD.encode(&payload));
out.push('.');
out.push_str(&URL_SAFE_NO_PAD.encode(tag));
Ok(out)
}
}
impl TokenVerifier for HmacBlobCodec {
fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
let (payload_b64, tag_b64) = token.split_once('.').ok_or(CodecError::Malformed)?;
let payload = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| CodecError::Malformed)?;
let tag = URL_SAFE_NO_PAD
.decode(tag_b64)
.map_err(|_| CodecError::Malformed)?;
let mut mac =
HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(&payload);
let expected = mac.finalize().into_bytes();
if expected.ct_eq(&tag).unwrap_u8() != 1 {
return Err(CodecError::SignatureMismatch);
}
let claims: Claims = serde_json::from_slice(&payload)?;
if claims.is_expired_at(now) {
return Err(CodecError::Expired);
}
Ok(claims)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::{DeviceBinding, DeviceId, UserId};
fn sample_claims(exp: i64) -> Claims {
Claims::new(
UserId::new("u1"),
DeviceId::new("d1"),
DeviceBinding::OidcGoogle,
1_000,
exp,
)
}
#[test]
fn hmac_blob_roundtrip() {
let codec = HmacBlobCodec::new([7u8; 32]);
let c = sample_claims(10_000);
let tok = codec.mint(&c).unwrap();
assert!(tok.contains('.'));
let back = codec.verify_at(&tok, 5_000).unwrap();
assert_eq!(back, c);
}
#[test]
fn hmac_blob_rejects_expired() {
let codec = HmacBlobCodec::new([7u8; 32]);
let c = sample_claims(100);
let tok = codec.mint(&c).unwrap();
let err = codec.verify_at(&tok, 200).unwrap_err();
assert!(matches!(err, CodecError::Expired));
}
#[test]
fn hmac_blob_rejects_wrong_key() {
let a = HmacBlobCodec::new([1u8; 32]);
let b = HmacBlobCodec::new([2u8; 32]);
let tok = a.mint(&sample_claims(10_000)).unwrap();
let err = b.verify_at(&tok, 5_000).unwrap_err();
assert!(matches!(err, CodecError::SignatureMismatch));
}
#[test]
fn hmac_blob_rejects_tampered_payload() {
let codec = HmacBlobCodec::new([7u8; 32]);
let tok = codec.mint(&sample_claims(10_000)).unwrap();
let (payload, tag) = tok.split_once('.').unwrap();
let altered = {
let mut bytes = URL_SAFE_NO_PAD.decode(payload).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0x01;
URL_SAFE_NO_PAD.encode(bytes)
};
let bad = format!("{altered}.{tag}");
let err = codec.verify_at(&bad, 5_000).unwrap_err();
assert!(matches!(err, CodecError::SignatureMismatch));
}
#[test]
fn hmac_blob_rejects_malformed() {
let codec = HmacBlobCodec::new([7u8; 32]);
assert!(matches!(
codec.verify_at("no-dot", 0).unwrap_err(),
CodecError::Malformed
));
assert!(matches!(
codec.verify_at("not_base64!.also_not", 0).unwrap_err(),
CodecError::Malformed
));
}
#[test]
fn paseto_v4_roundtrip() {
let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
let c = sample_claims(10_000);
let tok = codec.mint(&c).unwrap();
assert!(tok.starts_with("v4.local."));
let back = codec.verify_at(&tok, 5_000).unwrap();
assert_eq!(back, c);
}
#[test]
fn paseto_v4_rejects_expired() {
let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
let c = sample_claims(100);
let tok = codec.mint(&c).unwrap();
let err = codec.verify_at(&tok, 200).unwrap_err();
assert!(matches!(err, CodecError::Expired));
}
#[test]
fn paseto_v4_rejects_wrong_key() {
let a = PasetoV4Codec::new(&[1u8; 32]).unwrap();
let b = PasetoV4Codec::new(&[2u8; 32]).unwrap();
let tok = a.mint(&sample_claims(10_000)).unwrap();
let err = b.verify_at(&tok, 5_000).unwrap_err();
assert!(matches!(err, CodecError::SignatureMismatch));
}
#[test]
fn paseto_v4_rejects_malformed() {
let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
assert!(matches!(
codec.verify_at("not-a-paseto", 0).unwrap_err(),
CodecError::Malformed
));
}
fn v4_public_pair() -> (PasetoV4SecretMinter, PasetoV4PublicVerifier) {
PasetoV4SecretMinter::generate().unwrap()
}
#[test]
fn paseto_v4_public_roundtrip() {
let (minter, verifier) = v4_public_pair();
let c = sample_claims(10_000);
let tok = minter.mint(&c).unwrap();
assert!(tok.starts_with("v4.public."));
let back = verifier.verify_at(&tok, 5_000).unwrap();
assert_eq!(back, c);
}
#[test]
fn paseto_v4_public_rejects_expired() {
let (minter, verifier) = v4_public_pair();
let c = sample_claims(100);
let tok = minter.mint(&c).unwrap();
let err = verifier.verify_at(&tok, 200).unwrap_err();
assert!(matches!(err, CodecError::Expired));
}
#[test]
fn paseto_v4_public_rejects_wrong_key() {
let (minter, _) = v4_public_pair();
let (_, other_verifier) = v4_public_pair();
let tok = minter.mint(&sample_claims(10_000)).unwrap();
let err = other_verifier.verify_at(&tok, 5_000).unwrap_err();
assert!(matches!(err, CodecError::SignatureMismatch));
}
#[test]
fn paseto_v4_public_verifier_derives_from_minter() {
let (minter, _) = v4_public_pair();
let derived = minter.verifier().unwrap();
let c = sample_claims(10_000);
let tok = minter.mint(&c).unwrap();
assert_eq!(derived.verify_at(&tok, 5_000).unwrap(), c);
}
#[test]
fn paseto_v4_public_round_trips_through_key_bytes() {
let (minter, verifier) = v4_public_pair();
let c = sample_claims(10_000);
let tok = minter.mint(&c).unwrap();
let pub_bytes: [u8; 32] = verifier
.public_key()
.as_bytes()
.try_into()
.expect("v4 public key is 32 bytes");
let rebuilt = PasetoV4PublicVerifier::from_public_key(&pub_bytes).unwrap();
assert_eq!(rebuilt.verify_at(&tok, 5_000).unwrap(), c);
}
use cheers_core::{
Actor, AuthStrength, McpClaims, Owns, PrincipalId, Scope,
};
const TEST_KID: &str = "codec-test-kid-1";
fn sample_mcp_claims(exp: i64) -> McpClaims {
let mut owns = Owns::default();
owns.service = vec!["svc-a".into()];
owns.arch_doc = vec!["doc-1".into()];
McpClaims::new(
"https://cheers.example",
"https://kamaji.camp.example",
PrincipalId::user("alice"),
1_000,
exp,
"jti-mcp-1",
vec![Scope::CloudDeploy, Scope::CloudRead],
)
.with_act(Actor::new(PrincipalId::service("agent-claude")))
.with_camp_id("camp-xyz")
.with_owns(owns)
.with_auth_strength(AuthStrength::UserFresh)
}
#[test]
fn paseto_v4_public_mcp_roundtrip() {
let (minter, verifier) = v4_public_pair();
let c = sample_mcp_claims(10_000);
let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
assert!(tok.starts_with("v4.public."));
let back = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap();
assert_eq!(back, c);
}
#[test]
fn paseto_v4_public_mcp_rejects_expired() {
let (minter, verifier) = v4_public_pair();
let c = sample_mcp_claims(100);
let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
let err = verifier.verify_mcp_at(&tok, 200, TEST_KID).unwrap_err();
assert!(matches!(err, CodecError::Expired));
}
#[test]
fn paseto_v4_public_mcp_rejects_wrong_key() {
let (minter, _) = v4_public_pair();
let (_, other_verifier) = v4_public_pair();
let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
let err = other_verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
assert!(matches!(err, CodecError::SignatureMismatch));
}
#[test]
fn paseto_v4_public_mcp_rejects_unknown_kid() {
let (minter, verifier) = v4_public_pair();
let tok = minter
.mint_mcp(&sample_mcp_claims(10_000), "some-other-kid")
.unwrap();
let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
assert!(
matches!(err, CodecError::UnknownKid(ref k) if k == "some-other-kid"),
"got {err:?}"
);
}
#[test]
fn mcp_token_not_verifiable_under_session_verify_at() {
let (minter, verifier) = v4_public_pair();
let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
let err = verifier.verify_at(&tok, 5_000).unwrap_err();
assert!(
matches!(err, CodecError::Crypto(ref msg) if msg.contains("InvalidClaim")),
"got {err:?}"
);
}
#[test]
fn session_token_not_verifiable_under_mcp_verify_at() {
let (minter, verifier) = v4_public_pair();
let tok = minter.mint(&sample_claims(10_000)).unwrap();
let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
assert!(matches!(err, CodecError::MissingKid), "got {err:?}");
}
#[test]
fn paseto_v4_public_mcp_verifier_derives_from_minter() {
let (minter, _) = v4_public_pair();
let derived = minter.verifier().unwrap();
let c = sample_mcp_claims(10_000);
let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
assert_eq!(derived.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap(), c);
}
}