use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum BadgeStatus {
Active,
Warning,
Deprecated,
Expired,
Revoked,
}
impl BadgeStatus {
pub fn is_valid_for_connection(&self) -> bool {
matches!(self, Self::Active | Self::Warning | Self::Deprecated)
}
pub fn is_active(&self) -> bool {
matches!(self, Self::Active | Self::Warning)
}
pub fn should_reject(&self) -> bool {
matches!(self, Self::Expired | Self::Revoked)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum EventType {
AgentRegistered,
AgentRenewed,
AgentDeprecated,
AgentRevoked,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Badge {
pub status: BadgeStatus,
pub payload: BadgePayload,
pub schema_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merkle_proof: Option<MerkleProof>,
}
impl Badge {
pub fn agent_name(&self) -> &str {
&self.payload.producer.event.ans_name
}
pub fn agent_host(&self) -> &str {
&self.payload.producer.event.agent.host
}
pub fn agent_version(&self) -> &str {
&self.payload.producer.event.agent.version
}
pub fn server_cert_fingerprint(&self) -> &str {
&self
.payload
.producer
.event
.attestations
.server_cert
.fingerprint
}
pub fn identity_cert_fingerprint(&self) -> &str {
&self
.payload
.producer
.event
.attestations
.identity_cert
.fingerprint
}
pub fn agent_id(&self) -> Uuid {
self.payload.producer.event.ans_id
}
pub fn event_type(&self) -> EventType {
self.payload.producer.event.event_type
}
pub fn is_valid(&self) -> bool {
self.status.is_valid_for_connection()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct BadgePayload {
pub log_id: Uuid,
pub producer: Producer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Producer {
pub event: AgentEvent,
pub key_id: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentEvent {
pub ans_id: Uuid,
pub ans_name: String,
pub event_type: EventType,
pub agent: AgentInfo,
pub attestations: Attestations,
pub expires_at: DateTime<Utc>,
pub issued_at: DateTime<Utc>,
pub ra_id: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AgentInfo {
pub host: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Attestations {
pub domain_validation: String,
pub identity_cert: CertAttestation,
pub server_cert: CertAttestation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CertAttestation {
pub fingerprint: String,
#[serde(rename = "type")]
pub cert_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MerkleProof {
pub leaf_hash: String,
pub leaf_index: u64,
pub path: Vec<String>,
pub root_hash: String,
pub root_signature: String,
pub tree_size: u64,
pub tree_version: u64,
}
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_badge_status_valid_for_connection() {
assert!(BadgeStatus::Active.is_valid_for_connection());
assert!(BadgeStatus::Warning.is_valid_for_connection());
assert!(BadgeStatus::Deprecated.is_valid_for_connection());
assert!(!BadgeStatus::Expired.is_valid_for_connection());
assert!(!BadgeStatus::Revoked.is_valid_for_connection());
}
#[test]
fn test_badge_status_should_reject() {
assert!(!BadgeStatus::Active.should_reject());
assert!(!BadgeStatus::Warning.should_reject());
assert!(!BadgeStatus::Deprecated.should_reject());
assert!(BadgeStatus::Expired.should_reject());
assert!(BadgeStatus::Revoked.should_reject());
}
#[test]
fn test_deserialize_badge() {
let json = r#"{
"status": "ACTIVE",
"payload": {
"logId": "019be7f3-5720-77c9-9672-adae3394502f",
"producer": {
"event": {
"ansId": "7b93c61c-e261-488c-89a3-f948119be0a0",
"ansName": "ans://v1.0.0.agent.example.com",
"eventType": "AGENT_REGISTERED",
"agent": {
"host": "agent.example.com",
"name": "Test Agent",
"version": "v1.0.0"
},
"attestations": {
"domainValidation": "ACME-DNS-01",
"identityCert": {
"fingerprint": "SHA256:aebdc9da0c20d6d5e4999a773839095ed050a9d7252bf212056fddc0c38f3496",
"type": "X509-OV-CLIENT"
},
"serverCert": {
"fingerprint": "SHA256:e7b64d16f42055d6faf382a43dc35b98be76aba0db145a904b590a034b33b904",
"type": "X509-DV-SERVER"
}
},
"expiresAt": "2027-01-22T22:58:52.000000Z",
"issuedAt": "2026-01-22T22:58:51.839533Z",
"raId": "gd-ra-us-west-2-ote-db21525-9ffa069a429b4a938e09d1e3e701958c",
"timestamp": "2026-01-22T23:04:02.890851Z"
},
"keyId": "ra-gd-ra-us-west-2-ote",
"signature": "eyJhbGci..."
}
},
"schemaVersion": "V1"
}"#;
let badge: Badge = serde_json::from_str(json).unwrap();
assert_eq!(badge.status, BadgeStatus::Active);
assert_eq!(badge.agent_host(), "agent.example.com");
assert_eq!(badge.agent_version(), "v1.0.0");
assert!(badge.server_cert_fingerprint().starts_with("SHA256:"));
}
}