fix(pdf): refuse password protection at PDF 1.3 (40-bit RC4)

This commit is contained in:
Dylan M. Taylor
2026-11-03 11:51:18 +00:00
parent 94abb6ae9c
commit 53a78acf98
2 changed files with 16 additions and 26 deletions
+10 -15
View File
@@ -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<String> {
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
}
+6 -11
View File
@@ -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"));
}