fix(cos): put inherited crypt-filter selectors under the new protection

A stream that named its own crypt filter (/Filter /Crypt with
/DecodeParms /Name) kept that name when the document was given new
password protection. The new /Encrypt defines only /StdCF, so the
inherited name resolved to /Identity and the stream was written in
clear into a file the user asked to protect. When saving with protection
applied by set_encryption, a selector the new handler does not define is
removed with its parameters and the stream follows the new policy.
Saving with the original handler keeps the source's selectors.
This commit is contained in:
aiongg
2026-10-22 12:13:16 -04:00
committed by Dylan M. Taylor
parent a08d974ac1
commit 60d2d1e3f2
4 changed files with 113 additions and 13 deletions
+1 -1
View File
@@ -858,7 +858,7 @@ impl Document {
/// Decrypt a freshly parsed indirect object if the document is encrypted.
fn decrypted(&self, id: ObjRef, o: Object) -> Object {
match &self.security {
Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true),
Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt),
_ => o,
}
}
+65 -10
View File
@@ -101,36 +101,91 @@ fn is_signature_dict(d: &Dict) -> bool {
}
}
/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object,
/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]).
pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object {
/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters.
fn drop_crypt_filter(dict: &mut Dict) {
let i = match dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")),
Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0),
_ => None,
};
let Some(i) = i else { return };
let mut filters = match dict.get(b"Filter") {
Some(Object::Array(a)) => a.clone(),
_ => Vec::new(),
};
if i < filters.len() {
filters.remove(i);
}
// A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it).
match dict.get(b"DecodeParms").cloned() {
Some(Object::Array(mut parms)) if i < parms.len() => {
parms.remove(i);
dict.set(b"DecodeParms".to_vec(), Object::Array(parms));
}
Some(Object::Dict(_)) if i == 0 => {
dict.remove(b"DecodeParms");
}
_ => {}
}
if filters.is_empty() {
dict.remove(b"Filter");
if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) {
dict.remove(b"DecodeParms");
}
} else {
dict.set(b"Filter".to_vec(), Object::Array(filters));
}
}
/// What [`transform`] does to an object.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Pass {
/// Decrypt, with the handler the document was opened with.
Decrypt,
/// Encrypt with the handler the document was opened with: streams keep their crypt filters.
Encrypt,
/// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from
/// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the
/// stream follows the new policy instead of reading as `/Identity` and staying in clear.
EncryptReplacing,
}
/// Decrypt or encrypt every string and stream in an indirect object, except a signature
/// dictionary's `/Contents` (see [`is_signature_dict`]).
pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object {
let decrypt = pass == Pass::Decrypt;
let strings = |s: &PdfString| {
let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) };
PdfString { bytes, hex: s.hex || !decrypt }
};
walk(o, &strings, h, num, generation, decrypt)
walk(o, &strings, h, num, generation, pass)
}
fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object {
fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object {
let decrypt = pass == Pass::Decrypt;
match o {
Object::String(s) => Object::String(strings(s)),
Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()),
Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()),
Object::Dict(d) if is_signature_dict(d) => Object::Dict(
d.iter()
.map(|(k, v)| match (k.as_slice(), v) {
(b"Contents", Object::String(_)) => (k.clone(), v.clone()),
_ => (k.clone(), walk(v, strings, h, num, generation, decrypt)),
_ => (k.clone(), walk(v, strings, h, num, generation, pass)),
})
.collect(),
),
Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()),
Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()),
Object::Stream(s) => {
let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect();
let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect();
let ty = s.dict.name(b"Type");
if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
return Object::Stream(Stream { dict, raw: s.raw.clone() });
}
let named = named_crypt_filter(&s.dict);
let mut named = named_crypt_filter(&s.dict);
if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) {
drop_crypt_filter(&mut dict);
named = None;
}
let kind = match (&named, ty) {
(Some(n), _) => StreamKind::Named(n),
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
+4 -1
View File
@@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> {
match doc.output_security() {
(Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)),
(Some(h), encrypt_num) if Some(source_num) != encrypt_num => {
let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt };
Cow::Owned(crate::security::transform(h, o, num, generation, pass))
}
_ => Cow::Borrowed(o),
}
}
+43 -1
View File
@@ -9,11 +9,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O
const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET";
fn fixture() -> Vec<u8> {
fixture_with("")
}
/// The fixture with `extra` added to the content stream's dictionary.
fn fixture_with(extra: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\n<x:xmpmeta>meta</x:xmpmeta>\nendstream".replace("22", "26"),
"<< /Title (Quarterly numbers) >>".to_string(),
@@ -311,6 +316,43 @@ fn a_byte_range_does_not_exempt_an_annotation() {
}
}
/// Protecting a document puts every stream under the new handler. A stream that selects a
/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an
/// inherited name (or `/Identity`) would leave the stream in clear.
#[test]
fn protecting_drops_inherited_crypt_filter_selectors() {
for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] {
let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap();
assert_eq!(content(&doc), SECRET, "{selector}");
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted");
assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}");
assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}");
}
// Under the handler the document was opened with, `/Identity` is the author's choice (a
// stream meant to stay readable) and is kept.
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap();
let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap();
let contents = page.reference(b"Contents").unwrap();
let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") };
s.dict.set(b"Filter".to_vec(), Object::name("Crypt"));
doc.set(contents, Object::Stream(s));
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks");
assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET);
}
#[test]
fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() {
// A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is