From 77ee1c8a26c2efadc82952888ffd69ccba36e6de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:19 +0000 Subject: [PATCH] fix(sign): read DSS revocation evidence one stream at a time within caps Revocation checking decoded every /CRLs and /OCSPs entry under the general 1 GiB stream limit and kept them all before parsing any, so one compressed stream referenced many times multiplied decoding and memory. Evidence now decodes lazily, each stream once and within the 2 MiB evidence cap, with at most 1024 entries and 64 MiB per array. --- crates/sign/src/pdf.rs | 47 ++++++++++++++++++-------- crates/sign/src/pdf_discovery_tests.rs | 22 ++++++++++++ crates/sign/src/revocation.rs | 2 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee..04598e033 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -975,6 +975,37 @@ fn finish_validation( } } +/// Evidence streams read from one `/DSS` array. +const MAX_DSS_ENTRIES: usize = 1024; +/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. +const MAX_DSS_BYTES: usize = 64 << 20; + +/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so +/// each is checked before the next decodes: each stream once and within the evidence size cap, +/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. +fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let mut items = items.into_iter().take(MAX_DSS_ENTRIES); + let mut seen = HashSet::new(); + let mut left = MAX_DSS_BYTES; + std::iter::from_fn(move || { + for item in items.by_ref() { + if left == 0 { + return None; + } + if item.as_ref().is_some_and(|r| !seen.insert(r)) { + continue; + } + let o = doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; + left = left.saturating_sub(bytes.len()); + return Some(bytes); + } + None + }) +} + /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the /// certificate is revoked (which invalidates the signature). @@ -985,25 +1016,13 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else { return false; }; - let pull = |key: &[u8]| -> Vec> { - let mut out = Vec::new(); - for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - let o = doc.resolve(&item); - if let pdfcraft_cos::Object::Stream(s) = &*o - && let Ok(bytes) = s.decoded() - { - out.push(bytes); - } - } - out - }; let issuer = info.chain.get(1).unwrap_or(signer); // A verified revocation wins; "good" from any covering evidence is reported, but never // contradicts a revocation. let mut revoked = None; let mut covered = false; let mut sources = Vec::new(); - for r in pull(b"CRLs") { + for r in dss_evidence(doc, &dss, b"CRLs") { let Ok(list) = CertificateList::parse(&r) else { continue }; sources.push("CRL"); match list.check(signer, issuer, at) { @@ -1016,7 +1035,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio } } if revoked.is_none() { - for r in pull(b"OCSPs") { + for r in dss_evidence(doc, &dss, b"OCSPs") { let Ok(resp) = OcspResponse::parse(&r) else { continue }; sources.push("OCSP"); match resp.check(signer, issuer, at) { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 66bf7c8a0..9bc444823 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -371,3 +371,25 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() { assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } + +#[test] +fn dss_evidence_decodes_each_stream_once_within_its_caps() { + // One small stream listed 1000 times, then streams that inflate past the evidence cap. + let mut doc = fixture(&[]); + let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence"))); + let mut items = vec![Object::Ref(small); 1000]; + for _ in 0..4 { + items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1]))))); + } + let mut dss = Dict::new(); + dss.set(b"CRLs".to_vec(), Object::Array(items)); + let read: Vec> = dss_evidence(&doc, &dss, b"CRLs").collect(); + assert_eq!(read, vec![b"evidence".to_vec()]); + // Distinct streams stop at the entry and byte budgets. + let items: Vec = + (0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect(); + dss.set(b"OCSPs".to_vec(), Object::Array(items)); + let total: usize = dss_evidence(&doc, &dss, b"OCSPs").map(|b| b.len()).sum(); + assert!(total <= MAX_DSS_BYTES, "{total}"); + assert!(dss_evidence(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES); +} diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index b4a9d820f..057e0717d 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -8,7 +8,7 @@ use crate::x509::Certificate; use crate::{SignError, Time}; /// A DER size cap: real CRLs and OCSP responses are far smaller. -const MAX_EVIDENCE: usize = 2 * 1024 * 1024; +pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024; const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1"; const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9";