From f233df14ad38e1d97fe945e68df59706adada1f5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:21:20 -0500 Subject: [PATCH] craft-pdf: the standard security handler from PdfCraft (`crypt`) (#897) * 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. --- Cargo.lock | 18 +- crates/pdf/Cargo.toml | 10 + crates/pdf/README.md | 31 + crates/pdf/src/crypt.rs | 796 ++++++++++++++++++ crates/pdf/src/crypt/aes_cbc.rs | 89 ++ crates/pdf/src/crypt/rc4.rs | 22 + crates/pdf/src/crypt/tests.rs | 449 ++++++++++ crates/pdf/src/lib.rs | 2 + crates/pdf/tests/crypt/main.rs | 7 + .../pdf/tests/crypt/permissions_revision.rs | 126 +++ crates/pdf/tests/crypt/proptests.rs | 464 ++++++++++ crates/pdf/tests/crypt/qpdf_literals.rs | 326 +++++++ 12 files changed, 2339 insertions(+), 1 deletion(-) create mode 100644 crates/pdf/src/crypt.rs create mode 100644 crates/pdf/src/crypt/aes_cbc.rs create mode 100644 crates/pdf/src/crypt/rc4.rs create mode 100644 crates/pdf/src/crypt/tests.rs create mode 100644 crates/pdf/tests/crypt/main.rs create mode 100644 crates/pdf/tests/crypt/permissions_revision.rs create mode 100644 crates/pdf/tests/crypt/proptests.rs create mode 100644 crates/pdf/tests/crypt/qpdf_literals.rs diff --git a/Cargo.lock b/Cargo.lock index ebce5f5ad..1e9d85eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,7 +926,7 @@ version = "0.1.0" name = "craft-cms" version = "0.1.0" dependencies = [ - "md-5", + "md-5 0.10.6", "moxcms", "rayon", ] @@ -1371,6 +1371,7 @@ dependencies = [ name = "craft-pdf" version = "0.1.0" dependencies = [ + "aes", "bytemuck", "hayro", "hayro-cmap", @@ -1378,9 +1379,14 @@ dependencies = [ "hayro-syntax", "krilla", "kurbo 0.13.1", + "md-5 0.11.0", + "proptest", + "sha2 0.11.1", + "stringprep", "thiserror 2.0.21", "ttf-parser", "wasm-bindgen-test", + "zeroize", ] [[package]] @@ -4083,6 +4089,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 17911b7cf..305f1f418 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -18,6 +18,7 @@ raster = ["interpret", "dep:hayro", "dep:bytemuck"] write = ["dep:krilla", "dep:thiserror"] write-pdf = ["write", "krilla/pdf"] write-simple-text = ["write", "krilla/simple-text"] +crypt = ["dep:aes", "dep:md-5", "dep:sha2", "dep:stringprep", "dep:zeroize", "dep:thiserror"] [dependencies] hayro-syntax = { workspace = true, optional = true } @@ -26,6 +27,12 @@ hayro = { workspace = true, optional = true } bytemuck = { workspace = true, optional = true } krilla = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } +# The standard security handler (`crypt`): pure Rust, MIT OR Apache-2.0, builds for wasm. +aes = { version = "0.9", optional = true } +md-5 = { version = "0.11", optional = true } +sha2 = { version = "0.11", optional = true } +stringprep = { version = "0.1", optional = true } +zeroize = { version = "1", optional = true } [dev-dependencies] hayro-syntax = "=0.7.2" @@ -34,5 +41,8 @@ hayro-cmap = "=0.1.0" kurbo = "0.13" ttf-parser = "=0.25.1" +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +proptest = { workspace = true } + [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 816eba779..44af16bbf 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -12,6 +12,7 @@ No feature is enabled by default. | `write` | Guarded Krilla authoring; no shared reader dependency | | `write-pdf` | `write` plus Krilla's embedded-PDF support | | `write-simple-text` | `write` plus Krilla's optional text shaper and `draw_text` | +| `crypt` | The standard security handler: decrypt, encrypt and create encryption. No reader or writer dependency | ## Reading and rendering @@ -109,6 +110,36 @@ read-only consumer, then repeat with `--target wasm32-unknown-unknown` and the t These backend checks supplement the root workspace gates; excluded vendor crates are not covered by `cargo test --workspace` alone. +## Encryption + +`crypt` is the PDF standard security handler (ISO 32000-2 §7.6) for code that edits or writes +PDF files. The reader decrypts on its own and does not use it. It covers revisions 2 to 6: +40-bit and 128-bit RC4, AES-128 crypt filters, and AES-256 (R5 is read, R6 is read and written). + +- `SecurityHandler::open(dict, id0, password)` authenticates the owner password first, then the + user password. Passwords are tried as PDFDocEncoding and as typed (R2–R4), or in their + SASLprep form and as typed (R5/R6). `authenticate_owner` upgrades a user handler. + `without_key` is for a file opened without a password whose only encrypted data is behind + crypt filters authorised when an embedded file is opened; that data is left as stored. +- `decrypt_string`, `encrypt_string`, `decrypt_stream` and `encrypt_stream` take the object + number and generation. `StreamKind` selects the crypt filter: `/StmF`, `/EFF` for embedded + files, or a stream's own `/Crypt` filter name. `encrypts_metadata` says whether the caller + passes `/Metadata` streams through at all. AES decryption is tolerant: a trailing partial + block is ignored and bad padding is left in. +- `permissions()` gives the `/P` flags; for R5/R6 a permission counts only when `/Perms` grants + it too. Owners have every permission. +- `create(&NewEncryption, id0)` builds the `/Encrypt` values for RC4-40, RC4-128, AES-128 or + AES-256. The caller supplies 32 bytes of entropy (`seed`), so the module needs no OS access + and builds for the web. `file_id` derives a file identifier from the same seed. +- `EncryptDict` holds the dictionary's values. The caller reads them from, and writes them to, + its own object model; the module does not parse PDF syntax. Keys and prepared passwords are + zeroized when dropped. + +The code and its tests come from PdfCraft's `pdfcraft-crypt` crate (revision +`2b65ab000bfe0997dec5fbed2769d3da3799355b`), unchanged apart from the module paths. Tests: +`cargo test -p craft-pdf --no-default-features --features crypt`. `tests/crypt/qpdf_literals.rs` +checks key derivation against values another implementation wrote; only the numbers are stored. + ## Writing Plain `write` retains Krilla raster images without its optional text shaper. Applications diff --git a/crates/pdf/src/crypt.rs b/crates/pdf/src/crypt.rs new file mode 100644 index 000000000..7f7a51e09 --- /dev/null +++ b/crates/pdf/src/crypt.rs @@ -0,0 +1,796 @@ +//! The PDF standard security handler (ISO 32000-2 §7.6), for code that writes or edits PDF files. +//! The reader (`read`) decrypts on its own; this module also encrypts and creates encryption. +//! +//! Supports every revision of the standard handler: +//! - R2 (40-bit RC4), R3 (RC4 up to 128-bit), R4 (crypt filters: RC4 or AES-128); +//! - R5 (the deprecated AES-256 extension) and R6 (AES-256, PDF 2.0). +//! +//! It authenticates with the user or owner password (owner first, as viewers do) and derives +//! per-object keys. It decrypts and encrypts strings and streams, and honours crypt filters, +//! including `Identity` and embedded-file-only encryption. It can also *create* encryption +//! dictionaries, for an app's security settings and for test fixtures. +//! +//! The module knows nothing about a PDF object model. Callers pass in the values of the +//! `/Encrypt` dictionary and the first element of the trailer `/ID`. + +mod aes_cbc; +mod rc4; + +use md5::{Digest as _, Md5}; +use sha2::{Sha256, Sha384, Sha512}; +use zeroize::Zeroizing; + +pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt}; +pub use rc4::rc4; + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum CryptError { + #[error("the password is incorrect")] + WrongPassword, + #[error("unsupported security handler: {0}")] + Unsupported(String), + #[error("malformed encryption dictionary: {0}")] + Malformed(String), +} + +/// 32-byte padding string used by revisions 2–4 (§7.6.4.3.2, step a). +pub const PADDING: [u8; 32] = [ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, + 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, +]; + +/// How a crypt filter encrypts (`/CFM`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Method { + /// `None` / the `Identity` filter: data is not encrypted. + Identity, + /// `V2`: RC4. + Rc4, + /// `AESV2`: AES-128-CBC. + Aes128, + /// `AESV3`: AES-256-CBC. + Aes256, +} + +/// The values of an `/Encrypt` dictionary that the handler needs. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct EncryptDict { + /// `/Filter` (must be `Standard`). + pub filter: Vec, + pub v: i64, + pub r: i64, + /// Key length in bits (`/Length`; 40 when absent). + pub length_bits: i64, + pub o: Vec, + pub u: Vec, + pub oe: Vec, + pub ue: Vec, + pub perms: Vec, + pub p: i32, + pub encrypt_metadata: bool, + /// Crypt filters (`/CF`): name → (method, key length in bytes if given). + pub crypt_filters: Vec<(Vec, Method)>, + pub stm_f: Vec, + pub str_f: Vec, + pub ef_f: Vec, +} + +/// Which password authenticated the document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Auth { + Owner, + User, +} + +/// Permission flags from `/P`, read the way the handler's revision reads them (ISO 32000-1:2008, +/// Table 22). Owners have every permission. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Permissions { + pub bits: i32, + pub owner: bool, + /// The `/R` value of the handler that read `bits`. + pub revision: i64, +} + +impl Permissions { + fn bit(&self, n: u32) -> bool { + self.owner || (self.bits >> (n - 1)) & 1 == 1 + } + /// Revision 3 and later read the full flag list of Table 22; revision 2 has its own. + fn is_revision_3_or_later(&self) -> bool { + self.revision >= 3 + } + pub fn print(&self) -> bool { + self.bit(3) + } + /// Modify contents other than the operations below. + pub fn modify(&self) -> bool { + self.bit(4) + } + pub fn copy(&self) -> bool { + self.bit(5) + } + pub fn annotate(&self) -> bool { + self.bit(6) + } + pub fn fill_forms(&self) -> bool { + if self.is_revision_3_or_later() { self.bit(9) || self.bit(6) } else { self.bit(6) } + } + /// Revision 2 covers accessibility with the copy flag (bit 5); revision 3 and later with bit 10. + pub fn extract_for_accessibility(&self) -> bool { + if self.is_revision_3_or_later() { self.bit(10) } else { self.bit(5) } + } + /// Insert, rotate or delete pages; create bookmarks and thumbnails. Revision 3 and later grant + /// it by bit 11 alone. Table 22 defines bit 11 for those revisions only, so at revision 2 the + /// modify flag (bit 4) covers it. + pub fn assemble(&self) -> bool { + if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } + } + /// Take pages out into another document (Extract, Split, a source of Combine or Insert + /// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and + /// graphics into a new file without the document's protection (#5081). "Any except + /// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither. + pub fn extract_pages(&self) -> bool { + self.assemble() && self.copy() + } + /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those + /// revisions only: at revision 2 the print flag (bit 3) is all there is. + pub fn print_high_quality(&self) -> bool { + if self.is_revision_3_or_later() { self.print() && self.bit(12) } else { self.print() } + } +} + +/// What kind of stream is being decrypted (selects the crypt filter). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamKind<'a> { + Normal, + /// An embedded file stream (uses `/EFF`). + EmbeddedFile, + /// The stream names its own crypt filter (`/Filter [/Crypt …] /DecodeParms << /Name … >>`). + Named(&'a [u8]), +} + +/// An authenticated security handler for one document. +#[derive(Clone, Debug)] +pub struct SecurityHandler { + dict: EncryptDict, + key: Zeroizing>, + auth: Auth, + recovered_user: Option>>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } +} + +impl SecurityHandler { + fn new(dict: EncryptDict, key: Zeroizing>, auth: Auth, recovered_user: Option>>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + + /// Authenticate with `password` (tried as owner password, then user password). `None` + /// tries the empty password, which opens documents that only have an owner password. + pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { + if !dict.filter.is_empty() && dict.filter != b"Standard" { + return Err(CryptError::Unsupported(format!("/Filter /{}", String::from_utf8_lossy(&dict.filter)))); + } + let pw = password.unwrap_or(""); + let mut recovered_user = None; + let (key, auth) = match dict.r { + 2..=4 => { + let mut found = None; + for legacy in legacy_candidates(pw) { + if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) { + recovered_user = Some(user); + found = Some((k, Auth::Owner)); + } else if let Some(k) = user_key_r4(&dict, id0, &legacy) { + found = Some((k, Auth::User)); + } + if found.is_some() { + break; + } + } + found.ok_or(CryptError::WrongPassword)? + } + 5 | 6 => { + let pw = sasl_password(pw); + if let Some(k) = key_r6(&dict, &pw, true)? { + (k, Auth::Owner) + } else if let Some(k) = key_r6(&dict, &pw, false)? { + (k, Auth::User) + } else { + return Err(CryptError::WrongPassword); + } + } + r => return Err(CryptError::Unsupported(format!("revision {r}"))), + }; + 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 { + 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: Zeroizing::new(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 + /// different key is refused too. + pub fn authenticate_owner(&self, id0: &[u8], password: &str) -> Result { + let owner = Self::open(self.dict.clone(), id0, Some(password))?; + if owner.auth != Auth::Owner || owner.key != self.key { + return Err(CryptError::WrongPassword); + } + Ok(owner) + } + + pub fn auth(&self) -> Auth { + self.auth + } + + /// For R2–R4 documents opened with the owner password: the user password, which owner + /// authentication recovers (Algorithm 7). Lets components that only accept user passwords + /// open the document too. + pub fn recovered_user_password(&self) -> Option>> { + self.recovered_user.clone() + } + + /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key + /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an + /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. + pub fn permissions(&self) -> Permissions { + let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); + Permissions { bits, owner: self.auth == Auth::Owner, revision: self.dict.r } + } + + /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. + fn perms_p(&self) -> Option { + if !matches!(self.dict.r, 5 | 6) || !self.has_key() { + return None; + } + let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false)); + let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); + (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) + } + + pub fn dict(&self) -> &EncryptDict { + &self.dict + } + + /// The file encryption key (for tests and diagnostics). + pub fn file_key(&self) -> &[u8] { + &self.key + } + + /// Whether `/Metadata` streams are encrypted. + pub fn encrypts_metadata(&self) -> bool { + self.dict.encrypt_metadata + } + + /// The method for strings or a kind of stream. + fn method(&self, stream: Option) -> Method { + if self.dict.v < 4 { + return Method::Rc4; + } + match stream { + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), + } + } + + /// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly + /// (Algorithm 1.A, §7.6.3.3). + /// + /// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended + /// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as + /// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter + /// with a 40- or 48-bit key (pdf.js issue 19484). `hayro-syntax` (the `read` feature) reads them + /// the same way, so a page it shows is one this module can read and save. AES-128 keys are + /// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only + /// seen with RC4. Every key [`create`] makes under `/V 4` is 16 bytes already. + fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing> { + if method == Method::Aes256 { + return self.key.clone(); + } + let mut key = self.key.clone(); + if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 { + key.resize(16, 0); + } + let mut h = Md5::new(); + h.update(&key); + h.update(&num.to_le_bytes()[..3]); + h.update(generation.to_le_bytes()); + if method == Method::Aes128 { + h.update(b"sAlT"); + } + let mut digest = Zeroizing::new(h.finalize().to_vec()); + digest.truncate((key.len() + 5).min(16)); + digest + } + + fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec { + // 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), + Method::Aes128 | Method::Aes256 => { + let key = self.object_key(method, num, generation); + if encrypt { + // Deterministic IV derived from the key, object and content: saves are + // reproducible and no IV repeats for different plaintexts. + let mut h = Sha256::new(); + h.update(&key); + h.update(num.to_le_bytes()); + h.update(generation.to_le_bytes()); + h.update(data); + let digest = h.finalize(); + let mut iv = [0u8; 16]; + for (d, s) in iv.iter_mut().zip(digest.iter()) { + *d = *s; + } + let mut out = iv.to_vec(); + out.extend(aes_cbc_encrypt(&key, &iv, data, true)); + out + } else { + let Some((iv, body)) = data.split_first_chunk::<16>() else { + return Vec::new(); // only (part of) an IV: empty plaintext + }; + aes_cbc_decrypt(&key, iv, body, true) + } + } + } + } + + pub fn decrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec { + self.apply(self.method(None), num, generation, data, false) + } + + pub fn encrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec { + self.apply(self.method(None), num, generation, data, true) + } + + pub fn decrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec { + self.apply(self.method(Some(kind)), num, generation, data, false) + } + + pub fn encrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec { + self.apply(self.method(Some(kind)), num, generation, data, true) + } + + /// Whether strings are encrypted at all (false with `/StrF /Identity`). + pub fn encrypts_strings(&self) -> bool { + self.method(None) != Method::Identity + } +} + +// ── Passwords ────────────────────────────────────────────────────────────────────────────────── + +/// PDFDocEncoding (ISO 32000-2 Annex D) where it differs from Latin-1: bytes 0x18–0x1F and +/// 0x80–0xA0 (0x9F is undefined). Here because R2–R4 passwords need it; an object layer that +/// decodes text strings can use it too. +const PDFDOC_LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜']; +const PDFDOC_HIGH: [char; 33] = [ + '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', 'œ', + 'š', 'ž', '\u{FFFD}', '€', +]; + +/// PDFDocEncoding → Unicode. +pub fn pdfdoc_char(c: u8) -> char { + match c { + 0x18..=0x1F => PDFDOC_LOW[usize::from(c - 0x18)], + 0x80..=0xA0 => PDFDOC_HIGH[usize::from(c - 0x80)], + _ => char::from(c), + } +} + +/// Unicode → PDFDocEncoding, `None` for a character it lacks (ğ, ş, İ, Cyrillic, CJK, …). +pub fn pdfdoc_byte(c: char) -> Option { + let at = |table: &[char], base: u8| table.iter().position(|&t| t == c && t != '\u{FFFD}').and_then(|i| u8::try_from(i).ok()).map(|i| base + i); + at(&PDFDOC_LOW, 0x18).or_else(|| at(&PDFDOC_HIGH, 0x80)).or_else(|| match u32::from(c) { + 0x18..=0x1F | 0x80..=0xA0 => None, + n => u8::try_from(n).ok(), + }) +} + +/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes. +/// Characters PDFDocEncoding lacks are dropped. +fn legacy_password(pw: &str) -> Zeroizing> { + Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect()) +} + +/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification +/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives +/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords. +fn legacy_candidates(pw: &str) -> Vec>> { + let mut out = vec![legacy_password(pw)]; + if pw.chars().any(|c| pdfdoc_byte(c).is_none()) { + out.push(Zeroizing::new(pw.bytes().take(32).collect())); + } + out +} + +/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes. +fn sasl_password(pw: &str) -> Zeroizing> { + let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string())); + let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec()); + bytes.truncate(127); + bytes +} + +fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new(PADDING); + let n = pw.len().min(32); + out[..n].copy_from_slice(&pw[..n]); + out[n..].copy_from_slice(&PADDING[..32 - n]); + out +} + +fn key_len(d: &EncryptDict) -> usize { + if d.r == 2 { 5 } else { (d.length_bits.clamp(40, 128) / 8) as usize } +} + +// ── Revisions 2–4 ────────────────────────────────────────────────────────────────────────────── + +/// Algorithm 2: file key from a (user) password. +fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing> { + let n = key_len(d); + let mut h = Md5::new(); + h.update(*pad(pw)); + h.update(&d.o[..d.o.len().min(32)]); + h.update(d.p.to_le_bytes()); + h.update(id0); + if d.r >= 4 && !d.encrypt_metadata { + h.update([0xFF; 4]); + } + let mut digest = Zeroizing::new(h.finalize().to_vec()); + if d.r >= 3 { + for _ in 0..50 { + digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec()); + } + } + digest.truncate(n); + digest +} + +/// Algorithms 4/5: the `/U` value a key produces. +fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { + if d.r == 2 { + return rc4(key, &PADDING); + } + let mut h = Md5::new(); + h.update(PADDING); + h.update(id0); + let mut x = rc4(key, &h.finalize()); + for i in 1..=19u8 { + let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::>()); + x = rc4(&k, &x); + } + x.extend_from_slice(&[0u8; 16]); + x +} + +/// Algorithm 6: authenticate a user password; returns the file key. +/// +/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the +/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation +/// fails we retry the other way. +fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option>> { + let check = |d: &EncryptDict| { + let key = file_key_r4(d, id0, pw); + let u = u_value_r4(d, id0, &key); + let n = if d.r == 2 { 32 } else { 16 }; + (d.u.len() >= n && u[..n] == d.u[..n]).then_some(key) + }; + check(d).or_else(|| (d.r >= 4 && !d.encrypt_metadata).then(|| check(&EncryptDict { encrypt_metadata: true, ..d.clone() })).flatten()) +} + +/// The RC4 key derived from an owner password (Algorithm 3, steps a–d). +fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing> { + let n = key_len(d); + let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec()); + if d.r >= 3 { + for _ in 0..50 { + digest = Zeroizing::new(Md5::digest(&*digest).to_vec()); + } + } + digest.truncate(n); + digest +} + +type SecretBytes = Zeroizing>; + +/// Algorithm 7: authenticate an owner password (recover the user password from `/O`). +/// Returns the file key and the recovered user password (padding removed). +fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> { + let rk = owner_rc4_key(d, pw); + let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec()); + if d.r == 2 { + user = Zeroizing::new(rc4(&rk, &user)); + } else { + for i in (0..=19u8).rev() { + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); + user = Zeroizing::new(rc4(&k, &user)); + } + } + let key = user_key_r4(d, id0, &user)?; + // The recovered value is the padded password: strip the padding suffix. + let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32)); + user.truncate(len); + Some((key, user)) +} + +// ── Revisions 5–6 ────────────────────────────────────────────────────────────────────────────── + +/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B). +fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing> { + let mut k = Zeroizing::new({ + let mut h = Sha256::new(); + h.update(pw); + h.update(salt); + h.update(udata); + h.finalize().to_vec() + }); + if r == 5 { + return k; + } + let mut round = 0u32; + loop { + let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()))); + for _ in 0..64 { + k1.extend_from_slice(pw); + k1.extend_from_slice(&k); + k1.extend_from_slice(udata); + } + // `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16. + let Some((key, rest)) = k.split_first_chunk::<16>() else { break }; + let Some(iv) = rest.first_chunk::<16>() else { break }; + let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false)); + let m = e.iter().take(16).map(|b| u32::from(*b)).sum::() % 3; + k = Zeroizing::new(match m { + 0 => Sha256::digest(&*e).to_vec(), + 1 => Sha384::digest(&*e).to_vec(), + _ => Sha512::digest(&*e).to_vec(), + }); + round += 1; + if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) { + break; + } + } + k.truncate(32); + k +} + +/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key. +fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>>, CryptError> { + if d.u.len() < 48 || d.o.len() < 48 { + return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into())); + } + let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) }; + if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { + return Ok(None); + } + if encrypted.len() < 32 { + return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into())); + } + let k = hash_r6(d.r, pw, &hashed[40..48], udata); + Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))) +} + +// ── Creating encryption ──────────────────────────────────────────────────────────────────────── + +/// Which encryption to create. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Algorithm { + /// R2, 40-bit RC4 (legacy; for tests and compatibility only). + Rc4_40, + /// R3, 128-bit RC4. + Rc4_128, + /// R4, AES-128 crypt filters. + Aes128, + /// R6, AES-256 (PDF 2.0; the default for new documents). + 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) -> Zeroizing> { + 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.to_vec(), + } +} + +/// Parameters for new encryption. +#[derive(Clone, Debug)] +pub struct NewEncryption<'a> { + pub algorithm: Algorithm, + pub user_password: &'a str, + pub owner_password: &'a str, + pub permissions: i32, + pub encrypt_metadata: bool, + /// 32 bytes of entropy for keys and salts (the caller supplies randomness, so this crate + /// needs no OS access and tests are deterministic). + pub seed: [u8; 32], +} + +/// Expand `seed` into `n` bytes (SHA-256 in counter mode). +fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec { + let mut out = Vec::new(); + let mut counter = 0u32; + while out.len() < n { + let mut h = Sha256::new(); + h.update(seed); + h.update(label); + h.update(counter.to_le_bytes()); + out.extend_from_slice(&h.finalize()); + counter = counter.saturating_add(1); + } + out.truncate(n); + out +} + +/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`). +/// +/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public +/// value from being an unkeyed hash of the seed. +pub fn file_id(seed: &[u8; 32]) -> Vec { + expand(seed, b"id", 16) +} + +/// 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; + 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); + match params.algorithm { + Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => { + (d.v, d.r, d.length_bits) = match params.algorithm { + Algorithm::Rc4_40 => (1, 2, 40), + Algorithm::Rc4_128 => (2, 3, 128), + _ => (4, 4, 128), + }; + if params.algorithm == Algorithm::Aes128 { + d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + } else { + d.encrypt_metadata = true; // V < 4 always encrypts metadata + } + let owner = if params.owner_password.is_empty() { user.clone() } else { owner }; + // Algorithm 3: /O + let rk = owner_rc4_key(&d, &owner); + let mut o = rc4(&rk, &*pad(&user)); + if d.r >= 3 { + for i in 1..=19u8 { + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); + o = rc4(&k, &o); + } + } + d.o = o; + let key = file_key_r4(&d, id0, &user); + d.u = u_value_r4(&d, id0, &key); + Ok(SecurityHandler::new(d, key, Auth::Owner, Some(user))) + } + Algorithm::Aes256 => { + (d.v, d.r, d.length_bits) = (5, 6, 256); + d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + let key = Zeroizing::new(expand(¶ms.seed, b"file key", 32)); + let salts = expand(¶ms.seed, b"salts", 32); + // Algorithm 8: /U, /UE + let mut u = hash_r6(6, &user, &salts[0..8], &[]).to_vec(); + u.extend_from_slice(&salts[0..16]); + let uk = hash_r6(6, &user, &salts[8..16], &[]); + d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false); + // Algorithm 9: /O, /OE + let mut o = hash_r6(6, &owner, &salts[16..24], &u).to_vec(); + o.extend_from_slice(&salts[16..32]); + let ok = hash_r6(6, &owner, &salts[24..32], &u); + d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false); + d.u = u; + d.o = o; + // Algorithm 10: /Perms + let mut perms = [0u8; 16]; + perms[..4].copy_from_slice(&p.to_le_bytes()); + perms[4..8].copy_from_slice(&[0xFF; 4]); + perms[8] = if d.encrypt_metadata { b'T' } else { b'F' }; + perms[9..12].copy_from_slice(b"adb"); + perms[12..16].copy_from_slice(&expand(¶ms.seed, b"perms", 4)); + d.perms = aes_cbc_encrypt(&key, &[0; 16], &perms, false); + Ok(SecurityHandler::new(d, key, Auth::Owner, None)) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/crypt/aes_cbc.rs b/crates/pdf/src/crypt/aes_cbc.rs new file mode 100644 index 000000000..5d958f4f3 --- /dev/null +++ b/crates/pdf/src/crypt/aes_cbc.rs @@ -0,0 +1,89 @@ +//! AES-CBC (128 or 256-bit key, chosen by key length) with optional PKCS#5 padding. +//! +//! Decryption is tolerant, as viewers are: a trailing partial block is ignored, and invalid +//! padding leaves the data unpadded rather than failing. + +use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; +use aes::{Aes128, Aes256}; + +enum Cipher { + A128(Box), + A256(Box), +} + +impl Cipher { + fn new(key: &[u8]) -> Self { + if let Some(k) = key.first_chunk::<32>() { + Cipher::A256(Box::new(Aes256::new(&Array::from(*k)))) + } else { + let mut k = [0u8; 16]; + let n = key.len().min(16); + k[..n].copy_from_slice(&key[..n]); + Cipher::A128(Box::new(Aes128::new(&Array::from(k)))) + } + } + + fn encrypt(&self, block: &mut [u8; 16]) { + let mut b = Array::from(*block); + match self { + Cipher::A128(c) => c.encrypt_block(&mut b), + Cipher::A256(c) => c.encrypt_block(&mut b), + } + *block = b.into(); + } + + fn decrypt(&self, block: &mut [u8; 16]) { + let mut b = Array::from(*block); + match self { + Cipher::A128(c) => c.decrypt_block(&mut b), + Cipher::A256(c) => c.decrypt_block(&mut b), + } + *block = b.into(); + } +} + +/// Encrypt; with `pad`, PKCS#5 padding is added (otherwise `data` must be whole blocks, and a +/// trailing partial block is zero-padded). +pub fn aes_cbc_encrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec { + let c = Cipher::new(key); + let mut buf = data.to_vec(); + if pad { + let n = 16 - buf.len() % 16; + buf.extend(std::iter::repeat_n(n as u8, n)); + } else if !buf.len().is_multiple_of(16) { + buf.resize(buf.len().div_ceil(16) * 16, 0); + } + let mut prev = *iv; + for b in buf.as_chunks_mut::<16>().0 { + for (x, p) in b.iter_mut().zip(prev) { + *x ^= p; + } + c.encrypt(b); + prev = *b; + } + buf +} + +/// Decrypt; with `pad`, PKCS#5 padding is removed when valid. +pub fn aes_cbc_decrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec { + let c = Cipher::new(key); + let blocks = data.as_chunks::<16>().0; + let mut out = Vec::with_capacity(blocks.len() * 16); + let mut prev = *iv; + for &cipher in blocks { + let mut b = cipher; + c.decrypt(&mut b); + for (x, p) in b.iter_mut().zip(prev) { + *x ^= p; + } + out.extend_from_slice(&b); + prev = cipher; + } + if pad && let Some(&n) = out.last() { + let n = n as usize; + if (1..=16).contains(&n) && n <= out.len() && out[out.len() - n..].iter().all(|b| *b as usize == n) { + out.truncate(out.len() - n); + } + } + out +} diff --git a/crates/pdf/src/crypt/rc4.rs b/crates/pdf/src/crypt/rc4.rs new file mode 100644 index 000000000..952a8394d --- /dev/null +++ b/crates/pdf/src/crypt/rc4.rs @@ -0,0 +1,22 @@ +//! RC4 (used by PDF security handler revisions 2–4). Encryption and decryption are the same. + +pub fn rc4(key: &[u8], data: &[u8]) -> Vec { + if key.is_empty() { + return data.to_vec(); + } + let mut s: [u8; 256] = std::array::from_fn(|i| i as u8); + let mut j: u8 = 0; + for i in 0..256 { + j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]); + s.swap(i, j as usize); + } + let (mut i, mut j) = (0u8, 0u8); + data.iter() + .map(|b| { + i = i.wrapping_add(1); + j = j.wrapping_add(s[i as usize]); + s.swap(i as usize, j as usize); + b ^ s[s[i as usize].wrapping_add(s[j as usize]) as usize] + }) + .collect() +} diff --git a/crates/pdf/src/crypt/tests.rs b/crates/pdf/src/crypt/tests.rs new file mode 100644 index 000000000..46619427a --- /dev/null +++ b/crates/pdf/src/crypt/tests.rs @@ -0,0 +1,449 @@ +use super::*; + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +#[test] +fn file_id_is_a_one_way_16_byte_identifier() { + let seed = [7u8; 32]; + let id = file_id(&seed); + assert_eq!(id.len(), 16); + assert_eq!(id, file_id(&seed)); + assert_ne!(id, seed[..16]); + assert_ne!(id, file_id(&[8u8; 32])); +} + +#[test] +fn rc4_reference_vectors() { + assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3")); + assert_eq!(rc4(b"Wiki", b"pedia"), hex("1021bf0420")); + assert_eq!(rc4(b"Secret", b"Attack at dawn"), hex("45a01f645fc35b383552544b9bf5")); +} + +#[test] +fn aes_cbc_nist_sp800_38a_vectors() { + let iv: [u8; 16] = hex("000102030405060708090a0b0c0d0e0f").try_into().unwrap(); + let plain = hex("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"); + let k128 = hex("2b7e151628aed2a6abf7158809cf4f3c"); + let c128 = aes_cbc_encrypt(&k128, &iv, &plain, false); + assert_eq!(c128, hex("7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2")); + assert_eq!(aes_cbc_decrypt(&k128, &iv, &c128, false), plain); + let k256 = hex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"); + let c256 = aes_cbc_encrypt(&k256, &iv, &plain, false); + assert_eq!(c256, hex("f58c4c04d6e5f1ba779eabfb5f7bfbd69cfc4e967edb808d679f777bc6702c7d")); + assert_eq!(aes_cbc_decrypt(&k256, &iv, &c256, false), plain); +} + +#[test] +fn aes_padding_is_added_and_tolerantly_removed() { + let key = [7u8; 16]; + let iv = [1u8; 16]; + for n in [0, 1, 15, 16, 17, 100] { + let data: Vec = (0..n as u8).collect(); + let c = aes_cbc_encrypt(&key, &iv, &data, true); + assert_eq!(c.len() % 16, 0); + assert_eq!(aes_cbc_decrypt(&key, &iv, &c, true), data); + } + // Garbage (bad padding, partial block) decrypts without panicking. + let _ = aes_cbc_decrypt(&key, &iv, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], true); +} + +const ID: &[u8] = b"0123456789abcdef"; + +fn new(alg: Algorithm, user: &str, owner: &str, perms: i32) -> SecurityHandler { + create( + &NewEncryption { algorithm: alg, user_password: user, owner_password: owner, permissions: perms, encrypt_metadata: true, seed: [42; 32] }, + ID, + ) + .unwrap() +} + +const ALL: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]; + +#[test] +fn created_encryption_authenticates_owner_and_user() { + for alg in ALL { + let made = new(alg, "user pw", "owner pw", 0b0100); // print only + let d = made.dict().clone(); + let owner = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap(); + assert_eq!(owner.auth(), Auth::Owner, "{alg:?}"); + assert_eq!(owner.file_key(), made.file_key(), "{alg:?}"); + assert!(owner.permissions().modify(), "owners can do everything"); + let user = SecurityHandler::open(d.clone(), ID, Some("user pw")).unwrap(); + assert_eq!(user.auth(), Auth::User, "{alg:?}"); + assert_eq!(user.file_key(), made.file_key(), "{alg:?}"); + assert!(user.permissions().print() && !user.permissions().modify() && !user.permissions().copy(), "{alg:?}"); + assert_eq!(SecurityHandler::open(d.clone(), ID, Some("nope")).unwrap_err(), CryptError::WrongPassword, "{alg:?}"); + assert_eq!(SecurityHandler::open(d, ID, None).unwrap_err(), CryptError::WrongPassword, "{alg:?}"); + } +} + +#[test] +fn the_owner_password_upgrades_a_user_session() { + for alg in ALL { + let d = new(alg, "", "secret", 0b0100).dict().clone(); + let user = SecurityHandler::open(d, ID, None).unwrap(); + assert!(!user.permissions().modify(), "{alg:?}"); + // The (empty) user password and wrong ones don't unlock anything. + assert_eq!(user.authenticate_owner(ID, "").unwrap_err(), CryptError::WrongPassword, "{alg:?}"); + assert_eq!(user.authenticate_owner(ID, "nope").unwrap_err(), CryptError::WrongPassword, "{alg:?}"); + let owner = user.authenticate_owner(ID, "secret").unwrap(); + assert_eq!(owner.auth(), Auth::Owner, "{alg:?}"); + assert_eq!(owner.file_key(), user.file_key(), "{alg:?}"); + assert!(owner.permissions().modify(), "{alg:?}"); + } + // A separate open password is not the permissions password. + for alg in ALL { + let d = new(alg, "open", "perms", 0b0100).dict().clone(); + let user = SecurityHandler::open(d, ID, Some("open")).unwrap(); + assert_eq!(user.authenticate_owner(ID, "open").unwrap_err(), CryptError::WrongPassword, "{alg:?}"); + assert_eq!(user.authenticate_owner(ID, "perms").unwrap().auth(), Auth::Owner, "{alg:?}"); + } +} + +#[test] +fn empty_user_password_opens_without_asking() { + for alg in ALL { + let d = new(alg, "", "secret", -1).dict().clone(); + let h = SecurityHandler::open(d, ID, None).unwrap(); + assert_eq!(h.auth(), Auth::User, "{alg:?}"); + } +} + +#[test] +fn strings_and_streams_round_trip_and_are_scrambled() { + for alg in ALL { + let h = new(alg, "", "o", -1); + let text = b"Confidential quarterly numbers".to_vec(); + let enc = h.encrypt_string(12, 0, &text); + assert_ne!(enc, text, "{alg:?}"); + assert_eq!(h.decrypt_string(12, 0, &enc), text, "{alg:?}"); + if alg != Algorithm::Aes256 { + // R2–R4 derive a key per object; R6 uses the file key everywhere (§7.6.3.3). + assert_ne!(h.decrypt_string(13, 0, &enc), text, "object keys differ per object ({alg:?})"); + } + let data: Vec = (0..1000u32).map(|i| (i * 7) as u8).collect(); + let enc = h.encrypt_stream(5, 0, &data, StreamKind::Normal); + assert_eq!(h.decrypt_stream(5, 0, &enc, StreamKind::Normal), data, "{alg:?}"); + // Deterministic: the same object encrypts to the same bytes (reproducible saves). + assert_eq!(h.encrypt_stream(5, 0, &data, StreamKind::Normal), enc); + } +} + +thread_local! { + /// Crypt filter names compared or looked up, on this thread. + pub(crate) static FILTER_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + +#[test] +fn identity_crypt_filters_leave_data_alone() { + let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); + // Embedded-file-only encryption: strings and normal streams are not encrypted. + d.str_f = b"Identity".to_vec(); + d.stm_f = b"Identity".to_vec(); + d.ef_f = b"StdCF".to_vec(); + let h = SecurityHandler::open(d, ID, None).unwrap(); + assert!(!h.encrypts_strings()); + assert_eq!(h.decrypt_stream(3, 0, b"plain", StreamKind::Normal), b"plain"); + let enc = h.encrypt_stream(3, 0, b"attachment", StreamKind::EmbeddedFile); + assert_ne!(enc, b"attachment"); + assert_eq!(h.decrypt_stream(3, 0, &enc, StreamKind::EmbeddedFile), b"attachment"); + assert_eq!(h.decrypt_stream(3, 0, b"x", StreamKind::Named(b"Identity")), b"x"); +} + +#[test] +fn r6_passwords_are_saslprepped() { + // U+00AD (soft hyphen) is mapped to nothing by SASLprep; "ª" normalises to "a" (NFKC). + let d = new(Algorithm::Aes256, "SªSL\u{AD}prep", "o", -1).dict().clone(); + assert!(SecurityHandler::open(d.clone(), ID, Some("SaSLprep")).is_ok()); + assert!(SecurityHandler::open(d, ID, Some("SaSL-prep")).is_err()); +} + +#[test] +fn effective_passwords_are_the_bytes_the_keys_are_made_from() { + // AES-256: SASLprep drops soft hyphens, makes other spaces plain and folds compatibility + // characters (NFKC); at most 127 bytes of UTF-8. + let r6 = |pw: &str| effective_password(Algorithm::Aes256, pw); + assert_eq!(r6("\u{AD}"), r6(""), "a soft hyphen alone is no password"); + assert_eq!(r6("\u{FF21}"), r6("A"), "fullwidth A"); + assert_eq!(r6("\u{FB01}"), r6("fi"), "the fi ligature"); + assert_eq!(r6("a\u{3000}b"), b"a b", "an ideographic space"); + assert_ne!(r6("A"), r6("a"), "case matters"); + assert_eq!(r6(&"é".repeat(100)).len(), 127); + // RC4 and AES-128: PDFDocEncoding, padded or cut to exactly 32 bytes. + let (a31, a32) = ("a".repeat(31), "a".repeat(32)); + for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + assert_eq!(effective_password(alg, &format!("{a32}1")), a32.as_bytes(), "{alg:?}"); + assert_eq!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("1{a32}2")), "{alg:?}"); + assert_ne!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("2{a32}")), "{alg:?}"); + assert_eq!(effective_password(alg, &a31), effective_password(alg, &format!("{a31}(")), "{alg:?}: the padding starts with '('"); + assert_eq!(effective_password(alg, ""), PADDING, "{alg:?}"); + assert_eq!(effective_password(alg, "ılık€")[..5], [0x9A, b'l', 0x9A, b'k', 0xA0], "{alg:?}"); + } +} + +#[test] +fn passwords_with_the_same_effective_bytes_are_one_password() { + // A file protected with "\u{FF21}" (fullwidth A) opens with "A". + let d = new(Algorithm::Aes256, "\u{FF21}", "owner", 0b0100).dict().clone(); + assert_eq!(SecurityHandler::open(d.clone(), ID, Some("A")).unwrap().auth(), Auth::User); + assert_eq!(SecurityHandler::open(d, ID, Some("a")).unwrap_err(), CryptError::WrongPassword); + // A soft hyphen is no open password at all. + let d = new(Algorithm::Aes256, "\u{AD}", "owner", 0b0100).dict().clone(); + assert_eq!(SecurityHandler::open(d, ID, None).unwrap().auth(), Auth::User); + // Open and permissions passwords with the same bytes: the open password gives owner rights. + let d = new(Algorithm::Aes256, "\u{FB01}", "fi", 0b0100).dict().clone(); + assert_eq!(effective_password(Algorithm::Aes256, "\u{FB01}"), effective_password(Algorithm::Aes256, "fi")); + assert_eq!(SecurityHandler::open(d, ID, Some("\u{FB01}")).unwrap().auth(), Auth::Owner); + let a31 = "a".repeat(31); + let pairs = [(format!("{a31}a1"), format!("{a31}a2")), (a31.clone(), format!("{a31}("))]; + for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + for (open, perms) in &pairs { + assert_eq!(effective_password(alg, open), effective_password(alg, perms), "{alg:?}"); + let d = new(alg, open, perms, 0b0100).dict().clone(); + assert_eq!(SecurityHandler::open(d, ID, Some(open)).unwrap().auth(), Auth::Owner, "{alg:?} {open:?}"); + } + } +} + +#[test] +fn unsupported_handlers_are_reported() { + let mut d = new(Algorithm::Aes128, "", "o", -1).dict().clone(); + d.filter = b"Adobe.PubSec".to_vec(); + assert!(matches!(SecurityHandler::open(d.clone(), ID, None), Err(CryptError::Unsupported(_)))); + d.filter = b"Standard".to_vec(); + d.r = 7; + assert!(matches!(SecurityHandler::open(d, ID, None), Err(CryptError::Unsupported(_)))); +} + +#[test] +fn permission_bits_follow_table_22() { + let p = Permissions { bits: -3904, owner: false, revision: 3 }; // 0xFFFFF0C0: only reserved bits set + assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble()); + let p = Permissions { bits: -1, owner: false, revision: 3 }; + assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms()); + let p = Permissions { bits: 0, owner: true, revision: 3 }; + assert!(p.modify(), "owners ignore /P"); +} + +/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5). +#[test] +fn page_extraction_needs_assembly_and_copying() { + let bit = |n: u32| 1i32 << (n - 1); + let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision }; + // "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later. + assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages()); + // Pages may change in place, but copying is withheld. + assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages()); + assert!(user(bit(11) | bit(5), 6).extract_pages()); + // Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing. + assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld"); + assert!(user(bit(4) | bit(5), 2).extract_pages()); + assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2"); + assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P"); +} + +#[test] +fn perms_limits_what_an_edited_p_grants() { + let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only + let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions(); + let mut d = made.dict().clone(); + assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree"); + d.p = -4; // /P edited to allow everything, /Perms as written + let p = open(&d, "user pw"); + assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble()); + assert!(open(&d, "owner pw").modify(), "owners ignore /P"); + d.p = -3904; // a stricter /P still applies + assert!(!open(&d, "user pw").print()); + // No /Perms, or one that does not decrypt to an `adb` block: /P as before. + for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] { + let d = EncryptDict { p: -4, perms, ..made.dict().clone() }; + assert!(open(&d, "user pw").modify()); + } + // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open. + let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() }; + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword); +} + +// Not built for the web: proptest is a dev-dependency of the other targets only. +#[cfg(not(target_arch = "wasm32"))] +proptest::proptest! { + #[test] + fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60), + u in proptest::collection::vec(proptest::num::u8::ANY, 0..60), + r in 0i64..9, len in -10i64..300) { + let d = EncryptDict { filter: b"Standard".to_vec(), v: 4, r, length_bits: len, o, u, p: -1, encrypt_metadata: true, ..Default::default() }; + let _ = SecurityHandler::open(d, ID, Some("x")); + } +} + +/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484): +/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is +/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the +/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2` +/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or +/// full. Each object key is computed here by hand; data written with it must decrypt. +#[test] +fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() { + let text = b"Page tree in an object stream".to_vec(); + let cases = + [(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)]; + for (v, bits, method) in cases { + let mut d = EncryptDict { + filter: b"Standard".to_vec(), + v, + r: if v == 4 { 4 } else { 3 }, + length_bits: bits, + o: vec![7; 32], + p: -3392, + encrypt_metadata: v != 4, + ..Default::default() + }; + if v == 4 { + d.crypt_filters = vec![(b"StdCF".to_vec(), method)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + } + let key = file_key_r4(&d, ID, b""); + assert_eq!(key.len(), (bits / 8) as usize); + d.u = u_value_r4(&d, ID, &key); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let (num, generation) = (13u32, 0u16); + let extended = v == 4 && method == Method::Rc4 && key.len() < 16; + let mut md5 = Md5::new(); + md5.update(&key); + if extended { + md5.update(vec![0u8; 16 - key.len()]); + } + md5.update(&num.to_le_bytes()[..3]); + md5.update(generation.to_le_bytes()); + if method == Method::Aes128 { + md5.update(b"sAlT"); + } + let digest = md5.finalize(); + let n = if extended { 16 } else { key.len() }; + let object_key = digest[..(n + 5).min(16)].to_vec(); + let case = format!("V{v}, {bits}-bit key, {method:?}"); + let written = if method == Method::Rc4 { + rc4(&object_key, &text) + } else { + let iv = [3u8; 16]; + [iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat() + }; + assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}"); + assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}"); + let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal); + if method == Method::Rc4 { + assert_eq!(saved, written, "saved as it was read: {case}"); + } + assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}"); + } +} + +#[test] +fn owner_authentication_recovers_the_user_password() { + for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + let d = new(alg, "user pw", "owner pw", -1).dict().clone(); + let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap(); + let _: &Zeroizing> = &h.key; + let _: &Option>> = &h.recovered_user; + assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}"); + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None); + } + let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone(); + assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..])); +} + +/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys +/// `qpdf --show-encryption-key` reports. +fn qpdf_file(r: i64, o: &str, u: &str) -> EncryptDict { + let mut d = EncryptDict { + filter: b"Standard".to_vec(), + v: if r == 4 { 4 } else { 2 }, + r, + length_bits: 128, + o: hex(o), + u: hex(u), + p: -4, + encrypt_metadata: true, + ..Default::default() + }; + if r == 4 { + d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + } + d +} + +const QPDF_ID: &str = "56b57383aafa8314f3f98222995f5604"; + +#[test] +fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() { + let id = hex(QPDF_ID); + // R3 RC4: user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0); owner "Öğretmen", which + // PDFDocEncoding can't hold, so qpdf wrote its UTF-8 bytes. + let r3 = qpdf_file( + 3, + "d519aa8120ce8c54812b45e5391916fed167b1c84961d90c4ee4c182ad83534b", + "4985c03ddfa3eeb8a90c343512913ec40021446990b9e4114071a4d9104984c1", + ); + let user = SecurityHandler::open(r3.clone(), &id, Some("ılık€")).unwrap(); + assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f"))); + let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap(); + assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f"))); + assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0]))); + // Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it. + for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] { + assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}"); + } + // R4 AES-128: user "şifre" (UTF-8 bytes), owner "owner". + let r4 = qpdf_file( + 4, + "bb4f8f4bd2236569e765906caf64e4429a4c20d6e996fdef963e9b5080f9e083", + "4a965fb91882772cd09ce8bbff2c33270021446990b9e4114071a4d9104984c1", + ); + let user = SecurityHandler::open(r4.clone(), &id, Some("şifre")).unwrap(); + assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("9f19d465cfd8e525d32aa0bdf10acd06"))); + assert_eq!(SecurityHandler::open(r4.clone(), &id, Some("owner")).unwrap().auth(), Auth::Owner); + assert_eq!(SecurityHandler::open(r4, &id, Some("ifre")).unwrap_err(), CryptError::WrongPassword); +} + +#[test] +fn pdfdoc_encoding_round_trips() { + for b in 0..=255u8 { + if b != 0x9F { + assert_eq!(pdfdoc_byte(pdfdoc_char(b)), Some(b), "{b:#04x}"); + } + } + assert_eq!(pdfdoc_byte('ı'), Some(0x9A)); + assert_eq!(pdfdoc_byte('€'), Some(0xA0)); + assert_eq!(pdfdoc_byte('ç'), Some(0xE7)); + for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] { + assert_eq!(pdfdoc_byte(c), None, "{c}"); + } + 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())]); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9814c469c..965673fab 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -4,6 +4,8 @@ //! Advanced backend re-exports are coupled to the pinned backend, not a universal document model. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(feature = "crypt")] +pub mod crypt; #[cfg(feature = "interpret")] pub use hayro_interpret as interpret; mod link; diff --git a/crates/pdf/tests/crypt/main.rs b/crates/pdf/tests/crypt/main.rs new file mode 100644 index 000000000..702a0df7b --- /dev/null +++ b/crates/pdf/tests/crypt/main.rs @@ -0,0 +1,7 @@ +//! Tests of the `crypt` feature, linked into one test binary. Not built for the web: proptest +//! does not build there. +#![cfg(all(feature = "crypt", not(target_arch = "wasm32")))] + +mod permissions_revision; +mod proptests; +mod qpdf_literals; diff --git a/crates/pdf/tests/crypt/permissions_revision.rs b/crates/pdf/tests/crypt/permissions_revision.rs new file mode 100644 index 000000000..06a5a5b98 --- /dev/null +++ b/crates/pdf/tests/crypt/permissions_revision.rs @@ -0,0 +1,126 @@ +//! The permission accessors of `craft_pdf::crypt::Permissions`, checked against the flag table of +//! ISO 32000-1:2008 (Table 22). Expected values come from that table only. +//! +//! Bit n has the mask `1 << (n - 1)`. Readers use bits 3 to 6 and 9 to 12 and ignore the rest. + +use craft_pdf::crypt::{Algorithm, Auth, NewEncryption, Permissions, SecurityHandler, create}; + +const ID: &[u8] = b"0123456789abcdef"; +const T: bool = true; +const F: bool = false; + +/// The eight accessors in this order: print, high-quality print, modify, copy, accessibility, +/// annotate, fill forms, assemble. +fn cells(p: &Permissions) -> [bool; 8] { + [p.print(), p.print_high_quality(), p.modify(), p.copy(), p.extract_for_accessibility(), p.annotate(), p.fill_forms(), p.assemble()] +} + +/// Creates a file with `flags` at revision `revision`, opens it with the user password, and +/// returns the user's permissions. +fn user_view(alg: Algorithm, revision: i64, flags: i32) -> Permissions { + let made = create( + &NewEncryption { algorithm: alg, user_password: "user", owner_password: "owner", permissions: flags, encrypt_metadata: true, seed: [42; 32] }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.dict().r, revision, "the algorithm writes revision {revision}"); + let opened = SecurityHandler::open(made.dict().clone(), ID, Some("user")).expect("the user password opens the file"); + assert_eq!(opened.auth(), Auth::User, "the user password gives user access"); + opened.permissions() +} + +/// Revision 3 and later: each row is a flag word and the expected eight cells. +const REVISION_3_ROWS: &[(&str, i32, [bool; 8])] = &[ + ("every meaningful flag set, ignored flags clear", -196, [T, T, T, T, T, T, T, T]), + ("every flag set, ignored flags set too", -1, [T, T, T, T, T, T, T, T]), + ("reserved flags only", -4096, [F, F, F, F, F, F, F, F]), + ("ignored flags set, meaningful flags clear", -3901, [F, F, F, F, F, F, F, F]), + ("modify flag clear", -9, [T, T, F, T, T, T, T, T]), + ("copy flag clear", -17, [T, T, T, F, T, T, T, T]), + ("annotate flag clear", -33, [T, T, T, T, T, F, T, T]), + ("annotate and fill-existing flags clear", -289, [T, T, T, T, T, F, F, T]), + ("annotate flag set, fill-existing flag clear", -257, [T, T, T, T, T, T, T, T]), + ("accessibility flag clear", -513, [T, T, T, T, F, T, T, T]), + ("high-quality flag clear, print flag set", -2049, [T, F, T, T, T, T, T, T]), + ("bits 3 to 12 set, reserved bits clear", 4092, [T, T, T, T, T, T, T, T]), + ("positive flag word, bit 32 clear", 2_147_483_647, [T, T, T, T, T, T, T, T]), + ("revision 2 example read at revision 3", -44, [T, T, F, T, T, F, T, T]), +]; + +#[test] +fn revision_three_rows_hold_through_a_created_and_reopened_file() { + for (name, bits, want) in REVISION_3_ROWS { + assert_eq!(cells(&user_view(Algorithm::Rc4_128, 3, *bits)), *want, "{name}: flag word {bits:#010x}"); + } +} + +#[test] +fn owner_access_gives_every_flag() { + let made = create( + &NewEncryption { + algorithm: Algorithm::Rc4_128, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [42; 32], + }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.auth(), Auth::Owner, "the owner password gives owner access"); + assert_eq!(cells(&made.permissions()), [T; 8]); +} + +#[test] +fn revision_two_print_modify_copy_and_annotate_follow_the_table() { + for (bits, want) in [(-4, [T, T, T, T]), (-44, [T, F, T, F]), (-17, [T, T, F, T])] { + let p = user_view(Algorithm::Rc4_40, 2, bits); + assert_eq!([p.print(), p.modify(), p.copy(), p.annotate()], want, "revision 2, flag word {bits}"); + } +} + +#[test] +fn revision_two_with_every_flag_set_grants_accessibility_and_form_filling() { + let p = user_view(Algorithm::Rc4_40, 2, -4); + assert!(p.extract_for_accessibility(), "bit 5 is set, and at revision 2 it covers accessibility"); + assert!(p.fill_forms(), "bit 6 is set"); +} + +#[test] +fn revision_three_assembly_needs_its_own_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -1025); + assert!(p.modify(), "bit 4 is set"); + assert!(!p.assemble(), "assembly needs bit 11; bit 4 alone does not grant it"); +} + +#[test] +fn revision_three_high_quality_print_needs_the_print_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -5); + assert!(!p.print(), "bit 3 is clear"); + assert!(!p.print_high_quality(), "high-quality print needs bit 3 and bit 12"); +} + +#[test] +fn revision_two_accessibility_follows_the_copy_flag() { + let p = user_view(Algorithm::Rc4_40, 2, -17); + assert!(!p.copy(), "bit 5 is clear"); + assert!(!p.extract_for_accessibility(), "at revision 2 accessibility is bit 5"); +} + +#[test] +fn revision_two_form_filling_ignores_bit_nine() { + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.annotate(), "bit 6 is clear"); + assert!(!p.fill_forms(), "at revision 2 form filling is bit 6 alone"); +} + +#[test] +fn revision_two_ignores_the_flags_only_later_revisions_define() { + // Bit 4 is clear and bit 11 set: bit 11 means nothing at revision 2, so assembly stays refused. + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.assemble(), "at revision 2 assembly is bit 4 alone"); + // Bit 3 is set and bit 12 clear: printing at revision 2 has one quality. + let p = user_view(Algorithm::Rc4_40, 2, -2049); + assert!(p.print_high_quality(), "at revision 2 high-quality print is bit 3 alone"); +} diff --git a/crates/pdf/tests/crypt/proptests.rs b/crates/pdf/tests/crypt/proptests.rs new file mode 100644 index 000000000..a81808ab9 --- /dev/null +++ b/crates/pdf/tests/crypt/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for what the security handler does to *content*, and to the ciphertext a +//! stranger's file hands back. +//! +//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and +//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries. +//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a +//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`. +//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus +//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds. +//! +//! - plaintext survives encryption at every length, for each algorithm and each crypt filter; +//! - truncated or mutated ciphertext produces bytes rather than a panic (apps +//! open files from strangers, and encrypted streams in the wild do get truncated); +//! - object keys are scoped to their object, and AES-256's documented exception still holds; +//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over +//! generated Unicode passwords instead of the three examples in `src/crypt/tests.rs` (the pdfcraft #1185 rule); +//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and +//! crypt filters from a damaged file reach them with shapes `create` would never build. + +use craft_pdf::crypt::{ + Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create, + effective_password, pdfdoc_byte, rc4, +}; +use proptest::prelude::*; + +/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2). +const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21"; + +fn algorithms() -> impl Strategy { + prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]) +} + +/// Content at the lengths where CBC padding goes wrong, plus anything in between. +fn payloads() -> impl Strategy> { + prop_oneof![ + Just(Vec::new()), + Just(vec![0x5cu8; 1]), + Just(vec![0xa7u8; 15]), + Just(vec![0x3fu8; 16]), + Just(vec![0x9du8; 17]), + Just(vec![0x10u8; 32]), + prop::collection::vec(any::(), 1..96), + ] +} + +/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds +/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has +/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback. +const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}']; + +fn passwords() -> impl Strategy { + prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect()) +} + +/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a). +fn padded(bytes: &[u8]) -> Vec { + let mut out = PADDING; + let n = bytes.len().min(32); + out[..n].copy_from_slice(&bytes[..n]); + out.to_vec() +} + +/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and +/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such +/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the +/// first of these per algorithm, and it is what callers such as Protect check before encrypting. +fn candidates(algorithm: Algorithm, pw: &str) -> Vec> { + let mut out = vec![effective_password(algorithm, pw)]; + if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) { + out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::>())); + } + out +} + +/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner +/// password given, where it falls back to the user password's bytes. +fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec { + if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) } +} + +/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both +/// authenticate as owner — the order viewers use. `None` means it opens nothing. +fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option { + let tried = candidates(algorithm, pw); + if tried.iter().any(|c| c == owner_target) { + Some(Auth::Owner) + } else if tried.iter().any(|c| c == user_target) { + Some(Auth::User) + } else { + None + } +} + +fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler { + create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID) + .unwrap() +} + +proptest! { + /// Encrypting then decrypting returns the input at every length, as a string and as each kind + /// of stream — and is byte-for-byte reproducible, because re-running a save must not silently + /// produce different bytes. Payloads of at least one block must come back changed: RC4 would + /// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block. + #[test] + fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) { + let h = make(alg, "user", "owner"); + + // The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for + // AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and + // nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today + // produces a file key of the wrong size that every test still calls fine. + let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 }; + prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len); + + let h = make(alg, "user", "owner"); + for num in [0u32, 1, 4096, u32::MAX] { + let generation = (num % 65_535) as u16; + + let enc = h.encrypt_string(num, generation, &data); + prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg); + prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg); + + for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] { + let enc = h.encrypt_stream(num, generation, &data, kind); + prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind); + prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind); + } + + if data.len() >= 16 { + prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg); + } + } + } + + /// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or + /// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a + /// trailing partial block and leaves invalid padding in place rather than failing, so its + /// output can only be a whole number of blocks minus at most one; RC4 keeps its length + /// whatever the bytes are. An input shorter than the IV has nothing left to decode. + #[test] + fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::(), 0..64)) { + let h = make(alg, "user", "owner"); + for cut in 0..=enc.len() { + let broken = &enc[..cut]; + let out = h.decrypt_string(7, 3, broken); + if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 { + prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg); + } else { + let blocks = cut.saturating_sub(16) / 16; + prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len()); + prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg); + } + } + + // Flip every byte of a real ciphertext in turn: no position may crash the reader. + let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content"); + for i in 0..full.len() { + let mut mutated = full.clone(); + mutated[i] ^= 0xFF; + let _ = h.decrypt_string(7, 3, &mutated); + } + } + + /// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits + /// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF` + /// nor as `Identity` resolves to Identity rather than failing. The reported answer and the + /// bytes must agree, at every length including empty (where AES still adds a whole block). + /// `/EFF` is set here so embedded files and normal streams must take *different* paths: the + /// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says + /// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims + /// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since + /// encrypting what both sides agreed is plaintext would write a file other readers cannot open. + #[test] + fn crypt_filters_decide_what_gets_encrypted( + data in payloads(), + name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]), + bogus_identity_in_cf in any::(), + ) { + let mut d = make(Algorithm::Aes256, "", "owner").dict().clone(); + d.str_f = name.as_bytes().to_vec(); + d.stm_f = b"Identity".to_vec(); + d.ef_f = b"StdCF".to_vec(); + if bogus_identity_in_cf { + d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256)); + } + let h = SecurityHandler::open(d, ID, None).unwrap(); + + // Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name + // in neither place resolves to Identity rather than failing. + let strings_encrypted = name == "StdCF"; + prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted); + + let enc = h.encrypt_string(3, 0, &data); + if strings_encrypted { + prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name); + prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len()); + prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name); + } else { + prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name); + } + + // A normal stream follows `/StmF /Identity` here, whatever the string filter says. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity"); + // An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty + // included, where AES still emits a padded block — and comes back intact. + let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile); + prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len()); + prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip"); + // An unknown named filter is Identity, not an error; a known one uses its real method. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt"); + prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt"); + } + + /// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the + /// object number and generation into the key, so another object must not read these bytes back. + /// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object. + /// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes + /// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption* + /// differs would prove less, because our initial vector already covers `/Num`. + #[test] + fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::(), 8..40)) { + let h = make(alg, "user", "owner"); + + let enc = h.encrypt_string(100, 0, &data); + prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg); + prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg); + prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg); + + // Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned: + // numbers that differ inside those three bytes must separate (a fold to `[..2]` would let + // object 0 and object 65_536 share a key, silently handing one object's plaintext to another), + // and numbers that differ only ABOVE them must share it — the fold is three bytes by design, + // so a widening to four would break this and nothing else in the crate notices. + let low = h.encrypt_string(0, 0, &data); + prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg); + prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg); + prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg); + + let r6 = make(Algorithm::Aes256, "user", "owner"); + let enc6 = r6.encrypt_string(100, 0, &data); + prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)"); + // ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key + // for the whole file, so two objects holding the same bytes must still produce different + // ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse — + // the flaw that leaks structure in encrypted data. + prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects"); + prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations"); + } + + /// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes + /// equal one of theirs, and reports the role that match earned — owner first, since viewers try + /// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd + /// Unicode. This is the rule pdfcraft #1185 became (bytes decide, so a soft hyphen alone is no password + /// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`. + /// It also pins #1762's upgrade path: a user session gains owner rights on the owner password + /// and on nothing else. + #[test] + fn authentication_accepts_exactly_the_matching_passwords( + alg in algorithms(), + mut user in passwords(), + owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]), + extra in passwords(), + ) { + // `user` may have been grown past the 32-byte cut above; create with that spelling. + // Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a + // partner differing only beyond byte 32 is the same password to this file. + if alg != Algorithm::Aes256 { + user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2)); + } + + let h = make(alg, &user, &owner); + let d = h.dict().clone(); + let user_bytes = effective_password(alg, &user); + let owner_bytes = owner_target(alg, &user, &owner); + + // A collision engineered by construction, not by luck. Revisions 2-4 cut the password at + // 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password; + // revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the + // partner is a different string with identical bytes at every draw — earlier drafts here + // claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the + // padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws + // at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases. + + let mut suspects: Vec = vec![user.clone(), owner.clone(), String::new()]; + // Near misses are PREPENDED: appending to a legacy password already past the cut would be + // another collision rather than a refusal, which would silently thin the negative testing. + let mut near_misses: Vec = vec![format!("q{user}"), extra.clone()]; + if alg == Algorithm::Aes256 && !user.is_empty() { + near_misses.push(format!("{user}q")); + } + + let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") }; + prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string"); + prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user); + + // The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the + // same password, because it shifts the padding by one byte instead of filling a slot. + if alg != Algorithm::Aes256 { + prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision"); + prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision"); + } + + suspects.extend(near_misses); + suspects.push(collision_partner.clone()); + suspects.dedup(); + + for suspect in &suspects { + let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect); + match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) { + (Some(role), Ok(got)) => { + prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role); + prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg); + } + (Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e), + (None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e), + } + + // The owner password upgrades a user session; nothing else does. When the two share + // bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty + // (AES-256, pdfcraft #1185's sharp edge), so the open password *is* an owner password here. + if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) { + let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner); + let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth()); + if is_owner_password { + prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect); + } else { + prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect); + } + } + } + + // `None` means no password was offered: the handler tries the empty string, and nothing + // else (a file with a real open password never opens silently). + let want_none = expected_auth(alg, &user_bytes, &owner_bytes, ""); + match (want_none, SecurityHandler::open(d.clone(), ID, None)) { + (Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()), + (None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()), + (Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e), + } + } + + /// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters + /// hand them keys and data of shapes `create` would never build, so they must invert each other + /// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below + /// it, identically on both sides of the trip) and every payload length. + #[test] + fn primitives_are_invertible_for_any_key_and_length( + key in prop::collection::vec(any::(), 1..40), + data in payloads(), + iv in prop::collection::vec(any::(), 16..24), + ) { + let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change"); + + let enc = aes_cbc_encrypt(&key, &iv, &data, true); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len()); + prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len()); + + // Without padding, a partial block is zero-padded rather than refused, and extra trailing + // bytes decrypt to more output instead of being dropped. + let raw = aes_cbc_encrypt(&key, &iv, &data, false); + prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks"); + let mut longer = raw.clone(); + longer.extend_from_slice(b"0123456789abcdef"); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block"); + + // RC4 undoes itself at every length; an empty key encrypts nothing, which is why the + // handler never derives an empty object key. + prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len()); + prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through"); + } + + /// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it + /// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10). + /// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so + /// without this property a flipped metadata byte would tell every other viewer the wrong thing about + /// our own file while all our tests stayed green. The older revisions we create emit no `/Perms` + /// at all, which is recorded here as well so a change in what goes out shows up rather than + /// shipping quietly. + #[test] + fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::(), encrypt_metadata in any::()) { + let h = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] }, + ID, + ) + .unwrap(); + + prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block"); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false); + + // Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the + // encrypted certificate has to repeat it byte for byte. + let normalised = (permissions | !0xFFF | 0xC0) & !0x3; + prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P"); + prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn"); + prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set"); + prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata"); + prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present"); + + // A user session's answers come from those same bits, not from the owner shortcut. + let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing"); + prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying"); + prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights"); + + // Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that + // they move with the seed is the only way to notice a producer that freezes them at zero. + let other = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] }, + ID, + ) + .unwrap(); + let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false); + prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant"); + + // Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit. + for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + let legacy = make(older, "user", "owner"); + prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older); + } + } + /// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm + /// 2, step f), so the same password over the same `/ID` must produce a *different* key than when + /// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only, + /// which is exactly why that step is reachable but never walked: switching it off entirely changed + /// nothing any test could see. This also records the rule that revisions below 4 always encrypt + /// metadata whatever was asked, so the boundary between the two is named rather than assumed. + #[test] + fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) { + let build = |encrypt_metadata: bool| { + create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap() + }; + let (yes, no) = (build(true), build(false)); + + // Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays + // plain, so two files with identical passwords and the same /ID cannot share a key. + prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)"); + prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary"); + prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly"); + prop_assert!(yes.encrypts_metadata(), "and true must report true"); + + // Both still open on the same password and round trip at every length: a derivation that + // drifted would show up as an unreadable file rather than passing quietly. + for h in [&yes, &no] { + let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key"); + let enc = h.encrypt_string(7, 0, &data); + prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len()); + } + + // And the two files must not read each other's content: different keys, different plaintext. + if data.len() >= 8 { + prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it"); + } + + // Revisions below 4 always encrypt metadata, whatever the caller asked for. + let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap(); + prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents"); + } + +} diff --git a/crates/pdf/tests/crypt/qpdf_literals.rs b/crates/pdf/tests/crypt/qpdf_literals.rs new file mode 100644 index 000000000..e583eb815 --- /dev/null +++ b/crates/pdf/tests/crypt/qpdf_literals.rs @@ -0,0 +1,326 @@ +//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own, +//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms` +//! and `/ID` values were copied out of those files rather than computed here. The seventh row, +//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run +//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below), +//! so nothing outside computed that one either. +//! +//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but +//! self-consistent implementation round-trips cleanly against itself: across two review rounds we +//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f +//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes +//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what +//! was stored, so a derivation that diverges cannot authenticate at all. +//! +//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that +//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata +//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step +//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two +//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the +//! numbers are committed - never the PDFs, and no generator script that lives outside +//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with +//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,` +//! `allow=Permissions(extract=False), R=, aes=…, metadata=…)` plus `save(..., static_id=True)`, +//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf +//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`, +//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted +//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a +//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV. +//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than +//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4 +//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key +//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One +//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its +//! filter set leaves all three tests green, so those are held to review rather than to a test. The +//! rows come from PdfCraft, which uses qpdf as an oracle (its `crates/oracle/README.md`). + +use craft_pdf::crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt}; + +const USER: &str = "open pw"; +const OWNER: &str = "owner pw"; + +/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are +/// then exactly what qpdf emitted, with no runtime step in between to trust. +struct Case { + label: &'static str, + v: i64, + r: i64, + length_bits: i64, + p: i32, + encrypt_metadata: bool, + id0_hex: &'static str, + o_hex: &'static str, + u_hex: &'static str, + oe_hex: &'static str, + ue_hex: &'static str, + perms_hex: &'static str, + crypt_filters: fn() -> Vec<(Vec, Method)>, + stm_f: &'static str, + str_f: &'static str, + key_len_bytes: usize, +} + +impl Case { + fn dict(&self) -> EncryptDict { + EncryptDict { + filter: b"Standard".to_vec(), + v: self.v, + r: self.r, + length_bits: self.length_bits, + o: hex(self.o_hex), + u: hex(self.u_hex), + oe: hex(self.oe_hex), + ue: hex(self.ue_hex), + perms: hex(self.perms_hex), + p: self.p, + encrypt_metadata: self.encrypt_metadata, + crypt_filters: (self.crypt_filters)(), + stm_f: self.stm_f.as_bytes().to_vec(), + str_f: self.str_f.as_bytes().to_vec(), + ef_f: Vec::new(), + } + } + + fn id0(&self) -> Vec { + hex(self.id0_hex) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn no_filters() -> Vec<(Vec, Method)> { + Vec::new() +} + +fn aes128_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes128)] +} + +fn aes256_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes256)] +} + +/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`. +const CASES: &[Case] = &[ + Case { + label: "r2-40bit", + v: 1, + r: 2, + length_bits: 40, + p: -28, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125", + u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, + Case { + label: "r3-128rc4", + v: 2, + r: 3, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-enc", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-plain", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r6-aes256-meta-enc", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7", + u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151", + oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e", + ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56", + perms_hex: "5675a86560873d2532e856a37045247e", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + Case { + label: "r6-aes256-meta-plain", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e", + u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb", + oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103", + ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469", + perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + // HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3 + // (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's + // n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits - + // qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so + // the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft + // it by hand. With that combination live, the loop's truncation stops being a no-op: mutating + // `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating. + Case { + label: "crafted-r3-40bit-hand-derived", + v: 2, + r: 3, + length_bits: 40, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50", + u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, +]; + +#[test] +fn outside_derived_dictionaries_authenticate_with_the_intended_role() { + for case in CASES { + let d = case.dict(); + let id0 = case.id0(); + + // The user password must open as a user. Owners are tried first, so an `Owner` here would mean + // the two passwords had collapsed onto one set of bytes - on these spellings they have not. + let user = SecurityHandler::open(d.clone(), &id0, Some(USER)) + .unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label)); + assert_eq!(user.auth(), Auth::User, "{}", case.label); + + // The owner password must open as owner: revisions 2-4 recover the user password from `/O` + // (Algorithm 7), revision 6 hashes against qpdf's own `/O`. + let owner = + SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label)); + assert_eq!(owner.auth(), Auth::Owner, "{}", case.label); + + // A wrong password may never open it; and the key must be exactly as wide as the revision + // promises - those five bytes are what makes a revision 2 truncation detectable at all. + assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label); + assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label); + assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label); + + // A user session must not claim owner rights. We deliberately do NOT assert individual + // permission flags here: which bit means "extract" moves between revision 2 and revisions 4 + // (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of + // "extract for accessibility" is a reserved field), and pinning that mapping belongs with the + // permissions tests, not with this oracle. What matters here is only that authentication, role + // and key width agree with what an outside implementation produced. + assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label); + } +} + +#[test] +fn metadata_flag_from_outside_changes_the_file_key_it_certifies() { + // `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and + // changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation + // that ignored the flag would give identical keys. Internally this is no longer untested - + // `tests/it/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests + // still share our derivation, so what these rows add is a second implementation's own numbers. + for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] { + let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present"); + let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present"); + + let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}")); + let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}")); + + assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in"); + assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata"); + assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it"); + } + + // The outsider's numbers must differ across revisions; equality here would mean a transcription + // error, which would quietly void every assertion above. + let r2 = CASES.iter().find(|c| c.r == 2).unwrap(); + let r3 = CASES.iter().find(|c| c.r == 3).unwrap(); + assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs"); + assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes"); + assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)"); +} + +#[test] +fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() { + // `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it + // out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P` + // little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf + // checks the same fields as its Algorithm 13, though only three bytes of `/P`). + let mut checked = 0; + for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) { + let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label)); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false); + + assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label); + assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label); + assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label); + assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label); + assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label); + assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label); + checked += 1; + } + assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}"); +}