mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
M4.85: PDF security: encryption at every compatibility level, permissions, either password opens
- pdf/src/encrypt.rs encrypts a written PDF with the standard security handler: 128-bit RC4 (PDF 1.4, revision 3; PDF 1.5, revision 4), 128-bit AES (1.6) or 256-bit AES (1.7/2.0, revision 6), every string and stream, plaintext XMP metadata when asked, the permission bits (printing none/low/high, five change levels, copy, screen readers). Crypto from RustCrypto (aes, md-5, sha2: MIT OR Apache-2.0, pure Rust, wasm). - PDF/A and PDF/X refuse passwords; equal passwords and passwords the algorithm can't take are refused. - Opening: the permissions password opens RC4/AES-128 files too; editing data comes back from an encrypted PDF opened with its password. - Save PDF › Security: the passwords, permissions and plaintext metadata, the encryption level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b379f02527
commit
d8d1d472cf
@@ -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),
|
||||
|
||||
@@ -710,6 +710,8 @@ mod tests_text;
|
||||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfsecurity;
|
||||
#[cfg(test)]
|
||||
mod tests_svgenc;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user