mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user