use std::collections::HashMap;
use std::fmt;
use anyhow::Result;
pub mod pae;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
#[cfg(all(
feature = "ml-dsa-44",
not(any(feature = "ml-dsa-65", feature = "ml-dsa-87"))
))]
use ml_dsa::MlDsa44 as MlDsaParam;
#[cfg(all(
feature = "ml-dsa-65",
not(any(feature = "ml-dsa-44", feature = "ml-dsa-87"))
))]
use ml_dsa::MlDsa65 as MlDsaParam;
#[cfg(all(
feature = "ml-dsa-87",
not(any(feature = "ml-dsa-44", feature = "ml-dsa-65"))
))]
use ml_dsa::MlDsa87 as MlDsaParam;
#[cfg(not(any(feature = "ml-dsa-44", feature = "ml-dsa-65", feature = "ml-dsa-87")))]
compile_error!(
"Please enable exactly one of the features: `ml-dsa-44`, `ml-dsa-65`, or `ml-dsa-87`."
);
#[cfg(all(
feature = "ml-dsa-44",
any(feature = "ml-dsa-65", feature = "ml-dsa-87")
))]
compile_error!("Only one of `ml-dsa-44`, `ml-dsa-65`, or `ml-dsa-87` may be enabled.");
#[cfg(all(feature = "ml-dsa-65", feature = "ml-dsa-87"))]
compile_error!("Only one of `ml-dsa-44`, `ml-dsa-65`, or `ml-dsa-87` may be enabled.");
use ml_dsa::{
KeyGen,
signature::{SignatureEncoding, Signer, Verifier},
};
use hkdf::Hkdf;
use ml_kem::{
KemCore, MlKem768,
kem::{Decapsulate, Encapsulate},
};
pub use rand_core::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use zeroize::{Zeroize, ZeroizeOnDrop};
use time::OffsetDateTime;
use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{AeadCore, AeadInPlace, KeyInit, OsRng as AeadOsRng},
};
#[cfg(feature = "logging")]
use tracing::{debug, instrument, warn};
pub struct PasetoPQ;
pub use pae::pae_encode;
#[derive(Clone)]
pub struct KeyPair {
signing_key: SigningKey,
verifying_key: VerifyingKey,
}
#[derive(Clone)]
pub struct SigningKey(ml_dsa::SigningKey<MlDsaParam>);
#[derive(Clone)]
pub struct VerifyingKey(ml_dsa::VerifyingKey<MlDsaParam>);
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct SymmetricKey([u8; 32]);
#[derive(Clone)]
pub struct KemKeyPair {
pub encapsulation_key: EncapsulationKey,
pub decapsulation_key: DecapsulationKey,
}
#[derive(Clone)]
pub struct EncapsulationKey(<MlKem768 as KemCore>::EncapsulationKey);
#[derive(Clone)]
pub struct DecapsulationKey(<MlKem768 as KemCore>::DecapsulationKey);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Footer {
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer_meta: Option<String>,
#[serde(flatten)]
pub custom: HashMap<String, Value>,
}
impl Footer {
pub fn new() -> Self {
Self {
kid: None,
version: None,
issuer_meta: None,
custom: HashMap::new(),
}
}
pub fn set_kid(&mut self, kid: &str) -> Result<(), PqPasetoError> {
self.kid = Some(kid.to_string());
Ok(())
}
pub fn set_version(&mut self, version: &str) -> Result<(), PqPasetoError> {
self.version = Some(version.to_string());
Ok(())
}
pub fn set_issuer_meta(&mut self, issuer_meta: &str) -> Result<(), PqPasetoError> {
self.issuer_meta = Some(issuer_meta.to_string());
Ok(())
}
pub fn add_custom<T: Serialize + ?Sized>(
&mut self,
key: &str,
value: &T,
) -> Result<(), PqPasetoError> {
let json_value = serde_json::to_value(value)?;
self.custom.insert(key.to_string(), json_value);
Ok(())
}
pub fn get_custom(&self, key: &str) -> Option<&Value> {
self.custom.get(key)
}
pub fn kid(&self) -> Option<&str> {
self.kid.as_deref()
}
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
pub fn issuer_meta(&self) -> Option<&str> {
self.issuer_meta.as_deref()
}
pub fn to_base64(&self) -> Result<String, PqPasetoError> {
let json = serde_json::to_vec(self)?;
Ok(URL_SAFE_NO_PAD.encode(&json))
}
pub(crate) fn from_base64(encoded: &str) -> Result<Self, PqPasetoError> {
let bytes = URL_SAFE_NO_PAD.decode(encoded)?;
let footer = serde_json::from_slice(&bytes)?;
Ok(footer)
}
}
impl Default for Footer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub exp: Option<OffsetDateTime>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub nbf: Option<OffsetDateTime>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub iat: Option<OffsetDateTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
#[serde(flatten)]
pub custom: HashMap<String, Value>,
}
#[derive(Debug, Clone)]
pub struct VerifiedToken {
claims: Claims,
footer: Option<Footer>,
raw_token: String,
}
#[derive(Debug, Clone)]
pub struct ParsedToken {
purpose: String,
version: String,
payload: Vec<u8>,
signature_or_tag: Option<Vec<u8>>, footer: Option<Footer>,
raw_token: String,
}
#[derive(Debug, Clone)]
pub struct TokenSizeBreakdown {
pub prefix: usize,
pub payload: usize,
pub signature_or_tag: usize,
pub footer: Option<usize>,
pub separators: usize,
pub base64_overhead: usize,
}
#[derive(Debug, Clone)]
pub struct TokenSizeEstimator {
breakdown: TokenSizeBreakdown,
}
#[derive(Debug, thiserror::Error)]
pub enum PqPasetoError {
#[error("Invalid token format: {0}")]
InvalidFormat(String),
#[error("Signature verification failed")]
SignatureVerificationFailed,
#[error("Token has expired")]
TokenExpired,
#[error("Token is not yet valid (nbf claim)")]
TokenNotYetValid,
#[error("Invalid audience: expected {expected}, got {actual}")]
InvalidAudience { expected: String, actual: String },
#[error("Invalid issuer: expected {expected}, got {actual}")]
InvalidIssuer { expected: String, actual: String },
#[error("JSON serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Base64 decoding error: {0}")]
Base64Error(#[from] base64::DecodeError),
#[error("Time parsing error: {0}")]
TimeError(#[from] time::error::ComponentRange),
#[error("Cryptographic error: {0}")]
CryptoError(String),
#[error("Encryption error: {0}")]
EncryptionError(String),
#[error("Decryption error: {0}")]
DecryptionError(String),
#[error("Token parsing error: {0}")]
TokenParsingError(String),
}
pub const TOKEN_PREFIX_PUBLIC: &str = "paseto.pq1.public";
pub const TOKEN_PREFIX_LOCAL: &str = "paseto.pq1.local";
const MAX_TOKEN_SIZE: usize = 1024 * 1024; const SYMMETRIC_KEY_SIZE: usize = 32;
const NONCE_SIZE: usize = 12;
impl KeyPair {
#[cfg_attr(feature = "logging", instrument(skip(rng)))]
pub fn generate<R: CryptoRng + RngCore>(rng: &mut R) -> Self {
let keypair = MlDsaParam::key_gen(rng);
#[cfg(feature = "logging")]
debug!("Generated new ML-DSA key pair");
Self {
signing_key: SigningKey(keypair.signing_key().clone()),
verifying_key: VerifyingKey(keypair.verifying_key().clone()),
}
}
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
pub fn verifying_key(&self) -> &VerifyingKey {
&self.verifying_key
}
pub fn signing_key_to_bytes(&self) -> Vec<u8> {
let encoded = self.signing_key.0.encode();
encoded.to_vec()
}
pub fn signing_key_from_bytes(bytes: &[u8]) -> Result<SigningKey, PqPasetoError> {
let encoded = ml_dsa::EncodedSigningKey::<MlDsaParam>::try_from(bytes)
.map_err(|e| PqPasetoError::CryptoError(format!("Invalid key bytes: {:?}", e)))?;
let key = ml_dsa::SigningKey::<MlDsaParam>::decode(&encoded);
Ok(SigningKey(key))
}
pub fn verifying_key_to_bytes(&self) -> Vec<u8> {
let encoded = self.verifying_key.0.encode();
encoded.to_vec()
}
pub fn verifying_key_from_bytes(bytes: &[u8]) -> Result<VerifyingKey, PqPasetoError> {
let encoded = ml_dsa::EncodedVerifyingKey::<MlDsaParam>::try_from(bytes)
.map_err(|e| PqPasetoError::CryptoError(format!("Invalid key bytes: {:?}", e)))?;
let key = ml_dsa::VerifyingKey::<MlDsaParam>::decode(&encoded);
Ok(VerifyingKey(key))
}
}
impl SymmetricKey {
#[cfg_attr(feature = "logging", instrument(skip(rng)))]
pub fn generate<R: CryptoRng + RngCore>(rng: &mut R) -> Self {
let mut key_bytes = [0u8; SYMMETRIC_KEY_SIZE];
rng.fill_bytes(&mut key_bytes);
#[cfg(feature = "logging")]
debug!("Generated new symmetric key");
Self(key_bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, PqPasetoError> {
if bytes.len() != SYMMETRIC_KEY_SIZE {
return Err(PqPasetoError::CryptoError(format!(
"Invalid symmetric key length: expected {}, got {}",
SYMMETRIC_KEY_SIZE,
bytes.len()
)));
}
let mut key_bytes = [0u8; SYMMETRIC_KEY_SIZE];
key_bytes.copy_from_slice(bytes);
Ok(Self(key_bytes))
}
pub fn to_bytes(&self) -> [u8; SYMMETRIC_KEY_SIZE] {
self.0
}
pub fn derive_from_shared_secret(shared_secret: &[u8], info: &[u8]) -> Self {
let hk = Hkdf::<Sha256>::new(None, shared_secret);
let mut key_bytes = [0u8; SYMMETRIC_KEY_SIZE];
hk.expand(info, &mut key_bytes)
.expect("SYMMETRIC_KEY_SIZE (32) is valid for SHA-256 HKDF output");
Self(key_bytes)
}
}
impl KemKeyPair {
#[cfg_attr(feature = "logging", instrument(skip(_rng)))]
pub fn generate<R: CryptoRng + RngCore>(_rng: &mut R) -> Self {
let (dk, ek) = MlKem768::generate(&mut chacha20poly1305::aead::OsRng);
#[cfg(feature = "logging")]
debug!("Generated new ML-KEM-768 key pair");
Self {
encapsulation_key: EncapsulationKey(ek),
decapsulation_key: DecapsulationKey(dk),
}
}
pub fn encapsulation_key_to_bytes(&self) -> Vec<u8> {
use ml_kem::EncodedSizeUser;
self.encapsulation_key.0.as_bytes().to_vec()
}
pub fn encapsulation_key_from_bytes(bytes: &[u8]) -> Result<EncapsulationKey, PqPasetoError> {
use ml_kem::{EncodedSizeUser, array::Array};
if bytes.len() != 1184 {
return Err(PqPasetoError::CryptoError(
"Invalid encapsulation key length".to_string(),
));
}
let array: Array<u8, _> = Array::try_from(bytes)
.map_err(|_| PqPasetoError::CryptoError("Invalid key format".to_string()))?;
Ok(EncapsulationKey(
<MlKem768 as KemCore>::EncapsulationKey::from_bytes(&array),
))
}
pub fn decapsulation_key_to_bytes(&self) -> Vec<u8> {
use ml_kem::EncodedSizeUser;
self.decapsulation_key.0.as_bytes().to_vec()
}
pub fn decapsulation_key_from_bytes(bytes: &[u8]) -> Result<DecapsulationKey, PqPasetoError> {
use ml_kem::{EncodedSizeUser, array::Array};
if bytes.len() != 2400 {
return Err(PqPasetoError::CryptoError(
"Invalid decapsulation key length".to_string(),
));
}
let array: Array<u8, _> = Array::try_from(bytes)
.map_err(|_| PqPasetoError::CryptoError("Invalid key format".to_string()))?;
Ok(DecapsulationKey(
<MlKem768 as KemCore>::DecapsulationKey::from_bytes(&array),
))
}
pub fn encapsulate(&self) -> (SymmetricKey, Vec<u8>) {
let (ciphertext, shared_secret) = self
.encapsulation_key
.0
.encapsulate(&mut chacha20poly1305::aead::OsRng)
.unwrap();
let symmetric_key = SymmetricKey::derive_from_shared_secret(
shared_secret.as_slice(),
b"PASETO-PQ-LOCAL-pq1",
);
(symmetric_key, ciphertext.as_slice().to_vec())
}
pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<SymmetricKey, PqPasetoError> {
use ml_kem::array::Array;
if ciphertext.len() != 1088 {
return Err(PqPasetoError::CryptoError(
"Invalid ciphertext length".to_string(),
));
}
let ct_array: Array<u8, _> = Array::try_from(ciphertext)
.map_err(|_| PqPasetoError::CryptoError("Invalid ciphertext format".to_string()))?;
let ct = ml_kem::Ciphertext::<MlKem768>::from(ct_array);
let shared_secret = self.decapsulation_key.0.decapsulate(&ct).unwrap();
Ok(SymmetricKey::derive_from_shared_secret(
shared_secret.as_ref(),
b"PASETO-PQ-LOCAL-pq1",
))
}
}
impl Claims {
pub fn new() -> Self {
Self {
iss: None,
sub: None,
aud: None,
exp: None,
nbf: None,
iat: None,
jti: None,
kid: None,
custom: HashMap::new(),
}
}
pub fn set_issuer(&mut self, issuer: impl Into<String>) -> Result<(), PqPasetoError> {
self.iss = Some(issuer.into());
Ok(())
}
pub fn set_subject(&mut self, subject: impl Into<String>) -> Result<(), PqPasetoError> {
self.sub = Some(subject.into());
Ok(())
}
pub fn set_audience(&mut self, audience: impl Into<String>) -> Result<(), PqPasetoError> {
self.aud = Some(audience.into());
Ok(())
}
pub fn set_expiration(&mut self, exp: OffsetDateTime) -> Result<(), PqPasetoError> {
self.exp = Some(exp);
Ok(())
}
pub fn set_not_before(&mut self, nbf: OffsetDateTime) -> Result<(), PqPasetoError> {
self.nbf = Some(nbf);
Ok(())
}
pub fn set_issued_at(&mut self, iat: OffsetDateTime) -> Result<(), PqPasetoError> {
self.iat = Some(iat);
Ok(())
}
pub fn set_jti(&mut self, jti: impl Into<String>) -> Result<(), PqPasetoError> {
self.jti = Some(jti.into());
Ok(())
}
pub fn set_kid(&mut self, kid: impl Into<String>) -> Result<(), PqPasetoError> {
self.kid = Some(kid.into());
Ok(())
}
pub fn add_custom(
&mut self,
key: impl Into<String>,
value: impl Serialize,
) -> Result<(), PqPasetoError> {
let value = serde_json::to_value(value)?;
self.custom.insert(key.into(), value);
Ok(())
}
pub fn get_custom(&self, key: &str) -> Option<&Value> {
self.custom.get(key)
}
pub fn validate_time(
&self,
now: OffsetDateTime,
clock_skew_tolerance: time::Duration,
) -> Result<(), PqPasetoError> {
if let Some(exp) = self.exp {
if now > exp + clock_skew_tolerance {
return Err(PqPasetoError::TokenExpired);
}
}
if let Some(nbf) = self.nbf {
if now < nbf - clock_skew_tolerance {
return Err(PqPasetoError::TokenNotYetValid);
}
}
Ok(())
}
pub fn issuer(&self) -> Option<&str> {
self.iss.as_deref()
}
pub fn subject(&self) -> Option<&str> {
self.sub.as_deref()
}
pub fn audience(&self) -> Option<&str> {
self.aud.as_deref()
}
pub fn expiration(&self) -> Option<OffsetDateTime> {
self.exp
}
pub fn not_before(&self) -> Option<OffsetDateTime> {
self.nbf
}
pub fn issued_at(&self) -> Option<OffsetDateTime> {
self.iat
}
pub fn jti(&self) -> Option<&str> {
self.jti.as_deref()
}
pub fn kid(&self) -> Option<&str> {
self.kid.as_deref()
}
pub fn to_json_value(&self) -> serde_json::Value {
serde_json::Value::from(self.clone())
}
pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_string_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
impl Default for Claims {
fn default() -> Self {
Self::new()
}
}
impl From<Claims> for serde_json::Value {
fn from(claims: Claims) -> Self {
serde_json::to_value(claims).unwrap_or(serde_json::Value::Null)
}
}
impl From<&Claims> for serde_json::Value {
fn from(claims: &Claims) -> Self {
serde_json::to_value(claims).unwrap_or(serde_json::Value::Null)
}
}
impl TokenSizeBreakdown {
pub fn total(&self) -> usize {
self.prefix
+ self.payload
+ self.signature_or_tag
+ self.footer.unwrap_or(0)
+ self.separators
+ self.base64_overhead
}
}
impl TokenSizeEstimator {
pub fn public(claims: &Claims, has_footer: bool) -> Self {
let claims_json = serde_json::to_string(claims).unwrap_or_default();
let claims_bytes = claims_json.len();
let payload_b64_len = claims_bytes.div_ceil(3) * 4;
let prefix_len = TOKEN_PREFIX_PUBLIC.len() + 1; let signature_len = if cfg!(feature = "ml-dsa-44") {
2800 } else if cfg!(feature = "ml-dsa-65") {
4300 } else {
5000 };
let footer_len = if has_footer { 150 } else { 0 }; let separators = if has_footer { 3 } else { 2 }; let base64_overhead = (claims_bytes * 4).div_ceil(3) - claims_bytes;
let breakdown = TokenSizeBreakdown {
prefix: prefix_len,
payload: payload_b64_len,
signature_or_tag: signature_len,
footer: if has_footer { Some(footer_len) } else { None },
separators,
base64_overhead,
};
Self { breakdown }
}
pub fn local(claims: &Claims, has_footer: bool) -> Self {
let claims_json = serde_json::to_string(claims).unwrap_or_default();
let claims_bytes = claims_json.len();
let encrypted_payload_len = claims_bytes + 12 + 16; let payload_b64_len = encrypted_payload_len.div_ceil(3) * 4;
let prefix_len = TOKEN_PREFIX_LOCAL.len() + 1; let footer_len = if has_footer { 150 } else { 0 }; let separators = if has_footer { 2 } else { 1 }; let base64_overhead = (encrypted_payload_len * 4).div_ceil(3) - encrypted_payload_len;
let breakdown = TokenSizeBreakdown {
prefix: prefix_len,
payload: payload_b64_len,
signature_or_tag: 0, footer: if has_footer { Some(footer_len) } else { None },
separators,
base64_overhead,
};
Self { breakdown }
}
pub fn total_bytes(&self) -> usize {
self.breakdown.total()
}
pub fn fits_in_cookie(&self) -> bool {
self.total_bytes() <= 4096
}
pub fn fits_in_url(&self) -> bool {
self.total_bytes() <= 2048
}
pub fn fits_in_header(&self) -> bool {
self.total_bytes() <= 8192
}
pub fn breakdown(&self) -> &TokenSizeBreakdown {
&self.breakdown
}
pub fn optimization_suggestions(&self) -> Vec<String> {
let mut suggestions = Vec::new();
let total = self.total_bytes();
if total > 4096 {
suggestions.push("Token exceeds cookie size limit (4KB)".to_string());
suggestions.push("Consider using shorter claim values".to_string());
suggestions.push("Move large data to footer or external storage".to_string());
suggestions.push("Use local tokens for internal services (smaller)".to_string());
}
if total > 2048 {
suggestions.push("Token exceeds URL length limits".to_string());
suggestions.push("Avoid passing token in query parameters".to_string());
}
if self.breakdown.payload > total / 2 {
suggestions.push("Payload is majority of token size - reduce claim data".to_string());
}
if self.breakdown.footer.unwrap_or(0) > 200 {
suggestions.push("Footer is large - consider minimal metadata only".to_string());
}
suggestions
}
pub fn compare_to_jwt(&self) -> String {
let jwt_typical = 200; let ratio = self.total_bytes() as f64 / jwt_typical as f64;
format!(
"{:.1}x larger than typical JWT ({} bytes)",
ratio, jwt_typical
)
}
pub fn size_summary(&self) -> String {
format!(
"Token size: {} bytes (payload: {}, signature: {}, overhead: {})",
self.total_bytes(),
self.breakdown.payload,
self.breakdown.signature_or_tag,
self.breakdown.base64_overhead + self.breakdown.separators + self.breakdown.prefix
)
}
}
impl ParsedToken {
pub fn parse(token: &str) -> Result<Self, PqPasetoError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() < 4 {
return Err(PqPasetoError::TokenParsingError(format!(
"Invalid token format: expected at least 4 parts, got {}",
parts.len()
)));
}
if parts[0] != "paseto" {
return Err(PqPasetoError::TokenParsingError(format!(
"Invalid protocol: expected 'paseto', got '{}'",
parts[0]
)));
}
let version = parts[1].to_string();
let purpose = parts[2].to_string();
match (version.as_str(), purpose.as_str()) {
("pq1", "public") | ("pq1", "local") => {}
_ => {
return Err(PqPasetoError::TokenParsingError(format!(
"Unsupported token format: {}.{}.{}",
parts[0], parts[1], parts[2]
)));
}
}
let payload = URL_SAFE_NO_PAD.decode(parts[3]).map_err(|e| {
PqPasetoError::TokenParsingError(format!("Invalid payload base64: {}", e))
})?;
let mut signature_or_tag = None;
let mut footer = None;
match purpose.as_str() {
"public" => {
if parts.len() > 6 {
return Err(PqPasetoError::TokenParsingError(
"Public token has too many parts".to_string(),
));
}
if parts.len() >= 5 {
signature_or_tag = Some(URL_SAFE_NO_PAD.decode(parts[4]).map_err(|e| {
PqPasetoError::TokenParsingError(format!("Invalid signature base64: {}", e))
})?);
}
if parts.len() >= 6 {
footer = Some(Footer::from_base64(parts[5])?);
}
}
"local" => {
if parts.len() > 5 {
return Err(PqPasetoError::TokenParsingError(
"Local token has too many parts".to_string(),
));
}
if parts.len() >= 5 {
footer = Some(Footer::from_base64(parts[4])?);
}
}
_ => unreachable!(), }
Ok(ParsedToken {
purpose,
version,
payload,
signature_or_tag,
footer,
raw_token: token.to_string(),
})
}
pub fn purpose(&self) -> &str {
&self.purpose
}
pub fn version(&self) -> &str {
&self.version
}
pub fn has_footer(&self) -> bool {
self.footer.is_some()
}
pub fn footer(&self) -> Option<&Footer> {
self.footer.as_ref()
}
pub fn payload_bytes(&self) -> &[u8] {
&self.payload
}
pub fn signature_bytes(&self) -> Option<&[u8]> {
self.signature_or_tag.as_deref()
}
pub fn payload_length(&self) -> usize {
self.payload.len()
}
pub fn total_length(&self) -> usize {
self.raw_token.len()
}
pub fn raw_token(&self) -> &str {
&self.raw_token
}
pub fn footer_json(&self) -> Option<Result<String, serde_json::Error>> {
self.footer.as_ref().map(serde_json::to_string)
}
pub fn footer_json_pretty(&self) -> Option<Result<String, serde_json::Error>> {
self.footer.as_ref().map(serde_json::to_string_pretty)
}
pub fn is_public(&self) -> bool {
self.purpose == "public"
}
pub fn is_local(&self) -> bool {
self.purpose == "local"
}
pub fn format_summary(&self) -> String {
format!(
"paseto.{}.{} (payload: {} bytes, signature: {}, footer: {})",
self.version,
self.purpose,
self.payload.len(),
if self.signature_or_tag.is_some() {
"present"
} else {
"none"
},
if self.footer.is_some() {
"present"
} else {
"none"
}
)
}
}
impl VerifiedToken {
pub fn claims(&self) -> &Claims {
&self.claims
}
pub fn footer(&self) -> Option<&Footer> {
self.footer.as_ref()
}
pub fn raw_token(&self) -> &str {
&self.raw_token
}
pub fn into_claims(self) -> Claims {
self.claims
}
pub fn into_parts(self) -> (Claims, Option<Footer>) {
(self.claims, self.footer)
}
}
impl PasetoPQ {
pub fn public_token_prefix() -> &'static str {
TOKEN_PREFIX_PUBLIC
}
pub fn local_token_prefix() -> &'static str {
TOKEN_PREFIX_LOCAL
}
pub fn is_standard_paseto_compatible() -> bool {
false
}
pub fn parse_token(token: &str) -> Result<ParsedToken, PqPasetoError> {
ParsedToken::parse(token)
}
pub fn estimate_public_size(claims: &Claims, has_footer: bool) -> TokenSizeEstimator {
TokenSizeEstimator::public(claims, has_footer)
}
pub fn estimate_local_size(claims: &Claims, has_footer: bool) -> TokenSizeEstimator {
TokenSizeEstimator::local(claims, has_footer)
}
#[cfg_attr(feature = "logging", instrument(skip(signing_key)))]
pub fn sign(signing_key: &SigningKey, claims: &Claims) -> Result<String, PqPasetoError> {
Self::sign_with_footer(signing_key, claims, None)
}
#[cfg_attr(feature = "logging", instrument(skip(signing_key)))]
pub fn sign_with_footer(
signing_key: &SigningKey,
claims: &Claims,
footer: Option<&Footer>,
) -> Result<String, PqPasetoError> {
let payload_bytes = serde_json::to_vec(claims)?;
#[cfg(feature = "logging")]
debug!("Serialized claims to {} bytes", payload_bytes.len());
let footer_bytes = match footer {
Some(f) => serde_json::to_vec(f)?,
None => Vec::new(), };
let header = TOKEN_PREFIX_PUBLIC.as_bytes();
let pae_message =
crate::pae::pae_encode_public_token(header, &payload_bytes, &footer_bytes);
#[cfg(feature = "logging")]
debug!(
"Created PAE message of {} bytes for signing",
pae_message.len()
);
let signature = signing_key.0.sign(&pae_message);
let signature_bytes = signature.to_bytes();
let encoded_payload = URL_SAFE_NO_PAD.encode(&payload_bytes);
let encoded_signature = URL_SAFE_NO_PAD.encode(signature_bytes);
let token = match footer {
Some(f) => {
let footer_b64 = f.to_base64()?;
format!(
"{}.{}.{}.{}",
TOKEN_PREFIX_PUBLIC, encoded_payload, encoded_signature, footer_b64
)
}
None => format!(
"{}.{}.{}",
TOKEN_PREFIX_PUBLIC, encoded_payload, encoded_signature
),
};
#[cfg(feature = "logging")]
debug!(
"Generated v0.1.1 token with {} byte signature and PAE footer authentication{}",
signature_bytes.len(),
if footer.is_some() { " with footer" } else { "" }
);
Ok(token)
}
#[cfg_attr(feature = "logging", instrument(skip(symmetric_key)))]
pub fn encrypt(symmetric_key: &SymmetricKey, claims: &Claims) -> Result<String, PqPasetoError> {
Self::encrypt_with_footer(symmetric_key, claims, None)
}
#[cfg_attr(feature = "logging", instrument(skip(symmetric_key)))]
pub fn encrypt_with_footer(
symmetric_key: &SymmetricKey,
claims: &Claims,
footer: Option<&Footer>,
) -> Result<String, PqPasetoError> {
let payload_bytes = serde_json::to_vec(claims)?;
#[cfg(feature = "logging")]
debug!("Serialized claims to {} bytes", payload_bytes.len());
let cipher = ChaCha20Poly1305::new((&symmetric_key.0).into());
let nonce = ChaCha20Poly1305::generate_nonce(&mut AeadOsRng);
let footer_bytes = match footer {
Some(f) => serde_json::to_vec(f)?,
None => Vec::new(), };
let header = TOKEN_PREFIX_LOCAL.as_bytes();
let nonce_bytes = nonce.as_slice();
let aad = crate::pae::pae_encode_local_token(header, nonce_bytes, &footer_bytes);
#[cfg(feature = "logging")]
debug!(
"Created PAE AAD of {} bytes for footer authentication",
aad.len()
);
let mut buffer = payload_bytes.clone();
let tag = cipher
.encrypt_in_place_detached(&nonce, &aad, &mut buffer)
.map_err(|e| PqPasetoError::EncryptionError(format!("Encryption failed: {}", e)))?;
let mut ciphertext = buffer;
ciphertext.extend_from_slice(&tag);
let mut encrypted_data = Vec::new();
encrypted_data.extend_from_slice(&nonce);
encrypted_data.extend_from_slice(&ciphertext);
let encoded_payload = URL_SAFE_NO_PAD.encode(&encrypted_data);
let token = match footer {
Some(f) => {
let footer_b64 = f.to_base64()?;
format!("{}.{}.{}", TOKEN_PREFIX_LOCAL, encoded_payload, footer_b64)
}
None => format!("{}.{}", TOKEN_PREFIX_LOCAL, encoded_payload),
};
#[cfg(feature = "logging")]
debug!(
"Generated v0.1.1 local token with {} byte payload and PAE footer authentication{}",
encrypted_data.len(),
if footer.is_some() { " with footer" } else { "" }
);
Ok(token)
}
#[cfg_attr(feature = "logging", instrument(skip(symmetric_key)))]
pub fn decrypt(
symmetric_key: &SymmetricKey,
token: &str,
) -> Result<VerifiedToken, PqPasetoError> {
Self::decrypt_with_footer(symmetric_key, token)
}
#[cfg_attr(feature = "logging", instrument(skip(symmetric_key)))]
pub fn decrypt_with_footer(
symmetric_key: &SymmetricKey,
token: &str,
) -> Result<VerifiedToken, PqPasetoError> {
if token.len() > MAX_TOKEN_SIZE {
return Err(PqPasetoError::InvalidFormat("Token too large".into()));
}
let parts: Vec<&str> = token.splitn(5, '.').collect();
let (encoded_payload, footer) = if parts.len() == 5 {
if parts[0] != "paseto" || parts[1] != "pq1" || parts[2] != "local" {
return Err(PqPasetoError::InvalidFormat(
"Invalid token format - expected 'paseto.pq1.local'".into(),
));
}
let footer = Footer::from_base64(parts[4])?;
(parts[3], Some(footer))
} else if parts.len() == 4 {
if parts[0] != "paseto" || parts[1] != "pq1" || parts[2] != "local" {
return Err(PqPasetoError::InvalidFormat(
"Invalid token format - expected 'paseto.pq1.local'".into(),
));
}
(parts[3], None)
} else {
return Err(PqPasetoError::InvalidFormat(
"Expected 4 or 5 parts separated by '.' for local token".into(),
));
};
let encrypted_data = URL_SAFE_NO_PAD.decode(encoded_payload).map_err(|e| {
PqPasetoError::InvalidFormat(format!("Invalid payload encoding: {}", e))
})?;
if encrypted_data.len() < NONCE_SIZE + 16 {
return Err(PqPasetoError::DecryptionError(
"Encrypted data too short for nonce and tag".into(),
));
}
let nonce = Nonce::from_slice(&encrypted_data[..NONCE_SIZE]);
let ciphertext_with_tag = &encrypted_data[NONCE_SIZE..];
if ciphertext_with_tag.len() < 16 {
return Err(PqPasetoError::DecryptionError(
"Encrypted data too short for authentication tag".into(),
));
}
let tag_start = ciphertext_with_tag.len() - 16;
let mut ciphertext = ciphertext_with_tag[..tag_start].to_vec();
let tag = &ciphertext_with_tag[tag_start..];
let footer_bytes = match &footer {
Some(f) => serde_json::to_vec(f)?,
None => Vec::new(), };
let header = TOKEN_PREFIX_LOCAL.as_bytes();
let nonce_bytes = nonce.as_slice();
let aad = crate::pae::pae_encode_local_token(header, nonce_bytes, &footer_bytes);
#[cfg(feature = "logging")]
debug!(
"Reconstructed PAE AAD of {} bytes for footer validation",
aad.len()
);
let cipher = ChaCha20Poly1305::new((&symmetric_key.0).into());
use chacha20poly1305::aead::generic_array::GenericArray;
let tag_array = GenericArray::from_slice(tag);
let payload_bytes = cipher
.decrypt_in_place_detached(nonce, &aad, &mut ciphertext, tag_array)
.map_err(|e| {
PqPasetoError::DecryptionError(format!(
"Decryption failed (footer authentication failed): {}",
e
))
})
.map(|_| ciphertext)?;
#[cfg(feature = "logging")]
debug!("v0.1.1 PAE decryption successful with footer authentication");
let claims: Claims = serde_json::from_slice(&payload_bytes)?;
claims.validate_time(OffsetDateTime::now_utc(), time::Duration::seconds(30))?;
Ok(VerifiedToken {
claims,
footer,
raw_token: token.to_string(),
})
}
pub fn decrypt_with_options(
symmetric_key: &SymmetricKey,
token: &str,
expected_audience: Option<&str>,
expected_issuer: Option<&str>,
clock_skew_tolerance: time::Duration,
) -> Result<VerifiedToken, PqPasetoError> {
let verified = Self::decrypt(symmetric_key, token)?;
if let Some(expected_aud) = expected_audience {
match verified.claims.audience() {
Some(actual_aud) if actual_aud == expected_aud => {}
Some(actual_aud) => {
return Err(PqPasetoError::InvalidAudience {
expected: expected_aud.to_string(),
actual: actual_aud.to_string(),
});
}
None => {
return Err(PqPasetoError::InvalidAudience {
expected: expected_aud.to_string(),
actual: "none".to_string(),
});
}
}
}
if let Some(expected_iss) = expected_issuer {
match verified.claims.issuer() {
Some(actual_iss) if actual_iss == expected_iss => {}
Some(actual_iss) => {
return Err(PqPasetoError::InvalidIssuer {
expected: expected_iss.to_string(),
actual: actual_iss.to_string(),
});
}
None => {
return Err(PqPasetoError::InvalidIssuer {
expected: expected_iss.to_string(),
actual: "none".to_string(),
});
}
}
}
verified
.claims
.validate_time(OffsetDateTime::now_utc(), clock_skew_tolerance)?;
Ok(verified)
}
#[cfg_attr(feature = "logging", instrument(skip(verifying_key)))]
pub fn verify(
verifying_key: &VerifyingKey,
token: &str,
) -> Result<VerifiedToken, PqPasetoError> {
Self::verify_with_footer(verifying_key, token)
}
#[cfg_attr(feature = "logging", instrument(skip(verifying_key)))]
pub fn verify_with_footer(
verifying_key: &VerifyingKey,
token: &str,
) -> Result<VerifiedToken, PqPasetoError> {
if token.len() > MAX_TOKEN_SIZE {
return Err(PqPasetoError::InvalidFormat("Token too large".into()));
}
let parts: Vec<&str> = token.splitn(6, '.').collect();
let (encoded_payload, encoded_signature, footer) = if parts.len() == 6 {
if parts[0] != "paseto" || parts[1] != "pq1" || parts[2] != "public" {
return Err(PqPasetoError::InvalidFormat(
"Invalid token format - expected 'paseto.pq1.public'".into(),
));
}
let footer = Footer::from_base64(parts[5])?;
(parts[3], parts[4], Some(footer))
} else if parts.len() == 5 {
if parts[0] != "paseto" || parts[1] != "pq1" || parts[2] != "public" {
return Err(PqPasetoError::InvalidFormat(
"Invalid token format - expected 'paseto.pq1.public'".into(),
));
}
(parts[3], parts[4], None)
} else {
return Err(PqPasetoError::InvalidFormat(
"Expected 5 or 6 parts separated by '.' for public token".into(),
));
};
let payload_bytes = URL_SAFE_NO_PAD.decode(encoded_payload).map_err(|e| {
PqPasetoError::InvalidFormat(format!("Invalid payload encoding: {}", e))
})?;
let footer_bytes = match &footer {
Some(f) => serde_json::to_vec(f)?,
None => Vec::new(), };
let header = TOKEN_PREFIX_PUBLIC.as_bytes();
let pae_message =
crate::pae::pae_encode_public_token(header, &payload_bytes, &footer_bytes);
#[cfg(feature = "logging")]
debug!(
"Reconstructed PAE message of {} bytes for verification",
pae_message.len()
);
let signature_bytes = URL_SAFE_NO_PAD.decode(encoded_signature).map_err(|e| {
PqPasetoError::InvalidFormat(format!("Invalid signature encoding: {}", e))
})?;
let encoded_sig = ml_dsa::EncodedSignature::<MlDsaParam>::try_from(
signature_bytes.as_slice(),
)
.map_err(|e| PqPasetoError::CryptoError(format!("Invalid signature bytes: {:?}", e)))?;
let signature = ml_dsa::Signature::<MlDsaParam>::decode(&encoded_sig)
.ok_or_else(|| PqPasetoError::CryptoError("Failed to decode signature".into()))?;
verifying_key
.0
.verify(&pae_message, &signature)
.map_err(|_| PqPasetoError::SignatureVerificationFailed)?;
#[cfg(feature = "logging")]
debug!("v0.1.1 PAE signature verification successful with footer authentication");
let claims: Claims = serde_json::from_slice(&payload_bytes)?;
claims.validate_time(OffsetDateTime::now_utc(), time::Duration::seconds(30))?;
Ok(VerifiedToken {
claims,
footer,
raw_token: token.to_string(),
})
}
pub fn verify_with_options(
verifying_key: &VerifyingKey,
token: &str,
expected_audience: Option<&str>,
expected_issuer: Option<&str>,
clock_skew_tolerance: time::Duration,
) -> Result<VerifiedToken, PqPasetoError> {
let verified = Self::verify_with_footer(verifying_key, token)?;
if let Some(expected_aud) = expected_audience {
match verified.claims.audience() {
Some(actual_aud) if actual_aud == expected_aud => {}
Some(actual_aud) => {
return Err(PqPasetoError::InvalidAudience {
expected: expected_aud.to_string(),
actual: actual_aud.to_string(),
});
}
None => {
return Err(PqPasetoError::InvalidAudience {
expected: expected_aud.to_string(),
actual: "none".to_string(),
});
}
}
}
if let Some(expected_iss) = expected_issuer {
match verified.claims.issuer() {
Some(actual_iss) if actual_iss == expected_iss => {}
Some(actual_iss) => {
return Err(PqPasetoError::InvalidIssuer {
expected: expected_iss.to_string(),
actual: actual_iss.to_string(),
});
}
None => {
return Err(PqPasetoError::InvalidIssuer {
expected: expected_iss.to_string(),
actual: "none".to_string(),
});
}
}
}
verified
.claims
.validate_time(OffsetDateTime::now_utc(), clock_skew_tolerance)?;
Ok(verified)
}
}
impl fmt::Debug for SigningKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SigningKey")
.field("algorithm", &"ML-DSA-65")
.finish_non_exhaustive()
}
}
impl fmt::Debug for VerifyingKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VerifyingKey")
.field("algorithm", &"ML-DSA-65")
.finish_non_exhaustive()
}
}
impl fmt::Debug for KeyPair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyPair")
.field("signing_key", &self.signing_key)
.field("verifying_key", &self.verifying_key)
.finish()
}
}
impl fmt::Debug for SymmetricKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SymmetricKey")
.field("algorithm", &"ChaCha20-Poly1305")
.finish_non_exhaustive()
}
}
impl fmt::Debug for EncapsulationKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EncapsulationKey")
.field("algorithm", &"ML-KEM-768")
.finish_non_exhaustive()
}
}
impl fmt::Debug for DecapsulationKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DecapsulationKey")
.field("algorithm", &"ML-KEM-768")
.finish_non_exhaustive()
}
}
impl fmt::Debug for KemKeyPair {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KemKeyPair")
.field("encapsulation_key", &"<encapsulation_key>")
.field("decapsulation_key", &"<decapsulation_key>")
.finish()
}
}
impl Drop for SigningKey {
fn drop(&mut self) {
}
}
impl Drop for VerifyingKey {
fn drop(&mut self) {
}
}
impl Drop for KeyPair {
fn drop(&mut self) {
}
}
impl Drop for EncapsulationKey {
fn drop(&mut self) {
}
}
impl Drop for DecapsulationKey {
fn drop(&mut self) {
}
}
impl Drop for KemKeyPair {
fn drop(&mut self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rng;
use std::thread;
use time::Duration;
#[test]
fn test_keypair_generation() {
thread::Builder::new()
.name("keypair-generation-smoke".to_string())
.stack_size(16 * 1024 * 1024)
.spawn(|| {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let signing_bytes = keypair.signing_key_to_bytes();
let verifying_bytes = keypair.verifying_key_to_bytes();
assert!(!signing_bytes.is_empty());
assert!(!verifying_bytes.is_empty());
let imported_signing = KeyPair::signing_key_from_bytes(&signing_bytes).unwrap();
let imported_verifying =
KeyPair::verifying_key_from_bytes(&verifying_bytes).unwrap();
let mut claims = Claims::new();
claims.set_subject("test").unwrap();
let token1 = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
let token2 = PasetoPQ::sign(&imported_signing, &claims).unwrap();
PasetoPQ::verify(keypair.verifying_key(), &token1).unwrap();
PasetoPQ::verify(&imported_verifying, &token1).unwrap();
PasetoPQ::verify(keypair.verifying_key(), &token2).unwrap();
PasetoPQ::verify(&imported_verifying, &token2).unwrap();
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn test_basic_sign_and_verify() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("conflux-auth").unwrap();
claims.set_audience("conflux-network").unwrap();
claims.set_jti("token-id-123").unwrap();
claims.add_custom("tenant_id", "org_abc123").unwrap();
claims.add_custom("roles", ["user", "admin"]).unwrap();
let token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
assert!(token.starts_with("paseto.pq1.public."));
let verified = PasetoPQ::verify(keypair.verifying_key(), &token).unwrap();
let verified_claims = verified.claims();
assert_eq!(verified_claims.subject(), Some("user123"));
assert_eq!(verified_claims.issuer(), Some("conflux-auth"));
assert_eq!(verified_claims.audience(), Some("conflux-network"));
assert_eq!(verified_claims.jti(), Some("token-id-123"));
assert_eq!(
verified_claims.get_custom("tenant_id").unwrap().as_str(),
Some("org_abc123")
);
let roles: Vec<String> =
serde_json::from_value(verified_claims.get_custom("roles").unwrap().clone()).unwrap();
assert_eq!(roles, vec!["user", "admin"]);
}
#[test]
fn test_time_validation() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let now = OffsetDateTime::now_utc();
let mut expired_claims = Claims::new();
expired_claims.set_subject("user").unwrap();
expired_claims
.set_expiration(now - Duration::hours(1))
.unwrap();
let expired_token = PasetoPQ::sign(keypair.signing_key(), &expired_claims).unwrap();
let result = PasetoPQ::verify(keypair.verifying_key(), &expired_token);
assert!(matches!(result.unwrap_err(), PqPasetoError::TokenExpired));
let mut future_claims = Claims::new();
future_claims.set_subject("user").unwrap();
future_claims
.set_not_before(now + Duration::hours(1))
.unwrap();
let future_token = PasetoPQ::sign(keypair.signing_key(), &future_claims).unwrap();
let result = PasetoPQ::verify(keypair.verifying_key(), &future_token);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::TokenNotYetValid
));
let mut valid_claims = Claims::new();
valid_claims.set_subject("user").unwrap();
valid_claims
.set_not_before(now - Duration::minutes(5))
.unwrap();
valid_claims
.set_expiration(now + Duration::hours(1))
.unwrap();
let valid_token = PasetoPQ::sign(keypair.signing_key(), &valid_claims).unwrap();
let verified = PasetoPQ::verify(keypair.verifying_key(), &valid_token).unwrap();
assert_eq!(verified.claims().subject(), Some("user"));
}
#[test]
fn test_audience_and_issuer_validation() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user").unwrap();
claims.set_audience("api.example.com").unwrap();
claims.set_issuer("my-service").unwrap();
let token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
let verified = PasetoPQ::verify_with_options(
keypair.verifying_key(),
&token,
Some("api.example.com"),
Some("my-service"),
Duration::seconds(30),
)
.unwrap();
assert_eq!(verified.claims().subject(), Some("user"));
let result = PasetoPQ::verify_with_options(
keypair.verifying_key(),
&token,
Some("wrong-audience"),
Some("conflux-auth"),
Duration::seconds(30),
);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidAudience { .. }
));
let result = PasetoPQ::verify_with_options(
keypair.verifying_key(),
&token,
Some("api.example.com"),
Some("wrong-service"),
Duration::seconds(30),
);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidIssuer { .. }
));
}
#[test]
fn test_signature_verification_failure() {
let mut rng = rng();
let keypair1 = KeyPair::generate(&mut rng);
let keypair2 = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user").unwrap();
let token = PasetoPQ::sign(&keypair1.signing_key, &claims).unwrap();
let result = PasetoPQ::verify(&keypair2.verifying_key, &token);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::SignatureVerificationFailed
));
}
#[test]
fn test_malformed_tokens() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let result = PasetoPQ::verify(keypair.verifying_key(), "paseto.pq1");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidFormat(_)
));
let result = PasetoPQ::verify(keypair.verifying_key(), "wrong.pq1.pq.payload.sig");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidFormat(_)
));
let result = PasetoPQ::verify(keypair.verifying_key(), "paseto.pq1.public.invalid!!!.sig");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidFormat(_)
));
let result = PasetoPQ::verify(
keypair.verifying_key(),
"paseto.pq1.public.dGVzdA.invalid_sig",
);
assert!(matches!(result.unwrap_err(), PqPasetoError::CryptoError(_)));
}
#[test]
fn test_symmetric_key_generation() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let key_bytes = key.to_bytes();
assert_eq!(key_bytes.len(), SYMMETRIC_KEY_SIZE);
let imported_key = SymmetricKey::from_bytes(&key_bytes).unwrap();
assert_eq!(key.to_bytes(), imported_key.to_bytes());
}
#[test]
fn test_kem_keypair_generation() {
let mut rng = rng();
let keypair = KemKeyPair::generate(&mut rng);
let enc_bytes = keypair.encapsulation_key_to_bytes();
let dec_bytes = keypair.decapsulation_key_to_bytes();
assert!(!enc_bytes.is_empty());
assert!(!dec_bytes.is_empty());
let _imported_enc = KemKeyPair::encapsulation_key_from_bytes(&enc_bytes).unwrap();
let _imported_dec = KemKeyPair::decapsulation_key_from_bytes(&dec_bytes).unwrap();
let (sender_key, ciphertext) = keypair.encapsulate();
let receiver_key = keypair.decapsulate(&ciphertext).unwrap();
assert_eq!(sender_key.to_bytes(), receiver_key.to_bytes());
assert_ne!(sender_key.to_bytes(), [0u8; 32]); assert_eq!(ciphertext.len(), 1088); }
#[test]
fn test_basic_encrypt_and_decrypt() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("conflux-auth").unwrap();
claims.set_audience("conflux-network").unwrap();
claims.set_jti("token-id-123").unwrap();
claims.add_custom("tenant_id", "org_abc123").unwrap();
claims.add_custom("roles", ["user", "admin"]).unwrap();
let token = PasetoPQ::encrypt(&key, &claims).unwrap();
assert!(token.starts_with("paseto.pq1.local."));
let verified = PasetoPQ::decrypt(&key, &token).unwrap();
let verified_claims = verified.claims();
assert_eq!(verified_claims.subject(), Some("user123"));
assert_eq!(verified_claims.issuer(), Some("conflux-auth"));
assert_eq!(verified_claims.audience(), Some("conflux-network"));
assert_eq!(verified_claims.jti(), Some("token-id-123"));
assert_eq!(
verified_claims.get_custom("tenant_id").unwrap().as_str(),
Some("org_abc123")
);
let roles: Vec<String> =
serde_json::from_value(verified_claims.get_custom("roles").unwrap().clone()).unwrap();
assert_eq!(roles, vec!["user", "admin"]);
}
#[test]
fn test_local_token_time_validation() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let now = OffsetDateTime::now_utc();
let mut expired_claims = Claims::new();
expired_claims.set_subject("user").unwrap();
expired_claims
.set_expiration(now - Duration::hours(1))
.unwrap();
let expired_token = PasetoPQ::encrypt(&key, &expired_claims).unwrap();
let result = PasetoPQ::decrypt(&key, &expired_token);
assert!(matches!(result.unwrap_err(), PqPasetoError::TokenExpired));
let mut future_claims = Claims::new();
future_claims.set_subject("user").unwrap();
future_claims
.set_not_before(now + Duration::hours(1))
.unwrap();
let future_token = PasetoPQ::encrypt(&key, &future_claims).unwrap();
let result = PasetoPQ::decrypt(&key, &future_token);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::TokenNotYetValid
));
let mut valid_claims = Claims::new();
valid_claims.set_subject("user").unwrap();
valid_claims
.set_not_before(now - Duration::minutes(5))
.unwrap();
valid_claims
.set_expiration(now + Duration::hours(1))
.unwrap();
let valid_token = PasetoPQ::encrypt(&key, &valid_claims).unwrap();
let verified = PasetoPQ::decrypt(&key, &valid_token).unwrap();
assert_eq!(verified.claims().subject(), Some("user"));
}
#[test]
fn test_local_token_audience_and_issuer_validation() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("test-issuer").unwrap();
claims.set_audience("test-audience").unwrap();
let token = PasetoPQ::encrypt(&key, &claims).unwrap();
let verified = PasetoPQ::decrypt_with_options(
&key,
&token,
Some("test-audience"),
Some("test-issuer"),
Duration::seconds(30),
)
.unwrap();
assert_eq!(verified.claims().subject(), Some("user123"));
let result = PasetoPQ::decrypt_with_options(
&key,
&token,
Some("wrong-audience"),
Some("test-issuer"),
Duration::seconds(30),
);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidAudience { .. }
));
let result = PasetoPQ::decrypt_with_options(
&key,
&token,
Some("test-audience"),
Some("wrong-issuer"),
Duration::seconds(30),
);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidIssuer { .. }
));
}
#[test]
fn test_local_token_tamper_detection() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let token = PasetoPQ::encrypt(&key, &claims).unwrap();
let mut tampered_token = token.clone();
tampered_token.push('x');
let result = PasetoPQ::decrypt(&key, &tampered_token);
assert!(result.is_err());
let wrong_key = SymmetricKey::generate(&mut rng);
let result = PasetoPQ::decrypt(&wrong_key, &token);
assert!(matches!(
result.unwrap_err(),
PqPasetoError::DecryptionError(_)
));
}
#[test]
fn test_malformed_local_tokens() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let result = PasetoPQ::decrypt(&key, "wrong.pq1.local.payload");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidFormat(_)
));
let result = PasetoPQ::decrypt(&key, "paseto.pq1.local.invalid!!!");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::InvalidFormat(_)
));
let result = PasetoPQ::decrypt(&key, "paseto.pq1.local.dGVzdA");
assert!(matches!(
result.unwrap_err(),
PqPasetoError::DecryptionError(_)
));
}
#[test]
fn test_mixed_token_types() {
let mut rng = rng();
let asymmetric_keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let public_token = PasetoPQ::sign(asymmetric_keypair.signing_key(), &claims).unwrap();
let local_token = PasetoPQ::encrypt(&symmetric_key, &claims).unwrap();
assert!(public_token.starts_with("paseto.pq1.public."));
assert!(local_token.starts_with("paseto.pq1.local."));
let verified_public =
PasetoPQ::verify(asymmetric_keypair.verifying_key(), &public_token).unwrap();
let verified_local = PasetoPQ::decrypt(&symmetric_key, &local_token).unwrap();
assert_eq!(verified_public.claims().subject(), Some("user123"));
assert_eq!(verified_local.claims().subject(), Some("user123"));
let result = PasetoPQ::decrypt(&symmetric_key, &public_token);
assert!(result.is_err());
let result = PasetoPQ::verify(asymmetric_keypair.verifying_key(), &local_token);
assert!(result.is_err());
}
#[test]
fn test_footer_basic_functionality() {
let mut footer = Footer::new();
footer.set_kid("test-key-123").unwrap();
footer.set_version("1.0.0").unwrap();
footer.add_custom("env", "production").unwrap();
assert_eq!(footer.kid(), Some("test-key-123"));
assert_eq!(footer.version(), Some("1.0.0"));
assert_eq!(
footer.get_custom("env").unwrap().as_str(),
Some("production")
);
}
#[test]
fn test_public_token_with_footer() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let mut footer = Footer::new();
footer.set_kid("signing-key-2024").unwrap();
footer.add_custom("deployment", "us-east-1").unwrap();
let token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
assert!(token.starts_with("paseto.pq1.public."));
assert_eq!(token.split('.').count(), 6);
let verified = PasetoPQ::verify_with_footer(keypair.verifying_key(), &token).unwrap();
assert_eq!(verified.claims().subject(), Some("user123"));
let verified_footer = verified.footer().unwrap();
assert_eq!(verified_footer.kid(), Some("signing-key-2024"));
assert_eq!(
verified_footer.get_custom("deployment").unwrap().as_str(),
Some("us-east-1")
);
let token_no_footer = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
assert_eq!(token_no_footer.split('.').count(), 5);
let verified_no_footer =
PasetoPQ::verify(keypair.verifying_key(), &token_no_footer).unwrap();
assert_eq!(verified_no_footer.claims().subject(), Some("user123"));
assert!(verified_no_footer.footer().is_none());
}
#[test]
fn test_local_token_with_footer() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.add_custom("session_data", "confidential").unwrap();
let mut footer = Footer::new();
footer.set_kid("encryption-key-2024").unwrap();
footer.add_custom("session_type", "secure").unwrap();
let token = PasetoPQ::encrypt_with_footer(&key, &claims, Some(&footer)).unwrap();
assert!(token.starts_with("paseto.pq1.local."));
assert_eq!(token.split('.').count(), 5);
let verified = PasetoPQ::decrypt_with_footer(&key, &token).unwrap();
assert_eq!(verified.claims().subject(), Some("user123"));
assert_eq!(
verified
.claims()
.get_custom("session_data")
.unwrap()
.as_str(),
Some("confidential")
);
let verified_footer = verified.footer().unwrap();
assert_eq!(verified_footer.kid(), Some("encryption-key-2024"));
assert_eq!(
verified_footer.get_custom("session_type").unwrap().as_str(),
Some("secure")
);
let token_no_footer = PasetoPQ::encrypt(&key, &claims).unwrap();
assert_eq!(token_no_footer.split('.').count(), 4);
let verified_no_footer = PasetoPQ::decrypt(&key, &token_no_footer).unwrap();
assert_eq!(verified_no_footer.claims().subject(), Some("user123"));
assert!(verified_no_footer.footer().is_none());
}
#[test]
fn test_footer_serialization() {
let mut footer = Footer::new();
footer.set_kid("test-key").unwrap();
footer.set_version("1.0.0").unwrap();
footer.add_custom("custom_field", "custom_value").unwrap();
let encoded = footer.to_base64().unwrap();
let decoded = Footer::from_base64(&encoded).unwrap();
assert_eq!(footer.kid(), decoded.kid());
assert_eq!(footer.version(), decoded.version());
assert_eq!(
footer.get_custom("custom_field"),
decoded.get_custom("custom_field")
);
}
#[test]
fn test_footer_tamper_detection() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let mut footer = Footer::new();
footer.set_kid("test-key").unwrap();
let token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
let mut tampered_token = token.clone();
tampered_token.push('x');
let result = PasetoPQ::verify_with_footer(keypair.verifying_key(), &tampered_token);
assert!(result.is_err()); }
#[test]
fn test_backward_compatibility() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let public_token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
let local_token = PasetoPQ::encrypt(&symmetric_key, &claims).unwrap();
let verified_public =
PasetoPQ::verify_with_footer(keypair.verifying_key(), &public_token).unwrap();
let verified_local = PasetoPQ::decrypt_with_footer(&symmetric_key, &local_token).unwrap();
assert_eq!(verified_public.claims().subject(), Some("user123"));
assert_eq!(verified_local.claims().subject(), Some("user123"));
assert!(verified_public.footer().is_none());
assert!(verified_local.footer().is_none());
}
#[test]
fn test_claims_json_conversion() {
use serde_json::Value;
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("test-service").unwrap();
claims.set_audience("api.example.com").unwrap();
claims.add_custom("role", "admin").unwrap();
claims.add_custom("tenant_id", "org_abc123").unwrap();
claims
.add_custom("permissions", ["read", "write", "delete"])
.unwrap();
let json_value: Value = claims.clone().into();
assert!(json_value.is_object());
assert_eq!(json_value["sub"], "user123");
assert_eq!(json_value["iss"], "test-service");
assert_eq!(json_value["aud"], "api.example.com");
assert_eq!(json_value["role"], "admin");
assert_eq!(json_value["tenant_id"], "org_abc123");
assert_eq!(json_value["permissions"][0], "read");
let json_value_ref: Value = (&claims).into();
assert_eq!(json_value, json_value_ref);
let json_value_method = claims.to_json_value();
assert_eq!(json_value, json_value_method);
let json_string = claims.to_json_string().unwrap();
assert!(json_string.contains("\"sub\":\"user123\""));
assert!(json_string.contains("\"role\":\"admin\""));
let pretty_json = claims.to_json_string_pretty().unwrap();
assert!(pretty_json.contains("\"sub\": \"user123\""));
assert!(pretty_json.contains("\"role\": \"admin\""));
assert!(pretty_json.len() > json_string.len());
let minimal_claims = Claims::new();
let minimal_json: Value = minimal_claims.into();
assert!(minimal_json.is_object());
assert!(minimal_json.as_object().unwrap().is_empty());
}
#[test]
fn test_claims_json_with_time_fields() {
use serde_json::Value;
use time::OffsetDateTime;
let mut claims = Claims::new();
let now = OffsetDateTime::now_utc();
let exp_time = now + time::Duration::hours(1);
let nbf_time = now - time::Duration::minutes(5);
claims.set_subject("user456").unwrap();
claims.set_expiration(exp_time).unwrap();
claims.set_not_before(nbf_time).unwrap();
claims.set_issued_at(now).unwrap();
let json_value: Value = claims.into();
assert!(json_value["exp"].is_string());
assert!(json_value["nbf"].is_string());
assert!(json_value["iat"].is_string());
let exp_str = json_value["exp"].as_str().unwrap();
let parsed_exp =
OffsetDateTime::parse(exp_str, &time::format_description::well_known::Rfc3339).unwrap();
assert_eq!(parsed_exp.unix_timestamp(), exp_time.unix_timestamp());
}
#[test]
fn test_claims_json_integration_example() {
use serde_json::Value;
let mut claims = Claims::new();
claims.set_subject("user789").unwrap();
claims.set_issuer("auth-service").unwrap();
claims.set_audience("api.conflux.dev").unwrap();
claims
.add_custom("session_id", "sess_abc123def456")
.unwrap();
claims.add_custom("user_type", "premium").unwrap();
claims
.add_custom("scopes", ["profile", "email", "admin"])
.unwrap();
let json_string = claims.to_json_string().unwrap();
assert!(!json_string.is_empty());
let json_value: Value = claims.clone().into();
let serialized_for_db = serde_json::to_vec(&json_value).unwrap();
assert!(!serialized_for_db.is_empty());
let deserialized_value: Value = serde_json::from_slice(&serialized_for_db).unwrap();
assert_eq!(json_value, deserialized_value);
assert_eq!(deserialized_value["sub"], "user789");
assert_eq!(deserialized_value["session_id"], "sess_abc123def456");
assert_eq!(deserialized_value["scopes"].as_array().unwrap().len(), 3);
}
#[test]
fn test_token_parsing_public_tokens() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("test-user").unwrap();
claims.add_custom("role", "admin").unwrap();
let token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
let parsed = ParsedToken::parse(&token).unwrap();
assert_eq!(parsed.purpose(), "public");
assert_eq!(parsed.version(), "pq1");
assert!(!parsed.has_footer());
assert!(parsed.is_public());
assert!(!parsed.is_local());
assert!(parsed.signature_bytes().is_some());
assert_eq!(parsed.raw_token(), &token);
let parsed_alt = PasetoPQ::parse_token(&token).unwrap();
assert_eq!(parsed.purpose(), parsed_alt.purpose());
}
#[test]
fn test_token_parsing_public_tokens_with_footer() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("test-user").unwrap();
let mut footer = Footer::new();
footer.set_kid("test-key-123").unwrap();
footer.add_custom("tenant", "org_abc").unwrap();
let token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
let parsed = ParsedToken::parse(&token).unwrap();
assert!(parsed.has_footer());
let parsed_footer = parsed.footer().unwrap();
assert_eq!(parsed_footer.kid(), Some("test-key-123"));
assert_eq!(
parsed_footer.get_custom("tenant"),
Some(&serde_json::json!("org_abc"))
);
let footer_json = parsed.footer_json().unwrap().unwrap();
assert!(footer_json.contains("test-key-123"));
assert!(footer_json.contains("org_abc"));
let footer_pretty = parsed.footer_json_pretty().unwrap().unwrap();
assert!(footer_pretty.len() > footer_json.len()); }
#[test]
fn test_token_parsing_local_tokens() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("local-user").unwrap();
claims.add_custom("session_type", "confidential").unwrap();
let token = PasetoPQ::encrypt(&key, &claims).unwrap();
let parsed = ParsedToken::parse(&token).unwrap();
assert_eq!(parsed.purpose(), "local");
assert_eq!(parsed.version(), "pq1");
assert!(!parsed.has_footer());
assert!(!parsed.is_public());
assert!(parsed.is_local());
assert!(parsed.signature_bytes().is_none()); assert!(parsed.payload_length() > 0);
}
#[test]
fn test_token_parsing_local_tokens_with_footer() {
let mut rng = rng();
let key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("local-user").unwrap();
let mut footer = Footer::new();
footer.set_kid("encryption-key-456").unwrap();
footer.set_version("v2.1").unwrap();
let token = PasetoPQ::encrypt_with_footer(&key, &claims, Some(&footer)).unwrap();
let parsed = ParsedToken::parse(&token).unwrap();
assert!(parsed.has_footer());
let parsed_footer = parsed.footer().unwrap();
assert_eq!(parsed_footer.kid(), Some("encryption-key-456"));
assert_eq!(parsed_footer.version(), Some("v2.1"));
let summary = parsed.format_summary();
assert!(summary.contains("paseto.pq1.local"));
assert!(summary.contains("footer: present"));
}
#[test]
fn test_token_parsing_error_cases() {
let error_cases = vec![
("", "expected at least 4 parts"),
("not.a.token", "expected at least 4 parts"),
("wrong.pq1.public.payload", "Invalid protocol"),
("paseto.v2.public.payload", "Unsupported token format"),
("paseto.pq1.unknown.payload", "Unsupported token format"),
("paseto.pq1.public.invalid_base64", "Invalid payload base64"),
(
"paseto.pq1.public.dGVzdA.invalid!!!base64",
"Invalid signature base64",
),
(
"paseto.pq1.public.dGVzdA.dGVzdA.dGVzdA.extra.parts",
"too many parts",
),
("paseto.pq1.local.dGVzdA.dGVzdA.extra", "too many parts"),
];
for (token, expected_error) in error_cases {
let result = ParsedToken::parse(token);
assert!(result.is_err(), "Expected error for token: {}", token);
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains(expected_error),
"Expected '{}' in error '{}' for token '{}'",
expected_error,
error_msg,
token
);
}
}
#[test]
fn test_token_size_estimation_public_tokens() {
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("test-service").unwrap();
let estimator = TokenSizeEstimator::public(&claims, false);
let (expected_min_size, expected_max_size) = if cfg!(feature = "ml-dsa-44") {
(2800, 3200) } else if cfg!(feature = "ml-dsa-65") {
(4200, 4800) } else {
(5000, 5500) };
assert!(estimator.total_bytes() >= expected_min_size);
assert!(estimator.total_bytes() < expected_max_size);
if cfg!(feature = "ml-dsa-44") {
assert!(estimator.fits_in_cookie()); assert!(!estimator.fits_in_url()); } else {
assert!(!estimator.fits_in_cookie()); assert!(!estimator.fits_in_url()); }
assert!(estimator.fits_in_header());
let breakdown = estimator.breakdown();
assert!(breakdown.prefix > 0);
assert!(breakdown.payload > 0);
let expected_sig_size = if cfg!(feature = "ml-dsa-44") {
2800
} else if cfg!(feature = "ml-dsa-65") {
4300
} else {
5000
};
assert_eq!(breakdown.signature_or_tag, expected_sig_size);
assert_eq!(breakdown.footer, None);
assert!(breakdown.separators > 0);
assert!(breakdown.base64_overhead > 0);
}
#[test]
fn test_token_size_estimation_local_tokens() {
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
claims.set_issuer("test-service").unwrap();
let estimator = TokenSizeEstimator::local(&claims, false);
assert!(estimator.total_bytes() > 80);
assert!(estimator.total_bytes() < 300);
assert!(estimator.fits_in_cookie());
assert!(estimator.fits_in_url());
assert!(estimator.fits_in_header());
let breakdown = estimator.breakdown();
assert!(breakdown.prefix > 0);
assert!(breakdown.payload > 0);
assert_eq!(breakdown.signature_or_tag, 0); assert_eq!(breakdown.footer, None);
assert!(breakdown.separators > 0);
assert!(breakdown.base64_overhead > 0);
}
#[test]
fn test_token_size_estimation_with_footer() {
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let estimator_public = TokenSizeEstimator::public(&claims, true);
let estimator_public_no_footer = TokenSizeEstimator::public(&claims, false);
assert!(estimator_public.total_bytes() > estimator_public_no_footer.total_bytes());
assert!(estimator_public.breakdown().footer.is_some());
assert!(estimator_public_no_footer.breakdown().footer.is_none());
let estimator_local = TokenSizeEstimator::local(&claims, true);
let estimator_local_no_footer = TokenSizeEstimator::local(&claims, false);
assert!(estimator_local.total_bytes() > estimator_local_no_footer.total_bytes());
assert!(estimator_local.breakdown().footer.is_some());
assert!(estimator_local_no_footer.breakdown().footer.is_none());
}
#[test]
fn test_token_size_estimation_convenience_methods() {
let mut claims = Claims::new();
claims.set_subject("user123").unwrap();
let public_estimator = PasetoPQ::estimate_public_size(&claims, false);
let local_estimator = PasetoPQ::estimate_local_size(&claims, true);
let direct_public = TokenSizeEstimator::public(&claims, false);
let direct_local = TokenSizeEstimator::local(&claims, true);
assert_eq!(public_estimator.total_bytes(), direct_public.total_bytes());
assert_eq!(local_estimator.total_bytes(), direct_local.total_bytes());
}
#[test]
fn test_token_size_estimation_optimization_suggestions() {
let small_claims = Claims::new();
let small_estimator = TokenSizeEstimator::local(&small_claims, false);
let small_suggestions = small_estimator.optimization_suggestions();
assert!(small_suggestions.is_empty() || small_estimator.total_bytes() < 1000);
let mut large_claims = Claims::new();
large_claims
.add_custom("huge_data", "x".repeat(5000))
.unwrap();
let large_estimator = TokenSizeEstimator::public(&large_claims, false);
let large_suggestions = large_estimator.optimization_suggestions();
assert!(!large_suggestions.is_empty());
assert!(large_suggestions.iter().any(|s| s.contains("cookie")));
assert!(
large_suggestions
.iter()
.any(|s| s.contains("shorter claim"))
);
}
#[test]
fn test_token_size_breakdown_total() {
let breakdown = TokenSizeBreakdown {
prefix: 10,
payload: 200,
signature_or_tag: 3000,
footer: Some(50),
separators: 3,
base64_overhead: 100,
};
let expected_total = 10 + 200 + 3000 + 50 + 3 + 100;
assert_eq!(breakdown.total(), expected_total);
let breakdown_no_footer = TokenSizeBreakdown {
prefix: 10,
payload: 200,
signature_or_tag: 3000,
footer: None,
separators: 2,
base64_overhead: 100,
};
let expected_total_no_footer = 10 + 200 + 3000 + 2 + 100;
assert_eq!(breakdown_no_footer.total(), expected_total_no_footer);
}
#[test]
fn test_token_parsing_debugging_methods() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("debug-user").unwrap();
claims.add_custom("large_data", "x".repeat(500)).unwrap();
let mut footer = Footer::new();
footer.set_kid("debug-key").unwrap();
let token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
let parsed = ParsedToken::parse(&token).unwrap();
assert!(parsed.payload_length() > 100); assert!(parsed.total_length() > parsed.payload_length()); assert!(!parsed.payload_bytes().is_empty());
let summary = parsed.format_summary();
assert!(summary.contains("paseto.pq1.public"));
assert!(summary.contains("signature: present"));
assert!(summary.contains("footer: present"));
assert!(summary.contains(&format!("{} bytes", parsed.payload_length())));
}
#[test]
fn test_token_parsing_middleware_scenarios() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("middleware-test").unwrap();
let public_token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
let local_token = PasetoPQ::encrypt(&symmetric_key, &claims).unwrap();
let tokens = vec![
(public_token, "public", true), (local_token, "local", false),
];
for (token, expected_purpose, should_be_public) in tokens {
let parsed = ParsedToken::parse(&token).unwrap();
assert_eq!(parsed.purpose(), expected_purpose);
assert_eq!(parsed.is_public(), should_be_public);
assert_eq!(parsed.is_local(), !should_be_public);
let purpose = parsed.purpose();
let version = parsed.version();
let size = parsed.total_length();
assert!(!purpose.is_empty());
assert_eq!(version, "pq1");
assert!(size > 0);
if size > 2048 {
println!("Large token detected: {} bytes", size);
}
}
}
#[test]
fn test_symmetric_key_zeroization() {
{
let mut key = SymmetricKey([0x42u8; 32]);
assert_eq!(key.0[0], 0x42);
key.zeroize();
assert_eq!(key.0, [0u8; 32]);
}
{
let key = SymmetricKey([0x55u8; 32]);
assert_eq!(key.0[0], 0x55);
}
}
#[test]
fn test_key_operations_with_drop_cleanup() {
let mut rng = rng();
{
let keypair = KeyPair::generate(&mut rng);
let test_data = b"test message";
let signature = keypair.signing_key().0.sign(test_data);
assert!(
keypair
.verifying_key()
.0
.verify(test_data, &signature)
.is_ok()
);
}
{
let kem_keypair = KemKeyPair::generate(&mut rng);
let (key1, ciphertext) = kem_keypair.encapsulate();
let key2 = kem_keypair.decapsulate(&ciphertext).unwrap();
assert_eq!(key1.to_bytes(), key2.to_bytes());
}
{
let key = SymmetricKey::generate(&mut rng);
let key_bytes = key.to_bytes();
assert_eq!(key_bytes.len(), 32);
}
}
#[test]
fn test_token_versioning_configuration() {
assert_eq!(TOKEN_PREFIX_PUBLIC, "paseto.pq1.public");
assert_eq!(TOKEN_PREFIX_LOCAL, "paseto.pq1.local");
assert!(!PasetoPQ::is_standard_paseto_compatible());
assert_eq!(PasetoPQ::public_token_prefix(), TOKEN_PREFIX_PUBLIC);
assert_eq!(PasetoPQ::local_token_prefix(), TOKEN_PREFIX_LOCAL);
}
#[test]
fn test_actual_token_contains_correct_prefix() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let claims = Claims::new();
let public_token = PasetoPQ::sign(keypair.signing_key(), &claims).unwrap();
assert!(public_token.starts_with(TOKEN_PREFIX_PUBLIC));
let local_token = PasetoPQ::encrypt(&symmetric_key, &claims).unwrap();
assert!(local_token.starts_with(TOKEN_PREFIX_LOCAL));
let parsed_public = ParsedToken::parse(&public_token).unwrap();
let parsed_local = ParsedToken::parse(&local_token).unwrap();
assert_eq!(parsed_public.version(), "pq1");
assert_eq!(parsed_local.version(), "pq1");
assert_eq!(parsed_public.purpose(), "public");
assert_eq!(parsed_local.purpose(), "local");
}
#[test]
fn test_hkdf_implementation() {
let shared_secret1 = b"shared_secret_1";
let shared_secret2 = b"shared_secret_2";
let info = b"PASETO-PQ-LOCAL-pq1";
let key1 = SymmetricKey::derive_from_shared_secret(shared_secret1, info);
let key2 = SymmetricKey::derive_from_shared_secret(shared_secret2, info);
assert_ne!(key1.to_bytes(), key2.to_bytes());
let key1_repeat = SymmetricKey::derive_from_shared_secret(shared_secret1, info);
assert_eq!(key1.to_bytes(), key1_repeat.to_bytes());
let info2 = b"DIFFERENT-INFO";
let key_diff_info = SymmetricKey::derive_from_shared_secret(shared_secret1, info2);
assert_ne!(key1.to_bytes(), key_diff_info.to_bytes());
assert_eq!(key1.to_bytes().len(), 32);
assert_eq!(key2.to_bytes().len(), 32);
}
#[test]
fn test_footer_authentication_security_v0_1_1() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("test-user".to_string()).unwrap();
claims.set_issuer("test-issuer".to_string()).unwrap();
let mut footer = Footer::new();
footer.set_kid("test-key-id").unwrap();
footer.set_version("1.0").unwrap();
let public_token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
let verified =
PasetoPQ::verify_with_footer(keypair.verifying_key(), &public_token).unwrap();
assert_eq!(verified.claims().subject().unwrap(), "test-user");
assert_eq!(verified.footer().unwrap().kid().unwrap(), "test-key-id");
let mut token_parts: Vec<&str> = public_token.split('.').collect();
let tampered_footer = Footer::new();
let tampered_footer_b64 = tampered_footer.to_base64().unwrap();
token_parts[5] = &tampered_footer_b64;
let tampered_public = token_parts.join(".");
let result = PasetoPQ::verify_with_footer(keypair.verifying_key(), &tampered_public);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PqPasetoError::SignatureVerificationFailed
));
let local_token =
PasetoPQ::encrypt_with_footer(&symmetric_key, &claims, Some(&footer)).unwrap();
let decrypted = PasetoPQ::decrypt_with_footer(&symmetric_key, &local_token).unwrap();
assert_eq!(decrypted.claims().subject().unwrap(), "test-user");
assert_eq!(decrypted.footer().unwrap().kid().unwrap(), "test-key-id");
let mut token_parts: Vec<&str> = local_token.split('.').collect();
let tampered_footer = Footer::new();
let tampered_footer_b64 = tampered_footer.to_base64().unwrap();
token_parts[4] = &tampered_footer_b64;
let tampered_local = token_parts.join(".");
let result = PasetoPQ::decrypt_with_footer(&symmetric_key, &tampered_local);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PqPasetoError::DecryptionError(_)
));
}
#[test]
fn test_pae_integration_v0_1_1() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("pae-test".to_string()).unwrap();
let mut footer = Footer::new();
footer.set_kid("pae-key").unwrap();
let token_with_footer =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(&footer)).unwrap();
let token_without_footer =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, None).unwrap();
let verified_with =
PasetoPQ::verify_with_footer(keypair.verifying_key(), &token_with_footer).unwrap();
let verified_without =
PasetoPQ::verify_with_footer(keypair.verifying_key(), &token_without_footer).unwrap();
assert_eq!(verified_with.claims().subject().unwrap(), "pae-test");
assert_eq!(verified_without.claims().subject().unwrap(), "pae-test");
assert!(verified_with.footer().is_some());
assert!(verified_without.footer().is_none());
let claims_json = serde_json::to_vec(&claims).unwrap();
let empty_footer_bytes = Vec::new();
let header = "paseto.pq1.public".as_bytes();
let pae_message =
crate::pae::pae_encode_public_token(header, &claims_json, &empty_footer_bytes);
assert!(!pae_message.is_empty());
}
#[test]
fn test_v0_1_1_security_improvements() {
let mut rng = rng();
let keypair = KeyPair::generate(&mut rng);
let symmetric_key = SymmetricKey::generate(&mut rng);
let mut claims = Claims::new();
claims.set_subject("security-test".to_string()).unwrap();
claims.set_audience("api.example.com".to_string()).unwrap();
let mut footer1 = Footer::new();
footer1.set_kid("key-1").unwrap();
let mut footer2 = Footer::new();
footer2.set_version("2.0").unwrap();
footer2.set_kid("key-2").unwrap();
let mut footer3 = Footer::new();
let admin_value = "admin";
footer3.add_custom("role", &admin_value).unwrap();
let footers = [footer1, footer2, footer3];
for (i, footer) in footers.iter().enumerate() {
let public_token =
PasetoPQ::sign_with_footer(keypair.signing_key(), &claims, Some(footer)).unwrap();
let verified =
PasetoPQ::verify_with_footer(keypair.verifying_key(), &public_token).unwrap();
assert_eq!(verified.claims().subject().unwrap(), "security-test");
let local_token =
PasetoPQ::encrypt_with_footer(&symmetric_key, &claims, Some(footer)).unwrap();
let decrypted = PasetoPQ::decrypt_with_footer(&symmetric_key, &local_token).unwrap();
assert_eq!(decrypted.claims().subject().unwrap(), "security-test");
match i {
0 => assert_eq!(verified.footer().unwrap().kid().unwrap(), "key-1"),
1 => {
assert_eq!(verified.footer().unwrap().version().unwrap(), "2.0");
assert_eq!(verified.footer().unwrap().kid().unwrap(), "key-2");
}
2 => {
let custom = verified.footer().unwrap().get_custom("role").unwrap();
assert_eq!(custom.as_str().unwrap(), "admin");
}
_ => unreachable!(),
}
}
}
#[test]
fn test_hkdf_vs_simple_hash_difference() {
let shared_secret = b"test_shared_secret";
let info = b"PASETO-PQ-LOCAL-pq1";
let hkdf_key = SymmetricKey::derive_from_shared_secret(shared_secret, info);
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(shared_secret);
hasher.update(info);
let simple_hash = hasher.finalize();
assert_ne!(hkdf_key.to_bytes(), simple_hash.as_slice());
}
}