Skip to main content

claim169_core/model/
x509.rs

1//! X.509 certificate-related types for COSE headers.
2//!
3//! This module contains types for X.509 certificate information
4//! that can be included in COSE protected/unprotected headers.
5//!
6//! ## COSE X.509 Header Parameters (RFC 9360)
7//!
8//! | Label | Name | Description |
9//! |-------|------|-------------|
10//! | 32 | x5bag | Unordered bag of X.509 certificates (DER-encoded) |
11//! | 33 | x5chain | Ordered chain of X.509 certificates (DER-encoded) |
12//! | 34 | x5t | Certificate thumbprint (hash) |
13//! | 35 | x5u | URI pointing to X.509 certificate |
14
15use serde::{Deserialize, Serialize};
16
17/// Algorithm identifier for certificate hash.
18///
19/// Can be either a numeric COSE algorithm ID or a named string.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(untagged)]
22pub enum CertHashAlgorithm {
23    /// Numeric COSE algorithm identifier (e.g., -16 for SHA-256)
24    Numeric(i64),
25    /// Named algorithm string (for compatibility)
26    Named(String),
27}
28
29impl Default for CertHashAlgorithm {
30    fn default() -> Self {
31        // SHA-256 is the most common default
32        CertHashAlgorithm::Numeric(-16)
33    }
34}
35
36/// X.509 certificate hash (COSE_CertHash).
37///
38/// Contains the hash algorithm and the hash value of a certificate.
39/// Used with the x5t (label 34) header parameter.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CertificateHash {
43    /// Hash algorithm identifier
44    pub algorithm: CertHashAlgorithm,
45
46    /// Hash value (raw bytes, serialized as base64 in JSON)
47    #[serde(with = "crate::serde_utils::base64_bytes")]
48    pub hash_value: Vec<u8>,
49}
50
51impl CertificateHash {
52    /// Validate that the hash value length matches the expected length for known algorithms.
53    ///
54    /// Returns `true` if the hash length is correct for the algorithm, or if the
55    /// algorithm is unknown (in which case no validation is performed).
56    /// Returns `false` if the length does not match a known algorithm's expected output.
57    pub fn validate_length(&self) -> bool {
58        let expected = match &self.algorithm {
59            CertHashAlgorithm::Numeric(-16) => Some(32), // SHA-256
60            CertHashAlgorithm::Numeric(-43) => Some(48), // SHA-384
61            CertHashAlgorithm::Numeric(-44) => Some(64), // SHA-512
62            _ => None,
63        };
64        expected
65            .map(|length| self.hash_value.len() == length)
66            .unwrap_or(true)
67    }
68}
69
70/// X.509 headers extracted from COSE protected/unprotected headers.
71///
72/// These headers provide certificate information for signature verification.
73/// Fields are extracted from both protected and unprotected headers,
74/// with protected taking precedence.
75///
76/// ## Header Labels (RFC 9360)
77///
78/// - **x5bag (32)**: Unordered bag of X.509 certificates
79/// - **x5chain (33)**: Ordered certificate chain (leaf first, root last)
80/// - **x5t (34)**: Certificate thumbprint for key lookup
81/// - **x5u (35)**: URI to retrieve the certificate
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct X509Headers {
85    /// Unordered bag of X.509 certificates (DER-encoded, base64 in JSON).
86    ///
87    /// COSE label: 32 (x5bag)
88    #[serde(
89        skip_serializing_if = "Option::is_none",
90        with = "crate::serde_utils::option_vec_base64"
91    )]
92    pub x5bag: Option<Vec<Vec<u8>>>,
93
94    /// Ordered X.509 certificate chain (DER-encoded, base64 in JSON).
95    ///
96    /// The first certificate is the leaf (end-entity), and the last
97    /// is the root or trust anchor.
98    ///
99    /// COSE label: 33 (x5chain)
100    #[serde(
101        skip_serializing_if = "Option::is_none",
102        with = "crate::serde_utils::option_vec_base64"
103    )]
104    pub x5chain: Option<Vec<Vec<u8>>>,
105
106    /// Certificate thumbprint hash for key lookup.
107    ///
108    /// COSE label: 34 (x5t)
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub x5t: Option<CertificateHash>,
111
112    /// URI pointing to an X.509 certificate or certificate chain.
113    ///
114    /// COSE label: 35 (x5u)
115    ///
116    /// **Security warning**: Fetching this URI can expose the system to SSRF attacks.
117    /// Always validate that the URI uses HTTPS before making any network requests.
118    /// Use [`X509Headers::x5u_is_https`] to check.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub x5u: Option<String>,
121}
122
123impl X509Headers {
124    /// Create empty X509Headers with no certificates.
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Check if any X.509 headers are present.
130    pub fn is_empty(&self) -> bool {
131        self.x5bag.is_none() && self.x5chain.is_none() && self.x5t.is_none() && self.x5u.is_none()
132    }
133
134    /// Check if any certificates are present (x5bag or x5chain).
135    pub fn has_certificates(&self) -> bool {
136        self.x5bag.is_some() || self.x5chain.is_some()
137    }
138
139    /// Check if the x5u URI uses the HTTPS scheme.
140    ///
141    /// Returns `false` if a non-HTTPS URI is present (potential SSRF risk).
142    /// Returns `true` if no URI is set or if the URI uses HTTPS.
143    pub fn x5u_is_https(&self) -> bool {
144        self.x5u
145            .as_ref()
146            .map(|uri| uri.starts_with("https://"))
147            .unwrap_or(true)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_x509_headers_default() {
157        let headers = X509Headers::default();
158        assert!(headers.is_empty());
159        assert!(!headers.has_certificates());
160    }
161
162    #[test]
163    fn test_x509_headers_with_x5bag() {
164        let headers = X509Headers {
165            x5bag: Some(vec![vec![1, 2, 3]]),
166            ..Default::default()
167        };
168        assert!(!headers.is_empty());
169        assert!(headers.has_certificates());
170    }
171
172    #[test]
173    fn test_x509_headers_with_x5chain() {
174        let headers = X509Headers {
175            x5chain: Some(vec![vec![1, 2, 3], vec![4, 5, 6]]),
176            ..Default::default()
177        };
178        assert!(!headers.is_empty());
179        assert!(headers.has_certificates());
180    }
181
182    #[test]
183    fn test_x509_headers_with_x5t() {
184        let headers = X509Headers {
185            x5t: Some(CertificateHash {
186                algorithm: CertHashAlgorithm::Numeric(-16),
187                hash_value: vec![0xab; 32],
188            }),
189            ..Default::default()
190        };
191        assert!(!headers.is_empty());
192        assert!(!headers.has_certificates()); // x5t is not a certificate
193    }
194
195    #[test]
196    fn test_x509_headers_with_x5u() {
197        let headers = X509Headers {
198            x5u: Some("https://example.com/cert.pem".to_string()),
199            ..Default::default()
200        };
201        assert!(!headers.is_empty());
202        assert!(!headers.has_certificates()); // x5u is just a URI
203    }
204
205    #[test]
206    fn test_cert_hash_algorithm_serde() {
207        // Numeric
208        let numeric = CertHashAlgorithm::Numeric(-16);
209        let json = serde_json::to_string(&numeric).unwrap();
210        assert_eq!(json, "-16");
211
212        // Named
213        let named = CertHashAlgorithm::Named("sha-256".to_string());
214        let json = serde_json::to_string(&named).unwrap();
215        assert_eq!(json, r#""sha-256""#);
216    }
217
218    #[test]
219    fn test_x509_headers_json_serialization() {
220        let headers = X509Headers {
221            x5chain: Some(vec![vec![1, 2, 3]]),
222            x5u: Some("https://example.com/cert.pem".to_string()),
223            ..Default::default()
224        };
225
226        let json = serde_json::to_string(&headers).unwrap();
227        assert!(json.contains("x5chain"));
228        assert!(json.contains("x5u"));
229        assert!(!json.contains("x5bag")); // should be skipped
230        assert!(!json.contains("x5t")); // should be skipped
231    }
232
233    #[test]
234    fn test_x509_headers_equality() {
235        let h1 = X509Headers {
236            x5u: Some("https://example.com".to_string()),
237            ..Default::default()
238        };
239        let h2 = X509Headers {
240            x5u: Some("https://example.com".to_string()),
241            ..Default::default()
242        };
243        let h3 = X509Headers {
244            x5u: Some("https://other.com".to_string()),
245            ..Default::default()
246        };
247
248        assert_eq!(h1, h2);
249        assert_ne!(h1, h3);
250    }
251
252    #[test]
253    fn test_certificate_hash_validate_length_sha256() {
254        let hash = CertificateHash {
255            algorithm: CertHashAlgorithm::Numeric(-16),
256            hash_value: vec![0xab; 32],
257        };
258        assert!(hash.validate_length());
259    }
260
261    #[test]
262    fn test_certificate_hash_validate_length_sha256_wrong() {
263        let hash = CertificateHash {
264            algorithm: CertHashAlgorithm::Numeric(-16),
265            hash_value: vec![0xab; 16], // wrong length
266        };
267        assert!(!hash.validate_length());
268    }
269
270    #[test]
271    fn test_certificate_hash_validate_length_sha384() {
272        let hash = CertificateHash {
273            algorithm: CertHashAlgorithm::Numeric(-43),
274            hash_value: vec![0xab; 48],
275        };
276        assert!(hash.validate_length());
277    }
278
279    #[test]
280    fn test_certificate_hash_validate_length_sha512() {
281        let hash = CertificateHash {
282            algorithm: CertHashAlgorithm::Numeric(-44),
283            hash_value: vec![0xab; 64],
284        };
285        assert!(hash.validate_length());
286    }
287
288    #[test]
289    fn test_certificate_hash_validate_length_unknown_algorithm() {
290        let hash = CertificateHash {
291            algorithm: CertHashAlgorithm::Named("custom-hash".to_string()),
292            hash_value: vec![0xab; 20],
293        };
294        assert!(hash.validate_length()); // unknown algorithms pass
295    }
296
297    #[test]
298    fn test_x5u_is_https_with_https() {
299        let headers = X509Headers {
300            x5u: Some("https://example.com/cert.pem".to_string()),
301            ..Default::default()
302        };
303        assert!(headers.x5u_is_https());
304    }
305
306    #[test]
307    fn test_x5u_is_https_with_http() {
308        let headers = X509Headers {
309            x5u: Some("http://example.com/cert.pem".to_string()),
310            ..Default::default()
311        };
312        assert!(!headers.x5u_is_https());
313    }
314
315    #[test]
316    fn test_x5u_is_https_with_no_uri() {
317        let headers = X509Headers::default();
318        assert!(headers.x5u_is_https());
319    }
320
321    #[test]
322    fn test_x5u_is_https_with_ftp() {
323        let headers = X509Headers {
324            x5u: Some("ftp://example.com/cert.pem".to_string()),
325            ..Default::default()
326        };
327        assert!(!headers.x5u_is_https());
328    }
329}