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.
This commit is contained in:
aiongg
2026-10-22 15:16:19 +00:00
parent dfde90787f
commit 77ee1c8a26
3 changed files with 56 additions and 15 deletions
+33 -14
View File
@@ -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<Item = Vec<u8>> + '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<Vec<u8>> {
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) {
+22
View File
@@ -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<Vec<u8>> = 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<Object> =
(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);
}
+1 -1
View File
@@ -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";