From a08d974ac14bbaa37b34e5b1ccf728eb89b378ef Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:45 +0000 Subject: [PATCH] fix(cos): encrypt the /Contents of an annotation that carries a /ByteRange Saving with encryption left a dictionary's /Contents in clear whenever it had a string /Contents and an array /ByteRange, whatever else it was. A source file could add a /ByteRange to an ordinary annotation and its comment text was then written unencrypted. A dictionary with a /Subtype or a /Type other than Sig or DocTimeStamp is never a signature dictionary, so its /Contents is encrypted like any other string. --- crates/cos/src/security.rs | 13 ++++++++++--- crates/cos/tests/it/encryption.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a14..67d95aa7a 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,10 +88,17 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } /// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index e05161558..dd2dcb1a4 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -279,6 +279,38 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + #[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