mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 20:04:37 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c3c36f77cf
commit
390b2012e4
@@ -199,11 +199,12 @@ impl Document {
|
||||
// Read here, while nothing is decrypted yet.
|
||||
let members = self.encryption_members(enc);
|
||||
let handler = match credentials {
|
||||
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?),
|
||||
Credentials::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) {
|
||||
Ok(handler) => Arc::new(handler),
|
||||
Err(pdfcraft_crypt::CryptError::WrongPassword) if password.is_none() => Arc::new(self.without_password(&dict, params)?),
|
||||
Err(pdfcraft_crypt::CryptError::WrongPassword) => return Err(CosError::WrongPassword),
|
||||
Err(other) => return Err(CosError::Security(other.to_string())),
|
||||
},
|
||||
// The same encryption as the document whose key this is: that key opens it.
|
||||
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
|
||||
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
|
||||
@@ -217,6 +218,27 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The handler for a document opened without a password that only its embedded files need
|
||||
/// (see [`crate::security::efopen_only`]), else `NeedsPassword`. Not a repair: ISO 32000-2
|
||||
/// §7.6.6 has such a document open so; [`Document::embedded_files_locked`] tells it.
|
||||
fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Result<pdfcraft_crypt::SecurityHandler, CosError> {
|
||||
let handler = crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten();
|
||||
handler.ok_or(CosError::NeedsPassword)
|
||||
}
|
||||
|
||||
/// Whether the document opened without the password its embedded files need (`/AuthEvent
|
||||
/// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files.
|
||||
pub fn embedded_files_locked(&self) -> bool {
|
||||
self.security.as_deref().is_some_and(|h| !h.has_key())
|
||||
}
|
||||
|
||||
/// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the
|
||||
/// document opened without the password its embedded files need (see
|
||||
/// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts.
|
||||
pub fn reads_as_stored(&self, s: &crate::Stream) -> bool {
|
||||
self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s))
|
||||
}
|
||||
|
||||
/// The first file identifier, part of the encryption key (empty when there is none).
|
||||
fn first_id(&self) -> Vec<u8> {
|
||||
let id = self.trailer.get(b"ID").map(|id| self.resolve(id));
|
||||
|
||||
@@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every crypt filter of `d`, an `/Encrypt` dictionary, that encrypts at all (`/CFM`
|
||||
/// `V2`, `AESV2` or `AESV3`) is authorised only when an embedded file is opened (`/AuthEvent
|
||||
/// /EFOpen`, ISO 32000-2 §7.6.6, Table 25), and `/EFF` names one of them: what a document asks
|
||||
/// for its embedded files alone, which it opens without (§7.6.6: "EFOpen Authorization shall be
|
||||
/// required when accessing embedded files"). Its strings and streams have to be in the clear too,
|
||||
/// which [`SecurityHandler::without_key`] checks.
|
||||
pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool {
|
||||
let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec));
|
||||
let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false };
|
||||
let mut encrypting = Vec::new();
|
||||
for (k, v) in cf.iter() {
|
||||
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
|
||||
if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) {
|
||||
continue;
|
||||
}
|
||||
if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") {
|
||||
return false;
|
||||
}
|
||||
encrypting.push(k.clone());
|
||||
}
|
||||
name(d, b"EFF").is_some_and(|eff| encrypting.contains(&eff))
|
||||
}
|
||||
|
||||
/// Whether `s`, read from a document whose handler has no file key, holds data that handler would
|
||||
/// decrypt, which therefore reads as stored: still encrypted.
|
||||
pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool {
|
||||
if h.has_key() {
|
||||
return false;
|
||||
}
|
||||
let _scope = FollowScope::enter();
|
||||
let named = named_crypt_filter(doc, &s.dict);
|
||||
let ty = s.dict.get(b"Type").map(|t| followed(doc, t));
|
||||
let kind = match (&named, ty.as_deref().and_then(Object::as_name)) {
|
||||
(Some(n), _) => StreamKind::Named(n),
|
||||
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
|
||||
_ => StreamKind::Normal,
|
||||
};
|
||||
h.decrypts(kind)
|
||||
}
|
||||
|
||||
/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`.
|
||||
fn is_crypt(doc: &Document, f: &Object) -> bool {
|
||||
followed(doc, f).as_name() == Some(b"Crypt")
|
||||
|
||||
@@ -272,6 +272,16 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
if doc.stream_limited() {
|
||||
return Err(CosError::ReadOnlyLimit);
|
||||
}
|
||||
// Opened without the password its embedded files need, a document holds their data as stored:
|
||||
// new security would encrypt that data again (or drop its encryption), and renumbering breaks
|
||||
// a key that mixes in the object number.
|
||||
if let Some(h) = doc.security().filter(|h| !h.has_key())
|
||||
&& (doc.encryption_changed() || !h.survives_renumbering())
|
||||
{
|
||||
return Err(CosError::Security(
|
||||
"the embedded files are encrypted and the document was opened without their password: open it with the password to change its security or save it in full".into(),
|
||||
));
|
||||
}
|
||||
// A full traversal must not fill the editor's shared caches (which undo snapshots also
|
||||
// own). Parsed objects and decoded object streams are temporary working data here.
|
||||
let mut reader = doc.object_reader();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 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> {
|
||||
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(¶ms, &id0).unwrap();
|
||||
let d = handler.dict();
|
||||
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, StreamKind::EmbeddedFile);
|
||||
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 /StdCF >>",
|
||||
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 /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(_))));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod bounded_writer;
|
||||
mod corpus;
|
||||
mod efopen;
|
||||
mod encryption;
|
||||
mod generations;
|
||||
mod proptests;
|
||||
|
||||
+41
-1
@@ -222,6 +222,42 @@ impl SecurityHandler {
|
||||
Ok(Self::new(dict, key, auth, recovered_user))
|
||||
}
|
||||
|
||||
/// A handler with no file key, for a document opened without a password whose strings and
|
||||
/// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found
|
||||
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
|
||||
/// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it
|
||||
/// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or
|
||||
/// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4).
|
||||
pub fn without_key(dict: EncryptDict) -> Option<Self> {
|
||||
if dict.v < 4 {
|
||||
return None;
|
||||
}
|
||||
let methods = Methods::new(&dict);
|
||||
if methods.string != Method::Identity || methods.stream != Method::Identity {
|
||||
return None;
|
||||
}
|
||||
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
|
||||
}
|
||||
|
||||
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
|
||||
pub fn has_key(&self) -> bool {
|
||||
!self.key.is_empty()
|
||||
}
|
||||
|
||||
/// Whether data of `kind` is encrypted, so that reading it needs the file key.
|
||||
pub fn decrypts(&self, kind: StreamKind) -> bool {
|
||||
self.method(Some(kind)) != Method::Identity
|
||||
}
|
||||
|
||||
/// Whether data left as stored by a handler without the key still decrypts after its object
|
||||
/// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A).
|
||||
pub fn survives_renumbering(&self) -> bool {
|
||||
[self.methods.string, self.methods.stream, self.methods.embedded_file]
|
||||
.into_iter()
|
||||
.chain(self.methods.named.values().copied())
|
||||
.all(|m| matches!(m, Method::Identity | Method::Aes256))
|
||||
}
|
||||
|
||||
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
|
||||
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
|
||||
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
|
||||
@@ -255,7 +291,7 @@ impl SecurityHandler {
|
||||
|
||||
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
|
||||
fn perms_p(&self) -> Option<i32> {
|
||||
if !matches!(self.dict.r, 5 | 6) {
|
||||
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
|
||||
return None;
|
||||
}
|
||||
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
|
||||
@@ -307,6 +343,10 @@ impl SecurityHandler {
|
||||
}
|
||||
|
||||
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
|
||||
// Without the key, encrypted data stays as stored (see `without_key`).
|
||||
if !self.has_key() {
|
||||
return data.to_vec();
|
||||
}
|
||||
match method {
|
||||
Method::Identity => data.to_vec(),
|
||||
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+42
@@ -114,6 +114,48 @@ pub(crate) fn get(
|
||||
dict: &Dict<'_>,
|
||||
id: &[u8],
|
||||
password: &[u8],
|
||||
) -> Result<Decryptor, DecryptionError> {
|
||||
match decryptor(dict, id, password) {
|
||||
// PdfCraft patch (36): a document that encrypts only its embedded files, authorised when
|
||||
// one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25), opens without a
|
||||
// password: its strings and streams are in the clear, and nothing drawn reads an embedded
|
||||
// file.
|
||||
Err(DecryptionError::PasswordProtected) if password.is_empty() && efopen_only(dict) => {
|
||||
Ok(Decryptor::None)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (36): whether every crypt filter of `dict` that encrypts is authorised only when
|
||||
/// an embedded file is opened (`/AuthEvent /EFOpen`), `/EFF` names one, and neither `/StmF` nor
|
||||
/// `/StrF` does.
|
||||
fn efopen_only(dict: &Dict<'_>) -> bool {
|
||||
let Some(cf) = dict.get::<Dict<'_>>(CF) else {
|
||||
return false;
|
||||
};
|
||||
let mut encrypting = Vec::new();
|
||||
for key in cf.keys() {
|
||||
let Some(filter) = cf.get::<Dict<'_>>(key.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let method = filter.get::<Name<'_>>(CFM).and_then(|n| DecryptorTag::from_name(&n));
|
||||
if matches!(method, None | Some(DecryptorTag::None)) {
|
||||
continue;
|
||||
}
|
||||
if filter.get::<Name<'_>>(b"AuthEvent").is_none_or(|e| e.as_str() != "EFOpen") {
|
||||
return false;
|
||||
}
|
||||
encrypting.push(key.as_str().to_string());
|
||||
}
|
||||
let names = |key: &[u8]| dict.get::<Name<'_>>(key).is_some_and(|n| encrypting.iter().any(|e| e == n.as_str()));
|
||||
names(b"EFF") && !names(STM_F) && !names(STR_F)
|
||||
}
|
||||
|
||||
fn decryptor(
|
||||
dict: &Dict<'_>,
|
||||
id: &[u8],
|
||||
password: &[u8],
|
||||
) -> Result<Decryptor, DecryptionError> {
|
||||
let filter = dict.get::<Name<'_>>(FILTER).ok_or(InvalidEncryption)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user