Files
pdfcraft/crates/cos/tests/it/efopen.rs
T
mtlcl410andClaude Opus 5.5 8e2888aad1 M1: EFOpen review fixes: empty password, own crypt filters, keyless saves and unlock
The empty password opens an EFOpen-only document as no password does,
and the credentials of one opened without the key open no other. An
embedded file may name its EFOpen filter in a /Crypt filter of its own,
with no /EFF; handlers older than crypt filters (V1, V2) don't qualify
(hayro-syntax patch 36 too). Saves without the key refuse what they
would have to encrypt: a removed /Encrypt, or an edited or added
embedded file. The password given after opening unlocks the embedded
files, unless one read as stored has been edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-05 23:23:29 +08:00

225 lines
11 KiB
Rust

//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_crypt::StreamKind;
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
const ATTACHED: &[u8] = b"the attached file's own words";
const PASSWORD: &str = "attach pw";
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
fixture_with(algorithm, auth_event, false)
}
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
/// no `/EFF` (ISO 32000-2 §7.4.10).
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
let id0 = [7u8; 16];
let params =
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
let handler = pdfcraft_crypt::create(&params, &id0).unwrap();
let d = handler.dict();
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let (version, cfm, r6) = match algorithm {
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
};
let encrypt = format!(
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
hex(&d.o),
hex(&d.u),
d.p
);
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
stream.extend_from_slice(&cipher);
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
objs.push(encrypt.into_bytes());
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
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());
}
let trailer = format!(
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1,
hex(&id0),
hex(&id0)
);
out.extend_from_slice(trailer.as_bytes());
out
}
fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
other => panic!("object {} isn't a stream: {other:?}", r.num),
}
}
/// The attachment as the password reads it from `bytes`.
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
assert!(!doc.embedded_files_locked());
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
assert!(embedded.get(b"Names").is_some());
stream(&doc, EMBEDDED).decoded().unwrap()
}
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
// Not a repair: the specification has it open so.
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert!(doc.trailer().get(b"Encrypt").is_some());
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
let content = stream(&doc, CONTENT);
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
assert!(!doc.reads_as_stored(&content));
let embedded = stream(&doc, EMBEDDED);
assert!(doc.reads_as_stored(&embedded));
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
// The password still opens it, and reads the attachment.
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
// A wrong password is still refused.
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
}
#[test]
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
let bytes = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
#[test]
fn saves_keep_the_encrypted_embedded_files_as_stored() {
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
assert!(doc.embedded_files_locked(), "{algorithm:?}");
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
match algorithm {
Algorithm::Aes256 => {
let full = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(full.clone())).unwrap();
assert!(reopened.embedded_files_locked());
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
Object::Stream(s) => s.decoded().unwrap(),
other => panic!("{other:?}"),
};
assert_eq!(file, ATTACHED);
}
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
}
}
}
#[test]
fn security_cannot_change_without_the_embedded_files_password() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
doc.remove_encryption();
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
/// document opened without the key open no other.
#[test]
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
// The same /Encrypt values and /ID, authorised when the document opens.
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
}
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
#[test]
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
/// embedded file, is refused rather than written in the clear.
#[test]
fn saves_refuse_what_would_need_the_key() {
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let mut doc = open();
doc.trailer_mut().remove(b"Encrypt");
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
let mut doc = open();
let mut replaced = stream(&doc, EMBEDDED);
replaced.raw = b"new words".to_vec().into();
doc.set(EMBEDDED, Object::Stream(replaced));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
#[test]
fn the_password_given_later_unlocks_the_embedded_files() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
assert!(doc.embedded_files_locked());
doc.unlock(PASSWORD).unwrap();
assert!(!doc.embedded_files_locked());
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
// Not while an embedded file read as stored has been edited.
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let edited = stream(&doc, EMBEDDED);
doc.set(EMBEDDED, Object::Stream(edited));
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
}