use alloc::collections::BTreeSet;
use alloc::string::String;
use alloc::vec::Vec;
use core::time::Duration;
use crate::error::ClaimsError;
use crate::hash::RequestHash;
use crate::label;
use crate::types::{KeyId, MessageId, ResponseSubject, Subject, UnixTime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claims {
pub issuer: Option<Subject>,
pub audience: Option<Subject>,
pub expires_at: Option<UnixTime>,
pub issued_at: UnixTime,
pub message_id: MessageId,
pub sender_key_id: Option<KeyId>,
pub response_key_id: Option<KeyId>,
pub response_subject: Option<ResponseSubject>,
pub in_reply_to: Option<MessageId>,
pub request_hash: Option<RequestHash>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProtectedHeaders {
pub signer_certificates_jwt: Vec<String>,
}
impl ProtectedHeaders {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.signer_certificates_jwt.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRole {
Request,
Response,
Peer,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationParams {
pub now: UnixTime,
pub max_clock_skew: Duration,
pub max_ttl: Duration,
pub default_ttl: Duration,
pub allowed_audiences: BTreeSet<Subject>,
pub role: MessageRole,
}
#[allow(clippy::cast_possible_wrap)]
const fn secs_i64(d: Duration) -> i64 {
let s = d.as_secs();
if s > i64::MAX as u64 {
i64::MAX
} else {
s as i64
}
}
impl Claims {
pub fn validate(&self, params: &ValidationParams) -> Result<(), ClaimsError> {
let skew = secs_i64(params.max_clock_skew);
let iat = self.issued_at.0;
let now = params.now.0;
if iat > now.saturating_add(skew) {
return Err(ClaimsError::IssuedInFuture);
}
let effective_exp = match self.expires_at {
Some(UnixTime(exp)) => {
let span = exp.saturating_sub(iat);
if span <= 0 {
return Err(ClaimsError::NonPositiveTtl);
}
if span > secs_i64(params.max_ttl) {
return Err(ClaimsError::TtlTooLong { seconds: span });
}
exp
}
None => iat.saturating_add(secs_i64(params.default_ttl)),
};
if now > effective_exp.saturating_add(skew) {
return Err(ClaimsError::Expired);
}
if !params.allowed_audiences.is_empty()
&& let Some(aud) = &self.audience
&& !params.allowed_audiences.contains(aud)
{
return Err(ClaimsError::AudienceRejected);
}
self.validate_role(params.role)
}
pub fn validate_role(&self, role: MessageRole) -> Result<(), ClaimsError> {
let require = |present: bool, l: i64| {
if present {
Ok(())
} else {
Err(ClaimsError::MissingClaim { label: l })
}
};
let forbid = |present: bool, l: i64| {
if present {
Err(ClaimsError::ForbiddenClaim { label: l })
} else {
Ok(())
}
};
match role {
MessageRole::Request => {
require(self.sender_key_id.is_some(), label::SENDER_KEY_ID)?;
require(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
forbid(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
forbid(self.request_hash.is_some(), label::REQUEST_HASH)
}
MessageRole::Response => {
require(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
require(self.request_hash.is_some(), label::REQUEST_HASH)?;
forbid(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
forbid(self.response_subject.is_some(), label::RESPONSE_SUBJECT)
}
MessageRole::Peer => {
require(self.sender_key_id.is_some(), label::SENDER_KEY_ID)?;
forbid(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
forbid(self.request_hash.is_some(), label::REQUEST_HASH)?;
forbid(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
forbid(self.response_subject.is_some(), label::RESPONSE_SUBJECT)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::ToString;
use alloc::vec;
fn base_claims() -> Claims {
Claims {
issuer: None,
audience: None,
expires_at: None,
issued_at: UnixTime(1_000),
message_id: MessageId::from_bytes(vec![1, 2, 3]).unwrap(),
sender_key_id: Some(KeyId::from_text("alice").unwrap()),
response_key_id: None,
response_subject: None,
in_reply_to: None,
request_hash: None,
}
}
fn params(now: i64) -> ValidationParams {
ValidationParams {
now: UnixTime(now),
max_clock_skew: Duration::from_secs(5),
max_ttl: Duration::from_mins(5),
default_ttl: Duration::from_mins(1),
allowed_audiences: BTreeSet::new(),
role: MessageRole::Peer,
}
}
#[test]
fn accepts_fresh_message() {
assert_eq!(base_claims().validate(¶ms(1_010)), Ok(()));
}
#[test]
fn rejects_future_iat_beyond_skew() {
assert_eq!(
base_claims().validate(¶ms(990)),
Err(ClaimsError::IssuedInFuture)
);
}
#[test]
fn accepts_future_iat_within_skew() {
assert_eq!(base_claims().validate(¶ms(996)), Ok(()));
}
#[test]
fn rejects_expired_default_ttl() {
assert_eq!(base_claims().validate(¶ms(1_065)), Ok(()));
assert_eq!(
base_claims().validate(¶ms(1_066)),
Err(ClaimsError::Expired)
);
}
#[test]
fn explicit_exp_overrides_default() {
let mut c = base_claims();
c.expires_at = Some(UnixTime(1_100));
assert_eq!(c.validate(¶ms(1_105)), Ok(()));
assert_eq!(c.validate(¶ms(1_106)), Err(ClaimsError::Expired));
}
#[test]
fn rejects_over_long_explicit_ttl() {
let mut c = base_claims();
c.expires_at = Some(UnixTime(1_000 + 301));
assert_eq!(
c.validate(¶ms(1_010)),
Err(ClaimsError::TtlTooLong { seconds: 301 })
);
}
#[test]
fn rejects_non_positive_ttl() {
let mut c = base_claims();
c.expires_at = Some(UnixTime(1_000));
assert_eq!(c.validate(¶ms(1_010)), Err(ClaimsError::NonPositiveTtl));
}
#[test]
fn audience_rules() {
let mut p = params(1_010);
p.allowed_audiences
.insert(Subject::new("svc-a".to_string()).unwrap());
assert_eq!(base_claims().validate(&p), Ok(()));
let mut c = base_claims();
c.audience = Some(Subject::new("svc-a".to_string()).unwrap());
assert_eq!(c.validate(&p), Ok(()));
c.audience = Some(Subject::new("svc-b".to_string()).unwrap());
assert_eq!(c.validate(&p), Err(ClaimsError::AudienceRejected));
assert_eq!(c.validate(¶ms(1_010)), Ok(()));
}
#[test]
fn request_role_shape() {
let mut c = base_claims();
assert_eq!(
c.validate_role(MessageRole::Request),
Err(ClaimsError::MissingClaim {
label: label::RESPONSE_KEY_ID
})
);
c.response_key_id = Some(KeyId::from_text("bob").unwrap());
assert_eq!(c.validate_role(MessageRole::Request), Ok(()));
c.request_hash = Some(RequestHash([0; 32]));
assert_eq!(
c.validate_role(MessageRole::Request),
Err(ClaimsError::ForbiddenClaim {
label: label::REQUEST_HASH
})
);
}
#[test]
fn response_role_shape() {
let mut c = base_claims();
assert_eq!(
c.validate_role(MessageRole::Response),
Err(ClaimsError::MissingClaim {
label: label::IN_REPLY_TO
})
);
c.in_reply_to = Some(MessageId::from_bytes(vec![9]).unwrap());
c.request_hash = Some(RequestHash([0; 32]));
assert_eq!(c.validate_role(MessageRole::Response), Ok(()));
c.response_subject = Some(ResponseSubject::new("inbox".to_string()).unwrap());
assert_eq!(
c.validate_role(MessageRole::Response),
Err(ClaimsError::ForbiddenClaim {
label: label::RESPONSE_SUBJECT
})
);
}
#[test]
fn peer_role_shape() {
let mut c = base_claims();
assert_eq!(c.validate_role(MessageRole::Peer), Ok(()));
c.sender_key_id = None;
assert_eq!(
c.validate_role(MessageRole::Peer),
Err(ClaimsError::MissingClaim {
label: label::SENDER_KEY_ID
})
);
}
}