use base64ct::{Base64UrlUnpadded, Encoding};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::{
codec::{self, Claims},
crypto::{self, MasterKey, NONCE_SIZE},
error::KwtError,
};
pub const MAX_TOKEN_WIRE_BYTES: usize = 128 * 1024;
pub const MAX_CIPHERTEXT_BYTES: usize = 640 * 1024;
fn now_unix_secs() -> Result<u64, KwtError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| KwtError::SystemTime)
.map(|d| d.as_secs())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Version {
V1,
}
impl Version {
fn prefix(&self) -> &'static str {
match self {
Version::V1 => "v1",
}
}
fn from_prefix(s: &str) -> Result<Self, KwtError> {
match s {
"v1" => Ok(Version::V1),
other => Err(KwtError::UnknownVersion(other.to_owned())),
}
}
}
#[derive(Debug, Clone)]
pub struct KwtToken {
pub version: Version,
pub claims: Claims,
}
impl KwtToken {
pub fn issue(claims: &Claims, master_key: &MasterKey) -> Result<String, KwtError> {
let plaintext = codec::encode(claims)?;
let (nonce, ciphertext) = crypto::encrypt(&plaintext, master_key)?;
let nonce_b64 = Base64UrlUnpadded::encode_string(&nonce);
let ct_b64 = Base64UrlUnpadded::encode_string(&ciphertext);
let token = format!("{}.{}.{}", Version::V1.prefix(), nonce_b64, ct_b64);
if token.len() > MAX_TOKEN_WIRE_BYTES {
return Err(KwtError::MalformedToken(format!(
"issued token exceeds maximum wire size {} (got {})",
MAX_TOKEN_WIRE_BYTES,
token.len()
)));
}
Ok(token)
}
pub fn validate(
token_str: &str,
master_key: &MasterKey,
expected_audience: &str,
) -> Result<Self, KwtError> {
Self::validate_with(token_str, master_key, expected_audience, |_| Ok(()))
}
pub fn validate_with<F>(
token_str: &str,
master_key: &MasterKey,
expected_audience: &str,
check_jti: F,
) -> Result<Self, KwtError>
where
F: FnOnce(&Uuid) -> Result<(), KwtError>,
{
if token_str.len() > MAX_TOKEN_WIRE_BYTES {
return Err(KwtError::MalformedToken(format!(
"token exceeds maximum wire size {} (got {})",
MAX_TOKEN_WIRE_BYTES,
token_str.len()
)));
}
let parts: Vec<&str> = token_str.splitn(3, '.').collect();
if parts.len() != 3 {
return Err(KwtError::MalformedToken(
"expected 3 dot-separated segments".into(),
));
}
let version = Version::from_prefix(parts[0])?;
let nonce_bytes = Base64UrlUnpadded::decode_vec(parts[1])
.map_err(|e| KwtError::Base64Error(e.to_string()))?;
let ciphertext = Base64UrlUnpadded::decode_vec(parts[2])
.map_err(|e| KwtError::Base64Error(e.to_string()))?;
if ciphertext.len() > MAX_CIPHERTEXT_BYTES {
return Err(KwtError::MalformedToken(format!(
"ciphertext exceeds maximum decoded size {}",
MAX_CIPHERTEXT_BYTES
)));
}
if nonce_bytes.len() != NONCE_SIZE {
return Err(KwtError::MalformedToken(format!(
"nonce must be {} bytes, got {}",
NONCE_SIZE,
nonce_bytes.len()
)));
}
let mut nonce = [0u8; NONCE_SIZE];
nonce.copy_from_slice(&nonce_bytes);
let plaintext = crypto::decrypt(&ciphertext, &nonce, master_key)?;
let claims = codec::decode(plaintext.as_slice())?;
let now = now_unix_secs()?;
const ISSUED_AT_FUTURE_LEEWAY_SECS: u64 = 60;
if (claims.issued_at as u64) > now.saturating_add(ISSUED_AT_FUTURE_LEEWAY_SECS) {
return Err(KwtError::NotYetValid);
}
const EXPIRY_LEEWAY_SECS: u64 = 30;
if (claims.expires_at as u64).saturating_add(EXPIRY_LEEWAY_SECS) < now {
return Err(KwtError::Expired);
}
check_jti(&claims.jti)?;
if claims.audience != expected_audience {
return Err(KwtError::AudienceMismatch {
expected: expected_audience.to_owned(),
got: claims.audience.clone(),
});
}
Ok(KwtToken { version, claims })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{new_claims, Role, Scope};
fn make_key() -> MasterKey {
MasterKey::generate().unwrap()
}
fn make_claims() -> Claims {
let mut c = new_claims("user_882", "api.example.com", 3600).unwrap();
c.roles = vec![Role::Admin, Role::Editor];
c.scopes = vec![Scope::ReadStats, Scope::WriteLogs];
c
}
#[test]
fn issue_and_validate() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).expect("issue failed");
println!("Token: {}", token_str);
println!("Token length: {} bytes", token_str.len());
let validated = KwtToken::validate(&token_str, &key, "api.example.com")
.expect("validate failed");
assert_eq!(validated.claims.subject, "user_882");
assert_eq!(validated.claims.audience, "api.example.com");
assert_eq!(validated.claims.roles, vec![Role::Admin, Role::Editor]);
assert_eq!(validated.claims.scopes, vec![Scope::ReadStats, Scope::WriteLogs]);
assert_eq!(validated.version, Version::V1);
}
#[test]
fn wrong_audience_rejected() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).unwrap();
let result = KwtToken::validate(&token_str, &key, "api.other-service.com");
assert!(matches!(result, Err(KwtError::AudienceMismatch { .. })));
}
#[test]
fn expired_token_rejected() {
let key = make_key();
let mut c = new_claims("user_882", "api.example.com", 1).unwrap();
c.issued_at = 1_000_000; c.expires_at = 1_000_001;
let token_str = KwtToken::issue(&c, &key).unwrap();
let result = KwtToken::validate(&token_str, &key, "api.example.com");
assert!(matches!(result, Err(KwtError::Expired)));
}
#[test]
fn issued_at_far_future_rejected() {
let key = make_key();
let mut c = make_claims();
c.issued_at = 4_000_000_000;
c.expires_at = 4_000_000_600;
let token_str = KwtToken::issue(&c, &key).unwrap();
let result = KwtToken::validate(&token_str, &key, "api.example.com");
assert!(matches!(result, Err(KwtError::NotYetValid)));
}
#[test]
fn public_message_is_opaque() {
assert_eq!(
KwtError::AudienceMismatch {
expected: "secret".into(),
got: "leak".into(),
}
.public_message(),
"invalid token"
);
assert_eq!(KwtError::NotYetValid.public_message(), "token not yet valid");
assert_eq!(KwtError::Expired.public_message(), "token expired");
}
#[test]
fn tampered_token_rejected() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).unwrap();
let mut tampered = token_str.clone();
let last = tampered.pop().unwrap();
let replacement = if last == 'A' { 'B' } else { 'A' };
tampered.push(replacement);
let result = KwtToken::validate(&tampered, &key, "api.example.com");
assert!(result.is_err(), "tampered token should be rejected");
}
#[test]
fn token_size_is_compact() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).unwrap();
println!("Full KWT token size: {} bytes", token_str.len());
assert!(
token_str.len() < 200,
"token too large: {} bytes (target <200)",
token_str.len()
);
}
#[test]
fn malformed_token_rejected() {
let key = make_key();
let bad_tokens = vec![
"",
"notavalidtoken",
"v1.onlytwoparts",
"v99.aaaa.bbbb", "v1.!!!.ccc", ];
for bad in bad_tokens {
let result = KwtToken::validate(bad, &key, "api.example.com");
assert!(result.is_err(), "should reject malformed token: '{}'", bad);
}
}
#[test]
fn validate_with_propagates_jti_callback_error() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).unwrap();
let r = KwtToken::validate_with(&token_str, &key, "api.example.com", |_| {
Err(KwtError::Replayed)
});
assert!(matches!(r, Err(KwtError::Replayed)));
}
#[test]
fn validate_with_ok_when_jti_allowed() {
let key = make_key();
let claims = make_claims();
let token_str = KwtToken::issue(&claims, &key).unwrap();
let r = KwtToken::validate_with(&token_str, &key, "api.example.com", |_| Ok(()));
assert!(r.is_ok());
}
#[test]
fn oversized_wire_token_rejected() {
let key = make_key();
let huge = "v1.".to_string() + &"a".repeat(super::MAX_TOKEN_WIRE_BYTES);
assert!(KwtToken::validate(&huge, &key, "api.example.com").is_err());
}
}