use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecurityError {
#[error("signature verification failed: {0}")]
SignatureInvalid(String),
#[error("signature contains ds:Object element (rejected per E91)")]
SignatureContainsDsObject,
#[error("certificate error: {0}")]
CertificateError(String),
#[error("clock skew exceeded: difference {difference_seconds}s exceeds tolerance {tolerance_seconds}s")]
ClockSkewExceeded {
difference_seconds: i64,
tolerance_seconds: u64,
},
#[error("assertion replay detected: ID '{0}'")]
ReplayDetected(String),
#[error("destination mismatch: expected '{expected}', got '{actual}'")]
DestinationMismatch { expected: String, actual: String },
#[error("recipient mismatch: expected '{expected}', got '{actual}'")]
RecipientMismatch { expected: String, actual: String },
#[error("audience restriction not satisfied for entity '{0}'")]
AudienceRestrictionFailed(String),
#[error("condition not met: {0}")]
ConditionNotMet(String),
#[error("one-time-use condition violated for assertion '{0}'")]
OneTimeUseViolated(String),
#[error("proxy restriction exceeded: count {count}, limit {limit}")]
ProxyRestrictionExceeded { count: u32, limit: u32 },
#[error("missing required element: {0}")]
MissingRequired(String),
#[error("issuer mismatch: expected '{expected}', got '{actual}'")]
IssuerMismatch { expected: String, actual: String },
#[error("issuer format invalid: '{0}' (must be entity format or omitted)")]
IssuerFormatInvalid(String),
#[error("InResponseTo mismatch: expected '{expected}', got '{actual}'")]
InResponseToMismatch { expected: String, actual: String },
#[error("subject confirmation method not acceptable: {0}")]
SubjectConfirmationInvalid(String),
#[error("NotBefore present in bearer SubjectConfirmationData (forbidden)")]
BearerNotBeforePresent,
#[error("session expired: SessionNotOnOrAfter has passed")]
SessionExpired,
#[error("RelayState exceeds 80-byte limit: {0} bytes")]
RelayStateTooLong(usize),
#[error("RelayState contains unsafe content: {0}")]
RelayStateUnsafe(String),
#[error("CBC-mode encryption requires separate integrity protection (E93)")]
CbcWithoutIntegrity,
#[error(
"persistent identifier reassigned: '{0}' was previously assigned to a different principal"
)]
PersistentIdReassigned(String),
#[error("client address mismatch: expected '{expected}', got '{actual}'")]
AddressMismatch { expected: String, actual: String },
#[error("assertion too old: age {age_seconds}s exceeds maximum {max_seconds}s")]
AssertionTooOld { age_seconds: u64, max_seconds: u64 },
#[error("response status is not success: {0}")]
ResponseNotSuccess(String),
}
#[derive(Debug, Clone)]
pub struct ValidationCheck {
pub check_number: u32,
pub check_name: &'static str,
pub passed: bool,
pub detail: Option<String>,
}
impl ValidationCheck {
pub fn pass(check_number: u32, check_name: &'static str) -> Self {
Self {
check_number,
check_name,
passed: true,
detail: None,
}
}
pub fn fail(check_number: u32, check_name: &'static str, detail: impl Into<String>) -> Self {
Self {
check_number,
check_name,
passed: false,
detail: Some(detail.into()),
}
}
}
#[derive(Debug)]
pub struct ValidationResult {
pub checks: Vec<ValidationCheck>,
}
impl ValidationResult {
pub fn new() -> Self {
Self { checks: Vec::new() }
}
pub fn add(&mut self, check: ValidationCheck) {
self.checks.push(check);
}
pub fn is_valid(&self) -> bool {
self.checks.iter().all(|c| c.passed)
}
pub fn failures(&self) -> Vec<&ValidationCheck> {
self.checks.iter().filter(|c| !c.passed).collect()
}
pub fn passes(&self) -> Vec<&ValidationCheck> {
self.checks.iter().filter(|c| c.passed).collect()
}
pub fn total_checks(&self) -> usize {
self.checks.len()
}
}
impl Default for ValidationResult {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_check_pass() {
let check = ValidationCheck::pass(1, "Destination matches URL");
assert!(check.passed);
assert_eq!(check.check_number, 1);
assert!(check.detail.is_none());
}
#[test]
fn test_validation_check_fail() {
let check = ValidationCheck::fail(1, "Destination matches URL", "URL mismatch");
assert!(!check.passed);
assert_eq!(check.detail.as_deref(), Some("URL mismatch"));
}
#[test]
fn test_validation_result() {
let mut result = ValidationResult::new();
result.add(ValidationCheck::pass(1, "Check 1"));
result.add(ValidationCheck::fail(2, "Check 2", "failed"));
result.add(ValidationCheck::pass(3, "Check 3"));
assert!(!result.is_valid());
assert_eq!(result.total_checks(), 3);
assert_eq!(result.failures().len(), 1);
assert_eq!(result.passes().len(), 2);
}
#[test]
fn test_validation_result_all_pass() {
let mut result = ValidationResult::new();
result.add(ValidationCheck::pass(1, "Check 1"));
result.add(ValidationCheck::pass(2, "Check 2"));
assert!(result.is_valid());
}
#[test]
fn test_security_error_display() {
let err = SecurityError::DestinationMismatch {
expected: "https://sp.example.com/acs".to_string(),
actual: "https://evil.com/acs".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("https://sp.example.com/acs"));
assert!(msg.contains("https://evil.com/acs"));
}
#[test]
fn test_security_error_ds_object() {
let err = SecurityError::SignatureContainsDsObject;
assert!(err.to_string().contains("ds:Object"));
}
}