From 8b7f64e2e09c48be05bde41c7cc773dc18a4d1ef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:04:13 -0500 Subject: [PATCH] craft-pdf: crypt ports of two open PdfCraft changes (revision 2 reserved bits, attachments-only encryption) (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * craft-pdf: the standard security handler from PdfCraft (`crypt`) Adds the `crypt` feature: PdfCraft's pdfcraft-crypt crate (revision 2b65ab000bfe0997dec5fbed2769d3da3799355b) as craft_pdf::crypt, with its unit, integration and property tests. The code is unchanged; doc comments and test imports name the new module. It decrypts and encrypts strings and streams for revisions 2 to 6 and creates encryption dictionaries. The reader keeps its own decryptor. * craft-pdf: the crypt unit tests build for wasm The property test in src/crypt/tests.rs needs proptest, which is a dev-dependency of the non-wasm targets only. It is now compiled for those targets only; the other unit tests still build for wasm. * craft-pdf: crypt create sets every bit revision 2 reserves Revision 2 reserves bits 7 to 32 of /P, later revisions bits 7-8 and 13-32. create left bits 9 to 12 as the caller gave them for RC4-40. Ported from storytold/pdfcraft#2789 (the create half; the accessors already read /P by revision). * craft-pdf: crypt can encrypt only embedded files create_attachments_only builds AES encryption whose /StmF and /StrF are /Identity and whose /EFF is the standard filter (ISO 32000-2 §7.6.6). RC4-40 and RC4-128 have no crypt filters and are refused. Ported from storytold/pdfcraft#5071 (the crypt half). The test is new: the source change tests it through PdfCraft's object layer. --------- Co-authored-by: Ahmad Mujahid Co-authored-by: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> --- crates/pdf/src/crypt.rs | 21 ++++++++++++++-- crates/pdf/src/crypt/tests.rs | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/crypt.rs b/crates/pdf/src/crypt.rs index 7f7a51e0..069627db 100644 --- a/crates/pdf/src/crypt.rs +++ b/crates/pdf/src/crypt.rs @@ -726,8 +726,10 @@ pub fn file_id(seed: &[u8; 32]) -> Vec { /// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption. pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { - // Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2). - let p = (params.permissions | !0xFFF | 0xC0) & !0x3; + // R2 reserves bits 7-32; later revisions reserve bits 7-8 and 13-32. + // Reserved bits must be 1, and bits 1-2 must be 0 (§7.6.4.2). + let reserved = if params.algorithm == Algorithm::Rc4_40 { !0x3F } else { !0xFFF | 0xC0 }; + let p = (params.permissions | reserved) & !0x3; let mut d = EncryptDict { filter: b"Standard".to_vec(), p, encrypt_metadata: params.encrypt_metadata, ..Default::default() }; let user = prepared_password(params.algorithm, params.user_password); let owner = prepared_password(params.algorithm, params.owner_password); @@ -792,5 +794,20 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result Result { + if matches!(params.algorithm, Algorithm::Rc4_40 | Algorithm::Rc4_128) { + return Err(CryptError::Unsupported("encrypting only attachments needs AES (crypt filters)".into())); + } + let h = create(params, id0)?; + let mut dict = h.dict.clone(); + dict.stm_f = b"Identity".to_vec(); + dict.str_f = b"Identity".to_vec(); + dict.ef_f = b"StdCF".to_vec(); + Ok(SecurityHandler::new(dict, h.key, h.auth, h.recovered_user)) +} + #[cfg(test)] mod tests; diff --git a/crates/pdf/src/crypt/tests.rs b/crates/pdf/src/crypt/tests.rs index 46619427..04bb7c61 100644 --- a/crates/pdf/src/crypt/tests.rs +++ b/crates/pdf/src/crypt/tests.rs @@ -447,3 +447,49 @@ fn pdfdoc_encoding_round_trips() { assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]); assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]); } + +#[test] +fn r2_create_sets_every_bit_revision_2_reserves() { + // ISO 32000-1 section 7.6.3.2: -44 permits printing and copying only. + let d = new(Algorithm::Rc4_40, "user", "owner", 0x14).dict().clone(); + assert_eq!(d.p, -44); + assert_eq!(new(Algorithm::Rc4_40, "user", "owner", 0).dict().p, -64, "R2 reserves bits 7 through 32"); + let user = SecurityHandler::open(d, ID, Some("user")).unwrap(); + assert_eq!(user.dict().p, -44); + let p = user.permissions(); + assert_eq!((p.print(), p.print_high_quality(), p.copy(), p.extract_for_accessibility()), (true, true, true, true)); + assert_eq!((p.modify(), p.annotate(), p.fill_forms(), p.assemble()), (false, false, false, false)); + for alg in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + assert_eq!(new(alg, "user", "owner", 0x14).dict().p, -3884, "{alg:?}"); + } +} + +#[test] +fn attachments_only_encrypts_embedded_files_and_nothing_else() { + let params = |algorithm| NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [7; 32], + }; + for alg in [Algorithm::Aes128, Algorithm::Aes256] { + let h = create_attachments_only(¶ms(alg), ID).unwrap(); + let d = h.dict().clone(); + assert_eq!((d.stm_f.as_slice(), d.str_f.as_slice(), d.ef_f.as_slice()), (&b"Identity"[..], &b"Identity"[..], &b"StdCF"[..]), "{alg:?}"); + assert!(!h.encrypts_strings(), "{alg:?}"); + assert!(!h.decrypts(StreamKind::Normal) && h.decrypts(StreamKind::EmbeddedFile), "{alg:?}"); + assert_eq!(h.encrypt_string(3, 0, b"plain"), b"plain", "{alg:?}"); + assert_eq!(h.encrypt_stream(3, 0, b"page content", StreamKind::Normal), b"page content", "{alg:?}"); + let stored = h.encrypt_stream(4, 0, b"attachment", StreamKind::EmbeddedFile); + assert_ne!(stored, b"attachment", "{alg:?}"); + // The file a reader opens: the same dictionary, the user password. + let reader = SecurityHandler::open(d, ID, Some("user")).unwrap(); + assert_eq!(reader.decrypt_stream(4, 0, &stored, StreamKind::EmbeddedFile), b"attachment", "{alg:?}"); + assert_eq!(reader.decrypt_stream(3, 0, b"page content", StreamKind::Normal), b"page content", "{alg:?}"); + } + for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128] { + assert!(matches!(create_attachments_only(¶ms(alg), ID), Err(CryptError::Unsupported(_))), "{alg:?}"); + } +}