Merge pull request #106 from storytold/pr/m4-pdf-security

M4.85: PDF security
This commit is contained in:
BFlat 2026-10-05 15:30:53 +07:00 committed by GitHub
commit 88b9eee4ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1560 additions and 43 deletions

108
Cargo.lock generated
View File

@ -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",

View File

@ -136,7 +136,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
"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),

View File

@ -710,6 +710,8 @@ mod tests_text;
#[cfg(test)]
mod tests_dxf;
#[cfg(test)]
mod tests_pdfsecurity;
#[cfg(test)]
mod tests_svgenc;
#[cfg(test)]

View File

@ -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<CommandSpec> {
"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
),

View File

@ -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}");

View File

@ -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"));
}

View File

@ -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::<u8>()), 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}")))?;
}
}

View File

@ -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 }

View File

@ -63,7 +63,13 @@ pub(crate) fn seal(pdf: Vec<u8>) -> Result<Vec<u8>, PdfError> {
/// The native document `bytes` (a PDF) carries, if any, and whether its pages changed since.
pub fn editing(bytes: &[u8]) -> Option<Editing> {
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> {
editing_in(&crate::pages::open(bytes, password).ok()?)
}
/// [`editing`] of a parsed PDF.

1002
crates/pdf/src/encrypt.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
w.absorb(ex);
let (bytes, more) = w.finish()?;
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
let bytes = crate::encrypt::protect(bytes, set)?;
warnings.extend(more);
warnings.dedup();
Ok(ExportReport { bytes, warnings })

View File

@ -115,7 +115,7 @@ pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
}
/// The offset `startxref` gives, when it points at a cross-reference table.
fn xref_offset(pdf: &[u8]) -> Option<usize> {
pub(crate) fn xref_offset(pdf: &[u8]) -> Option<usize> {
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)

View File

@ -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;

View File

@ -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<Pdf, PdfError> {
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`

View File

@ -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()
}
}

View File

@ -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<u8> {
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<</ADBE<</BaseVersion/1.7/ExtensionLevel 8>>>>"));
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"<x:xmpmeta", 0).is_some(), "{v:?}: the XMP metadata is plain");
assert!(encrypt_dict(&bytes).contains("/EncryptMetadata false"), "{v:?}");
assert_eq!(shapes_of(&bytes, Some("pw")), 1, "{v:?}");
}
// PDF 1.4 encrypts it anyway, and says so.
let bytes = protected(Compatibility::Pdf14, security("pw", ""), false);
assert!(find(&bytes, b"<x:xmpmeta", 0).is_none());
let set = PdfSettings { compatibility: Compatibility::Pdf14, security: security("pw", ""), ..Default::default() };
assert!(set.warnings().iter().any(|w| w.contains("plaintext metadata")), "{:?}", set.warnings());
let set = PdfSettings { compatibility: Compatibility::Pdf15, ..set };
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
}
#[test]
fn a_permissions_password_alone_opens_without_a_password_and_restricts() {
for v in VERSIONS {
let sec = SecuritySettings { printing: Printing::Low, changes: Changes::Comments, copy: false, ..security("", "owner only") };
let bytes = protected(v, sec.clone(), false);
assert_eq!(shapes_of(&bytes, None), 1, "{v:?} opens without a password");
assert!(encrypt_dict(&bytes).contains(&format!("/P {}", sec.permission_bits())), "{v:?}");
}
}
#[test]
fn permission_bits_follow_the_options() {
let bits = |sec: SecuritySettings| sec.permission_bits() as u32;
let bit = |n: u32| 1u32 << (n - 1);
let reserved = 0xFFFF_F0C0;
// Without a permissions password everything is allowed.
assert_eq!(bits(SecuritySettings { printing: Printing::None, copy: false, ..security("open", "") }), 0xFFFF_FFFC);
// Everything allowed: print (3), modify (4), copy (5), annotate (6), forms (9), accessibility
// (10), assemble (11), high-quality print (12).
assert_eq!(bits(security("", "p")), reserved | [3, 4, 5, 6, 9, 10, 11, 12].iter().map(|n| bit(*n)).sum::<u32>());
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<</Encrypt<</R 3/O()>>>>startxref 0"] {
assert_eq!(crate::encrypt::user_password(junk, "own"), None);
}
}

View File

@ -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(_)));

View File

@ -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::<Standard>(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::<Printing>(ui, d, "security.printing", 260.0, |_| true);
});
@ -589,9 +615,18 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) {
pick::<Changes>(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::<Compatibility>(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);
}
}

View File

@ -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.