#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]
use std::collections::{BTreeMap, HashMap};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rusty_paseto::core::{
Footer, ImplicitAssertion, Key, Paseto, PasetoAsymmetricPublicKey, Public, UntrustedToken, V4,
};
use serde::{Deserialize, Serialize};
pub mod abac;
pub use abac::{Action, ActionPattern, Decision, Effect, Policy, ReloadablePolicy, Rule};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub email: String,
pub name: String,
pub iss: String,
pub aud: String,
pub exp: i64,
pub iat: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nbf: Option<i64>,
pub sid: String,
#[serde(default)]
pub scope: Vec<String>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attrs: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("malformed key set: {0}")]
Keys(String),
#[error("malformed token: {0}")]
Malformed(String),
#[error("token footer has no kid")]
MissingKid,
#[error("no verification key for kid {0:?}")]
UnknownKid(String),
#[error("token verification failed: {0}")]
Paseto(String),
#[error("claim rejected: {0}")]
Claim(String),
#[error("key {kid:?} uses unsupported algorithm {algorithm:?}")]
UnsupportedAlgorithm {
kid: String,
algorithm: String,
},
#[cfg(feature = "fetch")]
#[error("key set fetch failed: {0}")]
Fetch(String),
}
#[derive(Debug, Clone)]
enum VerificationKey {
Ed25519(Box<[u8; 32]>),
Unsupported {
label: String,
},
}
impl VerificationKey {
fn from_jwk(jwk: &serde_json::Value) -> Result<Option<(String, Self)>, VerifyError> {
let Some(kid) = jwk.get("kid").and_then(serde_json::Value::as_str) else {
if is_ed25519(jwk) {
return Err(VerifyError::Keys("ed25519 jwk missing \"kid\"".to_string()));
}
return Ok(None);
};
if !is_ed25519(jwk) {
return Ok(Some((
kid.to_string(),
Self::Unsupported {
label: algorithm_label(jwk),
},
)));
}
let x = jwk
.get("x")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| VerifyError::Keys(format!("jwk {kid} missing \"x\"")))?;
let bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|err| VerifyError::Keys(format!("jwk {kid}: bad base64url x: {err}")))?;
let key: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| VerifyError::Keys(format!("jwk {kid}: x is not 32 bytes")))?;
Ok(Some((kid.to_string(), Self::Ed25519(Box::new(key)))))
}
}
fn is_ed25519(jwk: &serde_json::Value) -> bool {
jwk.get("kty").and_then(serde_json::Value::as_str) == Some("OKP")
&& jwk.get("crv").and_then(serde_json::Value::as_str) == Some("Ed25519")
}
fn algorithm_label(jwk: &serde_json::Value) -> String {
let field = |name: &str| {
jwk.get(name)
.and_then(serde_json::Value::as_str)
.unwrap_or("?")
.to_string()
};
match jwk.get("alg").and_then(serde_json::Value::as_str) {
Some(alg) => format!("{}/{alg}", field("kty")),
None => format!("{}/{}", field("kty"), field("crv")),
}
}
pub struct Verifier {
keys: HashMap<String, VerificationKey>,
issuer: String,
audience: String,
}
impl Verifier {
pub fn from_paseto_keys_value(
keys_doc: &serde_json::Value,
issuer: &str,
audience: &str,
) -> Result<Self, VerifyError> {
let entries = keys_doc
.get("keys")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| VerifyError::Keys("missing \"keys\" array".to_string()))?;
let mut keys: HashMap<String, VerificationKey> = HashMap::new();
for jwk in entries {
let Some((kid, key)) = VerificationKey::from_jwk(jwk)? else {
continue;
};
if keys.insert(kid.clone(), key).is_some() {
return Err(VerifyError::Keys(format!(
"duplicate kid {kid:?} in key set"
)));
}
}
Ok(Self {
keys,
issuer: issuer.to_string(),
audience: audience.to_string(),
})
}
#[must_use]
pub fn key_count(&self) -> usize {
self.keys
.values()
.filter(|k| matches!(k, VerificationKey::Ed25519(_)))
.count()
}
#[must_use]
pub fn unsupported_key_count(&self) -> usize {
self.keys
.values()
.filter(|k| matches!(k, VerificationKey::Unsupported { .. }))
.count()
}
#[must_use]
pub fn algorithms(&self) -> Vec<String> {
let mut out: Vec<String> = self
.keys
.values()
.map(|k| match k {
VerificationKey::Ed25519(_) => "OKP/Ed25519".to_string(),
VerificationKey::Unsupported { label } => label.clone(),
})
.collect();
out.sort_unstable();
out.dedup();
out
}
pub fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
if !token.starts_with("v4.public.") {
return Err(VerifyError::Malformed("not a v4.public token".to_string()));
}
let untrusted =
UntrustedToken::try_parse(token).map_err(|err| VerifyError::Paseto(err.to_string()))?;
let footer = untrusted
.footer_str()
.map_err(|err| VerifyError::Paseto(err.to_string()))?
.ok_or(VerifyError::MissingKid)?;
let footer_json: serde_json::Value = serde_json::from_str(&footer)
.map_err(|err| VerifyError::Malformed(format!("footer is not json: {err}")))?;
let kid = footer_json
.get("kid")
.and_then(serde_json::Value::as_str)
.ok_or(VerifyError::MissingKid)?;
let selected = self
.keys
.get(kid)
.ok_or_else(|| VerifyError::UnknownKid(kid.to_string()))?;
let key_bytes = match selected {
VerificationKey::Ed25519(bytes) => bytes,
VerificationKey::Unsupported { label } => {
return Err(VerifyError::UnsupportedAlgorithm {
kid: kid.to_string(),
algorithm: label.clone(),
});
}
};
let key = Key::<32>::from(key_bytes.as_ref());
let public_key = PasetoAsymmetricPublicKey::<V4, Public>::from(&key);
let payload = Paseto::<V4, Public>::try_verify(
token,
&public_key,
Footer::from(footer.as_str()),
Option::<ImplicitAssertion>::None,
)
.map_err(|err| VerifyError::Paseto(err.to_string()))?;
let claims: Claims = serde_json::from_str(&payload)
.map_err(|err| VerifyError::Malformed(format!("payload is not claims json: {err}")))?;
self.check_claims(&claims)?;
Ok(claims)
}
fn check_claims(&self, claims: &Claims) -> Result<(), VerifyError> {
if claims.iss != self.issuer {
return Err(VerifyError::Claim(format!(
"issuer mismatch: expected {:?}, got {:?}",
self.issuer, claims.iss
)));
}
if claims.aud != self.audience {
return Err(VerifyError::Claim(format!(
"audience mismatch: expected {:?}, got {:?}",
self.audience, claims.aud
)));
}
let now = now_unix();
if let Some(nbf) = claims.nbf
&& now < nbf
{
return Err(VerifyError::Claim("token not yet valid (nbf)".to_string()));
}
if now >= claims.exp {
return Err(VerifyError::Claim("token expired (exp)".to_string()));
}
Ok(())
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(i64::MAX)
}
#[cfg(feature = "fetch")]
impl Verifier {
pub async fn from_paseto_keys_url(
url: &str,
issuer: &str,
audience: &str,
) -> Result<Self, VerifyError> {
const MAX_KEYS_BYTES: usize = 64 * 1024;
if !url_scheme_is_permitted(url) {
return Err(VerifyError::Fetch(format!(
"key-set URL must be https:// (or http:// on loopback); refusing to fetch {url}"
)));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| VerifyError::Fetch(e.to_string()))?;
let mut response = client
.get(url)
.send()
.await
.map_err(|e| VerifyError::Fetch(e.to_string()))?
.error_for_status()
.map_err(|e| VerifyError::Fetch(e.to_string()))?;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| VerifyError::Fetch(e.to_string()))?
{
if buf.len() + chunk.len() > MAX_KEYS_BYTES {
return Err(VerifyError::Fetch(format!(
"key set exceeds the {MAX_KEYS_BYTES}-byte limit"
)));
}
buf.extend_from_slice(&chunk);
}
let body: serde_json::Value =
serde_json::from_slice(&buf).map_err(|e| VerifyError::Fetch(e.to_string()))?;
Self::from_paseto_keys_value(&body, issuer, audience)
}
}
pub struct ReloadableVerifier {
inner: std::sync::RwLock<std::sync::Arc<Verifier>>,
}
impl ReloadableVerifier {
#[must_use]
pub fn new(verifier: Verifier) -> Self {
Self {
inner: std::sync::RwLock::new(std::sync::Arc::new(verifier)),
}
}
#[must_use]
pub fn current(&self) -> std::sync::Arc<Verifier> {
self.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn store(&self, verifier: Verifier) {
*self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(verifier);
}
}
#[cfg(feature = "fetch")]
fn url_scheme_is_permitted(url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url.trim()) else {
return false;
};
match parsed.scheme() {
"https" => true,
"http" => matches!(
parsed.host_str(),
Some("127.0.0.1" | "::1" | "[::1]" | "localhost")
),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rusty_paseto::core::{PasetoAsymmetricPrivateKey, Payload};
const TEST_SEED: [u8; 32] = [
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
7, 7,
];
const ISSUER: &str = "authentication-service";
const AUDIENCE: &str = "main-x-service";
const KID: &str = "test-key-1";
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&TEST_SEED)
}
fn test_keys() -> serde_json::Value {
let public = signing_key().verifying_key().to_bytes();
let x = URL_SAFE_NO_PAD.encode(public);
serde_json::json!({
"keys": [{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x }]
})
}
fn sign(kid: &str, claims: &Claims) -> String {
let payload = serde_json::to_string(claims).expect("serialize claims");
sign_payload(kid, &payload)
}
fn sign_payload(kid: &str, payload: &str) -> String {
let keypair = signing_key().to_keypair_bytes(); let key = Key::<64>::from(keypair);
let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
let footer = format!(r#"{{"kid":"{kid}"}}"#);
let mut builder = Paseto::<V4, Public>::builder();
builder.set_payload(Payload::from(payload));
builder.set_footer(Footer::from(footer.as_str()));
builder.try_sign(&private).expect("sign")
}
fn claims(exp_offset: i64) -> Claims {
let now = 1_900_000_000; Claims {
sub: "11111111-1111-1111-1111-111111111111".to_string(),
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
iss: ISSUER.to_string(),
aud: AUDIENCE.to_string(),
exp: now + exp_offset,
iat: now,
nbf: None,
sid: "22222222-2222-2222-2222-222222222222".to_string(),
scope: vec![],
roles: vec![],
attrs: BTreeMap::new(),
}
}
#[test]
fn valid_token_round_trips_claims() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
assert_eq!(verifier.key_count(), 1);
let token = sign(KID, &claims(3600));
let got = verifier.verify(&token).expect("verify");
assert_eq!(got.sub, "11111111-1111-1111-1111-111111111111");
assert_eq!(got.email, "alice@example.com");
assert_eq!(got.iss, ISSUER);
assert_eq!(got.aud, AUDIENCE);
assert_eq!(got.sid, "22222222-2222-2222-2222-222222222222");
}
#[test]
fn attrs_claim_round_trips_mint_to_verify() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let mut c = claims(3600);
c.attrs.insert(
"access".to_string(),
vec!["write".to_string(), "admin".to_string()],
);
c.attrs
.insert("dept".to_string(), vec!["cardiology".to_string()]);
let token = sign(KID, &c);
let got = verifier.verify(&token).expect("verify");
assert_eq!(got.attrs, c.attrs);
}
#[test]
fn absent_attrs_claim_verifies_to_empty_map() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let now = 1_900_000_000_i64;
let payload = serde_json::json!({
"sub": "11111111-1111-1111-1111-111111111111",
"email": "alice@example.com",
"name": "Alice",
"iss": ISSUER,
"aud": AUDIENCE,
"exp": now + 3600,
"iat": now,
"sid": "22222222-2222-2222-2222-222222222222",
})
.to_string();
let token = sign_payload(KID, &payload);
let got = verifier.verify(&token).expect("verify");
assert!(got.attrs.is_empty());
}
#[test]
fn expired_token_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let token = sign(KID, &claims(-10_000_000_000));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::Claim(_))
));
}
#[test]
fn not_yet_valid_token_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let mut c = claims(3600);
c.nbf = Some(1_900_000_000); let token = sign(KID, &c);
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::Claim(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let verifier =
Verifier::from_paseto_keys_value(&test_keys(), ISSUER, "some-other-service").unwrap();
let token = sign(KID, &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::Claim(_))
));
}
#[test]
fn wrong_issuer_is_rejected() {
let verifier =
Verifier::from_paseto_keys_value(&test_keys(), "some-other-issuer", AUDIENCE).unwrap();
let token = sign(KID, &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::Claim(_))
));
}
#[test]
fn unknown_kid_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let token = sign("not-a-known-kid", &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::UnknownKid(_))
));
}
#[test]
fn tampered_payload_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let token = sign(KID, &claims(3600));
let mut parts: Vec<&str> = token.split('.').collect();
let mut payload = parts[2].to_string();
let last = payload.len() - 1;
let swapped = if &payload[last..] == "A" { "B" } else { "A" };
payload.replace_range(last.., swapped);
parts[2] = &payload;
let tampered = parts.join(".");
assert!(verifier.verify(&tampered).is_err());
}
#[test]
fn garbage_token_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
assert!(verifier.verify("not.a.paseto").is_err());
assert!(verifier.verify("").is_err());
assert!(matches!(
verifier.verify("v2.public.aaaa"),
Err(VerifyError::Malformed(_))
));
}
#[test]
fn empty_key_set_builds_but_rejects_everything() {
let keys = serde_json::json!({ "keys": [] });
let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
assert_eq!(verifier.key_count(), 0);
let token = sign(KID, &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::UnknownKid(_))
));
}
#[test]
fn key_set_without_keys_array_errors() {
let keys = serde_json::json!({ "not_keys": [] });
assert!(matches!(
Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
Err(VerifyError::Keys(_))
));
}
#[test]
fn non_ed25519_keys_are_skipped() {
let keys = serde_json::json!({
"keys": [{ "kty": "RSA", "kid": "rsa-1", "n": "a", "e": "b" }]
});
let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
assert_eq!(verifier.key_count(), 0);
}
#[test]
fn ed25519_key_missing_kid_errors() {
let public = signing_key().verifying_key().to_bytes();
let x = URL_SAFE_NO_PAD.encode(public);
let keys = serde_json::json!({
"keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x }]
});
assert!(matches!(
Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
Err(VerifyError::Keys(_))
));
}
#[test]
fn ed25519_key_with_bad_x_errors() {
let keys = serde_json::json!({
"keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "bad-1", "x": "!!!not-base64!!!" }]
});
assert!(matches!(
Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
Err(VerifyError::Keys(_))
));
}
#[test]
fn ed25519_key_with_wrong_length_x_errors() {
let keys = serde_json::json!({
"keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "short-1", "x": URL_SAFE_NO_PAD.encode([1, 2, 3]) }]
});
assert!(matches!(
Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
Err(VerifyError::Keys(_))
));
}
#[cfg(feature = "fetch")]
#[tokio::test]
async fn from_paseto_keys_url_maps_transport_error_to_fetch() {
let result = Verifier::from_paseto_keys_url("not-a-url://nowhere", ISSUER, AUDIENCE).await;
assert!(matches!(result, Err(VerifyError::Fetch(_))));
}
#[test]
fn reloadable_verifier_swaps_the_key_set_for_rotation() {
let empty = serde_json::json!({ "keys": [] });
let holder = ReloadableVerifier::new(
Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap(),
);
let token = sign(KID, &claims(3600));
assert!(
holder.current().verify(&token).is_err(),
"before rotation: the empty key set rejects the token"
);
holder.store(Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap());
assert!(
holder.current().verify(&token).is_ok(),
"after rotation: the token verifies against the new key set"
);
let snapshot = holder.current();
holder.store(Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap());
assert!(
snapshot.verify(&token).is_ok(),
"an in-flight verification keeps the key set it snapshotted"
);
assert!(
holder.current().verify(&token).is_err(),
"new requests see the latest key set"
);
}
const ATTACKER_SEED: [u8; 32] = [
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
9, 9,
];
fn attacker_sign_payload(kid: &str, payload: &str) -> String {
let keypair = SigningKey::from_bytes(&ATTACKER_SEED).to_keypair_bytes();
let key = Key::<64>::from(keypair);
let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
let footer = format!(r#"{{"kid":"{kid}"}}"#);
let mut builder = Paseto::<V4, Public>::builder();
builder.set_payload(Payload::from(payload));
builder.set_footer(Footer::from(footer.as_str()));
builder.try_sign(&private).expect("attacker sign")
}
#[test]
fn cross_key_forgery_with_honest_kid_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let payload = serde_json::to_string(&claims(3600)).unwrap();
let forged = attacker_sign_payload(KID, &payload);
assert!(matches!(
verifier.verify(&forged),
Err(VerifyError::Paseto(_))
));
}
#[test]
fn token_missing_exp_is_rejected() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let now = 1_900_000_000_i64;
let payload = serde_json::json!({
"sub": "11111111-1111-1111-1111-111111111111",
"email": "alice@example.com",
"name": "Alice",
"iss": ISSUER,
"aud": AUDIENCE,
"iat": now,
"sid": "22222222-2222-2222-2222-222222222222",
})
.to_string();
let token = sign_payload(KID, &payload);
assert!(verifier.verify(&token).is_err(), "missing exp must reject");
}
#[test]
fn malformed_tokens_never_panic() {
let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
let valid = sign(KID, &claims(3600));
let mut cases: Vec<String> = vec![
String::new(),
".".into(),
"....".into(),
"v4".into(),
"v4.public".into(),
"v4.public.".into(),
"v4.public.!!!!".into(),
"v4.local.deadbeef".into(),
"v3.public.deadbeef".into(),
"v4.public.YWJj.YWJj".into(),
format!("v4.public.{}", "A".repeat(10_000)),
format!("{valid}.extrasegment"),
valid[..valid.len() / 2].to_string(),
"🔥.🔥.🔥".into(),
];
cases.push(sign_payload(
&"k".repeat(5_000),
&serde_json::to_string(&claims(3600)).unwrap(),
));
for c in cases {
let _ = verifier.verify(&c);
}
}
#[cfg(feature = "fetch")]
#[tokio::test]
async fn non_https_keys_url_is_refused() {
for url in [
"http://auth.example.com/.well-known/paseto-keys",
"ftp://x",
"//x",
"auth",
] {
let r = Verifier::from_paseto_keys_url(url, ISSUER, AUDIENCE).await;
assert!(
matches!(r, Err(VerifyError::Fetch(_))),
"non-https URL {url:?} must be refused"
);
}
}
#[cfg(feature = "fetch")]
#[test]
fn url_scheme_policy_allows_https_and_loopback_http_only() {
assert!(url_scheme_is_permitted("https://auth.example.com/keys"));
assert!(url_scheme_is_permitted("http://127.0.0.1:8080/keys"));
assert!(url_scheme_is_permitted("http://localhost:3000/keys"));
assert!(url_scheme_is_permitted("http://[::1]:9000/keys"));
assert!(!url_scheme_is_permitted("http://auth.example.com/keys"));
assert!(!url_scheme_is_permitted("http://10.0.0.5/keys"));
assert!(!url_scheme_is_permitted("ftp://x"));
assert!(!url_scheme_is_permitted("not a url"));
}
fn mixed_keys() -> serde_json::Value {
let public = signing_key().verifying_key().to_bytes();
let x = URL_SAFE_NO_PAD.encode(public);
serde_json::json!({
"keys": [
{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x },
{ "kty": "AKP", "alg": "ML-DSA-44", "use": "sig", "kid": "pq-1",
"pub": "irrelevant-to-this-build" }
]
})
}
#[test]
fn unknown_algorithm_in_the_key_set_does_not_break_ed25519() {
let verifier =
Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
assert_eq!(verifier.key_count(), 1, "one usable key");
assert_eq!(verifier.unsupported_key_count(), 1);
let claims = verifier.verify(&sign(KID, &claims(3600))).expect("verify");
assert_eq!(claims.iss, ISSUER);
}
#[test]
fn token_naming_an_unsupported_algorithm_reports_the_algorithm() {
let verifier =
Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
let err = verifier
.verify(&sign("pq-1", &claims(3600)))
.expect_err("must refuse");
match err {
VerifyError::UnsupportedAlgorithm { kid, algorithm } => {
assert_eq!(kid, "pq-1");
assert_eq!(algorithm, "AKP/ML-DSA-44", "the label must be actionable");
}
other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
}
}
#[test]
fn an_unsupported_key_can_never_produce_an_accept() {
let verifier =
Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
for offset in [3600, -3600] {
assert!(
verifier.verify(&sign("pq-1", &claims(offset))).is_err(),
"no token selecting an unsupported key may verify"
);
}
}
#[test]
fn duplicate_kid_is_rejected_rather_than_resolved_by_order() {
let public = signing_key().verifying_key().to_bytes();
let x = URL_SAFE_NO_PAD.encode(public);
let doc = serde_json::json!({
"keys": [
{ "kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x },
{ "kty": "AKP", "alg": "ML-DSA-44", "kid": KID }
]
});
let result = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE);
let Err(err) = result else {
panic!("duplicate kid must fail");
};
assert!(matches!(err, VerifyError::Keys(m) if m.contains("duplicate kid")));
}
#[test]
fn unselectable_unsupported_entry_is_skipped_not_fatal() {
let doc = serde_json::json!({
"keys": [{ "kty": "AKP", "alg": "ML-DSA-44" }]
});
let verifier = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE).expect("keys");
assert_eq!(verifier.key_count(), 0);
assert_eq!(verifier.unsupported_key_count(), 0);
let bad = serde_json::json!({
"keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "AAAA" }]
});
assert!(Verifier::from_paseto_keys_value(&bad, ISSUER, AUDIENCE).is_err());
}
#[test]
fn algorithms_reports_what_is_published() {
let verifier =
Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
assert_eq!(verifier.algorithms(), vec!["AKP/ML-DSA-44", "OKP/Ed25519"]);
}
}