From ff641c669e824f8037bc2218006ae81bde21e5d1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:00 -0400 Subject: [PATCH] M8.1: Protect Using Password: engine Protect/RemoveProtection edits (Acrobat permission levels: printing, changes, copy, screen readers; AES-256/AES-128/RC4 compatibility; encrypt-except-metadata), per-state passwords on the undo stack, owner rights kept after saving, pending security in Document Properties; dialog matching Acrobat's (Viewing/Editing, strength meter, Advanced Options), protect.password/protect.remove commands; doc_protect/doc_unprotect tools (passwords never echoed). Fixes: saving a password-protected document failed to rebase on the encrypted file; DocInfo.encrypted was false for every decrypted file Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 67 ++++++- crates/automation/src/tools.rs | 24 +++ crates/automation/tests/automation.rs | 34 ++++ crates/cos/src/document.rs | 9 +- crates/engine/src/catalog.rs | 4 +- crates/engine/src/commands.rs | 8 + crates/engine/src/lib.rs | 273 ++++++++++++++++++++++++-- crates/engine/src/tests.rs | 85 ++++++++ crates/render/src/inspect.rs | 3 +- crates/ui-egui/src/commands.rs | 16 ++ crates/ui-egui/src/dialogs.rs | 40 +++- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/protect.rs | 251 +++++++++++++++++++++++ crates/ui-egui/tests/commands.rs | 4 + crates/ui-egui/tests/protect.rs | 83 ++++++++ parity/acrobat-features.toml | 68 +++---- 16 files changed, 918 insertions(+), 58 deletions(-) create mode 100644 crates/ui-egui/src/protect.rs create mode 100644 crates/ui-egui/tests/protect.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8ab056e2..6c64bb64 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -209,6 +209,12 @@ impl Automation { let (path, page) = (a.path("path")?, self.page(&a)?); self.apply(&a, Edit::SetBookmarkPage { path, page })? } + "doc_protect" => self.doc_protect(&a)?, + "doc_unprotect" => { + let mut out = self.apply(&a, Edit::RemoveProtection)?; + out["security"] = security(self.doc(&a)?); + out + } "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "comment_reply" => self.comment_reply(&a)?, @@ -272,6 +278,44 @@ impl Automation { Ok(summary(self.doc(a)?)) } + fn doc_protect(&mut self, a: &Args) -> Result { + use printcraft_engine::{Algorithm, Changes, Printing, Protection}; + let d = Protection::default(); + let p = Protection { + open_password: a.opt_str("open_password")?.map(str::to_owned), + permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), + printing: match a.opt_str("printing")? { + None => d.printing, + Some("none") => Printing::None, + Some("low") => Printing::Low, + Some("high") => Printing::High, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), + }, + changes: match a.opt_str("changes")? { + None => d.changes, + Some("none") => Changes::None, + Some("pages") => Changes::Pages, + Some("fill-sign") => Changes::FillSign, + Some("comment-fill-sign") => Changes::CommentFillSign, + Some("any-except-extract") => Changes::AnyExceptExtract, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), + }, + copy: a.opt_bool("copy")?.unwrap_or(d.copy), + accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), + algorithm: match a.opt_str("compatibility")? { + None | Some("aes-256") => Algorithm::Aes256, + Some("aes-128") => Algorithm::Aes128, + Some("rc4-128") => Algorithm::Rc4_128, + Some("rc4-40") => Algorithm::Rc4_40, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), + }, + encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), + }; + let mut out = self.apply(a, Edit::Protect(p))?; + out["security"] = security(self.doc(a)?); + Ok(out) + } + fn insert_blank(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let at = self.position(a, "at")?; @@ -599,6 +643,24 @@ fn one_based(pages: &[i64]) -> Result> { .collect() } +/// The document's security as the next save writes it (never includes passwords). +fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + /// What every tool that changes a document returns. fn summary(d: &Document) -> Value { json!({ @@ -618,7 +680,10 @@ fn summary(d: &Document) -> Value { fn info(d: &Document) -> Value { let i = &d.info; let page1 = |p: usize| p + 1; - let security = d.security_summary().map(|s| json!({ "method": s.method, "owner": s.owner, "permissions": format!("{:?}", s.permissions) })); + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } json!({ "document": summary(d), "pdf_version": i.pdf_version, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index db487d85..e07fd143 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -249,6 +249,30 @@ pub fn tools() -> Vec { &["doc"], )), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t( + "doc_protect", + "Protect with passwords", + "Encrypt the document (applied by the next doc_save, a full rewrite). open_password is needed to open it; permissions_password is needed to change security and lifts the restrictions given by printing/changes/copy. Passwords are never echoed back. Undoable.", + ) + .cmd("protect.password") + .with(schema( + json!({ + "doc": doc(), + "open_password": { "type": "string", "minLength": 1 }, + "permissions_password": { "type": "string", "minLength": 1 }, + "printing": { "type": "string", "enum": ["none", "low", "high"], "description": "Default high." }, + "changes": { "type": "string", "enum": ["none", "pages", "fill-sign", "comment-fill-sign", "any-except-extract"], "description": "Default none." }, + "copy": { "type": "boolean", "description": "Allow copying text and images (default false)." }, + "accessibility": { "type": "boolean", "description": "Allow screen readers to read the text (default true)." }, + "compatibility": { "type": "string", "enum": ["aes-256", "aes-128", "rc4-128", "rc4-40"], "description": "Default aes-256 (Acrobat X and later)." }, + "encrypt_metadata": { "type": "boolean", "description": "Default true." }, + }), + &["doc"], + )), + t("doc_unprotect", "Remove security", "Remove password security (the document must have been opened with its permissions password, or have none). Applied by the next doc_save. Undoable.") + .cmd("protect.remove") + .destructive() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 8f5a95ca..ad53ac2e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -406,3 +406,37 @@ fn comments_through_tools() { let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } + +#[test] +fn protecting_through_tools() { + let dir = workdir("protect"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false); + assert!(matches!(a.call("doc_protect", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "a password is required"); + let r = ok( + &mut a, + "doc_protect", + json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "printing": "low", "changes": "comment-fill-sign" }), + ); + assert_eq!(r["security"]["pending"], true); + assert_eq!( + (r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()), + (Some("low"), Some(true), Some(false)) + ); + assert!(!r.to_string().contains("boss"), "passwords are never echoed"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + // Another session: the open password is required, and the restrictions hold. + let mut b = auto(&dir); + assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_)))); + let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_)))); + assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_)))); + ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" })); + // With the permissions password everything is possible, including removing security. + let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false); + ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" })); + let mut c = auto(&dir); + ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2e0e7184..b0c1f149 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -259,7 +259,8 @@ impl Document { self.security().map(|s| s.permissions()) } - pub(crate) fn encryption_changed(&self) -> bool { + /// Security was set or removed since the document was opened (the next save applies it). + pub fn encryption_changed(&self) -> bool { self.encryption_changed } @@ -269,6 +270,12 @@ impl Document { if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) } } + /// The security the next save writes: protection applied with `set_encryption`, none after + /// `remove_encryption`, otherwise the security the document was opened with. + pub fn output_handler(&self) -> Option<&printcraft_crypt::SecurityHandler> { + self.output_security().0 + } + /// Protect the document with a password (§7.6.4). Takes effect on the next save, which is /// always a full rewrite. Returns the handler (authenticated as owner). pub fn set_encryption(&mut self, params: &printcraft_crypt::NewEncryption) -> Result<(), CosError> { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index bed9ac22..1518ae8f 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -248,7 +248,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Protect", items: &[ - item("Protect with password", "lock", "protect.password", Planned("M8")), + item("Protect with password", "lock", "protect.password", Ready), item("Remove hidden information", "eye-off", "protect.sanitize", Planned("M8")), ], }, @@ -257,7 +257,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("Encrypt with certificate", "file-lock-2", "protect.certificate", Planned("M8")), item("Security properties", "shield-check", "protect.properties", Ready), - item("Remove security", "lock", "protect.remove", Planned("M8")), + item("Remove security", "lock-open", "protect.remove", Ready), ], }, ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e0f05c6c..5d49d8d2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -24,6 +24,10 @@ pub enum Needs { Modification, /// The open document's security allows adding and changing comments. Annotate, + /// The document's security may be changed (owner, or nothing restricted). + Security, + /// The document is protected and its security may be removed. + ProtectedSecurity, /// There is something to undo. Undo, /// There is something to redo. @@ -156,6 +160,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.square", "Draw a rectangle", None, None, Annotate, "square"), c("comment.circle", "Draw an oval", None, None, Annotate, "circle"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), + c("protect.password", "Protect using password…", FILE, None, Security, "lock"), + c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"), c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"), c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), @@ -193,6 +199,8 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Assembly => doc.is_some_and(|d| d.allows_assembly()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), + Security => doc.is_some_and(|d| d.allows_security_change()), + ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()), Undo => doc.is_some_and(|d| d.can_undo().is_some()), Redo => doc.is_some_and(|d| d.can_redo().is_some()), } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e9ae8c6f..18d7debe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,8 @@ pub use printcraft_organize::{SplitBy, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use printcraft_organize::LabelStyle; +pub use printcraft_cos::Algorithm; + /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; pub use printcraft_annot::{Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; @@ -37,12 +39,25 @@ pub struct DocId(pub u64); /// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. const MAX_UNDO: usize = 100; +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +struct Keys { + /// What the renderer and inspector open the working file with (the user password). + render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + reopen: Option, +} + +/// One undo/redo step: its label, the document state and its passwords. +type Snapshot = (String, printcraft_cos::Document, Keys); + /// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). #[derive(Clone)] struct Editor { cos: printcraft_cos::Document, - undo: Vec<(String, printcraft_cos::Document)>, - redo: Vec<(String, printcraft_cos::Document)>, + undo: Vec, + redo: Vec, + keys: Keys, } pub struct Document { @@ -69,11 +84,11 @@ pub struct Document { impl Document { pub fn can_undo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _)| l.as_str()) + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _, _)| l.as_str()) } pub fn can_redo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _)| l.as_str()) + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _, _)| l.as_str()) } pub fn editable(&self) -> bool { @@ -100,10 +115,17 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.annotate()) } - /// A summary of the document's security for Document Properties ▸ Security. + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). pub fn security_summary(&self) -> Option { let editor = self.editor.as_ref()?; - let h = editor.cos.security()?; + let h = editor.cos.output_handler()?; let d = h.dict(); let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); let method = match (d.v, stream) { @@ -113,7 +135,9 @@ impl Document { (_, Some(printcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", _ => "Attachments only", }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner, permissions: h.permissions() }) + let pending = editor.cos.encryption_changed(); + let permissions = if pending { printcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending }) } /// Current value of a document-information entry (Title, Author, …). @@ -149,7 +173,132 @@ pub struct SecuritySummary { pub method: String, /// Opened with the owner password (no restrictions apply). pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. pub permissions: printcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: printcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: printcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) { + return bad("passwords can't be empty"); + } + if self.algorithm != printcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + Ok(()) + } } /// Edits that can be applied to a document. Page indices are 0-based. @@ -260,6 +409,10 @@ pub enum Edit { opacity: Option, width: Option, }, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, /// Several edits applied as one undoable step (all or nothing). Batch { label: String, @@ -291,6 +444,8 @@ impl Edit { Edit::MoveAnnotation { .. } => "Move comment".into(), Edit::ResizeAnnotation { .. } => "Resize comment".into(), Edit::StyleAnnotation { .. } => "Change comment properties".into(), + Edit::Protect(_) => "Protect with password".into(), + Edit::RemoveProtection => "Remove security".into(), Edit::Batch { label, .. } => label.clone(), } } @@ -313,6 +468,13 @@ fn annotation_noun(s: &Shape) -> &'static str { } } +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +fn unrestricted(p: &printcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} + /// Whether the opening password allows an edit (§7.6.4.2, Table 22). fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), EditError> { match edit { @@ -348,6 +510,13 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), Err(EditError::NotPermitted("comments")) } } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } Edit::SetInfo { .. } => { if p.modify() { Ok(()) @@ -380,6 +549,21 @@ impl EditCtx { Self { date: now.map(printcraft_cos::pdf_date), seed, count: 0 } } + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. fn meta(&mut self) -> printcraft_annot::Meta { let mut next = || { @@ -446,6 +630,28 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::StyleAnnotation { page, index, color, opacity, width } => { printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?; } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = printcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } Edit::Batch { edits, .. } => { for e in edits { run_edit(doc, e, cx)?; @@ -455,6 +661,18 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Ok(()) } +/// The passwords after `edit`, if it changes them. +fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} + /// Parse another PDF to copy pages from. fn open_source(name: &str, bytes: &Arc>) -> Result { match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) { @@ -486,6 +704,8 @@ pub enum EditError { Bookmark(#[from] printcraft_organize::OutlineError), #[error("{0}")] Comment(#[from] printcraft_annot::AnnotError), + #[error("{0}")] + Protection(String), #[error("the edited document could not be written: {0}")] Write(String), #[error("the edited document could not be reopened: {0}")] @@ -549,7 +769,10 @@ impl Session { let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone()); let (editor, read_only_reason) = match cos { - Ok(Ok(cos)) => (Some(Editor { cos, undo: Vec::new(), redo: Vec::new() }), None), + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + (Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys }), None) + } Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; @@ -590,18 +813,23 @@ impl Session { let mut next = editor.cos.clone(); run_edit(&mut next, &edit, &mut cx)?; let previous = std::mem::replace(&mut editor.cos, next); - editor.undo.push((edit.label(), previous)); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((edit.label(), previous, previous_keys)); if editor.undo.len() > MAX_UNDO { editor.undo.remove(0); } editor.redo.clear(); + Self::adopt_keys(doc); if let Err(e) = Self::refresh(doc) { // Roll back: the edit produced something we cannot display. if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev)) = ed.undo.pop() + && let Some((_, prev, keys)) = ed.undo.pop() { ed.cos = prev; + ed.keys = keys; } + Self::adopt_keys(doc); let _ = Self::refresh(doc); return Err(e); } @@ -613,9 +841,11 @@ impl Session { pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; let current = std::mem::replace(&mut editor.cos, prev); - editor.redo.push((label.clone(), current)); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys)); + Self::adopt_keys(doc); Self::refresh(doc)?; doc.dirty = true; doc.generation += 1; @@ -625,15 +855,25 @@ impl Session { pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let (label, next, keys) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; let current = std::mem::replace(&mut editor.cos, next); - editor.undo.push((label.clone(), current)); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys)); + Self::adopt_keys(doc); Self::refresh(doc)?; doc.dirty = true; doc.generation += 1; Ok(label) } + /// Use the current state's password for viewing (protection edits change it). + fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + /// Rebuild working bytes, inspection and renderer from the current edit state. fn refresh(doc: &mut Document) -> Result<(), EditError> { let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; @@ -681,7 +921,10 @@ impl Session { pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { let doc = self.doc_mut(id)?; if let Some(editor) = doc.editor.as_mut() { - editor.cos = printcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + editor.cos = printcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()) + .map_err(|e| EditError::Reopen(e.to_string()))?; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 974e225d..42369470 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -240,6 +240,7 @@ fn encrypted_documents_open_edit_and_save_encrypted() { let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap(); assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason); assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit"); + assert!(s.get(id).unwrap().info.encrypted, "reported as encrypted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); let saved = s.save_bytes(id).unwrap(); assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental"); @@ -484,3 +485,87 @@ fn comment_permission_is_enforced() { let err = s.apply(id, rect_comment(0, [10.0, 10.0, 50.0, 50.0])).unwrap_err(); assert_eq!(err, EditError::NotPermitted("comments")); } + +#[test] +fn saving_a_password_protected_document_rebases_on_it() { + let bytes = protected("pw", "owner", -1); + let mut s = Session::new(); + let id = s.open("x.pdf", None, bytes, Some("pw")).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).expect("rebases on the encrypted file"); + assert!(!s.get(id).unwrap().dirty); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + let again = s.save_bytes(id).unwrap(); + assert_eq!(&again[..saved.len()], &saved[..], "the next save appends"); +} + +fn protection(open: Option<&str>, perms: Option<&str>) -> Protection { + Protection { open_password: open.map(Into::into), permissions_password: perms.map(Into::into), ..Protection::default() } +} + +#[test] +fn protect_with_an_open_password_then_save_reopen_and_undo() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.info.encrypted, "the working file is encrypted: {:?}", d.info.warnings); + assert_eq!(d.security_summary().unwrap().method, "AES, 256-bit"); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "still viewable in this session"); + let saved = s.save_bytes(id).unwrap(); + assert!(printcraft_cos::Document::open(saved.clone()).is_err(), "needs the password"); + s.mark_saved(id, saved.clone(), None).unwrap(); + // Further edits keep working and saving stays encrypted. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let again = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + assert!(s2.open("p.pdf", None, again.clone(), None).is_err()); + let id2 = s2.open("p.pdf", None, again, Some("secret")).unwrap(); + assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90); + // Nothing is restricted without a permissions password: the opener may remove security. + assert!(s2.get(id2).unwrap().allows_security_change()); + s2.apply(id2, Edit::RemoveProtection).unwrap(); + let plain = s2.save_bytes(id2).unwrap(); + assert!(printcraft_cos::Document::open(plain).is_ok()); +} + +#[test] +fn permissions_password_restricts_others_but_not_this_session() { + let (mut s, id) = session_with(1); + let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p.clone())).unwrap(); + assert!(s.get(id).unwrap().allows_modification(), "the author keeps full rights"); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_modification(), "…also after saving (re-opened as owner)"); + // Someone else opens it without a password: restricted as chosen. + let mut s2 = Session::new(); + let id2 = s2.open("r.pdf", None, saved.clone(), None).unwrap(); + let perm = s2.get(id2).unwrap().permissions().unwrap(); + assert!(perm.print() && !perm.print_high_quality() && perm.annotate() && perm.fill_forms() && !perm.copy() && !perm.modify()); + assert!(perm.extract_for_accessibility()); + assert!(!s2.get(id2).unwrap().allows_security_change()); + assert_eq!(s2.apply(id2, Edit::RemoveProtection), Err(EditError::NotPermitted("changing security"))); + assert!(s2.get(id2).unwrap().allows_annotation(), "commenting was allowed"); + // The owner password lifts everything. + let id3 = s2.open("r.pdf", None, saved, Some("boss")).unwrap(); + assert!(s2.get(id3).unwrap().allows_security_change()); +} + +#[test] +fn protection_is_validated_undoable_and_never_logged() { + let (mut s, id) = session_with(1); + assert!(matches!(s.apply(id, Edit::Protect(protection(None, None))), Err(EditError::Protection(_)))); + assert!(matches!(s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), Err(EditError::Protection(_)))); + let rc4 = Protection { algorithm: printcraft_cos::Algorithm::Rc4_128, ..protection(Some("pässword"), None) }; + assert!(matches!(s.apply(id, Edit::Protect(rc4)), Err(EditError::Protection(_)))); + assert!(!format!("{:?}", Edit::Protect(protection(Some("hunter2"), Some("x")))).contains("hunter2")); + s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + s.undo(id).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted, "undo removes the pending protection"); + assert!(s.get(id).unwrap().security_summary().is_none()); + s.redo(id).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(page_texts(&s, id), ["Page 1"]); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index a9376320..b59df553 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -277,7 +277,8 @@ impl<'a> Inspector<'a> { } fn fill(&self, info: &mut DocInfo) { - info.encrypted = self.doc.is_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok(); + // lopdf drops /Encrypt from the trailer once it has decrypted the file. + info.encrypted = self.doc.is_encrypted() || self.doc.was_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok(); if let Some(d) = self.doc.trailer.get(b"Info").ok().and_then(|o| self.dict(o)) { info.title = self.text(d, b"Title"); info.author = self.text(d, b"Author"); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 3a52ffa9..c876ea33 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -22,6 +22,13 @@ impl PrintCraftApp { let why = match spec.needs { commands::Needs::Undo => "Nothing to undo".to_string(), commands::Needs::Redo => "Nothing to redo".to_string(), + commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { + if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_security_change()) { + "This document isn't password-protected".to_string() + } else { + "Only the document's owner can change its security (open it with the permissions password)".to_string() + } + } commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { "The document's security settings don't allow this change".to_string() } @@ -48,6 +55,15 @@ impl PrintCraftApp { } "file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)), "protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)), + "protect.password" => { + self.protect_draft = Default::default(); + self.dialog = Some(Dialog::Protect); + } + "protect.remove" => { + if self.apply_edit(Edit::RemoveProtection) { + self.notify("Security will be removed when you save"); + } + } "page.number" => { if let Some(i) = active { let v = &self.views[i]; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 59a9f27a..875730ae 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -30,6 +30,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut number_now: Option = None; let mut apply_number = false; let mut link_command: Option<&'static str> = None; + let mut protect_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; @@ -93,6 +94,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { None => { row(ui, "Security method", "No security".into()); row(ui, "Restrictions", "None — everything is allowed".into()); + if doc.allows_security_change() && ui.button("Protect using password…").clicked() { + link_command = Some("protect.password"); + } } Some(sec) => { row(ui, "Security method", "Password security".into()); @@ -100,8 +104,24 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { row( ui, "Opened with", - if sec.owner { "Owner password (no restrictions apply)".into() } else { "User password".into() }, + if sec.pending { + "— (protection is applied when you save)".into() + } else if sec.owner { + "Owner password (no restrictions apply)".into() + } else { + "User password".into() + }, ); + if doc.allows_security_change() { + ui.horizontal(|ui| { + if ui.button("Change settings…").clicked() { + link_command = Some("protect.password"); + } + if ui.button("Remove security").clicked() { + link_command = Some("protect.remove"); + } + }); + } let p = sec.permissions; let yes = |b: bool| if b { "Allowed".to_string() } else { "Not allowed".to_string() }; row( @@ -194,6 +214,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::Protect => { + let (apply, cancel) = crate::protect::body(ui, app, &t); + protect_now = apply; + close = apply || cancel; + return; + } Dialog::NumberPages => { use printcraft_engine::LabelStyle as L; ui.label(egui::RichText::new("Number pages").font(theme::semibold(18.0))); @@ -392,8 +418,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { app.discard_recovered(&keys); } } - if let Some(cmd) = link_command { - app.execute(cmd); + if protect_now && app.apply_edit(app.protect_draft.edit()) { + app.protect_draft = Default::default(); + app.notify("Password protection will be applied when you save"); } if apply_number && let Some(edit) = number_now { app.apply_edit(edit); @@ -404,12 +431,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { if apply && let Some(edits) = draft_changes(app) { app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); } - if modal.should_close() || close { + // Protect / Remove security replace the Properties dialog. + let replaces = link_command.is_some_and(|c| c.starts_with("protect.")); + if modal.should_close() || close || replaces { app.dialog = None; app.props_draft = None; } else { app.dialog = Some(next); } + if let Some(cmd) = link_command { + app.execute(cmd); + } } /// Info edits needed to make the document match the Description draft. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c404daca..2c0f9cb9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ mod icon_data; pub mod icons; mod palette; mod panels; +mod protect; mod recovery; pub mod theme; mod widgets; @@ -76,6 +77,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Protect Using Password. + Protect, /// Documents from a session that ended unexpectedly. Recovery, } @@ -163,6 +166,8 @@ pub struct PrintCraftApp { pub bookmark_rename: Option<(Vec, String)>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, + /// Protect Using Password dialog state. + pub protect_draft: protect::ProtectDraft, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -225,6 +230,7 @@ impl PrintCraftApp { control: None, bookmark_rename: None, last_opened_url: None, + protect_draft: Default::default(), number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } @@ -514,6 +520,7 @@ impl PrintCraftApp { "advanced" => Some(Dialog::Properties(PropsTab::Advanced)), "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), + "protect" => Some(Dialog::Protect), "number-pages" => { // Same path as the menu, so the page range is seeded. self.execute("page.number"); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs new file mode 100644 index 00000000..9af4363c --- /dev/null +++ b/crates/ui-egui/src/protect.rs @@ -0,0 +1,251 @@ +//! Protect Using Password (Acrobat's simplified dialog, screenshot 13b), with the classic +//! Password Security settings behind "Advanced Options" (compatibility, what to encrypt, +//! printing, changes, copying, screen readers). Execution plan M8.1. + +use egui::{Align, Color32, Layout}; +use printcraft_engine::{Changes, Edit, Printing, Protection}; + +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +/// The dialog's state while it is open. +#[derive(Clone, Debug, PartialEq)] +pub struct ProtectDraft { + /// `true`: password required for Viewing; `false`: for Editing. + pub viewing: bool, + pub password: String, + pub confirm: String, + pub advanced: bool, + pub protection: Protection, +} + +impl Default for ProtectDraft { + fn default() -> Self { + Self { viewing: true, password: String::new(), confirm: String::new(), advanced: false, protection: Protection::default() } + } +} + +impl ProtectDraft { + /// Why Apply is disabled, if it is. + pub fn problem(&self) -> Option<&'static str> { + if self.password.is_empty() { + Some("Type a password.") + } else if self.password != self.confirm { + Some("The passwords don't match.") + } else if self.protection.algorithm != printcraft_engine::Algorithm::Aes256 && !self.password.chars().all(|c| (' '..='~').contains(&c)) { + Some("This compatibility level supports only plain ASCII passwords.") + } else { + None + } + } + + /// The edit Apply makes. + pub fn edit(&self) -> Edit { + let mut p = self.protection.clone(); + if self.viewing { + p.open_password = Some(self.password.clone()); + p.permissions_password = None; + } else { + p.open_password = None; + p.permissions_password = Some(self.password.clone()); + } + Edit::Protect(p) + } +} + +/// A rough password strength, as Acrobat's meter shows (Weak / Medium / Strong). +pub fn strength(pw: &str) -> (&'static str, Color32) { + let classes = [ + pw.chars().any(|c| c.is_lowercase()), + pw.chars().any(|c| c.is_uppercase()), + pw.chars().any(|c| c.is_ascii_digit()), + pw.chars().any(|c| !c.is_alphanumeric()), + ] + .iter() + .filter(|b| **b) + .count(); + let n = pw.chars().count(); + if n >= 12 && classes >= 3 { + ("Strong", Color32::from_rgb(0x2D, 0x9D, 0x5B)) + } else if n >= 8 && classes >= 2 { + ("Medium", Color32::from_rgb(0xE8, 0x8A, 0x1A)) + } else { + ("Weak", Color32::from_rgb(0xD3, 0x2F, 0x2F)) + } +} + +/// A radio button drawn like Acrobat's: an outlined circle, filled blue with a white dot when on. +fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(220.0, 26.0), egui::Sense::click()); + let c = egui::pos2(rect.left() + 9.0, rect.center().y); + if on { + ui.painter().circle_filled(c, 8.0, t.accent); + ui.painter().circle_filled(c, 3.0, Color32::WHITE); + } else { + ui.painter().circle_stroke(c, 7.5, egui::Stroke::new(1.5, t.text_muted)); + } + ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::regular(14.0), t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// Draw the dialog body; returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) { + use printcraft_engine::Algorithm as A; + let d = &mut app.protect_draft; + ui.label(egui::RichText::new("Protect Using Password").font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.separator(); + ui.add_space(6.0); + ui.label("Requires user to enter a password for:"); + ui.add_space(4.0); + if radio(ui, t, d.viewing, "Viewing").clicked() { + d.viewing = true; + } + if radio(ui, t, !d.viewing, "Editing").clicked() { + d.viewing = false; + } + ui.add_space(10.0); + // Bordered fields (Acrobat: 1 pt #B1B1B1, radius 4). + let field = |ui: &mut egui::Ui, label: &str, text: &mut String, id: &str| { + let l = ui.label(label); + egui::Frame::new() + .stroke(egui::Stroke::new(1.0, t.border)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.add(egui::TextEdit::singleline(text).password(true).desired_width(290.0).frame(egui::Frame::NONE).id_salt(id)).labelled_by(l.id) + }) + .inner + }; + let r = field(ui, "Type Password", &mut d.password, "protect-pw"); + if r.changed() || !d.password.is_empty() { + let (s, c) = strength(&d.password); + if !d.password.is_empty() { + ui.label(egui::RichText::new(format!("Strength: {s}")).font(theme::medium(11.5)).color(c)); + } + } + ui.add_space(6.0); + field(ui, "Re-type Password", &mut d.confirm, "protect-pw2"); + ui.add_space(6.0); + let chevron = if d.advanced { "⌃" } else { "⌄" }; + if ui.add(egui::Button::new(egui::RichText::new(format!("Advanced Options {chevron}")).font(theme::semibold(13.0))).frame(false)).clicked() { + d.advanced = !d.advanced; + } + if d.advanced { + let p = &mut d.protection; + egui::Grid::new("protect-advanced").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Compatibility"); + let algos = [ + (A::Aes256, "Acrobat X and later (256-bit AES)"), + (A::Aes128, "Acrobat 7.0 and later (128-bit AES)"), + (A::Rc4_128, "Acrobat 5.0 and later (128-bit RC4)"), + (A::Rc4_40, "Acrobat 3.0 and later (40-bit RC4)"), + ]; + let cur = algos.iter().find(|(a, _)| *a == p.algorithm).map_or(algos[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { + for (a, l) in algos { + ui.selectable_value(&mut p.algorithm, a, l); + } + }); + ui.end_row(); + ui.label("Encrypt"); + ui.vertical(|ui| { + ui.radio_value(&mut p.encrypt_metadata, true, "All document contents"); + // Unencrypted metadata needs crypt filters (Acrobat 6.0 and later). + ui.add_enabled_ui(p.algorithm != A::Rc4_40 && p.algorithm != A::Rc4_128, |ui| { + ui.radio_value(&mut p.encrypt_metadata, false, "All document contents except metadata"); + }); + }); + ui.end_row(); + if !d.viewing { + ui.label("Printing allowed"); + let opts = [(Printing::None, "None"), (Printing::Low, "Low Resolution (150 dpi)"), (Printing::High, "High Resolution")]; + let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or("High Resolution", |(_, l)| *l); + egui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { + for (o, l) in opts { + ui.selectable_value(&mut p.printing, o, l); + } + }); + ui.end_row(); + ui.label("Changes allowed"); + let opts = [ + (Changes::None, "None"), + (Changes::Pages, "Inserting, deleting, and rotating pages"), + (Changes::FillSign, "Filling in form fields and signing existing signature fields"), + (Changes::CommentFillSign, "Commenting, filling in form fields, and signing existing signature fields"), + (Changes::AnyExceptExtract, "Any except extracting pages"), + ]; + let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or("None", |(_, l)| *l); + egui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { + for (o, l) in opts { + ui.selectable_value(&mut p.changes, o, l); + } + }); + ui.end_row(); + ui.label(""); + ui.vertical(|ui| { + ui.checkbox(&mut p.copy, "Enable copying of text, images, and other content"); + ui.checkbox(&mut p.accessibility, "Enable text access for screen reader devices for the visually impaired"); + }); + ui.end_row(); + } + }); + if p.algorithm == A::Rc4_40 || p.algorithm == A::Rc4_128 { + p.encrypt_metadata = true; + } + } + ui.add_space(8.0); + if let Some(problem) = d.problem().filter(|_| !d.password.is_empty() || !d.confirm.is_empty()) { + ui.label(egui::RichText::new(problem).color(t.text_muted).font(theme::regular(12.0))); + } + ui.label( + egui::RichText::new(if d.viewing { + "Anyone opening the document will need this password. It's applied when you save." + } else { + "The document opens without a password; this password is needed to change the restrictions. It's applied when you save." + }) + .color(t.text_faint) + .font(theme::regular(11.5)), + ); + ui.add_space(12.0); + let (mut apply, mut cancel) = (false, false); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ok = d.problem().is_none(); + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Apply", true)).inner.clicked() { + apply = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (apply, cancel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drafts_validate_and_map_to_the_right_password() { + let mut d = ProtectDraft::default(); + assert!(d.problem().is_some()); + d.password = "abc".into(); + d.confirm = "abd".into(); + assert_eq!(d.problem(), Some("The passwords don't match.")); + d.confirm = "abc".into(); + assert_eq!(d.problem(), None); + match d.edit() { + Edit::Protect(p) => assert_eq!((p.open_password.as_deref(), p.permissions_password), (Some("abc"), None)), + other => panic!("{other:?}"), + } + d.viewing = false; + match d.edit() { + Edit::Protect(p) => assert_eq!((p.open_password, p.permissions_password.as_deref()), (None, Some("abc"))), + other => panic!("{other:?}"), + } + assert_eq!(strength("abc").0, "Weak"); + assert_eq!(strength("Abcdefg1").0, "Medium"); + assert_eq!(strength("Correct-Horse-9").0, "Strong"); + } +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 9a5e49bd..d998b283 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -58,6 +58,10 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + if spec.id == "protect.remove" { + let p = printcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() }; + app.apply_edit(printcraft_engine::Edit::Protect(p)); + } let dir = std::env::temp_dir().join(format!("printcraft-cmd-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); diff --git a/crates/ui-egui/tests/protect.rs b/crates/ui-egui/tests/protect.rs new file mode 100644 index 00000000..c38c6ebf --- /dev/null +++ b/crates/ui-egui/tests/protect.rs @@ -0,0 +1,83 @@ +//! Protect Using Password in the real shell (egui_kittest). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn type_into(h: &mut Harness<'static, PrintCraftApp>, label: &str, text: &str) { + h.get_by_label(label).click(); + h.run_steps(2); + h.get_by_label(label).type_text(text); + h.run_steps(2); +} + +#[test] +fn protecting_for_viewing_requires_matching_passwords_and_saves_encrypted() { + let mut h = harness(); + assert!(h.state_mut().execute("protect.password")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Protect)); + h.get_by_label("Viewing"); + type_into(&mut h, "Type Password", "Open-Sesame-42"); + type_into(&mut h, "Re-type Password", "Open-Sesame-4"); + h.get_by_label_contains("don't match"); + assert!(h.query_by(|n| n.label().as_deref() == Some("Apply") && n.is_disabled()).is_some(), "Apply is disabled"); + type_into(&mut h, "Re-type Password", "2"); + h.get_by_label("Strength: Strong"); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let id = h.state().views[0].id; + let doc = h.state().session.get(id).unwrap(); + assert!(doc.info.encrypted && doc.dirty); + assert!(doc.security_summary().unwrap().pending); + // What Save writes needs the password. + let bytes = h.state().session.save_bytes(id).unwrap(); + let mut s = printcraft_engine::Session::new(); + assert!(s.open("p.pdf", None, bytes.clone(), None).is_err()); + assert!(s.open("p.pdf", None, bytes, Some("Open-Sesame-42")).is_ok()); + // Remove security is now offered, and undo also takes the protection back. + assert!(h.state_mut().execute("protect.remove")); + h.state_mut().undo(); + h.state_mut().undo(); + assert!(!h.state().session.get(id).unwrap().info.encrypted); +} + +#[test] +fn editing_restrictions_via_advanced_options() { + let mut h = harness(); + h.state_mut().execute("protect.password"); + h.run_steps(2); + h.get_by_label("Editing").click(); + h.run_steps(2); + h.get_by_label("Advanced Options ⌄").click(); + h.run_steps(2); + h.get_by_label("Enable copying of text, images, and other content").click(); + h.run_steps(1); + type_into(&mut h, "Type Password", "boss"); + type_into(&mut h, "Re-type Password", "boss"); + h.get_by_label("Apply").click(); + h.run_steps(3); + let id = h.state().views[0].id; + let sec = h.state().session.get(id).unwrap().security_summary().unwrap(); + assert!(sec.permissions.copy() && sec.permissions.print() && !sec.permissions.modify() && !sec.permissions.annotate()); + // Remove security is disabled for nobody here: this session set it. + assert!(h.state().session.get(id).unwrap().allows_security_change()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 777205f2..d4e9c984 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5251,11 +5251,11 @@ title = "Protect with a password to open" area = "G" tier = "P0" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/cos/tests/encryption.rs::changing_and_removing_passwords"] -notes = "engine can set passwords; Protect UI and command are M8.1" +status = "shipped" +commands = ["protect.password"] +tools = ["doc_protect"] +evidence = ["crates/ui-egui/tests/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"] +notes = "Protect Using Password (Viewing); applied by the next save" [[feature]] id = "protect.permissions-password" @@ -5263,11 +5263,11 @@ title = "Restrict editing and printing with a permissions password" area = "G" tier = "P0" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/crypt/src/tests.rs::created_encryption_authenticates_owner_and_user"] -notes = "engine only; no UI" +status = "shipped" +commands = ["protect.password"] +tools = ["doc_protect"] +evidence = ["crates/ui-egui/tests/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/automation.rs::protecting_through_tools"] +notes = "Protect Using Password (Editing); the author keeps full rights after saving" [[feature]] id = "protect.permissions-matrix" @@ -5275,11 +5275,11 @@ title = "Choose printing, changes, copying and accessibility permissions" area = "G" tier = "P0" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/crypt/src/tests.rs::permission_bits_follow_table_22"] -notes = "engine only; no UI" +status = "shipped" +commands = ["protect.password"] +tools = ["doc_protect"] +evidence = ["crates/ui-egui/tests/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"] +notes = "Advanced Options: printing (none/low/high), changes (5 levels), copying, screen readers" [[feature]] id = "protect.encryption-level" @@ -5287,11 +5287,11 @@ title = "Choose encryption compatibility (RC4 40/128, AES-128, AES-256)" area = "G" tier = "P1" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] -notes = "engine only; AES-256 default in the planned UI" +status = "shipped" +commands = ["protect.password"] +tools = ["doc_protect"] +evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"] +notes = "AES-256 (default), AES-128, RC4-128, RC4-40" [[feature]] id = "protect.encrypt-except-metadata" @@ -5299,11 +5299,11 @@ title = "Encrypt everything except metadata" area = "G" tier = "P1" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/cos/tests/encryption.rs::unencrypted_metadata_is_left_readable"] -notes = "engine only" +status = "shipped" +commands = ["protect.password"] +tools = ["doc_protect"] +evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"] +notes = "Advanced Options; disabled for RC4 levels as in Acrobat" [[feature]] id = "protect.encrypt-attachments-only" @@ -5323,11 +5323,11 @@ title = "Password strength meter" area = "G" tier = "P2" milestone = "M8" -status = "planned" -commands = [] +status = "shipped" +commands = ["protect.password"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"] +notes = "Weak / Medium / Strong under the password field" [[feature]] id = "protect.remove-security" @@ -5335,11 +5335,11 @@ title = "Remove security" area = "G" tier = "P0" milestone = "M8" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/cos/tests/encryption.rs::changing_and_removing_passwords"] -notes = "engine only" +status = "shipped" +commands = ["protect.remove"] +tools = ["doc_unprotect"] +evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"] +notes = "needs the permissions password (or none set)" [[feature]] id = "protect.certificate-encryption"