mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:50:14 +00:00
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:
+33
-14
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user