From 76ae58c8bed5119513befecb8037190b95b88b47 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 23:00:44 +0800 Subject: [PATCH] M1: attachments held encrypted aren't copied by Combine; engine test and parity for EFOpen Combine refuses to copy an embedded file stream that its source holds encrypted (opened without the password its embedded files need), rather than write that data into another document as the file. An engine test opens and saves such a document through the session, and the parity entry core.attachment-only-encryption records what this covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/engine/src/tests.rs | 50 +++++++++++++ crates/organize/src/import/copy.rs | 5 ++ crates/organize/tests/efopen_attachments.rs | 78 +++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 crates/organize/tests/efopen_attachments.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d9e5e971d..dcedb95535 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10480,3 +10480,53 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files, +/// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its +/// attachment, and saves with the attachment still encrypted. +#[test] +fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password() { + // The password values of an AES-256 protection, as a save writes them. + let protected = protected("pw", "owner", -1); + let source = pdfcraft_cos::Document::open_with_password(protected, Some("pw")).unwrap(); + let encrypt = source.resolve(source.trailer().get(b"Encrypt").unwrap()); + let values: String = ["O", "U", "OE", "UE", "Perms"] + .iter() + .map(|k| { + let value = source.resolve(encrypt.as_dict().unwrap().get(k.as_bytes()).unwrap()); + format!(" /{k} <{}>", value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect::()) + }) + .collect(); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(), + "<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(), + "<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(), + format!( + "<< /Filter /Standard /V 5 /R 6{values} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>" + ), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(), + ); + let mut s = Session::new(); + let id = s.open("efopen.pdf", None, Arc::new(pdf), None).expect("opens without a password"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 1); + assert_eq!(doc.info.attachments.len(), 1); + let saved = s.save_bytes(id).unwrap(); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); +} diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 5e4be54150..bdc75c1341 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -98,6 +98,11 @@ impl<'a> Copier<'a> { Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + // Opened without the password its embedded files need, the source holds their data + // encrypted: copied into another document, it would read as the file. + Object::Stream(s) if self.src.reads_as_stored(s) => Err(OrganizeError::Invalid( + "an attached file is encrypted and the document was opened without its password: open it with the password to copy it".into(), + )), Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { let mut s = s.clone(); s.dict = dict; diff --git a/crates/organize/tests/efopen_attachments.rs b/crates/organize/tests/efopen_attachments.rs new file mode 100644 index 0000000000..c9faf52867 --- /dev/null +++ b/crates/organize/tests/efopen_attachments.rs @@ -0,0 +1,78 @@ +//! A document that encrypts only its embedded files (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, +//! Table 25), opened without its password, holds them encrypted: combining it into another +//! document must not copy that data as if it were the files. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full}; +use pdfcraft_organize::{OrganizeError, combine}; + +/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a +/// user password, as a save writes them. +fn r6_password_values() -> String { + let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let mut doc = Document::open(Arc::new(plain)).unwrap(); + let params = NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "user pw", + owner_password: "owner pw", + permissions: -4, + encrypt_metadata: true, + seed: [5; 32], + }; + doc.set_encryption(¶ms).unwrap(); + let saved = Document::open_with_password(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap()), Some("user pw")).unwrap(); + let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap()); + let encrypt = encrypt.as_dict().unwrap(); + ["O", "U", "OE", "UE", "Perms"] + .iter() + .map(|k| { + let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap()); + let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect(); + format!("/{k} <{hex}>") + }) + .collect::>() + .join(" ") +} + +/// One page in the clear and one attached file encrypted with `/StdCF`, authorised by `/EFOpen`. +fn efopen_pdf() -> Vec { + let encrypt = format!( + "<< /Filter /Standard /V 5 /R 6 {} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>", + r6_password_values() + ); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(), + "<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(), + "<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(), + encrypt, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(), + ); + out +} + +#[test] +fn combining_does_not_copy_attachments_held_encrypted() { + let src = Document::open(Arc::new(efopen_pdf())).unwrap(); + assert!(src.embedded_files_locked()); + match combine(&[("efopen.pdf", &src)]) { + Err(OrganizeError::Invalid(why)) => assert!(why.contains("password"), "{why}"), + Err(other) => panic!("{other}"), + Ok(_) => panic!("an encrypted attachment was copied as the file"), + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5cb6e1c56a..900f54160f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -235,11 +235,11 @@ title = "Attachment-only encryption (/EFF) opens without a password" area = "A" tier = "P1" milestone = "M1" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "two corpus files still ask for a password (M1.6)" +evidence = ["crates/cos/tests/it/efopen.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/cos/tests/it/efopen.rs::saves_keep_the_encrypted_embedded_files_as_stored", "crates/render/src/inspect/tests.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/engine/src/tests.rs::a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password", "crates/organize/tests/efopen_attachments.rs::combining_does_not_copy_attachments_held_encrypted"] +notes = "a document whose encrypting crypt filters are all /AuthEvent /EFOpen, with /StmF and /StrF /Identity, opens without a password (ISO 32000-2 7.6.6, Table 25); its attachments are listed but not read or copied without the password, and saves keep them encrypted (a full save is refused where the key depends on the object number). Not yet: entering the password after opening to read the attachments (the document is reopened with it). pdf.js encrypted-attachment.pdf has no /AuthEvent (DocOpen) and a non-empty user password, so it rightly asks for one; its broken startxref currently reports it as unreadable instead" [[feature]] id = "core.unencrypted-metadata"