Skip to main content

ans_types/
scitt.rs

1//! SCITT (Supply Chain Integrity, Transparency, and Trust) shared types.
2//!
3//! These are pure data types with no crypto library dependencies. They live in
4//! `ans-types` (not feature-gated) so all consumers can inspect SCITT metadata
5//! without pulling in `ciborium` or `p256`.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::badge::BadgeStatus;
13use crate::fingerprint::CertFingerprint;
14use crate::types::AnsName;
15
16/// Which verification tier produced the verification result.
17///
18/// Ordered by assurance level: `BadgeOnly` < `StatusTokenVerified` < `FullScitt`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[non_exhaustive]
21pub enum VerificationTier {
22    /// Traditional: DNS + transparency log badge only.
23    BadgeOnly,
24    /// Status token verified (signed current-status claim).
25    /// Sufficient for live connection verification.
26    StatusTokenVerified,
27    /// Both receipt and status token verified offline.
28    /// Highest assurance: proves both historical inclusion and current status.
29    FullScitt,
30}
31
32impl VerificationTier {
33    /// Returns `true` if this tier includes SCITT verification
34    /// (status token and/or receipt).
35    pub fn is_scitt(&self) -> bool {
36        matches!(self, Self::StatusTokenVerified | Self::FullScitt)
37    }
38
39    /// Returns `true` if this tier includes a verified receipt
40    /// proving append-only log inclusion.
41    pub fn has_receipt(&self) -> bool {
42        matches!(self, Self::FullScitt)
43    }
44}
45
46impl std::fmt::Display for VerificationTier {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::BadgeOnly => write!(f, "BadgeOnly"),
50            Self::StatusTokenVerified => write!(f, "StatusTokenVerified"),
51            Self::FullScitt => write!(f, "FullScitt"),
52        }
53    }
54}
55
56/// Certificate type for status token cert entries.
57///
58/// Constrains the `cert_type` field to known values, preventing typos or
59/// attacker-supplied garbage from bypassing cert-type-based verification logic.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[non_exhaustive]
62pub enum CertType {
63    /// X.509 Domain-Validated server certificate.
64    #[serde(rename = "X509-DV-SERVER")]
65    X509DvServer,
66    /// X.509 Organization-Validated client certificate (mTLS identity).
67    #[serde(rename = "X509-OV-CLIENT")]
68    X509OvClient,
69}
70
71impl std::str::FromStr for CertType {
72    type Err = String;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        match s {
76            "X509-DV-SERVER" => Ok(Self::X509DvServer),
77            "X509-OV-CLIENT" => Ok(Self::X509OvClient),
78            other => Err(format!("unknown cert_type: {other}")),
79        }
80    }
81}
82
83impl std::fmt::Display for CertType {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::X509DvServer => write!(f, "X509-DV-SERVER"),
87            Self::X509OvClient => write!(f, "X509-OV-CLIENT"),
88        }
89    }
90}
91
92/// One entry in a status token's certificate fingerprint array.
93///
94/// Each status token contains arrays of valid server and identity certificates.
95/// During verification, the presented certificate must match at least one entry.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[non_exhaustive]
98pub struct CertEntry {
99    /// Certificate fingerprint in `SHA256:<hex>` format.
100    pub fingerprint: CertFingerprint,
101    /// Certificate type.
102    pub cert_type: CertType,
103}
104
105impl CertEntry {
106    /// Create a new certificate entry.
107    pub fn new(fingerprint: CertFingerprint, cert_type: CertType) -> Self {
108        Self {
109            fingerprint,
110            cert_type,
111        }
112    }
113}
114
115impl StatusTokenPayload {
116    /// Create a new status token payload.
117    #[allow(clippy::too_many_arguments)]
118    pub fn new(
119        agent_id: Uuid,
120        status: BadgeStatus,
121        iat: i64,
122        exp: i64,
123        ans_name: AnsName,
124        valid_identity_certs: Vec<CertEntry>,
125        valid_server_certs: Vec<CertEntry>,
126        metadata_hashes: BTreeMap<String, String>,
127    ) -> Self {
128        Self {
129            agent_id,
130            status,
131            iat,
132            exp,
133            ans_name,
134            valid_identity_certs,
135            valid_server_certs,
136            metadata_hashes,
137        }
138    }
139}
140
141/// Decoded payload of a SCITT status token (after COSE signature verification).
142///
143/// Status tokens are `COSE_Sign1` structures with CBOR integer-keyed payloads.
144/// The CBOR keys (1-8) map to the fields below. The token is time-bounded
145/// by the `iat` (issued-at) and `exp` (expiry) Unix timestamps.
146///
147/// This struct is deserialized from CBOR by the `scitt` module in `ans-verify`.
148/// It is placed here in `ans-types` because it has no crypto dependencies —
149/// only standard serde types.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[non_exhaustive]
152pub struct StatusTokenPayload {
153    /// Agent's unique ID (CBOR key 1).
154    pub agent_id: Uuid,
155    /// Current agent status (CBOR key 2). Reuses the existing `BadgeStatus` enum.
156    pub status: BadgeStatus,
157    /// Issued-at timestamp as Unix seconds (CBOR key 3).
158    pub iat: i64,
159    /// Expiry timestamp as Unix seconds (CBOR key 4).
160    pub exp: i64,
161    /// Full ANS name, e.g. `"ans://v1.0.0.agent.example.com"` (CBOR key 5).
162    ///
163    /// Validated at deserialization time — always a well-formed ANS URI.
164    pub ans_name: AnsName,
165    /// Valid identity certificates for mTLS verification (CBOR key 6).
166    pub valid_identity_certs: Vec<CertEntry>,
167    /// Valid server certificates for TLS verification (CBOR key 7).
168    pub valid_server_certs: Vec<CertEntry>,
169    /// Optional metadata hashes (CBOR key 8).
170    /// `BTreeMap` for deterministic serialization ordering.
171    pub metadata_hashes: BTreeMap<String, String>,
172}
173
174#[allow(clippy::unwrap_used, clippy::expect_used)]
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn verification_tier_is_scitt() {
181        assert!(!VerificationTier::BadgeOnly.is_scitt());
182        assert!(VerificationTier::StatusTokenVerified.is_scitt());
183        assert!(VerificationTier::FullScitt.is_scitt());
184    }
185
186    #[test]
187    fn verification_tier_has_receipt() {
188        assert!(!VerificationTier::BadgeOnly.has_receipt());
189        assert!(!VerificationTier::StatusTokenVerified.has_receipt());
190        assert!(VerificationTier::FullScitt.has_receipt());
191    }
192
193    #[test]
194    fn verification_tier_display() {
195        assert_eq!(VerificationTier::BadgeOnly.to_string(), "BadgeOnly");
196        assert_eq!(
197            VerificationTier::StatusTokenVerified.to_string(),
198            "StatusTokenVerified"
199        );
200        assert_eq!(VerificationTier::FullScitt.to_string(), "FullScitt");
201    }
202
203    #[test]
204    fn verification_tier_serde_roundtrip() {
205        for tier in [
206            VerificationTier::BadgeOnly,
207            VerificationTier::StatusTokenVerified,
208            VerificationTier::FullScitt,
209        ] {
210            let json = serde_json::to_string(&tier).unwrap();
211            let deserialized: VerificationTier = serde_json::from_str(&json).unwrap();
212            assert_eq!(tier, deserialized);
213        }
214    }
215
216    #[test]
217    fn verification_tier_equality_and_hash() {
218        use std::collections::HashSet;
219        let mut set = HashSet::new();
220        set.insert(VerificationTier::BadgeOnly);
221        set.insert(VerificationTier::StatusTokenVerified);
222        set.insert(VerificationTier::FullScitt);
223        set.insert(VerificationTier::BadgeOnly); // duplicate
224        assert_eq!(set.len(), 3);
225    }
226
227    #[test]
228    fn verification_tier_clone_and_copy() {
229        let tier = VerificationTier::FullScitt;
230        let cloned = tier;
231        assert_eq!(tier, cloned); // Copy semantics
232    }
233
234    #[test]
235    fn cert_entry_new() {
236        let fp = CertFingerprint::from_bytes([
237            0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2,
238            0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4,
239            0xe5, 0xf6, 0xa1, 0xb2,
240        ]);
241        let entry = CertEntry::new(fp.clone(), CertType::X509DvServer);
242        assert_eq!(entry.fingerprint, fp);
243        assert_eq!(entry.cert_type, CertType::X509DvServer);
244    }
245
246    #[test]
247    fn status_token_payload_serde_roundtrip() {
248        let fp = CertFingerprint::from_bytes([
249            0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2,
250            0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa1, 0xb2, 0xc3, 0xd4,
251            0xe5, 0xf6, 0xa1, 0xb2,
252        ]);
253
254        let payload = StatusTokenPayload {
255            agent_id: Uuid::nil(),
256            status: BadgeStatus::Active,
257            iat: 1_700_000_000,
258            exp: 1_700_003_600,
259            ans_name: AnsName::parse("ans://v1.0.0.agent.example.com").unwrap(),
260            valid_identity_certs: vec![CertEntry::new(fp.clone(), CertType::X509OvClient)],
261            valid_server_certs: vec![CertEntry::new(fp, CertType::X509DvServer)],
262            metadata_hashes: BTreeMap::from([("key".to_string(), "value".to_string())]),
263        };
264
265        let json = serde_json::to_string(&payload).unwrap();
266        let deserialized: StatusTokenPayload = serde_json::from_str(&json).unwrap();
267
268        assert_eq!(deserialized.agent_id, payload.agent_id);
269        assert_eq!(deserialized.status, payload.status);
270        assert_eq!(deserialized.iat, payload.iat);
271        assert_eq!(deserialized.exp, payload.exp);
272        assert_eq!(deserialized.ans_name, payload.ans_name);
273        assert_eq!(deserialized.valid_identity_certs.len(), 1);
274        assert_eq!(deserialized.valid_server_certs.len(), 1);
275        assert_eq!(deserialized.metadata_hashes.len(), 1);
276    }
277
278    #[test]
279    fn status_token_payload_empty_cert_arrays() {
280        let payload = StatusTokenPayload {
281            agent_id: Uuid::nil(),
282            status: BadgeStatus::Warning,
283            iat: 0,
284            exp: 3600,
285            ans_name: AnsName::parse("ans://v0.1.0.test.example.com").unwrap(),
286            valid_identity_certs: vec![],
287            valid_server_certs: vec![],
288            metadata_hashes: BTreeMap::new(),
289        };
290
291        let json = serde_json::to_string(&payload).unwrap();
292        let deserialized: StatusTokenPayload = serde_json::from_str(&json).unwrap();
293        assert!(deserialized.valid_identity_certs.is_empty());
294        assert!(deserialized.valid_server_certs.is_empty());
295        assert!(deserialized.metadata_hashes.is_empty());
296    }
297
298    #[test]
299    fn status_token_payload_all_statuses() {
300        for status in [
301            BadgeStatus::Active,
302            BadgeStatus::Warning,
303            BadgeStatus::Deprecated,
304            BadgeStatus::Expired,
305            BadgeStatus::Revoked,
306        ] {
307            let payload = StatusTokenPayload {
308                agent_id: Uuid::nil(),
309                status,
310                iat: 0,
311                exp: 3600,
312                ans_name: AnsName::parse("ans://v1.0.0.test.example.com").unwrap(),
313                valid_identity_certs: vec![],
314                valid_server_certs: vec![],
315                metadata_hashes: BTreeMap::new(),
316            };
317            let json = serde_json::to_string(&payload).unwrap();
318            let deserialized: StatusTokenPayload = serde_json::from_str(&json).unwrap();
319            assert_eq!(deserialized.status, status);
320        }
321    }
322
323    #[test]
324    fn metadata_hashes_deterministic_ordering() {
325        let mut map = BTreeMap::new();
326        map.insert("zebra".to_string(), "hash_z".to_string());
327        map.insert("alpha".to_string(), "hash_a".to_string());
328        map.insert("middle".to_string(), "hash_m".to_string());
329
330        let keys: Vec<&String> = map.keys().collect();
331        assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
332    }
333}