diff --git a/Cargo.lock b/Cargo.lock index cbfed934..ea339402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -458,6 +469,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -611,6 +631,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -714,6 +744,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -763,6 +802,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -775,6 +824,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1309,6 +1368,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1802,6 +1871,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2073,6 +2151,16 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3370,6 +3458,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3902,6 +4001,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4266,14 +4371,17 @@ dependencies = [ name = "vectorcraft-pdf" version = "0.2.0" dependencies = [ + "aes", "hayro-interpret", "hayro-syntax", "image", "krilla", "kurbo 0.13.1", "log", + "md-5", "serde", "serde_json", + "sha2", "smallvec", "thiserror 2.0.21", "vectorcraft-brush", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 3b335234..7c6e43cf 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -136,7 +136,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "pdf" | "ai" | "ait" => { // Saved with Preserve Editing: the document it carries, unless some pages are picked. - let editing = opts.pages.is_none().then(|| vectorcraft_pdf::editing(bytes)).flatten().map(|e| (e.intact, move || Some(e.data))); + let editing = opts + .pages + .is_none() + .then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref())) + .flatten() + .map(|e| (e.intact, move || Some(e.data))); restore_or_import(editing, || super::pdfimport::import(bytes, opts))? } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 1e188a1b..756e7436 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -710,6 +710,8 @@ mod tests_text; #[cfg(test)] mod tests_dxf; +#[cfg(test)] +mod tests_pdfsecurity; #[cfg(test)] mod tests_svgenc; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index f07c1723..49abd21c 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -48,7 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ }, FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" }, FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" }, - FormatOption { name: "security", ty: "object", default: "null", description: "passwords (not supported yet) and permissions" }, + FormatOption { + name: "security", + ty: "object", + default: "null", + description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)", + }, FormatOption { name: "includeNonPrinting", ty: "boolean", @@ -64,7 +69,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 3d94ea52..bcd4f3f2 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -70,7 +70,8 @@ fn bad_options_are_refused() { json!({"standard": "pdfA2b", "compatibility": "2.0"}), json!({"compression": {"color": {"ppi": 0}}}), json!({"compression": "zip"}), - json!({"security": {"openPassword": "x"}}), + json!({"standard": "pdfA2b", "security": {"openPassword": "x"}}), + json!({"security": {"openPassword": "x", "permissionsPassword": "x"}}), ] { let e = s.execute("document.exportPdf", &p).unwrap_err(); assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}"); diff --git a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs new file mode 100644 index 00000000..d8c36a91 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs @@ -0,0 +1,58 @@ +//! Opening password-protected PDFs: the open password or the permissions password unlocks them. + +use serde_json::{Value, json}; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; + +use super::*; + +#[test] +fn the_permissions_password_opens_a_protected_pdf_too() { + let mut s = Session::new(); + // The test file's permissions (owner) password is its open password followed by `-owner`. + let locked = vectorcraft_format::base64_encode(&pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], Some("pw"))); + let info = |s: &mut Session, pw: &str| s.execute("document.pdfInfo", &json!({"dataBase64": locked, "password": pw})).unwrap(); + assert_eq!(info(&mut s, "pw-owner")["pages"], 1); + assert_eq!(info(&mut s, "pw-owners")["wrongPassword"], true); + let open = |s: &mut Session, pw: &str| s.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": locked, "password": pw})); + let e = open(&mut s, "nope").unwrap_err().to_string(); + assert!(e.contains("password is wrong"), "{e}"); + open(&mut s, "pw-owner").unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 80.0); +} + +#[test] +fn export_with_passwords_writes_a_pdf_that_only_its_passwords_open() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + for compatibility in ["1.4", "1.5", "1.6", "1.7", "2.0"] { + let p = json!({"compatibility": compatibility, "security": {"openPassword": "open", "permissionsPassword": "owner", "printing": "none"}}); + let out = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + let data = out["dataBase64"].as_str().unwrap().to_string(); + let bytes = vectorcraft_format::base64_decode(&data).unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"), "{compatibility}: not encrypted"); + let info = |s: &mut Session, p: Value| s.execute("document.pdfInfo", &p).unwrap(); + assert_eq!(info(&mut s, json!({"dataBase64": data}))["needsPassword"], true, "{compatibility}"); + assert_eq!(info(&mut s, json!({"dataBase64": data, "password": "nope"}))["wrongPassword"], true, "{compatibility}"); + for pw in ["open", "owner"] { + assert_eq!(info(&mut s, json!({"dataBase64": data, "password": pw}))["pages"], 1, "{compatibility} {pw}"); + } + // The open password restores the editable document (Preserve Editing is on by default). + let mut r = Session::new(); + r.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": data, "password": "open"})).unwrap(); + assert!(r.doc().unwrap().doc.node_count() >= 2, "{compatibility}: the art came back"); + } +} + +#[test] +fn permissions_without_a_password_warn_and_a_password_applies_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + let warns = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("permissions need a password")); + let open = s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(); + assert!(warns(&open), "{open}"); + let locked = s.execute("document.exportPdf", &json!({"security": {"permissionsPassword": "owner", "printing": "none"}})).unwrap(); + assert!(!warns(&locked), "{locked}"); + let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt")); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b329180b..ed0a8e21 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -857,3 +857,30 @@ proptest! { survive("mutated EPS file", || vectorcraft_eps::native(&bytes).and_then(|n| vectorcraft_format::load(&n).ok()))?; } } + +proptest! { + #![proptest_config(config())] + + /// Damaged password-protected PDFs, opened with the open password, the permissions password + /// (which reads the encryption dictionary to find the open password) or a wrong one. + #[test] + fn mutated_encrypted_pdfs_never_panic( + cut in 0usize..2_000, + edits in prop::collection::vec((0usize..2_000, any::()), 0..12), + password in prop::sample::select(vec!["pw", "pw-owner", "wrong", ""]), + ) { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let bytes = pdf(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 20 20 50 50 re f")], Some("pw")); + let b64 = mutated_b64(bytes, cut, &edits); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let p = json!({"name": "x.pdf", "dataBase64": b64, "password": password}); + let _ = s.execute("document.pdfInfo", &p); + if s.execute("document.open", &p).is_ok() { + let d = (*s.doc().unwrap().doc).clone(); + survive("encrypted pdf", || Some(d)).unwrap(); + } + }); + r.map_err(|msg| TestCaseError::fail(format!("{password}: panicked: {msg}")))?; + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index cf018bce..ebf9c123 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,6 +24,10 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em log = { workspace = true } smallvec = "1" serde = { workspace = true } +# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm. +aes = "0.8" +md-5 = { version = "0.10", default-features = false } +sha2 = { version = "0.10", default-features = false } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 32973d8e..5175825d 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -63,7 +63,13 @@ pub(crate) fn seal(pdf: Vec) -> Result, PdfError> { /// The native document `bytes` (a PDF) carries, if any, and whether its pages changed since. pub fn editing(bytes: &[u8]) -> Option { - editing_in(&Pdf::new(bytes.to_vec()).ok()?) + editing_with(bytes, None) +} + +/// [`editing`] of a PDF that may be encrypted, opened with `password` (its open or permissions +/// password). +pub fn editing_with(bytes: &[u8], password: Option<&str>) -> Option { + editing_in(&crate::pages::open(bytes, password).ok()?) } /// [`editing`] of a parsed PDF. diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs new file mode 100644 index 00000000..de58326d --- /dev/null +++ b/crates/pdf/src/encrypt.rs @@ -0,0 +1,1002 @@ +//! PDF security (Save PDF › Security): a password to open the file, a permissions password and +//! the permissions it guards, written with the standard security handler. +//! +//! The export writes a plain file; [`protect`] then encrypts every string and stream in it (the +//! XMP metadata stays readable with Plaintext Metadata) and adds the encryption dictionary. The +//! algorithm follows the compatibility ([`Encryption`]): 128-bit RC4 for PDF 1.4 (revision 3) and +//! 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and 256-bit AES for 1.7 and 2.0 +//! (revision 6). The random file key, salts and initialisation vectors come from a SHA-256 stream +//! seeded with the process's random hash keys, the time and the file itself. +//! +//! Reading: [`user_password`] turns the permissions (owner) password of a file encrypted with RC4 +//! or 128-bit AES into its open (user) password, so either password opens it. + +use aes::cipher::{BlockEncrypt, KeyInit}; +use md5::Md5; +use sha2::{Digest, Sha256, Sha384, Sha512}; + +use crate::lab_spot::{find, rfind, xref_offset}; +use crate::settings::choice; +use crate::{Changes, Compatibility, PdfError, PdfSettings, Printing, SecuritySettings, Standard}; + +choice! { + /// The encryption algorithm, which follows the PDF version (Compatibility). + Encryption { + Rc4 = "rc4", "128-bit RC4"; + Aes128 = "aes128", "128-bit AES"; + Aes256 = "aes256", "256-bit AES"; + } default Aes256 +} + +impl Encryption { + /// The algorithm a file of PDF version `c` is encrypted with. + pub fn for_compatibility(c: Compatibility) -> Self { + match c { + Compatibility::Pdf14 | Compatibility::Pdf15 => Self::Rc4, + Compatibility::Pdf16 => Self::Aes128, + Compatibility::Pdf17 | Compatibility::Pdf20 => Self::Aes256, + } + } +} + +/// The standard security handler revision written for PDF version `c`: 3 (RC4, metadata always +/// encrypted), 4 (crypt filters: RC4 or AES-128) or 6 (AES-256). +fn revision(c: Compatibility) -> u8 { + match c { + Compatibility::Pdf14 => 3, + Compatibility::Pdf15 | Compatibility::Pdf16 => 4, + Compatibility::Pdf17 | Compatibility::Pdf20 => 6, + } +} + +/// The longest password (in bytes) revisions 2–4 use: longer ones are cut to this. +const MAX_LEGACY_PASSWORD: usize = 32; +/// The longest password (UTF-8 bytes) revision 6 uses. +const MAX_PASSWORD: usize = 127; + +impl SecuritySettings { + /// A password is set: the file is encrypted. + pub fn protected(&self) -> bool { + !self.open_password.is_empty() || !self.permissions_password.is_empty() + } + + /// Some permission is withheld (only a permissions password applies them). + pub fn restricts(&self) -> bool { + self.printing != Printing::High || self.changes != Changes::Any || !self.copy || !self.screen_reader + } + + /// The encryption dictionary's `P` entry: the permission bits (1-based bit numbers of the PDF + /// reference's user access permissions). Everything is allowed without a permissions password. + pub fn permission_bits(&self) -> i32 { + if self.permissions_password.is_empty() { + return !3; + } + let bit = |n: u32| 1u32 << (n - 1); + // Bits 1–2 are clear; the reserved bits 7–8 and 13–32 are set. + let mut p = 0xFFFF_F0C0u32; + p |= match self.printing { + Printing::None => 0, + Printing::Low => bit(3), + Printing::High => bit(3) | bit(12), + }; + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::Forms => bit(9), + Changes::Comments => bit(6) | bit(9), + Changes::Any => bit(4) | bit(6) | bit(9) | bit(11), + }; + if self.copy { + p |= bit(5); + } + // Content copied may always be read out by screen readers. + if self.copy || self.screen_reader { + p |= bit(10); + } + p as i32 + } + + /// Refuse passwords the file can't carry: with a PDF standard (PDF/A and PDF/X forbid + /// encryption), two equal passwords, or passwords the algorithm of `c` can't take. + pub(crate) fn check(&self, standard: Standard, c: Compatibility) -> Result<(), PdfError> { + if !self.protected() { + return Ok(()); + } + if standard != Standard::None { + return Err(PdfError::BadSetting(format!( + "{} files can't be password-protected: remove the passwords or choose no standard", + standard.label() + ))); + } + if self.open_password == self.permissions_password { + return Err(PdfError::BadSetting("the open and permissions passwords must differ".into())); + } + for pw in [&self.open_password, &self.permissions_password] { + if revision(c) < 6 { + if !pw.is_ascii() || pw.len() > MAX_LEGACY_PASSWORD { + return Err(PdfError::BadSetting(format!( + "{} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any)", + c.label() + ))); + } + } else if pw.len() > MAX_PASSWORD { + return Err(PdfError::BadSetting(format!("passwords can have up to {MAX_PASSWORD} bytes"))); + } + } + Ok(()) + } +} + +impl PdfSettings { + /// The algorithm the file is encrypted with; `None` without a password. + pub fn encryption(&self) -> Option { + self.security.protected().then(|| Encryption::for_compatibility(self.compatibility)) + } +} + +/// The security options that are accepted but have no effect, one warning each. +pub(crate) fn warnings(set: &PdfSettings) -> Vec { + let s = &set.security; + let mut out = vec![]; + if s.protected() && s.plaintext_metadata && revision(set.compatibility) == 3 { + out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); + } + out +} + +// ---------- cryptography ---------- + +/// Padding of passwords (revisions 2–4). +const PAD: [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, +]; + +fn md5(parts: &[&[u8]]) -> [u8; 16] { + let mut h = Md5::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +/// `pw` cut or padded to 32 bytes. +fn padded(pw: &[u8]) -> [u8; 32] { + let mut out = PAD; + let n = pw.len().min(32); + out[..n].copy_from_slice(&pw[..n]); + out[n..].copy_from_slice(&PAD[..32 - n]); + out +} + +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 = 0u8; + for (i, k) in (0..256).zip(key.iter().cycle()) { + j = j.wrapping_add(s[i]).wrapping_add(*k); + s.swap(i, usize::from(j)); + } + let (mut i, mut j) = (0u8, 0u8); + data.iter() + .map(|b| { + i = i.wrapping_add(1); + j = j.wrapping_add(s[usize::from(i)]); + s.swap(usize::from(i), usize::from(j)); + b ^ s[usize::from(s[usize::from(i)].wrapping_add(s[usize::from(j)]))] + }) + .collect() +} + +/// `key` with every byte XORed with `i` (the 19 extra RC4 passes of revisions 3–4). +fn xored(key: &[u8], i: u8) -> Vec { + key.iter().map(|b| b ^ i).collect() +} + +/// An AES block cipher with a 128- or 256-bit key. +enum Aes { + A128(Box), + A256(Box), +} + +impl Aes { + fn new(key: &[u8]) -> Result { + let bad = |_| PdfError::Write("bad AES key".into()); + match key.len() { + 16 => aes::Aes128::new_from_slice(key).map(|c| Self::A128(Box::new(c))).map_err(bad), + _ => aes::Aes256::new_from_slice(key).map(|c| Self::A256(Box::new(c))).map_err(bad), + } + } + + fn block(&self, b: [u8; 16]) -> [u8; 16] { + let mut block = aes::Block::from(b); + match self { + Self::A128(c) => c.encrypt_block(&mut block), + Self::A256(c) => c.encrypt_block(&mut block), + } + block.into() + } + + /// CBC from `iv`; `pad`: PKCS #7 padding (else a trailing partial block is dropped). + fn cbc(&self, iv: [u8; 16], data: &[u8], pad: bool) -> Vec { + let mut out = Vec::with_capacity(data.len() + 16); + let mut prev = iv; + let mut put = |chunk: [u8; 16], out: &mut Vec| { + prev = self.block(std::array::from_fn(|i| chunk[i] ^ prev[i])); + out.extend_from_slice(&prev); + }; + let mut chunks = data.chunks_exact(16); + for c in &mut chunks { + put(std::array::from_fn(|i| c.get(i).copied().unwrap_or_default()), &mut out); + } + if pad { + let rest = chunks.remainder(); + let n = (16 - rest.len()) as u8; + put(std::array::from_fn(|i| rest.get(i).copied().unwrap_or(n)), &mut out); + } + out + } +} + +/// The first `N` bytes of `b` (zero-filled when shorter). +fn first(b: &[u8]) -> [u8; N] { + std::array::from_fn(|i| b.get(i).copied().unwrap_or_default()) +} + +/// The revision 6 password hash of `password` with `salt` (and the 48-byte U entry when hashing +/// the owner password). +fn hash6(password: &[u8], salt: &[u8], udata: &[u8]) -> Result<[u8; 32], PdfError> { + let mut k: Vec = Sha256::new().chain_update(password).chain_update(salt).chain_update(udata).finalize().to_vec(); + let mut round = 0u32; + loop { + let mut k1 = Vec::with_capacity(64 * (password.len() + k.len() + udata.len())); + for _ in 0..64 { + k1.extend_from_slice(password); + k1.extend_from_slice(&k); + k1.extend_from_slice(udata); + } + let key: [u8; 16] = first(&k); + let e = Aes::new(&key)?.cbc(first(k.get(16..).unwrap_or_default()), &k1, false); + // The first 16 bytes of E as a big-endian number, modulo 3 (256 ≡ 1 mod 3). + k = match e.iter().take(16).map(|b| u32::from(*b)).sum::() % 3 { + 0 => Sha256::digest(&e).to_vec(), + 1 => Sha384::digest(&e).to_vec(), + _ => Sha512::digest(&e).to_vec(), + }; + round += 1; + if round >= 64 && u32::from(e.last().copied().unwrap_or_default()) <= round - 32 { + return Ok(first(&k)); + } + } +} + +/// Unpredictable bytes: SHA-256 of a seed and a counter. +struct Entropy { + seed: [u8; 32], + counter: u64, +} + +impl Entropy { + /// Seeded with the process's random hash keys (none on the web), the time and `material`. + fn new(material: &[&[u8]]) -> Self { + use std::hash::BuildHasher; + let state = std::hash::RandomState::new(); + let mut h = Sha256::new(); + for i in 0u8..4 { + h.update(state.hash_one(i).to_le_bytes()); + } + h.update(vectorcraft_doc::metadata::now_unix().unwrap_or_default().to_le_bytes()); + for m in material { + h.update((m.len() as u64).to_le_bytes()); + h.update(m); + } + Self { seed: h.finalize().into(), counter: 0 } + } + + fn bytes(&mut self) -> [u8; N] { + let mut out = [0u8; N]; + for chunk in out.chunks_mut(32) { + self.counter += 1; + let block: [u8; 32] = Sha256::new().chain_update(self.seed).chain_update(self.counter.to_le_bytes()).finalize().into(); + chunk.copy_from_slice(&block[..chunk.len()]); + } + out + } +} + +fn hex(b: &[u8]) -> String { + let mut s = String::with_capacity(2 * b.len() + 2); + s.push('<'); + for x in b { + s.push_str(&format!("{x:02X}")); + } + s.push('>'); + s +} + +/// What encrypts the strings and streams of one file. +struct Handler { + /// The file encryption key. + key: Vec, + cipher: Encryption, + encrypt_metadata: bool, + /// The encryption dictionary. + dict: String, +} + +impl Handler { + /// The handler for `sec` at version `c`, for a file whose first ID is `id`. + fn new(sec: &SecuritySettings, c: Compatibility, id: &[u8], rng: &mut Entropy) -> Result { + let r = revision(c); + let cipher = Encryption::for_compatibility(c); + let user = sec.open_password.as_bytes(); + // Without a permissions password the open password grants every permission anyway. + let owner = if sec.permissions_password.is_empty() { user } else { sec.permissions_password.as_bytes() }; + let p = sec.permission_bits(); + let encrypt_metadata = r == 3 || !sec.plaintext_metadata; + let meta = if encrypt_metadata { "" } else { "/EncryptMetadata false" }; + if r == 6 { + let key: [u8; 32] = rng.bytes(); + let (uv, uk): ([u8; 8], [u8; 8]) = (rng.bytes(), rng.bytes()); + let u = [&hash6(user, &uv, &[])?[..], &uv[..], &uk[..]].concat(); + let ue = Aes::new(&hash6(user, &uk, &[])?)?.cbc([0; 16], &key, false); + let (ov, ok): ([u8; 8], [u8; 8]) = (rng.bytes(), rng.bytes()); + let o = [&hash6(owner, &ov, &u)?[..], &ov[..], &ok[..]].concat(); + let oe = Aes::new(&hash6(owner, &ok, &u)?)?.cbc([0; 16], &key, false); + let mut perms = [0xFFu8; 16]; + perms[..4].copy_from_slice(&p.to_le_bytes()); + perms[8] = if encrypt_metadata { b'T' } else { b'F' }; + perms[9..12].copy_from_slice(b"adb"); + perms[12..].copy_from_slice(&rng.bytes::<4>()); + let perms = Aes::new(&key)?.block(perms); + let dict = format!( + "<>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}/OE{}/UE{}/Perms{}{meta}>>", + hex(&o), + hex(&u), + hex(&oe), + hex(&ue), + hex(&perms) + ); + return Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict }); + } + // O (algorithm 3), the file key (algorithm 2) and U (algorithm 5), for a 16-byte key. + let owner_key = owner_key(owner, r, 16); + let mut o = rc4(&owner_key, &padded(user)); + for i in 1..=19 { + o = rc4(&xored(&owner_key, i), &o); + } + let no_meta: &[u8] = if encrypt_metadata { &[] } else { &[0xFF; 4] }; + let mut key = md5(&[&padded(user), &o, &p.to_le_bytes(), id, no_meta]); + for _ in 0..50 { + key = md5(&[&key]); + } + let mut u = rc4(&key, &md5(&[&PAD, id])); + for i in 1..=19 { + u = rc4(&xored(&key, i), &u); + } + u.extend_from_slice(&rng.bytes::<16>()); + let dict = if r == 3 { + format!("<>", hex(&o), hex(&u)) + } else { + let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" }; + format!( + "<>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>", + hex(&o), + hex(&u) + ) + }; + Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict }) + } + + /// `data` of object `num generation` encrypted. + fn encrypt(&self, num: u32, generation: u16, data: &[u8], rng: &mut Entropy) -> Result, PdfError> { + let object_key = || { + let salt: &[u8] = if self.cipher == Encryption::Aes128 { b"sAlT" } else { &[] }; + md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt]) + }; + let aes = |key: &[u8], rng: &mut Entropy| -> Result, PdfError> { + let iv: [u8; 16] = rng.bytes(); + Ok([&iv[..], &Aes::new(key)?.cbc(iv, data, true)].concat()) + }; + match self.cipher { + Encryption::Rc4 => Ok(rc4(&object_key(), data)), + Encryption::Aes128 => aes(&object_key(), rng), + Encryption::Aes256 => aes(&self.key, rng), + } + } +} + +/// The RC4 key the owner password encrypts O with (algorithm 3, steps a–d). +fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec { + let mut h = md5(&[&padded(owner)]); + if r >= 3 { + for _ in 0..50 { + h = md5(&[h.get(..n).unwrap_or(&h)]); + } + } + h.get(..n).unwrap_or(&h).to_vec() +} + +// ---------- PDF syntax ---------- + +fn is_white(b: u8) -> bool { + matches!(b, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_regular(b: u8) -> bool { + !is_white(b) && !b"()<>[]{}/%".contains(&b) +} + +#[derive(Debug)] +enum Tok<'a> { + DictOpen, + DictClose, + ArrayOpen, + ArrayClose, + Name(&'a [u8]), + Str(Vec), + /// Numbers and keywords (`true`, `R`, `obj`, `stream`…). + Word(&'a [u8]), +} + +/// A token with its byte span. +type Spanned<'a> = (Tok<'a>, usize, usize); + +/// Deepest nesting of arrays and dictionaries read. +const MAX_DEPTH: usize = 64; + +/// A parsed object, with what encryption needs: the strings and dictionaries with their spans. +#[derive(Debug)] +enum Obj<'a> { + /// `start` is where `<<` is, `end` just after `>>`. + Dict { + entries: Vec<(&'a [u8], Obj<'a>)>, + start: usize, + end: usize, + }, + Array(Vec>), + Str { + bytes: Vec, + start: usize, + end: usize, + }, + Name(&'a [u8]), + Int { + value: i64, + start: usize, + end: usize, + }, + Ref(u32, u16), + /// Reals, booleans, null. + Other, +} + +impl<'a> Obj<'a> { + fn get(&self, key: &[u8]) -> Option<&Obj<'a>> { + match self { + Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v), + _ => None, + } + } + + fn name(&self, key: &[u8]) -> Option<&'a [u8]> { + match self.get(key)? { + Self::Name(n) => Some(n), + _ => None, + } + } + + fn int(&self, key: &[u8]) -> Option { + match self.get(key)? { + Self::Int { value, .. } => Some(*value), + _ => None, + } + } + + fn string(&self, key: &[u8]) -> Option<&[u8]> { + match self.get(key)? { + Self::Str { bytes, .. } => Some(bytes), + _ => None, + } + } + + /// The first string of the array at `key` (a file's ID). + fn first_string(&self, key: &[u8]) -> Option<&[u8]> { + match self.get(key)? { + Self::Array(items) => items.iter().find_map(|i| match i { + Self::Str { bytes, .. } => Some(bytes.as_slice()), + _ => None, + }), + _ => None, + } + } + + /// Every string in this object, in file order. + fn strings<'s>(&'s self, out: &mut Vec<&'s Obj<'a>>) { + match self { + Self::Str { .. } => out.push(self), + Self::Dict { entries, .. } => entries.iter().for_each(|(_, v)| v.strings(out)), + Self::Array(items) => items.iter().for_each(|v| v.strings(out)), + _ => {} + } + } +} + +fn int(w: &[u8]) -> Option { + std::str::from_utf8(w).ok()?.parse().ok() +} + +#[derive(Clone)] +struct Lexer<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn at(buf: &'a [u8], pos: usize) -> Self { + Self { buf, pos } + } + + fn peek_byte(&self, ahead: usize) -> Option { + self.buf.get(self.pos + ahead).copied() + } + + fn skip_space(&mut self) { + while let Some(b) = self.peek_byte(0) { + if is_white(b) { + self.pos += 1; + } else if b == b'%' { + while self.peek_byte(0).is_some_and(|b| b != b'\n' && b != b'\r') { + self.pos += 1; + } + } else { + break; + } + } + } + + fn next(&mut self) -> Option> { + self.skip_space(); + let start = self.pos; + let b = self.peek_byte(0)?; + let tok = match (b, self.peek_byte(1)) { + (b'<', Some(b'<')) => { + self.pos += 2; + Tok::DictOpen + } + (b'>', Some(b'>')) => { + self.pos += 2; + Tok::DictClose + } + (b'<', _) => Tok::Str(self.hex()?), + (b'(', _) => Tok::Str(self.literal()?), + (b'[', _) => { + self.pos += 1; + Tok::ArrayOpen + } + (b']', _) => { + self.pos += 1; + Tok::ArrayClose + } + (b'/', _) => { + self.pos += 1; + self.word(); + Tok::Name(self.buf.get(start + 1..self.pos)?) + } + _ if !is_regular(b) => { + // A stray delimiter. + self.pos += 1; + Tok::Word(self.buf.get(start..self.pos)?) + } + _ => { + self.word(); + Tok::Word(self.buf.get(start..self.pos)?) + } + }; + Some((tok, start, self.pos)) + } + + fn word(&mut self) { + while self.peek_byte(0).is_some_and(is_regular) { + self.pos += 1; + } + } + + fn hex(&mut self) -> Option> { + self.pos += 1; + let (mut out, mut high) = (vec![], None); + loop { + let b = self.peek_byte(0)?; + self.pos += 1; + if b == b'>' { + out.extend(high.map(|h: u8| h << 4)); + return Some(out); + } + if is_white(b) { + continue; + } + let v = char::from(b).to_digit(16)? as u8; + match high.take() { + Some(h) => out.push(h << 4 | v), + None => high = Some(v), + } + } + } + + fn literal(&mut self) -> Option> { + self.pos += 1; + let (mut out, mut depth) = (vec![], 1u32); + loop { + let b = self.peek_byte(0)?; + self.pos += 1; + match b { + b'\\' => { + let e = self.peek_byte(0)?; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + // A backslash before an end of line continues the string. + b'\r' => { + if self.peek_byte(0) == Some(b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = u32::from(e - b'0'); + for _ in 0..2 { + match self.peek_byte(0) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + u32::from(d - b'0'); + self.pos += 1; + } + _ => break, + } + } + out.push((v & 0xFF) as u8); + } + // `\(`, `\)`, `\\`; other escapes stand for the character. + _ => out.push(e), + } + } + b'(' => { + depth += 1; + out.push(b); + } + b')' => { + depth -= 1; + if depth == 0 { + return Some(out); + } + out.push(b); + } + // Ends of lines read as `\n`. + b'\r' => { + if self.peek_byte(0) == Some(b'\n') { + self.pos += 1; + } + out.push(b'\n'); + } + _ => out.push(b), + } + } + } + + /// One object (a reference `n g R` reads as one). + fn object(&mut self, depth: usize) -> Option> { + if depth > MAX_DEPTH { + return None; + } + let (tok, start, end) = self.next()?; + Some(match tok { + Tok::DictOpen => { + let mut entries = vec![]; + loop { + let mut look = self.clone(); + match look.next()? { + (Tok::DictClose, _, end) => { + *self = look; + return Some(Obj::Dict { entries, start, end }); + } + (Tok::Name(k), ..) => { + *self = look; + entries.push((k, self.object(depth + 1)?)); + } + _ => return None, + } + } + } + Tok::ArrayOpen => { + let mut items = vec![]; + loop { + let mut look = self.clone(); + if matches!(look.next()?, (Tok::ArrayClose, ..)) { + *self = look; + return Some(Obj::Array(items)); + } + items.push(self.object(depth + 1)?); + } + } + Tok::Str(bytes) => Obj::Str { bytes, start, end }, + Tok::Name(n) => Obj::Name(n), + Tok::Word(w) => match int(w) { + Some(value) => { + let mut look = self.clone(); + match (look.next(), look.next()) { + (Some((Tok::Word(g), ..)), Some((Tok::Word(b"R"), ..))) => { + match (u32::try_from(value), int(g).and_then(|g| u16::try_from(g).ok())) { + (Ok(n), Some(g)) => { + *self = look; + Obj::Ref(n, g) + } + _ => Obj::Int { value, start, end }, + } + } + _ => Obj::Int { value, start, end }, + } + } + None => Obj::Other, + }, + Tok::DictClose | Tok::ArrayClose => return None, + }) + } + + /// The header `num generation obj` of an indirect object. + fn header(&mut self) -> Option<(u32, u16)> { + let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else { + return None; + }; + Some((u32::try_from(int(n)?).ok()?, u16::try_from(int(g)?).ok()?)) + } +} + +// ---------- encrypting a written file ---------- + +fn failed(why: &str) -> PdfError { + PdfError::Write(format!("can't encrypt the PDF: {why}")) +} + +/// A replacement of bytes `start..end` of the file. +type Edit = (usize, usize, Vec); + +/// The catalog entry declaring 256-bit AES in a PDF 1.7 file (extension level 8). +const AES256_EXTENSION: &[u8] = b"/Extensions<>>>"; + +/// An in-use cross-reference entry: object number, generation, offset. +type Entry = (u32, u16, usize); + +/// The in-use entries of the cross-reference table at `xref` and the trailer dictionary after it. +fn xref_table(pdf: &[u8], xref: usize) -> Result<(Vec, Obj<'_>), PdfError> { + let mut lx = Lexer::at(pdf, xref + 4); + let mut entries = vec![]; + loop { + let word = |t: Option>| match t { + Some((Tok::Word(w), ..)) => int(w), + _ => None, + }; + let mut look = lx.clone(); + if let Some((Tok::Word(b"trailer"), ..)) = look.next() { + lx = look; + break; + } + let (first, count) = (word(lx.next()), word(lx.next())); + let (Some(first), Some(count)) = (first, count) else { return Err(failed("bad cross-reference table")) }; + for i in 0..count { + let (off, generation, kind) = (word(lx.next()), word(lx.next()), lx.next()); + let (Some(off), Some(generation), Some((Tok::Word(kind), ..))) = (off, generation, kind) else { + return Err(failed("bad cross-reference entry")); + }; + if kind == b"n" { + let num = u32::try_from(first + i).map_err(|_| failed("bad object number"))?; + entries.push((num, u16::try_from(generation).unwrap_or(0), usize::try_from(off).map_err(|_| failed("bad offset"))?)); + } + } + } + let trailer = lx.object(0).ok_or_else(|| failed("bad trailer"))?; + Ok((entries, trailer)) +} + +/// The edits encrypting the object at `off` (its strings and stream data); `catalog`: add the +/// 256-bit AES extension to it. +fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut Entropy, edits: &mut Vec) -> Result<(), PdfError> { + let mut lx = Lexer::at(pdf, off); + let (num, generation) = lx.header().ok_or_else(|| failed("an object's header is missing"))?; + let obj = lx.object(0).ok_or_else(|| failed("unreadable object"))?; + let mut strings = vec![]; + obj.strings(&mut strings); + for s in strings { + if let Obj::Str { bytes, start, end } = s { + edits.push((*start, *end, hex(&h.encrypt(num, generation, bytes, rng)?).into_bytes())); + } + } + if let (true, Obj::Dict { start, .. }) = (catalog, &obj) + && obj.get(b"Extensions").is_none() + { + edits.push((start + 2, start + 2, AES256_EXTENSION.to_vec())); + } + let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(()) }; + let ty = obj.name(b"Type"); + if matches!(ty, Some(b"XRef" | b"ObjStm")) { + return Err(failed("cross-reference and object streams aren't supported")); + } + let Some(Obj::Int { value, start: len_start, end: len_end }) = obj.get(b"Length") else { + return Err(failed("a stream's length isn't a number")); + }; + // The data starts after the end of line that follows `stream`. + let mut data = keyword_end; + if pdf.get(data) == Some(&b'\r') { + data += 1; + } + if pdf.get(data) == Some(&b'\n') { + data += 1; + } + let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or_else(|| failed("bad stream length"))?; + let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?; + if !matches!(Lexer::at(pdf, data_end).next(), Some((Tok::Word(b"endstream"), ..))) { + return Err(failed("a stream's length is wrong")); + } + if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata { + return Ok(()); + } + let enc = h.encrypt(num, generation, bytes, rng)?; + edits.push((*len_start, *len_end, enc.len().to_string().into_bytes())); + edits.push((data, data_end, enc)); + Ok(()) +} + +/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security +/// section says; without a password it comes back as it is. +pub(crate) fn protect(pdf: Vec, set: &PdfSettings) -> Result, PdfError> { + let sec = &set.security; + if !sec.protected() { + return Ok(pdf); + } + let xref = xref_offset(&pdf).ok_or_else(|| failed("no cross-reference table"))?; + let (mut objects, trailer) = xref_table(&pdf, xref)?; + let Obj::Dict { start: t_start, end: t_end, .. } = &trailer else { return Err(failed("bad trailer")) }; + if trailer.get(b"Prev").is_some() || trailer.get(b"Encrypt").is_some() { + return Err(failed("the file was updated or encrypted already")); + } + let Some(Obj::Int { value: size, start: size_start, end: size_end }) = trailer.get(b"Size") else { + return Err(failed("the trailer has no size")); + }; + let size = u32::try_from(*size).ok().filter(|s| *s as usize <= 2 * objects.len() + 16).ok_or_else(|| failed("bad trailer size"))?; + let root = match trailer.get(b"Root") { + Some(Obj::Ref(n, _)) => Some(*n), + _ => None, + }; + let mut rng = Entropy::new(&[sec.open_password.as_bytes(), sec.permissions_password.as_bytes(), &pdf]); + let (id, new_id) = match trailer.first_string(b"ID") { + Some(id) => (id.to_vec(), None), + None => { + let id: [u8; 16] = rng.bytes(); + (id.to_vec(), Some(id)) + } + }; + let h = Handler::new(sec, set.compatibility, &id, &mut rng)?; + let extension = h.cipher == Encryption::Aes256 && set.compatibility == Compatibility::Pdf17; + + objects.sort_by_key(|o| o.2); + let mut edits = vec![]; + for (num, _, off) in &objects { + object_edits(&pdf, *off, &h, extension && root == Some(*num), &mut rng, &mut edits)?; + } + edits.sort_by_key(|e| (e.0, e.1)); + + // The objects with their edits, recording where each one now starts. + let mut out = Vec::with_capacity(pdf.len() + pdf.len() / 8 + 1024); + let mut offsets = vec![None; size as usize + 1]; + let (mut at, mut pending) = (0, edits.iter().peekable()); + let mut copy_to = |to: usize, out: &mut Vec, at: &mut usize| -> Result<(), PdfError> { + while let Some((s, e, new)) = pending.next_if(|e| e.0 < to) { + out.extend_from_slice(pdf.get(*at..*s).ok_or_else(|| failed("overlapping edits"))?); + out.extend_from_slice(new); + *at = *e; + } + out.extend_from_slice(pdf.get(*at..to).ok_or_else(|| failed("overlapping edits"))?); + *at = to; + Ok(()) + }; + for (num, generation, off) in &objects { + copy_to(*off, &mut out, &mut at)?; + let slot = offsets.get_mut(*num as usize).filter(|_| *num < size).ok_or_else(|| failed("bad object number"))?; + *slot = Some((out.len(), *generation)); + } + copy_to(xref, &mut out, &mut at)?; + if let Some(slot) = offsets.get_mut(size as usize) { + *slot = Some((out.len(), 0)); + } + out.extend_from_slice(format!("{size} 0 obj\n{}\nendobj\n", h.dict).as_bytes()); + + // A new cross-reference table and the trailer naming the encryption dictionary. + let table = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n", size + 1).as_bytes()); + for o in &offsets { + let entry = match o { + Some((off, generation)) => format!("{off:010} {generation:05} n\r\n"), + None => "0000000000 65535 f\r\n".to_string(), + }; + out.extend_from_slice(entry.as_bytes()); + } + out.extend_from_slice(b"trailer\n"); + let dict_body = t_end.checked_sub(2).filter(|b| *b >= *size_end).ok_or_else(|| failed("bad trailer"))?; + out.extend_from_slice(pdf.get(*t_start..*size_start).ok_or_else(|| failed("bad trailer"))?); + out.extend_from_slice((size + 1).to_string().as_bytes()); + out.extend_from_slice(pdf.get(*size_end..dict_body).ok_or_else(|| failed("bad trailer"))?); + if let Some(id) = new_id { + out.extend_from_slice(format!("/ID[{}{}]", hex(&id), hex(&id)).as_bytes()); + } + out.extend_from_slice(format!("/Encrypt {size} 0 R>>\nstartxref\n{table}\n%%EOF").as_bytes()); + Ok(out) +} + +// ---------- reading ---------- + +/// The trailer dictionary of `pdf`: after its last cross-reference table, or the dictionary of +/// its last cross-reference stream. +fn trailer(pdf: &[u8]) -> Option> { + let sx = rfind(pdf, b"startxref")?; + let mut lx = Lexer::at(pdf, sx + 9); + let off = match lx.next()? { + (Tok::Word(w), ..) => usize::try_from(int(w)?).ok()?, + _ => return None, + }; + if pdf.get(off..)?.starts_with(b"xref") { + let at = find(pdf, b"trailer", off)?; + return Lexer::at(pdf, at + 7).object(0); + } + let mut lx = Lexer::at(pdf, off); + lx.header()?; + lx.object(0) +} + +/// The object `num generation` of `pdf`, found by its header (the last one in the file). +fn find_object(pdf: &[u8], num: u32, generation: u16) -> Option> { + let head = format!("{num} {generation} obj"); + let mut end = pdf.len(); + while let Some(at) = rfind(pdf.get(..end)?, head.as_bytes()) { + if at == 0 || pdf.get(at - 1).is_some_and(|b| is_white(*b)) { + let mut lx = Lexer::at(pdf, at); + lx.header()?; + return lx.object(0); + } + end = at + head.len() - 1; + } + None +} + +/// The open (user) password of `pdf`, from its permissions (owner) password `owner`: for files +/// encrypted with RC4 or 128-bit AES (revisions 2–4), whose user password is in their O entry. +/// `None` for other files, a wrong password or a user password that isn't UTF-8. +pub(crate) fn user_password(pdf: &[u8], owner: &str) -> Option { + let trailer = trailer(pdf)?; + let found; + let enc = match trailer.get(b"Encrypt")? { + Obj::Ref(n, g) => { + found = find_object(pdf, *n, *g)?; + &found + } + d @ Obj::Dict { .. } => d, + _ => return None, + }; + let r = u8::try_from(enc.int(b"R")?).ok().filter(|r| (2..=4).contains(r))?; + let n = if r == 2 { 5 } else { usize::try_from(enc.int(b"Length").unwrap_or(40) / 8).ok()?.clamp(5, 16) }; + let key = owner_key(owner.as_bytes(), r, n); + let mut user = enc.string(b"O")?.get(..32)?.to_vec(); + if r == 2 { + user = rc4(&key, &user); + } else { + for i in (0..=19).rev() { + user = rc4(&xored(&key, i), &user); + } + } + // The password is what comes before the padding. + let len = (0..=32).find(|l| user.get(*l..) == PAD.get(..32 - l))?; + String::from_utf8(user.get(..len)?.to_vec()).ok() +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4bf68cb6..d38e043b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -92,6 +92,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Option { } /// The offset `startxref` gives, when it points at a cross-reference table. -fn xref_offset(pdf: &[u8]) -> Option { +pub(crate) fn xref_offset(pdf: &[u8]) -> Option { let sx = rfind(pdf, b"startxref")?; let off: usize = std::str::from_utf8(&pdf[sx + 9..]).ok()?.split_whitespace().next()?.parse().ok()?; pdf.get(off..)?.starts_with(b"xref").then_some(off) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index d13e433d..e040e721 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -28,9 +28,13 @@ //! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]). //! - [`print()`] lays a document out on paper as File → Print does ([`PrintSettings`]) and writes //! the job as a print-ready PDF: composite or one page per ink. +//! - Security: the open and permissions passwords encrypt the written file with the standard +//! security handler, the algorithm following the compatibility ([`Encryption`]); either password +//! opens an encrypted file. #![forbid(unsafe_code)] mod editing; +mod encrypt; mod export; mod images; mod import; @@ -46,7 +50,8 @@ mod presets; mod print; mod settings; -pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing}; +pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with}; +pub use encrypt::Encryption; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; pub use pages::{PageInfo, PdfInfo, info, is_postscript}; @@ -193,6 +198,8 @@ mod tests_dashalign; #[cfg(test)] mod tests_editing; #[cfg(test)] +mod tests_encrypt; +#[cfg(test)] mod tests_focal; #[cfg(test)] mod tests_fx; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 5ddfcb75..701fcbb4 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -32,17 +32,28 @@ pub fn is_postscript(bytes: &[u8]) -> bool { bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) } -/// Read a PDF, decrypting it with `password` (none: the empty user password). +/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions +/// password opens a file too. pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } let password = password.unwrap_or_default(); - Pdf::new_with_password(bytes.to_vec(), password).map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword, - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword, - e => PdfError::Parse(format!("{e:?}")), - }) + let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw); + read(password) + .or_else(|e| match e { + // The reader takes only the open password of RC4 and 128-bit AES files: the + // permissions password gives it. + LoadPdfError::Decryption(DecryptionError::PasswordProtected) if !password.is_empty() => { + crate::encrypt::user_password(bytes, password).map_or(Err(e), |user| read(&user)) + } + e => Err(e), + }) + .map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword, + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword, + e => PdfError::Parse(format!("{e:?}")), + }) } /// The pages and their boxes. An encrypted PDF needs its `password` diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index d1fe5eff..fcaae398 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -391,15 +391,12 @@ pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result } impl PdfSettings { - /// Refuse settings the writer can't honour: an unsupported standard, a password (the file - /// would not be protected), or what [`Self::check_values`] refuses. + /// Refuse settings the writer can't honour: an unsupported standard, or what + /// [`Self::check_values`] refuses. pub fn check(&self) -> Result<(), PdfError> { if !self.standard.supported() { return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label()))); } - if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() { - return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into())); - } self.check_values() } @@ -407,6 +404,7 @@ impl PdfSettings { /// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or /// an out-of-range value. pub fn check_values(&self) -> Result<(), PdfError> { + self.security.check(self.standard, self.compatibility)?; if !self.standard.allows(self.compatibility) { return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label()))); } @@ -452,17 +450,19 @@ impl PdfSettings { ), (!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"), ( - s.printing != d.security.printing - || s.changes != d.security.changes - || s.copy != d.security.copy - || s.screen_reader != d.security.screen_reader - || s.plaintext_metadata != d.security.plaintext_metadata, - "permissions need a permissions password, which is not supported yet: they are not applied", + !s.protected() + && (s.printing != d.security.printing + || s.changes != d.security.changes + || s.copy != d.security.copy + || s.screen_reader != d.security.screen_reader + || s.plaintext_metadata != d.security.plaintext_metadata), + "permissions need a password: without one they are not applied", ), ] .into_iter() .filter(|(on, _)| *on) .map(|(_, w)| w.to_string()) + .chain(crate::encrypt::warnings(self)) .collect() } } diff --git a/crates/pdf/src/tests_encrypt.rs b/crates/pdf/src/tests_encrypt.rs new file mode 100644 index 00000000..16a50ea0 --- /dev/null +++ b/crates/pdf/src/tests_encrypt.rs @@ -0,0 +1,220 @@ +//! PDF security: password-protected files open only with a password, carry the permissions asked +//! for, and refuse what a PDF standard forbids. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::encrypt::protect; +use crate::lab_spot::find; +use crate::*; + +const VERSIONS: [Compatibility; 5] = [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf16, Compatibility::Pdf17, Compatibility::Pdf20]; + +fn doc() -> Document { + let mut d = Document::new(200.0, 100.0); + d.title = "Tïtle (x)".into(); + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 40.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + d +} + +fn security(open: &str, permissions: &str) -> SecuritySettings { + SecuritySettings { open_password: open.into(), permissions_password: permissions.into(), ..Default::default() } +} + +/// `doc()` exported at `version` (with Preserve Editing when `native`), then protected by `sec`. +fn protected(version: Compatibility, sec: SecuritySettings, native: bool) -> Vec { + let mut opts = PdfOptions::default(); + opts.settings.compatibility = version; + opts.settings.preserve_editing = native; + opts.native = native.then(|| b"{\"native\": true}".to_vec()); + let plain = export(&doc(), &opts).unwrap(); + opts.settings.security = sec; + protect(plain, &opts.settings).unwrap() +} + +fn shapes_of(bytes: &[u8], password: Option<&str>) -> usize { + let d = import_with_report(bytes, &ImportOptions { password: password.map(str::to_string), ..Default::default() }).unwrap().document; + let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!("a layer") }; + children.len() +} + +/// The encryption dictionary's text. +fn encrypt_dict(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + let at = text.find("/Filter/Standard").unwrap(); + text[at..at + text[at..].find("\nendobj").unwrap()].to_string() +} + +#[test] +fn every_level_opens_only_with_a_password() { + for (v, (head, cfm)) in VERSIONS.into_iter().zip([ + ("/V 2/R 3/Length 128", ""), + ("/V 4/R 4/Length 128", "/CFM/V2"), + ("/V 4/R 4/Length 128", "/CFM/AESV2"), + ("/V 5/R 6/Length 256", "/CFM/AESV3"), + ("/V 5/R 6/Length 256", "/CFM/AESV3"), + ]) { + let bytes = protected(v, security("open sesame", "owner"), false); + let dict = encrypt_dict(&bytes); + assert!(dict.contains(head) && dict.contains(cfm), "{v:?}: {dict}"); + assert!(bytes.starts_with(format!("%PDF-{}", v.id()).as_bytes()), "{v:?} keeps its version"); + assert_eq!(info(&bytes, None).unwrap_err(), PdfError::NeedsPassword, "{v:?}"); + assert_eq!(info(&bytes, Some("guess")).unwrap_err(), PdfError::WrongPassword, "{v:?}"); + // The open password and the permissions password both open it, and the art decrypts. + assert_eq!(shapes_of(&bytes, Some("open sesame")), 1, "{v:?}"); + assert_eq!(shapes_of(&bytes, Some("owner")), 1, "{v:?}: the permissions password opens it too"); + } +} + +#[test] +fn strings_and_streams_are_encrypted() { + let mut sec = security("pw", ""); + sec.plaintext_metadata = false; + let bytes = protected(Compatibility::Pdf17, sec, true); + let text = String::from_utf8_lossy(&bytes); + for plain in ["VectorCraft", "native", "xmpmeta", "FEFF0054"] { + assert!(!text.contains(plain), "`{plain}` is readable"); + } + // The trailer names the dictionary; the ID stays as it was. + let trailer = &text[text.rfind("trailer").unwrap()..]; + assert!(trailer.contains("/Encrypt ") && trailer.contains("/ID["), "{trailer}"); + // 256-bit AES in a PDF 1.7 file is declared in the catalog. + assert!(text.contains("/Extensions<>>>")); + assert!(!String::from_utf8_lossy(&protected(Compatibility::Pdf20, security("pw", ""), false)).contains("/Extensions")); +} + +#[test] +fn plaintext_metadata_stays_readable() { + for v in [Compatibility::Pdf15, Compatibility::Pdf16, Compatibility::Pdf17] { + let bytes = protected(v, security("pw", ""), false); + assert!(find(&bytes, b"()); + let none = SecuritySettings { printing: Printing::None, changes: Changes::None, copy: false, screen_reader: false, ..security("", "p") }; + assert_eq!(bits(none.clone()), reserved); + assert_eq!(bits(SecuritySettings { printing: Printing::Low, ..none.clone() }), reserved | bit(3)); + assert_eq!(bits(SecuritySettings { printing: Printing::High, ..none.clone() }), reserved | bit(3) | bit(12)); + for (changes, set) in [ + (Changes::Pages, bit(11)), + (Changes::Forms, bit(9)), + (Changes::Comments, bit(6) | bit(9)), + (Changes::Any, bit(4) | bit(6) | bit(9) | bit(11)), + ] { + assert_eq!(bits(SecuritySettings { changes, ..none.clone() }), reserved | set, "{changes:?}"); + } + assert_eq!(bits(SecuritySettings { screen_reader: true, ..none.clone() }), reserved | bit(10)); + assert_eq!(bits(SecuritySettings { copy: true, ..none }), reserved | bit(5) | bit(10), "copying lets screen readers read"); +} + +#[test] +fn standards_and_bad_passwords_are_refused() { + let set = |standard, compatibility, sec| PdfSettings { standard, compatibility, security: sec, ..Default::default() }; + for standard in [Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] { + let e = set(standard, Compatibility::Pdf17, security("pw", "")).check_values().unwrap_err(); + assert!(matches!(&e, PdfError::BadSetting(m) if m.contains("password-protected")), "{standard:?}: {e}"); + let e = set(standard, Compatibility::Pdf17, security("", "pw")).check_values().unwrap_err(); + assert!(e.to_string().contains("password-protected"), "{standard:?}: {e}"); + assert!(set(standard, Compatibility::Pdf17, security("", "")).check_values().is_ok(), "{standard:?} without passwords"); + } + let bad = |c, sec| set(Standard::None, c, sec).check_values().unwrap_err().to_string(); + assert!(bad(Compatibility::Pdf17, security("same", "same")).contains("must differ")); + assert!(bad(Compatibility::Pdf16, security("pässword", "")).contains("ASCII")); + assert!(bad(Compatibility::Pdf14, security(&"x".repeat(33), "")).contains("32")); + assert!(bad(Compatibility::Pdf17, security(&"x".repeat(128), "")).contains("127")); + assert!(set(Standard::None, Compatibility::Pdf17, security("pässwörd", "ünïcode")).check_values().is_ok()); + assert!(set(Standard::None, Compatibility::Pdf14, security(&"x".repeat(32), "")).check_values().is_ok()); +} + +#[test] +fn unicode_passwords_open_aes256_files() { + let bytes = protected(Compatibility::Pdf20, security("pässwörd", "ünïcode"), false); + assert_eq!(info(&bytes, Some("pässwörd")).unwrap().pages.len(), 1); + assert_eq!(info(&bytes, Some("ünïcode")).unwrap().pages.len(), 1); + assert_eq!(info(&bytes, Some("passwort")).unwrap_err(), PdfError::WrongPassword); +} + +#[test] +fn editing_data_comes_back_with_the_password() { + // The editing file is found by its name (a string) and read from its stream: both decrypt. + for v in VERSIONS { + let bytes = protected(v, security("pw", "own"), true); + assert_eq!(editing(&bytes), None, "{v:?}: locked"); + for pw in ["pw", "own"] { + let e = editing_with(&bytes, Some(pw)).unwrap(); + assert_eq!((e.data.as_slice(), e.intact), (&b"{\"native\": true}"[..], true), "{v:?} {pw}"); + } + } +} + +#[test] +fn without_a_password_nothing_changes() { + let mut opts = PdfOptions::default(); + let plain = export(&doc(), &opts).unwrap(); + opts.settings.security = SecuritySettings { printing: Printing::None, ..Default::default() }; + assert_eq!(protect(plain.clone(), &opts.settings).unwrap(), plain); + assert_eq!(opts.settings.encryption(), None); + opts.settings.security.open_password = "x".into(); + assert_eq!(opts.settings.encryption(), Some(Encryption::Aes256)); + for (c, e) in VERSIONS.into_iter().zip([Encryption::Rc4, Encryption::Rc4, Encryption::Aes128, Encryption::Aes256, Encryption::Aes256]) { + assert_eq!(Encryption::for_compatibility(c), e); + } +} + +#[test] +fn the_permissions_password_opens_files_of_other_writers() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + // Revision 2 (40-bit RC4): the owner password is the user password followed by `-owner`. + let bytes = pdf(&[PdfPage::new(50.0, 50.0, "0 g 0 0 10 10 re f")], Some("pw")); + assert_eq!(info(&bytes, Some("pw-owner")).unwrap().pages.len(), 1); + assert_eq!(info(&bytes, Some("pw-other")).unwrap_err(), PdfError::WrongPassword); +} + +#[test] +fn damaged_files_never_panic_when_reading_the_owner_password() { + let bytes = protected(Compatibility::Pdf15, security("pw", "own"), false); + for cut in (0..bytes.len()).step_by(97) { + let _ = crate::encrypt::user_password(&bytes[..cut], "own"); + let _ = info(&bytes[cut..], Some("own")); + } + for junk in [&b""[..], b"startxref", b"startxref\n99999999999999999999", b"trailer<>>>startxref 0"] { + assert_eq!(crate::encrypt::user_password(junk, "own"), None); + } +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index bc60dc72..84ef0e9e 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -54,8 +54,10 @@ fn checks_refuse_what_the_writer_cant_honour() { assert_eq!(PdfSettings::default().check(), Ok(())); let refused = |v: serde_json::Value| settings(v).check().unwrap_err(); assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_))); - assert!(matches!(refused(json!({"security": {"openPassword": "x"}})), PdfError::Unsupported(_))); - assert!(matches!(refused(json!({"security": {"permissionsPassword": "x"}})), PdfError::Unsupported(_))); + // Passwords are written (the file is encrypted); a password with a standard is not. + assert_eq!(settings(json!({"security": {"openPassword": "x"}})).check(), Ok(())); + assert_eq!(settings(json!({"security": {"permissionsPassword": "x"}})).check(), Ok(())); + assert!(matches!(refused(json!({"standard": "pdfA2b", "security": {"openPassword": "x"}})), PdfError::BadSetting(_))); assert!(matches!(refused(json!({"compression": {"color": {"ppi": 5}}})), PdfError::BadSetting(m) if m.contains("compression.color.ppi"))); assert!(matches!(refused(json!({"bleed": {"left": 100}})), PdfError::BadSetting(m) if m.contains("bleed.left"))); assert!(matches!(refused(json!({"marks": {"weight": 0}})), PdfError::BadSetting(_))); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index b5bee3ec..1088d03c 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -15,8 +15,8 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; use vectorcraft_engine::cmd::fileio::{SaveMode, pdf}; use vectorcraft_pdf::{ - Changes, Choice, ColorConversion, Compatibility, Downsample, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, Printing, - ProfileInclusion, Standard, + Changes, Choice, ColorConversion, Compatibility, Downsample, Encryption, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, + Printing, ProfileInclusion, Standard, }; use super::{DialogSpec, form}; @@ -311,8 +311,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { set(d, "compatibility", json!(Compatibility::Pdf17.id())); } if picked && standard != Standard::None { - // Files of a standard don't carry the editing data. + // Files of a standard don't carry the editing data, nor passwords. set(d, "preserveEditing", json!(false)); + for p in [OPEN_PASSWORD, PERMISSIONS_PASSWORD] { + set(d, p, json!("")); + } } ui.add_space(16.0); ui.label(egui::RichText::new("Compatibility:").color(t.text)); @@ -569,19 +572,42 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The password fields (never stored in presets). +const OPEN_PASSWORD: &str = "security.openPassword"; +const PERMISSIONS_PASSWORD: &str = "security.permissionsPassword"; + +/// A checkbox turning a password on (UI-only `flag`) or off (clearing it); checked while the +/// password is set. Returns whether the password applies. +fn password_check(ui: &mut egui::Ui, d: &mut Dialog, path: &str, flag: &str, label: &str, enabled: bool) -> bool { + let on = d.bool(flag) || get(d, path).as_str().is_some_and(|s| !s.is_empty()); + if widgets::check(ui, label, on, enabled) { + d.fields.insert(flag.into(), json!(!on)); + if on { + set(d, path, json!("")); + } + } + on && enabled +} + +fn password_field(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { + let mut s = get(d, path).as_str().unwrap_or_default().to_string(); + if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).password(true).desired_width(200.0)).changed() { + set(d, path, json!(s)); + } +} + fn security(ui: &mut egui::Ui, d: &mut Dialog) { - note(ui, "Password protection is not available yet, so these options are off."); + let plain = choice::(d, "standard").unwrap_or_default() == Standard::None; + if !plain { + note(ui, "Files of a PDF standard can't be password-protected."); + } heading(ui, "Document open password"); - widgets::check(ui, "Require a password to open the document", false, false); - row(ui, "Password:", |ui| { - ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0)); - }); + let open = password_check(ui, d, OPEN_PASSWORD, "__requireOpenPassword", "Require a password to open the document", plain); + row(ui, "Open password:", |ui| password_field(ui, d, OPEN_PASSWORD, open)); heading(ui, "Permissions"); - widgets::check(ui, "Restrict printing, editing and other tasks", false, false); - row(ui, "Permissions password:", |ui| { - ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0)); - }); - ui.add_enabled_ui(false, |ui| { + let restrict = password_check(ui, d, PERMISSIONS_PASSWORD, "__restrictPermissions", "Restrict printing, editing and other tasks", plain); + row(ui, "Permissions password:", |ui| password_field(ui, d, PERMISSIONS_PASSWORD, restrict)); + ui.add_enabled_ui(restrict, |ui| { row(ui, "Printing allowed:", |ui| { pick::(ui, d, "security.printing", 260.0, |_| true); }); @@ -589,9 +615,18 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) { pick::(ui, d, "security.changes", 260.0, |_| true); }); }); - flag(ui, d, "security.copy", "Enable copying of text, images and other content", false); - flag(ui, d, "security.screenReader", "Enable text access for screen readers", false); - flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", false); + flag(ui, d, "security.copy", "Enable copying of text, images and other content", restrict); + let reader = "Enable text access for screen readers"; + if get(d, "security.copy").as_bool() == Some(true) { + // What can be copied can be read out. + widgets::check(ui, reader, true, false); + } else { + flag(ui, d, "security.screenReader", reader, restrict); + } + let compatibility = choice::(d, "compatibility").unwrap_or_default(); + flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", (open || restrict) && compatibility != Compatibility::Pdf14); + ui.add_space(6.0); + note(ui, &format!("Encryption: {} (set by Compatibility)", Encryption::for_compatibility(compatibility).label())); } /// The section a changed option belongs to (for the Summary's order). @@ -841,4 +876,25 @@ mod tests { assert_eq!(section_of("bleed.top"), 2); assert_eq!(SECTIONS[section_of("standard")], "General"); } + + #[test] + fn the_security_section_takes_passwords_that_presets_drop() { + let (mut app, _, _) = app(); + app.run("ui.savePdfDialog", json!({})).unwrap(); + set_field(&mut app, "__section", json!("Security")); + set_field(&mut app, OPEN_PASSWORD, json!("open")); + set_field(&mut app, PERMISSIONS_PASSWORD, json!("own")); + set_field(&mut app, "security.copy", json!(false)); + frame(&mut app); + let p = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!((p["security"]["openPassword"].as_str(), p["security"]["permissionsPassword"].as_str()), (Some("open"), Some("own"))); + assert_eq!(p["security"]["copy"], false); + // With a standard the section still draws (greyed); a preset brings its own security. + set_field(&mut app, "standard", json!(Standard::PdfA2b.id())); + frame(&mut app); + let mut d = app.ui.dialog.clone().unwrap(); + apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET); + assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords"); + assert_eq!(get(&d, "security.copy"), true); + } } diff --git a/docs/mcp.md b/docs/mcp.md index a033aa4d..d78f2db6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -215,7 +215,9 @@ Long-tail commands: PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: -options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. +options accepted but not applied yet, and features approximated or left out. PDF/X and PDF/A-2b at 2.0 are refused. +A `security` password encrypts the file (RC4 128-bit at PDF 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7 and 2.0) with its +permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused. Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the document's raster effects resolution, clipped the same way.