From 53a78acf981f62a82c7394bb0f7e214f510b5bec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:51:18 +0000 Subject: [PATCH] fix(pdf): refuse password protection at PDF 1.3 (40-bit RC4) --- crates/pdf/src/encrypt.rs | 25 ++++++++++--------------- crates/pdf/src/tests/pdf13.rs | 17 ++++++----------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 2c00c3734..bf49bcc3c 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -3,12 +3,13 @@ //! //! The export writes a plain file; [`protect`] then encrypts every string and stream in it (the //! XMP metadata stays readable with Plaintext Metadata) and adds the encryption dictionary. The -//! algorithm follows the compatibility ([`Encryption`]): 40-bit RC4 for PDF 1.3 (revision 2), -//! 128-bit RC4 for PDF 1.4 (revision 3) and 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and -//! 256-bit AES for 1.7 and 2.0 (revision 6). The random file key, salts and initialisation vectors -//! come from a SHA-256 stream seeded with the process's random hash keys, the time and the file -//! itself. A linearised file is encrypted between its two steps ([`protect_keeping`]): what -//! encrypted it then encrypts the hint stream written last ([`Cipher`]). +//! algorithm follows the compatibility ([`Encryption`]): 128-bit RC4 for PDF 1.4 (revision 3) and +//! 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and 256-bit AES for 1.7 and 2.0 (revision 6). +//! PDF 1.3 files are not password-protected: its 40-bit RC4 (revision 2) is only read. The random +//! file key, salts and initialisation vectors come from a SHA-256 stream seeded with the process's +//! random hash keys, the time and the file itself. A linearised file is encrypted between its two +//! steps ([`protect_keeping`]): what encrypted it then encrypts the hint stream written last +//! ([`Cipher`]). //! //! Reading: [`user_password`] turns the permissions (owner) password of a file encrypted with RC4 //! or 128-bit AES into its open (user) password, so either password opens it. @@ -117,10 +118,10 @@ impl SecuritySettings { standard.label() ))); } - // Revision 2 has one printing bit: it can't say "low resolution only". - if revision(c) == 2 && self.printing == Printing::Low && !self.permissions_password.is_empty() { + // Revision 2's 40-bit file key can be searched offline in minutes, whatever the passwords. + if revision(c) == 2 { return Err(PdfError::BadSetting(format!( - "{} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing", + "{} encrypts with a 40-bit key, which can be searched in minutes: choose PDF 1.6 or later to password-protect the file", c.label() ))); } @@ -157,12 +158,6 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec { if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 { out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); } - if s.protected() && set.compatibility == Compatibility::Pdf13 { - out.push(format!( - "{} encrypts with a 40-bit key, which can be searched in minutes: choose PDF 1.6 or later for stronger protection", - set.compatibility.label() - )); - } out } diff --git a/crates/pdf/src/tests/pdf13.rs b/crates/pdf/src/tests/pdf13.rs index 4df56f3ff..f2a3d94fe 100644 --- a/crates/pdf/src/tests/pdf13.rs +++ b/crates/pdf/src/tests/pdf13.rs @@ -1,5 +1,5 @@ //! Compatibility PDF 1.3: which standards allow it, the header and metadata that say so, the -//! writer refusing transparency (the app flattens it first) and 40-bit RC4 encryption. +//! writer refusing transparency (the app flattens it first) and password protection (40-bit RC4). use serde_json::json; use vectorcraft_color::{BlendMode, Color, Paint}; @@ -65,15 +65,10 @@ fn pdf_1_3_files_say_so_and_have_no_transparency() { } #[test] -fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { - let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); - let r = export_with_report(&doc(1.0), &o).unwrap(); - assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); - assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); - assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); - for password in ["open", "owner"] { - let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); - assert_eq!(opened.document.layers[0].children().map_or(0, |c| c.len()), 1, "{password}"); +fn pdf_1_3_files_are_not_password_protected() { + // 40-bit RC4 (revision 2) is refused: its file key can be searched offline. + for security in [json!({"openPassword": "open"}), json!({"permissionsPassword": "owner", "printing": "low"})] { + let o = options(json!({"compatibility": "1.3", "security": security})); + assert!(matches!(export(&doc(1.0), &o), Err(PdfError::BadSetting(m)) if m.contains("40-bit"))); } - assert_eq!(crate::encrypt::user_password(&r.bytes, "owner").as_deref(), Some("open")); }