Skip to main content

zpdf_document/
trust.rs

1//! X.509 certificate-chain verification for PDF signatures, against
2//! caller-provided trust anchors.
3//!
4//! [`crate::signature`] answers "are the bytes intact and signed by the key in
5//! the embedded certificate?". This module answers the follow-up: **"does that
6//! certificate chain to a root I trust?"** — chain building (subject/issuer
7//! matching over the CMS `certificates` set), per-link signature verification
8//! (RSA PKCS#1 v1.5 and ECDSA P-256/P-384, SHA-1/256/384/512), validity-period
9//! checks, and anchoring at a caller-supplied root set (PEM or DER).
10//!
11//! Deliberately out of scope: revocation (CRL/OCSP fetching needs a network),
12//! name constraints, policy mapping, and RSA-PSS.
13
14use sha2::Digest;
15
16/// A trusted root certificate.
17pub struct TrustAnchor {
18    cert: CertInfo,
19}
20
21/// Verdict of a chain verification.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ChainStatus {
24    /// Every link verified and the chain terminates at a provided anchor.
25    /// Carries the subject CNs from leaf to anchor.
26    Trusted(Vec<String>),
27    /// The chain is structurally complete but fails verification — a broken
28    /// signature, an expired certificate, or no path to any anchor.
29    Untrusted(String),
30    /// The chain could not be evaluated (no certificates, unparseable
31    /// certificate, unsupported algorithm).
32    Unsupported(String),
33}
34
35impl ChainStatus {
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            ChainStatus::Trusted(_) => "trusted",
39            ChainStatus::Untrusted(_) => "untrusted",
40            ChainStatus::Unsupported(_) => "unsupported",
41        }
42    }
43}
44
45/// Parse trust anchors from PEM (`-----BEGIN CERTIFICATE-----` blocks) or,
46/// when no PEM markers are found, a single DER certificate.
47pub fn parse_trust_anchors(data: &[u8]) -> Vec<TrustAnchor> {
48    let mut out = Vec::new();
49    let text = String::from_utf8_lossy(data);
50    let mut found_pem = false;
51    let mut collecting = false;
52    let mut b64 = String::new();
53    for line in text.lines() {
54        let line = line.trim();
55        if line.starts_with("-----BEGIN CERTIFICATE") {
56            collecting = true;
57            found_pem = true;
58            b64.clear();
59        } else if line.starts_with("-----END CERTIFICATE") {
60            collecting = false;
61            if let Some(der) = base64_decode(&b64) {
62                if let Some(cert) = CertInfo::parse(&der) {
63                    out.push(TrustAnchor { cert });
64                }
65            }
66        } else if collecting {
67            b64.push_str(line);
68        }
69    }
70    if !found_pem {
71        if let Some(cert) = CertInfo::parse(data) {
72            out.push(TrustAnchor { cert });
73        }
74    }
75    out
76}
77
78/// Verify the certificate chain embedded in a CMS `SignedData` blob (a PDF
79/// signature's `/Contents`) against `anchors`.
80///
81/// `at_seconds_since_epoch` is the validation time (e.g. `SystemTime::now()`);
82/// pass `None` to skip validity-period checks.
83pub fn verify_certificate_chain(
84    cms_blob: &[u8],
85    anchors: &[TrustAnchor],
86    at_seconds_since_epoch: Option<u64>,
87) -> ChainStatus {
88    if anchors.is_empty() {
89        return ChainStatus::Unsupported("no trust anchors provided".into());
90    }
91    let certs = match extract_cms_certificates(cms_blob) {
92        Some(c) if !c.is_empty() => c,
93        _ => return ChainStatus::Unsupported("no certificates in signature".into()),
94    };
95    let parsed: Vec<CertInfo> = certs.iter().filter_map(|d| CertInfo::parse(d)).collect();
96    if parsed.is_empty() {
97        return ChainStatus::Unsupported("certificates could not be parsed".into());
98    }
99
100    // The leaf is the first certificate (the same convention the signature
101    // verifier uses for the signer key).
102    let mut chain: Vec<&CertInfo> = vec![&parsed[0]];
103    let mut names = vec![parsed[0].subject_cn.clone().unwrap_or_default()];
104
105    const MAX_CHAIN: usize = 16;
106    loop {
107        if chain.len() > MAX_CHAIN {
108            return ChainStatus::Untrusted("chain too long".into());
109        }
110        let current = *chain.last().expect("nonempty");
111
112        // Validity window.
113        if let Some(now) = at_seconds_since_epoch {
114            if let Some((nb, na)) = current.validity {
115                if now < nb {
116                    return ChainStatus::Untrusted(format!(
117                        "certificate '{}' not yet valid",
118                        current.subject_cn.as_deref().unwrap_or("?")
119                    ));
120                }
121                if now > na {
122                    return ChainStatus::Untrusted(format!(
123                        "certificate '{}' expired",
124                        current.subject_cn.as_deref().unwrap_or("?")
125                    ));
126                }
127            }
128        }
129
130        // Anchored? (issuer matches an anchor's subject and the anchor key
131        // verifies this cert — or the cert IS an anchor byte-for-byte.)
132        for anchor in anchors {
133            if anchor.cert.subject_raw == current.subject_raw
134                && anchor.cert.spki_raw == current.spki_raw
135            {
136                return ChainStatus::Trusted(names);
137            }
138            if anchor.cert.subject_raw == current.issuer_raw {
139                match verify_cert_signature(current, &anchor.cert) {
140                    Some(true) => {
141                        names.push(anchor.cert.subject_cn.clone().unwrap_or_default());
142                        return ChainStatus::Trusted(names);
143                    }
144                    Some(false) => {
145                        return ChainStatus::Untrusted(format!(
146                            "signature of '{}' does not verify against anchor",
147                            current.subject_cn.as_deref().unwrap_or("?")
148                        ));
149                    }
150                    None => {} // unsupported algorithm; try other paths
151                }
152            }
153        }
154
155        // Otherwise find the issuer among the embedded certificates.
156        let next = parsed.iter().find(|c| {
157            c.subject_raw == current.issuer_raw
158                && !std::ptr::eq(*c, current)
159                && !chain.iter().any(|seen| std::ptr::eq(*seen, *c))
160        });
161        match next {
162            Some(issuer) => match verify_cert_signature(current, issuer) {
163                Some(true) => {
164                    names.push(issuer.subject_cn.clone().unwrap_or_default());
165                    chain.push(issuer);
166                }
167                Some(false) => {
168                    return ChainStatus::Untrusted(format!(
169                        "signature of '{}' does not verify against its issuer",
170                        current.subject_cn.as_deref().unwrap_or("?")
171                    ));
172                }
173                None => {
174                    return ChainStatus::Unsupported(format!(
175                        "unsupported signature algorithm in chain at '{}'",
176                        current.subject_cn.as_deref().unwrap_or("?")
177                    ));
178                }
179            },
180            None => {
181                return ChainStatus::Untrusted(format!(
182                    "no path to a trust anchor from '{}'",
183                    names.last().map(String::as_str).unwrap_or("?")
184                ));
185            }
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// X.509 parsing (minimal, hand-written DER — mirrors signature.rs's approach)
192// ---------------------------------------------------------------------------
193
194const SEQUENCE: u8 = 0x30;
195const SET: u8 = 0x31;
196const OID: u8 = 0x06;
197const BIT_STRING: u8 = 0x03;
198const UTC_TIME: u8 = 0x17;
199const GENERALIZED_TIME: u8 = 0x18;
200const CONTEXT_0: u8 = 0xA0;
201
202const OID_CN: &[u8] = &[0x55, 0x04, 0x03];
203const OID_SIGNED_DATA: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02];
204const OID_RSA_PREFIX: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01];
205const OID_ECDSA_PREFIX: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04];
206const OID_EC_PUBLIC_KEY: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
207const OID_RSA_PUBLIC_KEY: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01];
208const OID_CURVE_P256: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
209const OID_CURVE_P384: &[u8] = &[0x2b, 0x81, 0x04, 0x00, 0x22];
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum HashAlg {
213    Sha1,
214    Sha256,
215    Sha384,
216    Sha512,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220enum ChainKeyAlg {
221    Rsa,
222    EcP256,
223    EcP384,
224}
225
226/// The parts of one parsed certificate needed for chain verification.
227struct CertInfo {
228    /// Full DER of the `tbsCertificate` element (tag+len+content) — the bytes
229    /// the issuer signed.
230    tbs_raw: Vec<u8>,
231    /// Raw content bytes of the subject / issuer `Name` (compared for equality).
232    subject_raw: Vec<u8>,
233    issuer_raw: Vec<u8>,
234    subject_cn: Option<String>,
235    /// (notBefore, notAfter) as seconds since the Unix epoch.
236    validity: Option<(u64, u64)>,
237    /// Raw SPKI element (for anchor identity comparison).
238    spki_raw: Vec<u8>,
239    key_alg: Option<ChainKeyAlg>,
240    /// RSA: PKCS#1 RSAPublicKey DER. EC: SEC1 point.
241    key_bytes: Vec<u8>,
242    /// The certificate's signatureAlgorithm → hash, and the signature bits.
243    sig_hash: Option<HashAlg>,
244    sig_is_ecdsa: bool,
245    signature: Vec<u8>,
246}
247
248impl CertInfo {
249    fn parse(der: &[u8]) -> Option<CertInfo> {
250        // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
251        let (tag, cert, _) = tlv(der)?;
252        if tag != SEQUENCE {
253            return None;
254        }
255        let parts = children_raw(cert, 4);
256        if parts.len() < 3 {
257            return None;
258        }
259        let (tbs_tag, tbs, tbs_raw) = parts[0];
260        if tbs_tag != SEQUENCE {
261            return None;
262        }
263        let (alg_tag, alg, _) = parts[1];
264        if alg_tag != SEQUENCE {
265            return None;
266        }
267        let (sig_tag, sig_bits, _) = parts[2];
268        if sig_tag != BIT_STRING {
269            return None;
270        }
271        let signature = sig_bits
272            .split_first()
273            .and_then(|(unused, rest)| (*unused == 0).then(|| rest.to_vec()))?;
274
275        // signatureAlgorithm → hash + family.
276        let alg_oid = children(alg, 2)
277            .into_iter()
278            .find(|(t, _)| *t == OID)?
279            .1
280            .to_vec();
281        let (sig_hash, sig_is_ecdsa) = classify_sig_alg(&alg_oid);
282
283        // TBSCertificate ::= SEQUENCE { version [0]?, serialNumber, signature,
284        //   issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
285        // The SEQUENCEs in order (skipping [0] version and INTEGER serial):
286        //   0: signature AlgorithmIdentifier
287        //   1: issuer Name
288        //   2: validity
289        //   3: subject Name
290        //   4: subjectPublicKeyInfo
291        let tbs_children = children_raw(tbs, 16);
292        let seqs: Vec<(u8, &[u8], &[u8])> = tbs_children
293            .iter()
294            .filter(|(t, _, _)| *t == SEQUENCE)
295            .copied()
296            .collect();
297        if seqs.len() < 5 {
298            return None;
299        }
300        let issuer_raw = seqs[1].1.to_vec();
301        let validity_body = seqs[2].1;
302        let subject_raw = seqs[3].1.to_vec();
303        let subject_cn = name_cn(seqs[3].1);
304        let spki = seqs[4];
305
306        // validity ::= SEQUENCE { notBefore Time, notAfter Time }
307        let times = children(validity_body, 2);
308        let validity = match (times.first(), times.get(1)) {
309            (Some(&(t1, v1)), Some(&(t2, v2))) => match (parse_time(t1, v1), parse_time(t2, v2)) {
310                (Some(nb), Some(na)) => Some((nb, na)),
311                _ => None,
312            },
313            _ => None,
314        };
315
316        // SubjectPublicKeyInfo.
317        let spki_parts = children(spki.1, 2);
318        let key_alg_body = spki_parts.iter().find(|(t, _)| *t == SEQUENCE)?.1;
319        let key_bits = spki_parts.iter().find(|(t, _)| *t == BIT_STRING)?.1;
320        let key_bytes = key_bits
321            .split_first()
322            .and_then(|(unused, rest)| (*unused == 0).then(|| rest.to_vec()))?;
323        let alg_children = children(key_alg_body, 2);
324        let key_oid = alg_children.iter().find(|(t, _)| *t == OID)?.1;
325        let key_alg = if key_oid == OID_RSA_PUBLIC_KEY {
326            Some(ChainKeyAlg::Rsa)
327        } else if key_oid == OID_EC_PUBLIC_KEY {
328            // Curve is the second OID.
329            match alg_children.iter().filter(|(t, _)| *t == OID).nth(1) {
330                Some((_, curve)) if *curve == OID_CURVE_P256 => Some(ChainKeyAlg::EcP256),
331                Some((_, curve)) if *curve == OID_CURVE_P384 => Some(ChainKeyAlg::EcP384),
332                _ => None,
333            }
334        } else {
335            None
336        };
337
338        Some(CertInfo {
339            tbs_raw: tbs_raw.to_vec(),
340            subject_raw,
341            issuer_raw,
342            subject_cn,
343            validity,
344            spki_raw: spki.2.to_vec(),
345            key_alg,
346            key_bytes,
347            sig_hash,
348            sig_is_ecdsa,
349            signature,
350        })
351    }
352}
353
354/// Signature algorithm OID → (hash, is-ecdsa).
355fn classify_sig_alg(oid: &[u8]) -> (Option<HashAlg>, bool) {
356    if oid.starts_with(OID_RSA_PREFIX) && oid.len() == 9 {
357        let hash = match oid[8] {
358            0x05 => Some(HashAlg::Sha1),   // sha1WithRSA
359            0x0b => Some(HashAlg::Sha256), // sha256WithRSA
360            0x0c => Some(HashAlg::Sha384),
361            0x0d => Some(HashAlg::Sha512),
362            _ => None,
363        };
364        (hash, false)
365    } else if oid.starts_with(OID_ECDSA_PREFIX) {
366        // ecdsa-with-SHA1 = ...04 01; with-SHA2xx = ...04 03 0{2,3,4}
367        let hash = match (oid.get(6), oid.get(7)) {
368            (Some(0x01), None) => Some(HashAlg::Sha1),
369            (Some(0x03), Some(0x02)) => Some(HashAlg::Sha256),
370            (Some(0x03), Some(0x03)) => Some(HashAlg::Sha384),
371            (Some(0x03), Some(0x04)) => Some(HashAlg::Sha512),
372            _ => None,
373        };
374        (hash, true)
375    } else {
376        (None, false)
377    }
378}
379
380/// Verify `child`'s signature with `issuer`'s public key.
381fn verify_cert_signature(child: &CertInfo, issuer: &CertInfo) -> Option<bool> {
382    let hash = child.sig_hash?;
383    let digest: Vec<u8> = match hash {
384        HashAlg::Sha1 => sha1_digest(&child.tbs_raw),
385        HashAlg::Sha256 => sha2::Sha256::digest(&child.tbs_raw).to_vec(),
386        HashAlg::Sha384 => sha2::Sha384::digest(&child.tbs_raw).to_vec(),
387        HashAlg::Sha512 => sha2::Sha512::digest(&child.tbs_raw).to_vec(),
388    };
389    match (issuer.key_alg?, child.sig_is_ecdsa) {
390        (ChainKeyAlg::Rsa, false) => rsa_verify(hash, &issuer.key_bytes, &digest, &child.signature),
391        (ChainKeyAlg::EcP256, true) => {
392            use p256::ecdsa::signature::hazmat::PrehashVerifier;
393            use p256::ecdsa::{Signature, VerifyingKey};
394            let key = VerifyingKey::from_sec1_bytes(&issuer.key_bytes).ok()?;
395            let sig = Signature::from_der(&child.signature).ok()?;
396            Some(key.verify_prehash(&digest, &sig).is_ok())
397        }
398        (ChainKeyAlg::EcP384, true) => {
399            use p384::ecdsa::signature::hazmat::PrehashVerifier;
400            use p384::ecdsa::{Signature, VerifyingKey};
401            let key = VerifyingKey::from_sec1_bytes(&issuer.key_bytes).ok()?;
402            let sig = Signature::from_der(&child.signature).ok()?;
403            Some(key.verify_prehash(&digest, &sig).is_ok())
404        }
405        _ => None, // algorithm family mismatch
406    }
407}
408
409fn rsa_verify(alg: HashAlg, key_der: &[u8], hashed: &[u8], sig: &[u8]) -> Option<bool> {
410    use rsa::pkcs1::DecodeRsaPublicKey;
411    use rsa::{Pkcs1v15Sign, RsaPublicKey};
412    let key = RsaPublicKey::from_pkcs1_der(key_der).ok()?;
413    let scheme = match alg {
414        HashAlg::Sha1 => Pkcs1v15Sign::new::<sha1::Sha1>(),
415        HashAlg::Sha256 => Pkcs1v15Sign::new::<sha2::Sha256>(),
416        HashAlg::Sha384 => Pkcs1v15Sign::new::<sha2::Sha384>(),
417        HashAlg::Sha512 => Pkcs1v15Sign::new::<sha2::Sha512>(),
418    };
419    Some(key.verify(scheme, hashed, sig).is_ok())
420}
421
422fn sha1_digest(data: &[u8]) -> Vec<u8> {
423    use sha1::{Digest as _, Sha1};
424    Sha1::digest(data).to_vec()
425}
426
427/// Every certificate DER in a CMS blob's `certificates [0]` field.
428fn extract_cms_certificates(blob: &[u8]) -> Option<Vec<Vec<u8>>> {
429    let (tag, ci, _) = tlv(blob)?;
430    if tag != SEQUENCE {
431        return None;
432    }
433    let ci = children(ci, 4);
434    let ctype = ci.iter().find(|(t, _)| *t == OID)?;
435    if ctype.1 != OID_SIGNED_DATA {
436        return None;
437    }
438    let content = ci.iter().find(|(t, _)| *t == CONTEXT_0)?;
439    let (tag, signed_data, _) = tlv(content.1)?;
440    if tag != SEQUENCE {
441        return None;
442    }
443    let sd = children(signed_data, 16);
444    let certs_body = sd.iter().find(|(t, _)| *t == CONTEXT_0)?.1;
445
446    let mut out = Vec::new();
447    let mut rest = certs_body;
448    while !rest.is_empty() && out.len() < 32 {
449        let before = rest;
450        let Some((tag, _, next)) = tlv(rest) else {
451            break;
452        };
453        let consumed = before.len() - next.len();
454        if tag == SEQUENCE {
455            out.push(before[..consumed].to_vec());
456        }
457        rest = next;
458    }
459    Some(out)
460}
461
462/// The CN of an X.501 `Name` body.
463fn name_cn(name_body: &[u8]) -> Option<String> {
464    for (tag, rdn) in children(name_body, 32) {
465        if tag != SET {
466            continue;
467        }
468        for (tag, atv) in children(rdn, 8) {
469            if tag != SEQUENCE {
470                continue;
471            }
472            let parts = children(atv, 2);
473            let is_cn = parts
474                .iter()
475                .find(|(t, _)| *t == OID)
476                .is_some_and(|(_, o)| *o == OID_CN);
477            if is_cn {
478                if let Some((_, value)) = parts.iter().rev().find(|(t, _)| *t != OID) {
479                    return Some(String::from_utf8_lossy(value).into_owned());
480                }
481            }
482        }
483    }
484    None
485}
486
487/// UTCTime (`YYMMDDHHMMSSZ`) or GeneralizedTime (`YYYYMMDDHHMMSSZ`) → Unix
488/// seconds. Fractional seconds and offsets are not handled (CAs emit Z).
489fn parse_time(tag: u8, body: &[u8]) -> Option<u64> {
490    let s = std::str::from_utf8(body).ok()?;
491    let s = s.strip_suffix('Z').unwrap_or(s);
492    let (year, rest): (i64, &str) = match tag {
493        UTC_TIME => {
494            let yy: i64 = s.get(0..2)?.parse().ok()?;
495            // RFC 5280: 00-49 ⇒ 20xx, 50-99 ⇒ 19xx.
496            (if yy < 50 { 2000 + yy } else { 1900 + yy }, s.get(2..)?)
497        }
498        GENERALIZED_TIME => (s.get(0..4)?.parse().ok()?, s.get(4..)?),
499        _ => return None,
500    };
501    let month: u32 = rest.get(0..2)?.parse().ok()?;
502    let day: u64 = rest.get(2..4)?.parse().ok()?;
503    let hour: u64 = rest.get(4..6)?.parse().ok()?;
504    let minute: u64 = rest.get(6..8)?.parse().ok()?;
505    let second: u64 = rest.get(8..10).and_then(|t| t.parse().ok()).unwrap_or(0);
506    if !(1..=12).contains(&month) || day == 0 || day > 31 {
507        return None;
508    }
509
510    // Days since epoch (civil-from-days inverse, Howard Hinnant's algorithm).
511    let y = year - i64::from(month <= 2);
512    let era = if y >= 0 { y } else { y - 399 } / 400;
513    let yoe = (y - era * 400) as u64;
514    let mp = ((month + 9) % 12) as u64;
515    let doy = (153 * mp + 2) / 5 + day - 1;
516    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
517    let days = era * 146_097 + doe as i64 - 719_468;
518    if days < 0 {
519        return None;
520    }
521    Some(days as u64 * 86_400 + hour * 3_600 + minute * 60 + second)
522}
523
524// -- DER primitives (same shapes as signature.rs's private cms module) -------
525
526fn tlv(buf: &[u8]) -> Option<(u8, &[u8], &[u8])> {
527    if buf.len() < 2 {
528        return None;
529    }
530    let tag = buf[0];
531    let first = buf[1];
532    let (len, header) = if first < 0x80 {
533        (first as usize, 2)
534    } else {
535        let n = (first & 0x7f) as usize;
536        if n == 0 || n > 4 || buf.len() < 2 + n {
537            return None;
538        }
539        let mut len = 0usize;
540        for &b in &buf[2..2 + n] {
541            len = (len << 8) | b as usize;
542        }
543        (len, 2 + n)
544    };
545    let end = header.checked_add(len)?;
546    if end > buf.len() {
547        return None;
548    }
549    Some((tag, &buf[header..end], &buf[end..]))
550}
551
552fn children(content: &[u8], max: usize) -> Vec<(u8, &[u8])> {
553    let mut out = Vec::new();
554    let mut rest = content;
555    while !rest.is_empty() && out.len() < max {
556        let Some((tag, body, next)) = tlv(rest) else {
557            break;
558        };
559        out.push((tag, body));
560        rest = next;
561    }
562    out
563}
564
565#[allow(clippy::type_complexity)]
566fn children_raw(content: &[u8], max: usize) -> Vec<(u8, &[u8], &[u8])> {
567    let mut out = Vec::new();
568    let mut rest = content;
569    while !rest.is_empty() && out.len() < max {
570        let before = rest;
571        let Some((tag, body, next)) = tlv(rest) else {
572            break;
573        };
574        let consumed = before.len() - next.len();
575        out.push((tag, body, &before[..consumed]));
576        rest = next;
577    }
578    out
579}
580
581/// Minimal base64 decoder (standard alphabet, ignores whitespace).
582fn base64_decode(s: &str) -> Option<Vec<u8>> {
583    fn val(c: u8) -> Option<u8> {
584        match c {
585            b'A'..=b'Z' => Some(c - b'A'),
586            b'a'..=b'z' => Some(c - b'a' + 26),
587            b'0'..=b'9' => Some(c - b'0' + 52),
588            b'+' => Some(62),
589            b'/' => Some(63),
590            _ => None,
591        }
592    }
593    let mut out = Vec::with_capacity(s.len() * 3 / 4);
594    let mut acc = 0u32;
595    let mut bits = 0u32;
596    for &c in s.as_bytes() {
597        if c.is_ascii_whitespace() || c == b'=' {
598            continue;
599        }
600        acc = (acc << 6) | u32::from(val(c)?);
601        bits += 6;
602        if bits >= 8 {
603            bits -= 8;
604            out.push((acc >> bits) as u8);
605        }
606    }
607    Some(out)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn base64_roundtrip() {
616        assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
617        assert_eq!(base64_decode("aGVs\nbG8=").unwrap(), b"hello");
618        assert!(base64_decode("!!!").is_none());
619    }
620
621    #[test]
622    fn utc_time_parses() {
623        // 2026-01-02 03:04:05 UTC
624        let t = parse_time(UTC_TIME, b"260102030405Z").unwrap();
625        assert_eq!(t, 1_767_323_045);
626        // Generalized form of the same instant.
627        let g = parse_time(GENERALIZED_TIME, b"20260102030405Z").unwrap();
628        assert_eq!(g, t);
629    }
630
631    #[test]
632    fn empty_anchor_set_is_unsupported() {
633        let status = verify_certificate_chain(b"junk", &[], None);
634        assert_eq!(status.as_str(), "unsupported");
635    }
636
637    #[test]
638    fn garbage_cms_is_unsupported() {
639        let anchors = parse_trust_anchors(b"not a cert");
640        // Garbage anchor input yields no anchors → unsupported either way.
641        let status = verify_certificate_chain(b"junk", &anchors, None);
642        assert_eq!(status.as_str(), "unsupported");
643    }
644}