mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
1 parent
390b2012e4
commit
76ae58c8be
4 files changed
+136
-3
No files matched your search
@@ -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::<String>())
|
||||
})
|
||||
.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");
|
||||
}
|
||||
@@ -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::<Result<Vec<_>, _>>().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;
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// One page in the clear and one attached file encrypted with `/StdCF`, authorised by `/EFOpen`.
|
||||
fn efopen_pdf() -> Vec<u8> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user