From 931dfa0d7d886dda0dba0daa34c20408be71cb4d Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 12 Oct 2026 04:00:00 +0530 Subject: [PATCH] Issue #1185: Protect refuses passwords that encryption would make empty or equal (#1186) Encryption derives its keys from a prepared password, not the typed one: AES-256 (R6) applies SASLprep, falls back to the password as given if SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128 (R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the padding starts with "(". Protection::validate checked only the typed strings, so a permissions password of U+00AD wrote a file that opens with owner rights and no password, and an open password "A" with a permissions password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a file whose open password lifts the restrictions. pdfcraft-crypt gains effective_password(algorithm, password), the bytes the keys are derived from (it and create share the private prepared_password helper), re-exported from pdfcraft-cos. validate refuses a password whose effective bytes are those of "", and an open and a permissions password whose effective bytes are equal. Opening files is unchanged. Fixes #1185. --- crates/automation/tests/automation.rs | 19 +++++++++ crates/cos/src/lib.rs | 4 +- crates/crypt/src/lib.rs | 33 +++++++++++++-- crates/crypt/src/tests.rs | 47 +++++++++++++++++++++ crates/engine/src/lib.rs | 13 ++++++ crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0298b396..0e5da853 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1313,6 +1313,25 @@ fn protecting_with_an_open_password_alone_restricts_nothing() { } } +/// AES-256 keys on the SASLprep form of a password, which drops soft hyphens: a password made +/// only of them is no password at all (as the permissions password, it would give everyone owner +/// rights), so it is refused rather than written. +#[test] +fn protecting_with_a_password_encryption_ignores_is_refused() { + let dir = workdir("protect-ignored"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let answers: Vec = ["open_password", "permissions_password"] + .into_iter() + .map(|key| match a.call("doc_protect", &json!({ "doc": doc, key: "\u{AD}" })) { + Err(ToolError::Failed(m)) => m, + other => format!("{key}: {other:?}"), + }) + .collect(); + assert!(answers.iter().all(|m| m.contains("soft hyphens")), "{answers:?}"); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false, "a refused call changes nothing"); +} + #[test] fn forms_through_tools() { let dir = workdir("forms"); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index b6fe827e..bb855cc0 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,7 +25,9 @@ pub use bytes::Bytes; pub use document::{Document, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, parse_indirect}; -pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, pdfdoc_byte, pdfdoc_char}; +pub use pdfcraft_crypt::{ + Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, +}; pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index b93fd825..fd600cb9 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -539,6 +539,32 @@ pub enum Algorithm { Aes256, } +/// A password as [`create`] prepares it: for R2–R4 its PDFDocEncoding (characters it lacks +/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed, +/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8, +/// at most 127 bytes. +fn prepared_password(algorithm: Algorithm, password: &str) -> Vec { + match algorithm { + Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password), + Algorithm::Aes256 => sasl_password(password), + } +} + +/// The bytes new encryption with `algorithm` derives its keys from for `password`: for R2–R4 its +/// PDFDocEncoding cut or padded to exactly 32 bytes (Algorithm 2); for R6 its SASLprep (the +/// password as given if SASLprep refuses it), at most 127 bytes of UTF-8. Two passwords with the +/// same bytes are the same password to the file, and a password with the bytes of `""` opens it +/// without asking, so callers can refuse either before encrypting. Different bytes can still give +/// the same key (RC4-40 keeps 5 bytes of a hash). `create` still uses the user password for an +/// empty R2–R4 owner password. +pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec { + let prepared = prepared_password(algorithm, password); + match algorithm { + Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(), + Algorithm::Aes256 => prepared, + } +} + /// Parameters for new encryption. #[derive(Clone, Debug)] pub struct NewEncryption<'a> { @@ -573,6 +599,8 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = match params.algorithm { @@ -587,8 +615,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result Result (Result<(), EditError>, Option<[Option; 3]>) { + let (mut s, id) = session_with(1); + let result = s.apply(id, Edit::Protect(p.clone())); + let reopened = result.is_ok().then(|| { + let saved = s.save_bytes(id).unwrap(); + let auth = |pw: Option<&str>| pdfcraft_cos::Document::open_with_password(saved.clone(), pw).ok()?.security().map(|h| h.auth()); + [auth(None), auth(p.open_password.as_deref()), auth(p.permissions_password.as_deref())] + }); + (result, reopened) +} + +#[test] +fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { + use pdfcraft_cos::Algorithm::{Aes128, Rc4_128}; + use pdfcraft_cos::Auth::{Owner, User}; + let empty = "passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"; + let same = "the open password and the permissions password must be different (PDF readers treat these two as the same password)"; + // AES-256 keys on the SASLprep form of a password (soft hyphens dropped, then NFKC), RC4 and + // AES-128 on its first 32 bytes. Written as typed, these would make a file that opens + // without a password, or whose open password lifts the restrictions. + let (a31, a32) = ("a".repeat(31), "a".repeat(32)); + let (long1, long2) = (format!("{a32}1"), format!("{a32}2")); + // The 32-byte padding starts with "(": 31 characters and the same followed by "(" are one key. + let padded = format!("{a31}("); + let cases = [ + ("soft hyphen open password", protection(Some("\u{AD}"), None), empty), + ("soft hyphen permissions password", protection(None, Some("\u{AD}")), empty), + ("soft hyphen permissions password beside an open one", protection(Some("0"), Some("\u{AD}")), empty), + ("fullwidth A, then A", protection(Some("\u{FF21}"), Some("A")), same), + ("A, then fullwidth A", protection(Some("A"), Some("\u{FF21}")), same), + ("fi ligature, then fi", protection(Some("\u{FB01}"), Some("fi")), same), + ("fi, then fi ligature", protection(Some("fi"), Some("\u{FB01}")), same), + ("RC4-128, same first 32 bytes", Protection { algorithm: Rc4_128, ..protection(Some(&long1), Some(&long2)) }, same), + ("AES-128, same first 32 bytes", Protection { algorithm: Aes128, ..protection(Some(&long1), Some(&long2)) }, same), + ("RC4-128, same once padded", Protection { algorithm: Rc4_128, ..protection(Some(&a31), Some(&padded)) }, same), + ("AES-128, same once padded", Protection { algorithm: Aes128, ..protection(Some(&a31), Some(&padded)) }, same), + ]; + let got: Vec<_> = cases.iter().map(|(case, p, _)| (*case, protect_and_reopen(p))).collect(); + let want: Vec<_> = cases.iter().map(|(case, _, message)| (*case, (Err(EditError::Protection(message.to_string())), None))).collect(); + assert_eq!(got, want, "each is refused, so no file is written"); + // Passwords that are empty or equal as typed keep their messages. + let (mut s, id) = session_with(1); + assert_eq!(s.apply(id, Edit::Protect(protection(Some(""), None))), Err(EditError::Protection("passwords can't be empty".into()))); + assert_eq!( + s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), + Err(EditError::Protection("the open password and the permissions password must be different".into())) + ); + // Passwords that stay different are fine: only the permissions password gives owner rights. + let distinct = [ + protection(Some("A"), Some("\u{FF21}\u{FF22}")), + Protection { algorithm: Rc4_128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + Protection { algorithm: Aes128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + ]; + for p in &distinct { + assert_eq!(protect_and_reopen(p), (Ok(()), Some([None, Some(User), Some(Owner)])), "{:?}", p.algorithm); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [