Skip to main content

email_auth/bimi/
vmc.rs

1use sha2::{Sha256, Digest};
2use std::collections::HashSet;
3use std::fmt;
4use x509_parser::der_parser::oid::Oid;
5use x509_parser::pem::parse_x509_pem;
6use x509_parser::prelude::*;
7
8use super::svg::validate_svg_tiny_ps;
9
10/// BIMI EKU OID: 1.3.6.1.5.5.7.3.31
11/// id-kp-BrandIndicatorforMessageIdentification
12const BIMI_EKU_OID: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 3, 31];
13
14/// LogoType extension OID (RFC 3709): 1.3.6.1.5.5.7.1.12
15const LOGOTYPE_OID: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 1, 12];
16
17/// VMC validation errors.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum VmcError {
20    /// PEM parsing failure.
21    PemParse(String),
22    /// No certificates found in PEM data.
23    NoCertificates,
24    /// Multiple VMC (end-entity) certificates found.
25    MultipleVmcs,
26    /// Certificate chain is out of order.
27    OutOfOrder,
28    /// Duplicate certificate in chain.
29    DuplicateCert,
30    /// Missing BIMI EKU OID.
31    MissingBimiEku,
32    /// SAN does not match expected selector._bimi.domain.
33    SanMismatch { expected: String },
34    /// Certificate expired.
35    Expired,
36    /// Certificate not yet valid.
37    NotYetValid,
38    /// LogoType extension not found.
39    MissingLogoType,
40    /// Failed to extract SVG from LogoType extension.
41    LogoTypeExtractFailed(String),
42    /// SVG validation failed.
43    SvgValidation(String),
44    /// Logo hash mismatch between DNS-fetched and VMC-embedded.
45    LogoHashMismatch,
46    /// Chain validation failure (issuer mismatch).
47    ChainValidation(String),
48    /// X.509 parsing error.
49    X509Parse(String),
50}
51
52impl fmt::Display for VmcError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            VmcError::PemParse(e) => write!(f, "PEM parse error: {}", e),
56            VmcError::NoCertificates => write!(f, "no certificates in PEM data"),
57            VmcError::MultipleVmcs => write!(f, "multiple VMC certificates in chain"),
58            VmcError::OutOfOrder => write!(f, "certificate chain out of order"),
59            VmcError::DuplicateCert => write!(f, "duplicate certificate in chain"),
60            VmcError::MissingBimiEku => write!(f, "missing BIMI EKU OID 1.3.6.1.5.5.7.3.31"),
61            VmcError::SanMismatch { expected } => {
62                write!(f, "SAN does not match {}", expected)
63            }
64            VmcError::Expired => write!(f, "certificate expired"),
65            VmcError::NotYetValid => write!(f, "certificate not yet valid"),
66            VmcError::MissingLogoType => write!(f, "LogoType extension not found"),
67            VmcError::LogoTypeExtractFailed(e) => {
68                write!(f, "LogoType SVG extraction failed: {}", e)
69            }
70            VmcError::SvgValidation(e) => write!(f, "SVG validation failed: {}", e),
71            VmcError::LogoHashMismatch => {
72                write!(f, "logo hash mismatch: DNS-fetched != VMC-embedded")
73            }
74            VmcError::ChainValidation(e) => write!(f, "chain validation: {}", e),
75            VmcError::X509Parse(e) => write!(f, "X.509 parse error: {}", e),
76        }
77    }
78}
79
80/// Result of VMC validation.
81#[derive(Debug)]
82pub struct VmcValidationResult {
83    /// Extracted SVG from LogoType extension (validated against SVG Tiny PS).
84    pub embedded_svg: String,
85}
86
87/// Parse PEM certificate chain and return DER-decoded certificates.
88/// Validates: no duplicates, VMC first, ordered chain.
89fn parse_pem_chain(pem_data: &[u8]) -> Result<Vec<Vec<u8>>, VmcError> {
90    let mut der_certs: Vec<Vec<u8>> = Vec::new();
91    let mut remaining = pem_data;
92
93    loop {
94        match parse_x509_pem(remaining) {
95            Ok((rest, pem)) => {
96                if pem.label != "CERTIFICATE" {
97                    remaining = rest;
98                    continue;
99                }
100                der_certs.push(pem.contents);
101                if rest.is_empty() {
102                    break;
103                }
104                remaining = rest;
105            }
106            Err(_) => break,
107        }
108    }
109
110    if der_certs.is_empty() {
111        return Err(VmcError::NoCertificates);
112    }
113
114    // Check for duplicate certificates (by raw DER)
115    let mut seen = HashSet::new();
116    for cert_der in &der_certs {
117        if !seen.insert(cert_der.clone()) {
118            return Err(VmcError::DuplicateCert);
119        }
120    }
121
122    Ok(der_certs)
123}
124
125/// Validate a VMC certificate chain.
126///
127/// `pem_data`: PEM-encoded certificate chain (VMC first, then issuer chain)
128/// `selector`: BIMI selector (e.g., "default")
129/// `domain`: author domain (e.g., "example.com")
130/// `dns_logo_svg`: Optional DNS-fetched logo SVG for hash comparison
131///
132/// Returns extracted SVG from VMC on success.
133pub fn validate_vmc(
134    pem_data: &[u8],
135    selector: &str,
136    domain: &str,
137    dns_logo_svg: Option<&str>,
138) -> Result<VmcValidationResult, VmcError> {
139    let der_certs = parse_pem_chain(pem_data)?;
140
141    // Parse all certificates
142    let mut parsed_certs: Vec<X509Certificate<'_>> = Vec::new();
143
144    // We need to keep the DER data alive for the parsed references
145    // Parse each cert from its DER bytes
146    for der in &der_certs {
147        let (_, cert) = X509Certificate::from_der(der)
148            .map_err(|e| VmcError::X509Parse(format!("{}", e)))?;
149        parsed_certs.push(cert);
150    }
151
152    if parsed_certs.is_empty() {
153        return Err(VmcError::NoCertificates);
154    }
155
156    // CHK-985: Multiple VMCs → reject
157    // Count end-entity (non-CA) certs — should be exactly 1
158    let vmc_count = parsed_certs.iter().filter(|c| !c.tbs_certificate.is_ca()).count();
159    if vmc_count > 1 {
160        return Err(VmcError::MultipleVmcs);
161    }
162
163    // The first cert MUST be the VMC (end-entity)
164    let vmc = &parsed_certs[0];
165    if vmc.tbs_certificate.is_ca() && parsed_certs.len() > 1 {
166        return Err(VmcError::OutOfOrder);
167    }
168
169    // CHK-983: Chain ordering — each cert should be issued by the next
170    for i in 0..parsed_certs.len().saturating_sub(1) {
171        let child = &parsed_certs[i];
172        let parent = &parsed_certs[i + 1];
173        if child.issuer() != parent.subject() {
174            return Err(VmcError::OutOfOrder);
175        }
176    }
177
178    // CHK-974: Check validity period
179    let validity = vmc.validity();
180    if !validity.is_valid() {
181        if validity.not_after.timestamp() < chrono_now() {
182            return Err(VmcError::Expired);
183        }
184        return Err(VmcError::NotYetValid);
185    }
186
187    // CHK-976/CHK-969: Check EKU contains BIMI OID
188    check_bimi_eku(vmc)?;
189
190    // CHK-977/CHK-971: Match SAN to selector._bimi.domain
191    let expected_san = format!("{}._bimi.{}", selector, domain);
192    check_san_match(vmc, &expected_san)?;
193
194    // CHK-978/CHK-970: Extract SVG from LogoType extension
195    let embedded_svg = extract_logotype_svg(vmc)?;
196
197    // CHK-979: Validate extracted SVG against SVG Tiny PS profile
198    validate_svg_tiny_ps(&embedded_svg)
199        .map_err(|e| VmcError::SvgValidation(format!("{}", e)))?;
200
201    // CHK-980: Compare logo hash if DNS-fetched logo provided
202    if let Some(dns_svg) = dns_logo_svg {
203        let dns_hash = sha256_hash(dns_svg.as_bytes());
204        let vmc_hash = sha256_hash(embedded_svg.as_bytes());
205        if dns_hash != vmc_hash {
206            return Err(VmcError::LogoHashMismatch);
207        }
208    }
209
210    // CHK-973: Chain signature validation
211    validate_chain_signatures(&parsed_certs)?;
212
213    Ok(VmcValidationResult { embedded_svg })
214}
215
216/// Check that the certificate has the BIMI EKU OID.
217fn check_bimi_eku(cert: &X509Certificate<'_>) -> Result<(), VmcError> {
218    let eku = cert
219        .tbs_certificate
220        .extended_key_usage()
221        .map_err(|e| VmcError::X509Parse(format!("EKU: {}", e)))?;
222
223    match eku {
224        Some(ext) => {
225            let bimi_oid = Oid::from(BIMI_EKU_OID)
226                .map_err(|_| VmcError::MissingBimiEku)?;
227            if ext.value.other.iter().any(|o| o == &bimi_oid) {
228                Ok(())
229            } else {
230                Err(VmcError::MissingBimiEku)
231            }
232        }
233        None => Err(VmcError::MissingBimiEku),
234    }
235}
236
237/// Check that the SAN contains the expected DNS name.
238fn check_san_match(cert: &X509Certificate<'_>, expected: &str) -> Result<(), VmcError> {
239    let san = cert
240        .tbs_certificate
241        .subject_alternative_name()
242        .map_err(|e| VmcError::X509Parse(format!("SAN: {}", e)))?;
243
244    match san {
245        Some(ext) => {
246            for name in &ext.value.general_names {
247                if let GeneralName::DNSName(dns) = name {
248                    if dns.eq_ignore_ascii_case(expected) {
249                        return Ok(());
250                    }
251                }
252            }
253            Err(VmcError::SanMismatch {
254                expected: expected.to_string(),
255            })
256        }
257        None => Err(VmcError::SanMismatch {
258            expected: expected.to_string(),
259        }),
260    }
261}
262
263/// Extract SVG from the LogoType extension (RFC 3709).
264///
265/// The LogoType extension contains a data URI:
266/// `data:image/svg+xml;base64,<base64-encoded-svg>`
267///
268/// We parse the extension raw data, searching for the data URI pattern,
269/// then decode the base64 SVG.
270fn extract_logotype_svg(cert: &X509Certificate<'_>) -> Result<String, VmcError> {
271    let logotype_oid = Oid::from(LOGOTYPE_OID)
272        .map_err(|_| VmcError::MissingLogoType)?;
273
274    let ext = cert
275        .tbs_certificate
276        .get_extension_unique(&logotype_oid)
277        .map_err(|e| VmcError::X509Parse(format!("LogoType: {}", e)))?
278        .ok_or(VmcError::MissingLogoType)?;
279
280    // The LogoType extension value is ASN.1-encoded.
281    // We search the raw bytes for the data URI pattern.
282    let raw = ext.value;
283    let raw_str = String::from_utf8_lossy(raw);
284
285    // Look for data:image/svg+xml;base64, pattern
286    let marker = "data:image/svg+xml;base64,";
287    if let Some(start) = raw_str.find(marker) {
288        let b64_start = start + marker.len();
289        // Find end of base64 data (next non-base64 char)
290        let b64_data: String = raw_str[b64_start..]
291            .chars()
292            .take_while(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/' || *c == '=')
293            .collect();
294
295        if b64_data.is_empty() {
296            return Err(VmcError::LogoTypeExtractFailed(
297                "empty base64 data after marker".into(),
298            ));
299        }
300
301        use base64::Engine;
302        let svg_bytes = base64::engine::general_purpose::STANDARD
303            .decode(&b64_data)
304            .map_err(|e| VmcError::LogoTypeExtractFailed(format!("base64 decode: {}", e)))?;
305
306        let svg = String::from_utf8(svg_bytes)
307            .map_err(|e| VmcError::LogoTypeExtractFailed(format!("UTF-8: {}", e)))?;
308
309        return Ok(svg);
310    }
311
312    // If data URI not found in string form, try binary scan
313    let marker_bytes = marker.as_bytes();
314    if let Some(pos) = raw.windows(marker_bytes.len()).position(|w| w == marker_bytes) {
315        let b64_start = pos + marker_bytes.len();
316        let b64_data: Vec<u8> = raw[b64_start..]
317            .iter()
318            .copied()
319            .take_while(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/' || *b == b'=')
320            .collect();
321
322        if b64_data.is_empty() {
323            return Err(VmcError::LogoTypeExtractFailed(
324                "empty base64 data".into(),
325            ));
326        }
327
328        use base64::Engine;
329        let svg_bytes = base64::engine::general_purpose::STANDARD
330            .decode(&b64_data)
331            .map_err(|e| VmcError::LogoTypeExtractFailed(format!("base64 decode: {}", e)))?;
332
333        let svg = String::from_utf8(svg_bytes)
334            .map_err(|e| VmcError::LogoTypeExtractFailed(format!("UTF-8: {}", e)))?;
335
336        return Ok(svg);
337    }
338
339    Err(VmcError::MissingLogoType)
340}
341
342/// Compute SHA-256 hash of data.
343fn sha256_hash(data: &[u8]) -> Vec<u8> {
344    let mut hasher = Sha256::new();
345    hasher.update(data);
346    hasher.finalize().to_vec()
347}
348
349/// Get current Unix timestamp.
350fn chrono_now() -> i64 {
351    std::time::SystemTime::now()
352        .duration_since(std::time::UNIX_EPOCH)
353        .map(|d| d.as_secs() as i64)
354        .unwrap_or(0)
355}
356
357/// Validate chain signatures: each cert[i] should be signed by cert[i+1].
358/// The last cert (root or self-signed) is verified against itself.
359fn validate_chain_signatures(certs: &[X509Certificate<'_>]) -> Result<(), VmcError> {
360    for i in 0..certs.len().saturating_sub(1) {
361        let child = &certs[i];
362        let parent = &certs[i + 1];
363        child
364            .verify_signature(Some(&parent.tbs_certificate.subject_pki))
365            .map_err(|e| {
366                VmcError::ChainValidation(format!(
367                    "cert {} not signed by cert {}: {}",
368                    i,
369                    i + 1,
370                    e
371                ))
372            })?;
373    }
374    Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use rcgen::{
381        CertificateParams, CustomExtension, DnType, ExtendedKeyUsagePurpose,
382        IsCa, BasicConstraints, SanType, KeyPair,
383    };
384
385    /// Test SVG for embedding in VMC LogoType extension.
386    const TEST_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100"><title>Test</title><rect width="100" height="100" fill="red"/></svg>"#;
387
388    /// Build a LogoType extension value containing a data URI with base64-encoded SVG.
389    /// This is a simplified ASN.1 structure — wraps the data URI in an OCTET STRING.
390    fn build_logotype_extension(svg: &str) -> Vec<u8> {
391        use base64::Engine;
392        let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
393        let data_uri = format!("data:image/svg+xml;base64,{}", b64);
394        // Wrap in a minimal ASN.1 structure that x509-parser can read as extension value.
395        // The LogoType extension is complex ASN.1, but x509-parser gives us raw bytes.
396        // We embed the data URI directly so our string search finds it.
397        data_uri.into_bytes()
398    }
399
400    /// BIMI EKU OID as rcgen ExtendedKeyUsagePurpose
401    fn bimi_eku() -> ExtendedKeyUsagePurpose {
402        ExtendedKeyUsagePurpose::Other(BIMI_EKU_OID.to_vec())
403    }
404
405    /// Create a self-signed VMC test certificate with BIMI EKU, SAN, and LogoType.
406    fn make_vmc_cert(
407        selector: &str,
408        domain: &str,
409        svg: &str,
410        expired: bool,
411        not_yet_valid: bool,
412        include_eku: bool,
413        include_san: bool,
414        include_logotype: bool,
415    ) -> (String, KeyPair) {
416        let mut params = CertificateParams::new(Vec::<String>::new())
417            .expect("CertificateParams");
418
419        params
420            .distinguished_name
421            .push(DnType::CommonName, format!("{}._bimi.{}", selector, domain));
422
423        // Validity
424        if expired {
425            params.not_before = rcgen::date_time_ymd(2020, 1, 1);
426            params.not_after = rcgen::date_time_ymd(2021, 1, 1);
427        } else if not_yet_valid {
428            params.not_before = rcgen::date_time_ymd(2030, 1, 1);
429            params.not_after = rcgen::date_time_ymd(2031, 1, 1);
430        } else {
431            params.not_before = rcgen::date_time_ymd(2024, 1, 1);
432            params.not_after = rcgen::date_time_ymd(2030, 12, 31);
433        }
434
435        // EKU
436        if include_eku {
437            params.extended_key_usages.push(bimi_eku());
438        }
439
440        // SAN
441        if include_san {
442            let san_name = format!("{}._bimi.{}", selector, domain);
443            params.subject_alt_names.push(SanType::DnsName(san_name.try_into().expect("dns name")));
444        }
445
446        // LogoType extension (custom)
447        if include_logotype {
448            let logotype_oid = LOGOTYPE_OID.to_vec();
449            let ext_value = build_logotype_extension(svg);
450            let ext = CustomExtension::from_oid_content(&logotype_oid, ext_value);
451            params.custom_extensions.push(ext);
452        }
453
454        params.is_ca = IsCa::NoCa;
455
456        let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
457            .expect("key pair");
458        let cert = params.self_signed(&key_pair).expect("self-signed cert");
459        (cert.pem(), key_pair)
460    }
461
462    /// Create a CA cert for chain testing.
463    fn make_ca_cert(cn: &str) -> (CertificateParams, KeyPair) {
464        let mut params = CertificateParams::new(Vec::<String>::new())
465            .expect("CertificateParams");
466        params.distinguished_name.push(DnType::CommonName, cn);
467        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
468        params.not_before = rcgen::date_time_ymd(2024, 1, 1);
469        params.not_after = rcgen::date_time_ymd(2030, 12, 31);
470
471        let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
472            .expect("key pair");
473        (params, key_pair)
474    }
475
476    // ─── CHK-1021: Valid VMC with BIMI EKU OID → pass ────────────────
477
478    #[test]
479    fn valid_vmc() {
480        let (pem, _kp) =
481            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
482        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
483        assert!(result.is_ok(), "expected Ok, got {:?}", result);
484        assert_eq!(result.unwrap().embedded_svg, TEST_SVG);
485    }
486
487    // ─── CHK-1022: Missing BIMI EKU OID → fail ──────────────────────
488
489    #[test]
490    fn missing_bimi_eku() {
491        let (pem, _kp) =
492            make_vmc_cert("default", "example.com", TEST_SVG, false, false, false, true, true);
493        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
494        assert_eq!(result.unwrap_err(), VmcError::MissingBimiEku);
495    }
496
497    // ─── CHK-1023: SAN matches selector._bimi.domain → pass ─────────
498
499    #[test]
500    fn san_match() {
501        let (pem, _kp) =
502            make_vmc_cert("brand", "example.com", TEST_SVG, false, false, true, true, true);
503        let result = validate_vmc(pem.as_bytes(), "brand", "example.com", None);
504        assert!(result.is_ok());
505    }
506
507    // ─── CHK-1024: SAN mismatch → fail ──────────────────────────────
508
509    #[test]
510    fn san_mismatch() {
511        let (pem, _kp) =
512            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
513        let result = validate_vmc(pem.as_bytes(), "default", "other.com", None);
514        assert!(matches!(result.unwrap_err(), VmcError::SanMismatch { .. }));
515    }
516
517    // ─── CHK-1025: Expired certificate → fail ────────────────────────
518
519    #[test]
520    fn expired_cert() {
521        let (pem, _kp) =
522            make_vmc_cert("default", "example.com", TEST_SVG, true, false, true, true, true);
523        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
524        assert_eq!(result.unwrap_err(), VmcError::Expired);
525    }
526
527    // ─── CHK-1026: Not-yet-valid certificate → fail ──────────────────
528
529    #[test]
530    fn not_yet_valid_cert() {
531        let (pem, _kp) =
532            make_vmc_cert("default", "example.com", TEST_SVG, false, true, true, true, true);
533        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
534        assert_eq!(result.unwrap_err(), VmcError::NotYetValid);
535    }
536
537    // ─── CHK-1027: Extract SVG from LogoType extension ───────────────
538
539    #[test]
540    fn extract_logotype_svg_test() {
541        let (pem, _kp) =
542            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
543        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
544        assert!(result.is_ok());
545        assert_eq!(result.unwrap().embedded_svg, TEST_SVG);
546    }
547
548    // ─── CHK-1028: Logo hash match → pass ────────────────────────────
549
550    #[test]
551    fn logo_hash_match() {
552        let (pem, _kp) =
553            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
554        // DNS-fetched SVG matches VMC-embedded SVG
555        let result = validate_vmc(pem.as_bytes(), "default", "example.com", Some(TEST_SVG));
556        assert!(result.is_ok());
557    }
558
559    // ─── CHK-1029: Logo hash mismatch → fail ─────────────────────────
560
561    #[test]
562    fn logo_hash_mismatch() {
563        let (pem, _kp) =
564            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
565        // DNS-fetched SVG is different from VMC-embedded
566        let different_svg = r#"<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100"><title>Diff</title><rect width="100" height="100" fill="blue"/></svg>"#;
567        let result =
568            validate_vmc(pem.as_bytes(), "default", "example.com", Some(different_svg));
569        assert_eq!(result.unwrap_err(), VmcError::LogoHashMismatch);
570    }
571
572    // ─── CHK-1030: PEM chain: VMC → Intermediate → Root ──────────────
573
574    #[test]
575    fn valid_pem_chain() {
576        // Create a 2-cert chain: VMC signed by CA
577        let (ca_params, ca_kp) = make_ca_cert("Test CA");
578        let ca_cert = ca_params.self_signed(&ca_kp).expect("CA cert");
579
580        let mut vmc_params = CertificateParams::new(Vec::<String>::new())
581            .expect("CertificateParams");
582        vmc_params.distinguished_name.push(DnType::CommonName, "default._bimi.example.com");
583        vmc_params.is_ca = IsCa::NoCa;
584        vmc_params.not_before = rcgen::date_time_ymd(2024, 1, 1);
585        vmc_params.not_after = rcgen::date_time_ymd(2030, 12, 31);
586        vmc_params.extended_key_usages.push(bimi_eku());
587        vmc_params.subject_alt_names.push(
588            SanType::DnsName("default._bimi.example.com".try_into().expect("dns"))
589        );
590        let logotype_ext = CustomExtension::from_oid_content(
591            &LOGOTYPE_OID.to_vec(),
592            build_logotype_extension(TEST_SVG),
593        );
594        vmc_params.custom_extensions.push(logotype_ext);
595
596        let vmc_kp = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
597            .expect("vmc key pair");
598        let vmc_cert = vmc_params.signed_by(&vmc_kp, &ca_cert, &ca_kp).expect("VMC signed");
599
600        let chain_pem = format!("{}{}", vmc_cert.pem(), ca_cert.pem());
601        let result = validate_vmc(chain_pem.as_bytes(), "default", "example.com", None);
602        assert!(result.is_ok(), "expected Ok, got {:?}", result);
603    }
604
605    // ─── CHK-1031: Out-of-order PEM chain → reject ───────────────────
606
607    #[test]
608    fn out_of_order_chain() {
609        // Put CA cert first, then VMC — wrong order
610        let (ca_params, ca_kp) = make_ca_cert("Test CA");
611        let ca_cert = ca_params.self_signed(&ca_kp).expect("CA cert");
612
613        let mut vmc_params = CertificateParams::new(Vec::<String>::new())
614            .expect("CertificateParams");
615        vmc_params.distinguished_name.push(DnType::CommonName, "default._bimi.example.com");
616        vmc_params.is_ca = IsCa::NoCa;
617        vmc_params.not_before = rcgen::date_time_ymd(2024, 1, 1);
618        vmc_params.not_after = rcgen::date_time_ymd(2030, 12, 31);
619        vmc_params.extended_key_usages.push(bimi_eku());
620        vmc_params.subject_alt_names.push(
621            SanType::DnsName("default._bimi.example.com".try_into().expect("dns"))
622        );
623        let logotype_ext = CustomExtension::from_oid_content(
624            &LOGOTYPE_OID.to_vec(),
625            build_logotype_extension(TEST_SVG),
626        );
627        vmc_params.custom_extensions.push(logotype_ext);
628
629        let vmc_kp = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
630            .expect("vmc key pair");
631        let vmc_cert = vmc_params.signed_by(&vmc_kp, &ca_cert, &ca_kp).expect("VMC signed");
632
633        // Wrong order: CA first, then VMC
634        let chain_pem = format!("{}{}", ca_cert.pem(), vmc_cert.pem());
635        let result = validate_vmc(chain_pem.as_bytes(), "default", "example.com", None);
636        assert!(result.is_err());
637        // Should fail because first cert is CA, not VMC (out of order)
638        let err = result.unwrap_err();
639        assert!(
640            matches!(err, VmcError::OutOfOrder | VmcError::MissingBimiEku),
641            "expected OutOfOrder or MissingBimiEku, got {:?}",
642            err
643        );
644    }
645
646    // ─── CHK-1032: Multiple VMC certificates → reject ────────────────
647
648    #[test]
649    fn multiple_vmcs_in_chain() {
650        let (pem1, _kp1) =
651            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
652        let (pem2, _kp2) =
653            make_vmc_cert("default", "other.com", TEST_SVG, false, false, true, true, true);
654
655        let chain_pem = format!("{}{}", pem1, pem2);
656        let result = validate_vmc(chain_pem.as_bytes(), "default", "example.com", None);
657        assert_eq!(result.unwrap_err(), VmcError::MultipleVmcs);
658    }
659
660    // ─── CHK-984: Duplicate certificates → reject ────────────────────
661
662    #[test]
663    fn duplicate_cert_in_chain() {
664        let (pem, _kp) =
665            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, true);
666        let chain_pem = format!("{}{}", pem, pem);
667        let result = validate_vmc(chain_pem.as_bytes(), "default", "example.com", None);
668        assert_eq!(result.unwrap_err(), VmcError::DuplicateCert);
669    }
670
671    // ─── Additional: No certificates in PEM → error ──────────────────
672
673    #[test]
674    fn no_certificates() {
675        let result = validate_vmc(b"not a PEM", "default", "example.com", None);
676        assert_eq!(result.unwrap_err(), VmcError::NoCertificates);
677    }
678
679    // ─── Additional: Missing LogoType extension ──────────────────────
680
681    #[test]
682    fn missing_logotype() {
683        let (pem, _kp) =
684            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, true, false);
685        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
686        assert_eq!(result.unwrap_err(), VmcError::MissingLogoType);
687    }
688
689    // ─── Additional: Missing SAN entirely ────────────────────────────
690
691    #[test]
692    fn missing_san() {
693        let (pem, _kp) =
694            make_vmc_cert("default", "example.com", TEST_SVG, false, false, true, false, true);
695        let result = validate_vmc(pem.as_bytes(), "default", "example.com", None);
696        assert!(matches!(result.unwrap_err(), VmcError::SanMismatch { .. }));
697    }
698}