M1: a document that encrypts only its embedded files opens without a password

A crypt filter whose /AuthEvent is /EFOpen needs its password only when
an embedded file is accessed (ISO 32000-2 7.6.6, Table 25). A document
whose strings and streams are in the clear (/StmF, /StrF /Identity) and
whose encrypting filters are all EFOpen ones, /EFF naming one, now opens
without a password in cos and in the renderer (hayro-syntax patch 36).

Without the password there is no file key: the embedded files' data is
held and saved as stored, and Document::embedded_files_locked and
reads_as_stored say so. Asking for such an attachment's bytes is an
error. A full save that would renumber data whose key mixes in the
object number (RC4, AES-128), or change the document's security, is
refused. The password still opens the document and its attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 22:49:48 +08:00
1 parent c3c36f77cf
commit 390b2012e4
10 files changed
+405 -8

No files matched your search

+6 -1
View File
@@ -231,9 +231,14 @@ impl<'a> Inspector<'a> {
}
}
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs.
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. A
/// document opened without the password its embedded files need (`/AuthEvent /EFOpen`, ISO
/// 32000-2 §7.6.6) holds them encrypted: asking for one is an error, never its encrypted bytes.
pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result<Vec<u8>, String> {
let run = || -> Result<Vec<u8>, String> {
if pdfcraft_cos::Document::open_with_password(std::sync::Arc::new(bytes.to_vec()), password).is_ok_and(|d| d.embedded_files_locked()) {
return Err("the attachment is encrypted: open the document with its password to read it".into());
}
let options = load_options(password);
let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?;
let insp = Inspector::new(&doc);
+79
View File
@@ -1912,3 +1912,82 @@ fn inspection_keeps_no_private_page_data() {
};
assert_eq!(report(with), report(without), "what inspection reports does not change");
}
/// 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 = pdfcraft_cos::Document::open(Arc::new(plain)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "user pw",
owner_password: "owner pw",
permissions: -4,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
let saved = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let saved = pdfcraft_cos::Document::open_with_password(Arc::new(saved), 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(" ")
}
/// A document whose crypt filter `/StdCF` is authorised by `auth_event`, encrypting only its
/// embedded file (`/StmF /StrF /Identity`, `/EFF /StdCF`), protected by a user password.
fn efopen_pdf(auth_event: &str) -> Vec<u8> {
let encrypt = format!(
"<< /Filter /Standard /V 5 /R 6 {} /P -4 /CF << /StdCF << /AuthEvent /{auth_event} /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
}
/// ISO 32000-2 §7.6.6, Table 25: a crypt filter whose `/AuthEvent` is `/EFOpen` needs its password
/// only when an embedded file is opened. Such a document opens without one, and its attachment is
/// listed, but asking for the attachment's bytes is an error, not its encrypted data.
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = Arc::new(efopen_pdf("EFOpen"));
assert!(check_pages(bytes.clone(), None, 1).is_ok());
let info = inspect(bytes.clone(), None).expect("opens without a password");
assert_eq!(info.pages.len(), 1);
let listed = info.attachments.iter().find(|a| a.name == "note.txt").expect("the attachment is listed");
let refused = attachment_data(&bytes, None, listed).expect_err("encrypted bytes aren't the file");
assert!(refused.contains("password"), "{refused}");
// Authorised when the document opens, the same filter still needs the password.
let doc_open = Arc::new(efopen_pdf("DocOpen"));
assert!(matches!(inspect(doc_open.clone(), None), Err(OpenError::NeedsPassword)));
assert!(matches!(check_pages(doc_open, None, 1), Err(OpenError::NeedsPassword)));
}