From bb1d9a0d8ad2534adf6404dbeafb56d6b804e7c1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 02:59:19 -0400 Subject: [PATCH] M9.6: Use a certificate in the app: Digitally sign / Certify (draw a rectangle, or click an empty signature field) and Certify (invisible); Sign with a Digital ID, Configure New Digital ID (create a self-signed ID or use a .p12/.pfx) and Sign as dialogs with appearance preview, reason, location and certification permissions; signing saves; Signatures panel (status, details, trust, view signed version, export certificate); signature message bar; digital IDs and trusted certificates persist. Fix: locate signature placeholders by their unique ByteRange marker (full saves renumber objects) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 1 + crates/engine/src/catalog.rs | 9 +- crates/engine/src/commands.rs | 5 + crates/sign/src/pdf.rs | 27 +- crates/sign/tests/pdf.rs | 18 + crates/ui-egui/src/canvas.rs | 81 +++- crates/ui-egui/src/chrome.rs | 3 + crates/ui-egui/src/commands.rs | 15 + crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 7 + crates/ui-egui/src/files.rs | 2 +- crates/ui-egui/src/forms_ui.rs | 5 +- crates/ui-egui/src/lib.rs | 32 +- crates/ui-egui/src/panels.rs | 21 + crates/ui-egui/src/sign_ui.rs | 790 ++++++++++++++++++++++++++++++++ crates/ui-egui/tests/signing.rs | 225 +++++++++ parity/acrobat-features.toml | 212 ++++----- 17 files changed, 1315 insertions(+), 140 deletions(-) create mode 100644 crates/ui-egui/src/sign_ui.rs create mode 100644 crates/ui-egui/tests/signing.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index be0262c24a..fcce5d605b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -508,6 +508,7 @@ pub fn tools() -> Vec { &["name", "password", "path"], )), t("sign_document", "Sign a document", "Sign with a digital ID (.p12/.pfx) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + .cmd("sign.digital") .with(schema( json!({ "doc": doc(), diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3becb44622..f1d3923841 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -357,14 +357,15 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "badge-check", hue: TEAL, badge: None, - availability: Planned("M9"), + availability: Ready, sections: &[ToolSection { title: "Certificates", items: &[ - item("Digitally sign", "signature", "sign.digital", Planned("M9")), + item("Digitally sign", "signature", "sign.digital", Ready), item("Timestamp", "clock-3", "sign.timestamp", Planned("M9")), - item("Validate all signatures", "badge-check", "sign.validate", Planned("M9")), - item("Certify (visible signature)", "badge-check", "sign.certify", Planned("M9")), + item("Validate all signatures", "badge-check", "sign.validate", Ready), + item("Certify (visible signature)", "badge-check", "sign.certify", Ready), + item("Certify (invisible signature)", "badge-check", "sign.certify_invisible", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 386deac41b..7d6d280f7c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -219,6 +219,11 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.add.button", "Add a button", None, None, Modification, "square"), c("form.add.date", "Add a date field", None, None, Modification, "clock-3"), c("form.add.signature", "Add a digital signature field", None, None, Modification, "signature"), + c("sign.digital", "Digitally sign", None, None, Document, "signature"), + c("sign.certify", "Certify (visible signature)", None, None, Document, "badge-check"), + c("sign.certify_invisible", "Certify (invisible signature)", None, None, Document, "badge-check"), + c("sign.validate", "Validate all signatures", None, None, Document, "badge-check"), + c("sign.panel", "Signatures panel", VIEW, None, Document, "signature"), c("sign.fill.text", "Fill & Sign: add text", None, None, Annotate, "type"), c("sign.fill.check", "Fill & Sign: checkmark", None, None, Annotate, "check"), c("sign.fill.cross", "Fill & Sign: cross", None, None, Annotate, "x"), diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index a96065fc9e..a4e3d3ee6e 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -737,7 +737,7 @@ pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result Result` hex string's span. -fn locate(out: &[u8], num: u32, reserve: usize) -> Result<(usize, (usize, usize)), SignError> { - let head = format!("{num} 0 obj"); - let starts: Vec = out - .windows(head.len()) - .enumerate() - .filter(|(i, w)| *w == head.as_bytes() && (*i == 0 || matches!(out[i - 1], b'\n' | b'\r' | b' '))) - .map(|(i, _)| i) - .collect(); - let obj = *starts.last().ok_or_else(|| SignError::Pdf("the signature object was not written".into()))?; - let end = obj + out[obj..].windows(6).position(|w| w == b"endobj").ok_or_else(|| SignError::Pdf("unterminated signature object".into()))?; - let region = &out[obj..end]; +/// Find the placeholders in the written file: the offset of the ByteRange numbers, and the +/// `<…>` hex string's span. The ByteRange marker is unique; the Contents placeholder is the one +/// in the same object (a full save may renumber objects, so they are not found by number). +fn locate(out: &[u8], reserve: usize) -> Result<(usize, (usize, usize)), SignError> { let mark = format!("0 {} {} {}", BR_MARK[0], BR_MARK[1], BR_MARK[2]); - let br = region.windows(mark.len()).position(|w| w == mark.as_bytes()).ok_or_else(|| SignError::Pdf("ByteRange placeholder not found".into()))?; + let hits: Vec = out.windows(mark.len()).enumerate().filter(|(_, w)| *w == mark.as_bytes()).map(|(i, _)| i).collect(); + let [br] = hits.as_slice() else { return Err(SignError::Pdf("ByteRange placeholder not found".into())) }; + let start = out[..*br].windows(3).rposition(|w| w == b"obj").ok_or_else(|| SignError::Pdf("signature object not found".into()))?; + let end = br + out[*br..].windows(6).position(|w| w == b"endobj").ok_or_else(|| SignError::Pdf("unterminated signature object".into()))?; + let region = &out[start..end]; let mut zeros = vec![b'<']; zeros.extend(std::iter::repeat_n(b'0', reserve * 2)); zeros.push(b'>'); let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).ok_or_else(|| SignError::Pdf("Contents placeholder not found".into()))?; - Ok((obj + br, (obj + c, obj + c + zeros.len()))) + Ok((*br, (start + c, start + c + zeros.len()))) } /// "2026.10.02 14:03:11 +02'00'" from a PDF date. diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 0d2226897e..d4bb8497a5 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -188,3 +188,21 @@ fn validates_a_signature_made_by_openssl() { let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().next().unwrap(); assert_eq!(s.modification, Modification::Allowed(vec!["comments".into()])); } + +#[test] +fn files_without_a_cross_reference_table_are_signed_with_a_full_write() { + // No xref: the document is reconstructed, so saving rewrites (and renumbers) everything. + let bytes = 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" + .to_vec(); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + for certify in [None, Some(2)] { + let signed = printcraft_sign::sign(&open(&bytes), &id, &SignOptions { certify, rect: None, ..opts() }).unwrap(); + let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert_eq!((s.status, s.certify, s.modification.clone()), (Status::Unknown, certify, Modification::None), "{:?}", s.details); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c27d6449be..ba622f94de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -150,6 +150,8 @@ pub struct DocView { pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, + /// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked. + pub sign: crate::sign_ui::SignView, /// Fill & Sign text being typed. pub fill_text: Option, /// A non-edit action requested by the organize toolbar, handled by the app. @@ -230,6 +232,7 @@ impl DocView { redact_drag: None, pending_redaction: None, crop_drag: None, + sign: Default::default(), fill_text: None, } } @@ -826,8 +829,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - if notices(view, info, secured, ui, &t) { - app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)); + match notices(view, info, secured, crate::sign_ui::banner(&doc.signatures), ui, &t) { + Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), + Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), + None => {} } if view.organize { organize_grid(view, info, &doc.renderer, doc.allows_assembly(), ui, &t); @@ -891,9 +896,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::Comment(t) => t.markup().is_some(), QuickTool::Select => !preparing, QuickTool::Redact => true, - QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) | QuickTool::AddText | QuickTool::Stamp(_) | QuickTool::Link => { - false - } + QuickTool::Hand + | QuickTool::Crop + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::AddText + | QuickTool::Stamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -1080,6 +1090,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, &resp, &xf, i, info, view); + } if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { view.current = i; open_boxes = true; @@ -1379,6 +1392,25 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } + // A signature rectangle was drawn, or an empty signature field clicked. + let drawn = app.views[index].sign.drawn.take(); + if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { + tool = QuickTool::Select; + app.quick_tool = tool; + app.start_signing(page, Some(rect), None, certify.then_some(2)); + } + if let Some(field) = app.views[index].sign.field.take() { + let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); + if signed { + app.right = Some(RightPanel::Signatures); + if !app.sig_expanded.contains(&field) { + app.sig_expanded.push(field); + } + } else { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); + } + } app.quick_tool = tool; if let Some((p, i)) = open_props { app.open_comment_props(p, i); @@ -1528,10 +1560,39 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: } } -/// The notice bar above the pages. Returns `true` when "Security settings" was clicked. -fn notices(view: &mut DocView, info: &DocInfo, secured: bool, ui: &mut egui::Ui, t: &Tokens) -> bool { +/// What the notice bar's buttons ask for. +enum Notice { + Security, + Signatures, +} + +/// The notice bar above the pages: the signature status first (Acrobat's signature bar), then +/// security, forms and warnings. +fn notices( + view: &mut DocView, + info: &DocInfo, + secured: bool, + signed: Option<(&str, Color32, String)>, + ui: &mut egui::Ui, + t: &Tokens, +) -> Option { + if let Some((icon, color, text)) = signed { + let mut open = false; + egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(icons::image(icon, 16.0, color)); + ui.label(egui::RichText::new(text).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::pill_button(ui, "Signature panel", false).clicked() { + open = true; + } + }); + }); + }); + return open.then_some(Notice::Signatures); + } if view.notice_dismissed { - return false; + return None; } let mut open_security = false; let msg = if secured { @@ -1543,7 +1604,7 @@ fn notices(view: &mut DocView, info: &DocInfo, secured: bool, ui: &mut egui::Ui, } else { None }; - let Some((icon, text, fields)) = msg else { return false }; + let (icon, text, fields) = msg?; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { ui.horizontal(|ui| { ui.add(icons::image(icon, 16.0, t.accent_text)); @@ -1564,7 +1625,7 @@ fn notices(view: &mut DocView, info: &DocInfo, secured: bool, ui: &mut egui::Ui, }); }); }); - open_security + open_security.then_some(Notice::Security) } /// The floating quick-action bar at the left edge of the document area. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0e212fa320..3d82b9499d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -235,6 +235,7 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) { (RightPanel::Fields, "Fields"), (RightPanel::Layers, "Layers"), (RightPanel::Attachments, "Attachments"), + (RightPanel::Signatures, "Signatures"), ] { if ui.radio(app.right == Some(p), label).clicked() { app.right = Some(p); @@ -250,6 +251,7 @@ pub fn right_rail(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; let Some(doc) = app.session.get(id) else { return }; + let has_signatures = doc.is_signed(); let (has_comments, has_outline, has_fields, has_layers, has_files) = ( !doc.info.annotations.is_empty(), !doc.info.outline.is_empty(), @@ -282,6 +284,7 @@ pub fn right_rail(app: &mut PrintCraftApp, ui: &mut egui::Ui) { rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); + rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); // Page navigation cluster at the bottom (as in Acrobat's rail). let view = &mut app.views[index]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index fed726dc80..6a1334a925 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -292,6 +292,21 @@ impl PrintCraftApp { } "comment.export" => self.export_data_dialog(true, false), "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), + "sign.digital" | "sign.certify" => { + let certify = id == "sign.certify"; + self.quick_tool = crate::QuickTool::SignArea { certify }; + self.notify("Drag to draw the area where the signature should appear."); + } + "sign.certify_invisible" => { + let page = active.map_or(0, |i| self.views[i].current); + self.start_signing(page, None, None, Some(2)); + } + "sign.validate" => { + let certs = self.session.trusted_certificates().to_vec(); + self.session.set_trusted_certificates(certs); + self.right = Some(RightPanel::Signatures); + } + "sign.panel" => self.right = Some(RightPanel::Signatures), "comment.hide_all" => { if let Some(i) = active { let id = self.views[i].id; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6924654090..fe8edc3920 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -541,6 +541,8 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Redact => "redact".to_string(), crate::QuickTool::AddText => "add-text".to_string(), crate::QuickTool::Link => "link".to_string(), + crate::QuickTool::SignArea { certify: false } => "sign".to_string(), + crate::QuickTool::SignArea { certify: true } => "certify".to_string(), crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4251f16e08..01d5d70b0d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -449,6 +449,13 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { }); return; } + Dialog::Sign => { + if crate::sign_ui::dialog(ui, app, &t) { + app.sign_draft = None; + close = true; + } + return; + } Dialog::SummarizeComments => { ui.label(egui::RichText::new("Summarize Options").font(theme::semibold(18.0))); ui.add_space(8.0); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bf450ceb13..f8981691b8 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -255,7 +255,7 @@ impl PrintCraftApp { } /// Write named files into a chosen folder (desktop) or as downloads (web). Returns how many. - fn write_files(&mut self, named: &[(String, Arc>)], title: &str) -> usize { + pub(crate) fn write_files(&mut self, named: &[(String, Arc>)], title: &str) -> usize { #[cfg(not(target_arch = "wasm32"))] { let dir = match &self.export_dir_override { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index e8d162286e..9e7875f492 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -74,7 +74,8 @@ pub(crate) fn page_input( f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && widget_rect(xf, info, page, w.rect).contains(p)).map(|(i, w)| (f, i, w)) }); let Some((f, wi, w)) = hit else { return false }; - let usable = allowed && fillable(f); + // Empty signature fields are signed by clicking them (Use a certificate). + let usable = allowed && (fillable(f) || f.kind == FormFieldKind::Signature); ui.ctx().set_cursor_icon(match (usable, f.kind) { (false, _) => egui::CursorIcon::NotAllowed, (true, FormFieldKind::Text) => egui::CursorIcon::Text, @@ -93,7 +94,7 @@ pub(crate) fn page_input( Some(a) => view.forms.button = Some((f.name.clone(), a.clone())), None => view.forms.notice = Some(format!("{} has no action", f.name)), }, - FormFieldKind::Signature => view.forms.notice = Some("Signing arrives with digital signatures (M9)".into()), + FormFieldKind::Signature => view.sign.field = Some(f.name.clone()), FormFieldKind::CheckBox => { view.forms.focus = None; view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Check(f.value.is_empty()) }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14e339694b..0d260f69d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ mod create_ui; mod crop; mod export_ui; mod marks_ui; +mod sign_ui; /// Header & footer / watermark / background dialog types (tests and automation). pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; @@ -24,6 +25,7 @@ mod content_ui; mod link_ui; pub use create_ui::Clip; pub use link_ui::LinkDraft; +pub use sign_ui::{DigitalIdEntry, SignDraft, SignStep}; mod dialogs; mod editing; mod files; @@ -79,6 +81,7 @@ pub enum RightPanel { Fields, Layers, Attachments, + Signatures, } /// Quick-action bar tools (the vertical floating strip). @@ -102,6 +105,10 @@ pub enum QuickTool { Stamp(printcraft_engine::StampKind), /// Edit a PDF ▸ Link: draw link areas, select and edit links. Link, + /// Use a certificate ▸ Digitally sign / Certify (visible): drag the signature's rectangle. + SignArea { + certify: bool, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -149,6 +156,8 @@ pub enum Dialog { Recovery, /// Comments ▸ Summarize comments (options). SummarizeComments, + /// Sign with a Digital ID ▸ Configure ▸ Sign as. + Sign, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -221,6 +230,12 @@ pub struct PrintCraftApp { pub rotate_draft: RotateDraft, /// Summarize Comments: sort order. pub summary_sort: printcraft_engine::SummarySort, + /// The signing dialogs' state. + pub sign_draft: Option, + /// Digital ID files the user has created or added. + pub digital_ids: Vec, + /// Signatures panel: expanded entries (field names). + pub sig_expanded: Vec, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// Entries left by a previous session, offered in the Recovery dialog. @@ -320,6 +335,9 @@ impl PrintCraftApp { extract_draft: ExtractDraft::default(), rotate_draft: RotateDraft::default(), summary_sort: Default::default(), + sign_draft: None, + digital_ids: Vec::new(), + sig_expanded: Vec::new(), recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), @@ -582,7 +600,9 @@ impl PrintCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { - serde_json::json!({ "recent": self.recent, "theme": self.theme, "signature": self.signature }).to_string() + let trusted: Vec = self.session.trusted_certificates().iter().map(printcraft_engine::sign::x509::to_pem).collect(); + serde_json::json!({ "recent": self.recent, "theme": self.theme, "signature": self.signature, "digital_ids": self.digital_ids, "trusted": trusted }) + .to_string() } /// Restore state written by `persist`. Unknown or malformed data is ignored. @@ -602,6 +622,13 @@ impl PrintCraftApp { { self.signature = Some(s); } + if let Ok(ids) = serde_json::from_value::>(v["digital_ids"].clone()) { + self.digital_ids = ids; + } + if let Ok(pems) = serde_json::from_value::>(v["trusted"].clone()) { + let certs = pems.iter().filter_map(|p| printcraft_engine::sign::x509::load_certificates(p.as_bytes()).ok()).flatten().collect(); + self.session.set_trusted_certificates(certs); + } } /// `true` while any open document still waits for page renders (used by headless capture). @@ -627,6 +654,7 @@ impl PrintCraftApp { "fields" => Some(RightPanel::Fields), "layers" => Some(RightPanel::Layers), "attachments" => Some(RightPanel::Attachments), + "signatures" => Some(RightPanel::Signatures), "none" => None, other => return Err(format!("unknown panel {other}")), } @@ -730,6 +758,8 @@ impl PrintCraftApp { "redact" => QuickTool::Redact, "add-text" => QuickTool::AddText, "link" => QuickTool::Link, + "sign" => QuickTool::SignArea { certify: false }, + "certify" => QuickTool::SignArea { certify: true }, stamp if stamp.starts_with("stamp-") => QuickTool::Stamp( printcraft_engine::StampKind::ALL .into_iter() diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8cfe430c95..87c3beb6b0 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -325,6 +325,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut bm_action: Option = None; let mut panel_edit: Option = None; let mut panel_command: Option<&'static str> = None; + let mut sig_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); { @@ -332,6 +333,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let info = &doc.info; let view = &mut app.views[index]; let prefs = &app.comment_prefs; + let sig_expanded = &mut app.sig_expanded; let comment_allowed = doc.allows_annotation(); egui::Panel::right("right_panel") .resizable(true) @@ -351,6 +353,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { RightPanel::Fields => ("Fields", Some(info.fields.len())), RightPanel::Layers => ("Layers", Some(info.layers.len())), RightPanel::Attachments => ("Attachments", Some(info.attachments.len())), + RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())), }; ui.horizontal(|ui| { ui.label(egui::RichText::new(title).font(theme::semibold(15.5))); @@ -430,6 +433,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { } } } + RightPanel::Signatures => { + sig_action = crate::sign_ui::panel(ui, &t, &doc.signatures, sig_expanded); + } RightPanel::Attachments => { if info.attachments.is_empty() { empty(ui, &t, "paperclip", "This document has no attachments."); @@ -476,6 +482,21 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if let Some(c) = panel_command { app.run_command(c); } + match sig_action { + Some(crate::sign_ui::PanelAction::Validate) => app.run_command("sign.validate"), + Some(crate::sign_ui::PanelAction::GoTo(p)) => app.views[index].go_to_page(p), + Some(crate::sign_ui::PanelAction::Trust(c)) => app.trust_certificate(*c), + Some(crate::sign_ui::PanelAction::ViewSigned(len)) => app.view_signed_version(len), + Some(crate::sign_ui::PanelAction::Sign(field)) => { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); + } + Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => { + let pem = crate::sign_ui::certificate_pem(&c); + app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + } + None => {} + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs new file mode 100644 index 0000000000..a144c51d3d --- /dev/null +++ b/crates/ui-egui/src/sign_ui.rs @@ -0,0 +1,790 @@ +//! Use a certificate (execution plan M9.6): Digitally sign and Certify (drag a rectangle, or +//! click an empty signature field), the Sign with a Digital ID / Configure New Digital ID / +//! Sign as dialogs, the Signatures panel, and the signature message bar. + +use std::path::PathBuf; + +use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; +use printcraft_engine::sign::{self, Appearance, Certificate, DigitalId, Modification, Name, PrivateKey}; +use printcraft_engine::{SignOptions, SignatureInfo, SignatureStatus}; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, icons, widgets}; + +/// A digital ID the app knows about (Acrobat: Digital ID files). The file stays where it is; +/// its password is asked for at each signing. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DigitalIdEntry { + pub path: String, + pub name: String, + pub issuer: String, + pub email: String, + pub expires: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignStep { + /// Sign with a Digital ID: pick one. + Choose, + /// Configure a Digital ID for signing: from a file or a new self-signed one. + Configure, + /// Sign as "Name": appearance, password, Sign. + SignAs, +} + +/// The key algorithms of a new digital ID (Acrobat offers RSA; P-256 also works in browsers). +pub const KEY_ALGORITHMS: [(&str, &str); 4] = + [("2048-bit RSA", "rsa2048"), ("3072-bit RSA", "rsa3072"), ("4096-bit RSA", "rsa4096"), ("256-bit ECDSA (P-256)", "p256")]; + +#[derive(Clone, Debug, PartialEq)] +pub struct NewIdDraft { + /// `true`: create a self-signed ID; `false`: use an ID from a file. + pub create: bool, + pub name: String, + pub unit: String, + pub organization: String, + pub email: String, + pub country: String, + pub key: usize, + pub password: String, + pub confirm: String, + /// Use a Digital ID from a file: its path and password. + pub file: String, + pub file_password: String, +} + +impl Default for NewIdDraft { + fn default() -> Self { + Self { + create: true, + name: String::new(), + unit: String::new(), + organization: String::new(), + email: String::new(), + country: String::new(), + key: 0, + password: String::new(), + confirm: String::new(), + file: String::new(), + file_password: String::new(), + } + } +} + +/// The signing dialogs' state. +#[derive(Clone, Debug, PartialEq)] +pub struct SignDraft { + pub page: usize, + /// The signature rectangle (user space); `None`: invisible. + pub rect: Option<[f64; 4]>, + /// Sign this empty signature field instead. + pub field: Option, + /// Certify (DocMDP) with these permissions: 1 none, 2 form fill and signing, 3 also comments. + pub certify: Option, + pub step: SignStep, + pub selected: Option, + pub password: String, + pub reason: String, + pub location: String, + pub appearance: Appearance, + pub new_id: NewIdDraft, + pub error: Option, +} + +impl SignDraft { + fn new(page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option, ids: usize) -> Self { + Self { + page, + rect, + field, + certify, + step: if ids == 0 { SignStep::Configure } else { SignStep::Choose }, + selected: (ids > 0).then_some(0), + password: String::new(), + reason: String::new(), + location: String::new(), + appearance: Appearance::default(), + new_id: NewIdDraft::default(), + error: None, + } + } +} + +/// What a page asks of the signing flow. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SignView { + /// A signature rectangle being dragged: (page, start on screen). + pub drag: Option<(usize, Pos2)>, + /// A rectangle was drawn: (page, user-space rect). + pub drawn: Option<(usize, [f64; 4])>, + /// An empty signature field was clicked (its name). + pub field: Option, +} + +/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, info: &printcraft_render::DocInfo, view: &mut DocView) { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let origin = ui.input(|i| i.pointer.press_origin()); + if pointer.is_some_and(|p| xf.rect.contains(p)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) + { + view.sign.drag = Some((page, o)); + } + if let Some((p, start)) = view.sign.drag + && p == page + && let Some(end) = pointer + { + let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); + let r = Rect::from_two_pos(start, end); + ui.painter().rect_filled(r, CornerRadius::ZERO, Color32::from_rgba_unmultiplied(0x14, 0x73, 0xE6, 28)); + ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); + if resp.drag_stopped() { + view.sign.drag = None; + if r.width() >= 8.0 && r.height() >= 8.0 { + let user = |q: Pos2| { + let (vx, vy) = xf.screen_to_view(q); + info.pages[page].view_to_user(vx, vy) + }; + let (a, b) = (user(r.left_top()), user(r.right_bottom())); + let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64]; + view.sign.drawn = Some((page, rect)); + } + } + } +} + +/// Where new digital IDs are saved: next to the recovery folder (`…/PrintCraft/Digital IDs`). +fn id_dir() -> Option { + crate::recovery::RecoveryStore::default_dir().and_then(|d| d.parent().map(|p| p.join("Digital IDs"))) +} + +pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { + DigitalIdEntry { + path: path.to_string(), + name: c.display_name(), + issuer: c.issuer.common_name().map(str::to_string).unwrap_or_else(|| c.issuer.display()), + email: c.subject.email().unwrap_or("").to_string(), + expires: format!("{:04}.{:02}.{:02}", c.not_after.year, c.not_after.month, c.not_after.day), + } +} + +impl PrintCraftApp { + /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. + pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { + self.sign_draft = Some(SignDraft::new(page, rect, field, certify, self.digital_ids.len())); + self.dialog = Some(crate::Dialog::Sign); + } + + /// Add a digital ID file to the list (or select it if it's there). + pub fn add_digital_id(&mut self, path: &str, cert: &Certificate) -> usize { + if let Some(i) = self.digital_ids.iter().position(|e| e.path == path) { + self.digital_ids[i] = entry_for(path, cert); + return i; + } + self.digital_ids.push(entry_for(path, cert)); + self.digital_ids.len() - 1 + } + + /// Configure New Digital ID ▸ Create: a self-signed ID saved as a .p12 next to the app data + /// (or in `export_dir_override`). Returns its list index. + fn create_digital_id(&mut self) -> Result { + let Some(d) = self.sign_draft.as_ref().map(|d| d.new_id.clone()) else { return Err("nothing to create".into()) }; + if d.name.trim().is_empty() { + return Err("Enter a name.".into()); + } + if d.password.chars().count() < 6 { + return Err("The password must have at least 6 characters.".into()); + } + if d.password != d.confirm { + return Err("The passwords do not match.".into()); + } + let key = match KEY_ALGORITHMS[d.key.min(KEY_ALGORITHMS.len() - 1)].1 { + "rsa3072" => PrivateKey::generate_rsa(3072), + "rsa4096" => PrivateKey::generate_rsa(4096), + "p256" => PrivateKey::generate_p256(), + _ => PrivateKey::generate_rsa(2048), + } + .map_err(|e| e.to_string())?; + let now = self.session.now_secs(); + let dn = Name::build(&d.name, &d.unit, &d.organization, &d.email, &d.country); + let serial = sign::keys::DigestAlg::Sha256.digest(&[d.name.as_bytes(), &now.to_be_bytes()])[..8].to_vec(); + let cert = Certificate::self_signed(&dn, &key, sign::Time::from_unix(now), 5, &serial).map_err(|e| e.to_string())?; + let id = DigitalId { key, certificate: cert.clone(), chain: Vec::new(), friendly_name: Some(d.name.trim().to_string()) }; + let p12 = sign::pkcs12::write(&id, &d.password).map_err(|e| e.to_string())?; + let dir = match &self.export_dir_override { + Some(p) => Some(PathBuf::from(p)), + None => id_dir(), + } + .ok_or("There is no folder to save the digital ID in.")?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stem: String = d.name.trim().chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let path = (1..) + .map(|i| dir.join(if i == 1 { format!("{stem}.p12") } else { format!("{stem} {i}.p12") })) + .find(|p| !p.exists()) + .expect("a free name"); + std::fs::write(&path, &p12).map_err(|e| e.to_string())?; + Ok(self.add_digital_id(&path.to_string_lossy(), &cert)) + } + + /// Configure New Digital ID ▸ Use a Digital ID from a file. + fn import_digital_id(&mut self) -> Result { + let Some(d) = self.sign_draft.as_ref().map(|d| d.new_id.clone()) else { return Err("nothing to import".into()) }; + let bytes = std::fs::read(&d.file).map_err(|e| format!("{}: {e}", d.file))?; + let id = sign::pkcs12::open(&bytes, &d.file_password).map_err(|e| match e { + sign::SignError::WrongPassword => "The password is incorrect.".to_string(), + e => e.to_string(), + })?; + Ok(self.add_digital_id(&d.file, &id.certificate)) + } + + /// Sign as …: open the ID, sign, save the signed file (Save As), and show it. + fn finish_signing(&mut self) -> Result<(), String> { + let Some((_, doc_id)) = self.active_ids() else { return Err("no document".into()) }; + let d = self.sign_draft.clone().ok_or("nothing to sign")?; + let entry = d.selected.and_then(|i| self.digital_ids.get(i)).cloned().ok_or("Choose a digital ID.")?; + let bytes = std::fs::read(&entry.path).map_err(|e| format!("{}: {e}", entry.path))?; + let id = sign::pkcs12::open(&bytes, &d.password).map_err(|e| match e { + sign::SignError::WrongPassword => "The password is incorrect.".to_string(), + e => e.to_string(), + })?; + let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_string()); + let opts = SignOptions { + field: d.field.clone(), + page: d.page, + rect: d.rect, + reason: some(&d.reason), + location: some(&d.location), + certify: d.certify, + appearance: d.appearance.clone(), + ..SignOptions::default() + }; + let signed = self.session.sign(doc_id, &id, opts).map_err(|e| e.to_string())?; + // Signing saves, as in Acrobat: choose where (a cancelled save cancels signing). + let name = self.session.get(doc_id).map(|d| d.name.clone()).unwrap_or_default(); + let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let path = match self.save_override.clone() { + Some(p) => Some(PathBuf::from(p)), + #[cfg(not(target_arch = "wasm32"))] + None => rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf")).save_file(), + #[cfg(target_arch = "wasm32")] + None => None, + }; + let Some(path) = path else { return Err(String::new()) }; + std::fs::write(&path, signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?; + self.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?; + if let Some((i, _)) = self.active_ids() { + self.views[i].invalidate_content(); + } + self.right = Some(crate::RightPanel::Signatures); + self.notify(format!("Signed and saved to {}", path.display())); + Ok(()) + } + + /// Trust a certificate (Signatures panel ▸ Add to trusted certificates), revalidating. + pub fn trust_certificate(&mut self, cert: Certificate) { + let mut certs = self.session.trusted_certificates().to_vec(); + if !certs.iter().any(|c| c.raw == cert.raw) { + certs.push(cert); + } + self.session.set_trusted_certificates(certs); + } + + /// Open the signed version of a signature as a new document (View signed version). + pub fn view_signed_version(&mut self, len: usize) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let bytes = doc.bytes[..len.min(doc.bytes.len())].to_vec(); + let name = format!("{} (signed version)", doc.name.trim_end_matches(".pdf")); + match self.session.open(format!("{name}.pdf"), None, std::sync::Arc::new(bytes), doc.password.clone().as_deref()) { + Ok(new) => { + let info = &self.session.get(new).expect("just opened").info; + self.views.push(DocView::new(new, info)); + self.active = Some(self.views.len() - 1); + } + Err(e) => self.notify(format!("Couldn't open the signed version: {e}")), + } + } +} + +/// Draw the signing dialogs; returns `true` when the dialog should close. +pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + let Some(step) = app.sign_draft.as_ref().map(|d| d.step) else { return true }; + match step { + SignStep::Choose => choose(ui, app, t), + SignStep::Configure => configure(ui, app, t), + SignStep::SignAs => sign_as(ui, app, t), + } +} + +fn title(ui: &mut egui::Ui, text: &str) { + ui.label(egui::RichText::new(text).font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.separator(); + ui.add_space(8.0); +} + +fn error(ui: &mut egui::Ui, err: &Option) { + if let Some(e) = err.as_ref().filter(|e| !e.is_empty()) { + ui.colored_label(Color32::from_rgb(0xD7, 0x37, 0x3F), e); + ui.add_space(4.0); + } +} + +fn choose(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + title(ui, "Sign with a Digital ID"); + ui.label("Choose the digital ID that you want to use for signing:"); + ui.add_space(6.0); + let ids = app.digital_ids.clone(); + let d = app.sign_draft.as_mut().expect("checked"); + let mut close = false; + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for (i, e) in ids.iter().enumerate() { + let selected = d.selected == Some(i); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, selected, &e.name)); + let fill = if selected { + t.accent_soft + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(6), fill); + icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, t.accent); + ui.painter().text(rect.min + vec2(40.0, 9.0), egui::Align2::LEFT_TOP, &e.name, theme::semibold(13.0), t.text); + let sub = format!( + "{}Issued by: {}, Expires: {}", + if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, + e.issuer, + e.expires + ); + ui.painter().text(rect.min + vec2(40.0, 28.0), egui::Align2::LEFT_TOP, sub, theme::regular(11.5), t.text_muted); + if resp.clicked() { + d.selected = Some(i); + } + if resp.double_clicked() { + d.step = SignStep::SignAs; + } + } + if ids.is_empty() { + ui.label(egui::RichText::new("No digital IDs yet.").color(t.text_muted)); + } + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if widgets::pill_button(ui, "Configure New Digital ID", false).clicked() { + d.step = SignStep::Configure; + d.error = None; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, "Continue", true)).inner.clicked() { + d.step = SignStep::SignAs; + d.error = None; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } + }); + }); + close +} + +fn configure(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + title(ui, "Configure a Digital ID for Signing"); + let d = app.sign_draft.as_mut().expect("checked"); + let mut close = false; + let mut go = false; + ui.radio_value(&mut d.new_id.create, false, "Use a Digital ID from a file"); + ui.radio_value(&mut d.new_id.create, true, "Create a new Digital ID (self-signed, saved to a password-protected file)"); + ui.add_space(8.0); + let n = &mut d.new_id; + if n.create { + egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (label, value) in [ + ("Name", &mut n.name), + ("Organizational Unit", &mut n.unit), + ("Organization Name", &mut n.organization), + ("Email Address", &mut n.email), + ] { + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(value).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + } + let l = ui.label("Country/Region"); + ui.add(egui::TextEdit::singleline(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); + ui.end_row(); + ui.label("Key Algorithm"); + egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { + for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { + ui.selectable_value(&mut n.key, i, *label); + } + }); + ui.end_row(); + let l = ui.label("Password"); + ui.add(egui::TextEdit::singleline(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label("Confirm Password"); + ui.add(egui::TextEdit::singleline(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.label( + egui::RichText::new("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others.") + .small() + .color(t.text_muted), + ); + } else { + egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let l = ui.label("File (.p12, .pfx)"); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut n.file).desired_width(220.0)).labelled_by(l.id); + #[cfg(not(target_arch = "wasm32"))] + if ui.button("Browse…").clicked() + && let Some(p) = rfd::FileDialog::new().add_filter("Digital ID", &["p12", "pfx"]).pick_file() + { + n.file = p.to_string_lossy().into_owned(); + } + }); + ui.end_row(); + let l = ui.label("Password"); + ui.add(egui::TextEdit::singleline(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + }); + } + ui.add_space(6.0); + error(ui, &d.error); + ui.horizontal(|ui| { + if !app.digital_ids.is_empty() && widgets::pill_button(ui, "Back", false).clicked() { + d.step = SignStep::Choose; + d.error = None; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, if d.new_id.create { "Save" } else { "Continue" }, true).clicked() { + go = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } + }); + }); + if go { + let create = app.sign_draft.as_ref().is_some_and(|d| d.new_id.create); + let result = if create { app.create_digital_id() } else { app.import_digital_id() }; + let d = app.sign_draft.as_mut().expect("checked"); + match result { + Ok(i) => { + d.selected = Some(i); + d.step = SignStep::Choose; + d.error = None; + d.new_id = NewIdDraft::default(); + } + Err(e) => d.error = Some(e), + } + } + close +} + +/// The appearance preview: Acrobat's standard layout (name left, details right). +fn preview(ui: &mut egui::Ui, t: &Tokens, name: &str, d: &SignDraft) { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover()); + let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE }; + ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let inner = rect.shrink(10.0); + let a = &d.appearance; + if a.name { + let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height())); + let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width()); + ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK); + } + let mut lines = Vec::new(); + if a.name { + lines.push(if a.labels { format!("Digitally signed by {name}") } else { name.to_string() }); + } + if a.reason && !d.reason.trim().is_empty() { + lines.push(format!("{}{}", if a.labels { "Reason: " } else { "" }, d.reason.trim())); + } + if a.location && !d.location.trim().is_empty() { + lines.push(format!("{}{}", if a.labels { "Location: " } else { "" }, d.location.trim())); + } + if a.date { + lines.push(format!("{}{}", if a.labels { "Date: " } else { "" }, "(the signing time)")); + } + let x = if a.name { inner.center().x + 6.0 } else { inner.left() }; + let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x); + ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK); +} + +fn sign_as(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + let ids = app.digital_ids.clone(); + let d = app.sign_draft.as_mut().expect("checked"); + let Some(entry) = d.selected.and_then(|i| ids.get(i)).cloned() else { + d.step = SignStep::Choose; + return false; + }; + title(ui, &format!("{} as \"{}\"", if d.certify.is_some() { "Certify" } else { "Sign" }, entry.name)); + let mut close = false; + let mut go = false; + if d.rect.is_some() || d.field.is_some() { + ui.label(egui::RichText::new("Appearance").font(theme::semibold(12.5))); + preview(ui, t, &entry.name, d); + ui.horizontal_wrapped(|ui| { + let a = &mut d.appearance; + ui.checkbox(&mut a.name, "Name"); + ui.checkbox(&mut a.date, "Date"); + ui.checkbox(&mut a.reason, "Reason"); + ui.checkbox(&mut a.location, "Location"); + ui.checkbox(&mut a.distinguished_name, "Distinguished name"); + ui.checkbox(&mut a.labels, "Labels"); + }); + ui.add_space(6.0); + } else { + ui.label(egui::RichText::new("The signature will be invisible.").color(t.text_muted)); + } + egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + if d.certify.is_some() { + ui.label("Permitted actions after certifying"); + let mut p = d.certify.unwrap_or(2); + let label = |p: u8| match p { + 1 => "No changes allowed", + 3 => "Annotations, form fill-in, and digital signatures", + _ => "Form fill-in and digital signatures", + }; + egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { + for v in [1u8, 2, 3] { + ui.selectable_value(&mut p, v, label(v)); + } + }); + d.certify = Some(p); + ui.end_row(); + } + let l = ui.label("Reason"); + ui.add(egui::TextEdit::singleline(&mut d.reason).hint_text("Optional").desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label("Location"); + ui.add(egui::TextEdit::singleline(&mut d.location).hint_text("Optional").desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label("Digital ID password"); + let r = ui.add(egui::TextEdit::singleline(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + go = true; + } + ui.end_row(); + }); + ui.add_space(6.0); + error(ui, &d.error); + ui.horizontal(|ui| { + if widgets::pill_button(ui, "Back", false).clicked() { + d.step = SignStep::Choose; + d.error = None; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Sign", true).clicked() { + go = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } + }); + }); + if go { + match app.finish_signing() { + Ok(()) => { + app.sign_draft = None; + return true; + } + Err(e) => { + if let Some(d) = app.sign_draft.as_mut() { + d.error = Some(e); + } + } + } + } + close +} + +/// The message bar for signed documents: (icon, colour, text, certified). +pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, String)> { + let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect(); + if signed.is_empty() { + return None; + } + if let Some(c) = signed.iter().find(|s| s.certify.is_some()) + && c.status == SignatureStatus::Valid + { + let by = c.certificate.as_ref().map(|x| { + let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default(); + format!("{}{org}, certificate issued by {}.", x.display_name(), x.issuer.common_name().unwrap_or("an unknown issuer")) + }); + return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), format!("Certified by {}", by.unwrap_or_default()))); + } + Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) { + ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.".into()) + } else if signed.iter().all(|s| s.status == SignatureStatus::Valid) { + ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.".into()) + } else { + ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.".into()) + }) +} + +fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) { + if !s.signed { + return ("pen-line", Color32::from_gray(0x80)); + } + match (s.status, &s.modification) { + (SignatureStatus::Valid, Modification::None) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), + (SignatureStatus::Valid, _) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), + (SignatureStatus::Unknown, _) => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)), + (SignatureStatus::Invalid, _) => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)), + } +} + +/// What the Signatures panel asks the app to do. +#[derive(Clone, Debug)] +pub enum PanelAction { + Validate, + GoTo(usize), + Trust(Box), + ViewSigned(usize), + Sign(String), + ExportCertificate(Box), +} + +/// The Signatures panel body. +pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec) -> Option { + let mut action = None; + ui.horizontal(|ui| { + if widgets::pill_button(ui, "Validate all", false).clicked() { + action = Some(PanelAction::Validate); + } + }); + ui.add_space(6.0); + if sigs.is_empty() { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.add(icons::image("signature", 32.0, t.text_faint)); + ui.add_space(6.0); + ui.label(egui::RichText::new("This document has no signatures.").color(t.text_muted)); + }); + return action; + } + let mut ordered: Vec<&SignatureInfo> = sigs.iter().collect(); + ordered.sort_by_key(|s| (!s.signed, s.revision)); + for s in ordered { + let open = expanded.contains(&s.field); + let (icon, color) = status_icon(s); + let head = if s.signed { + format!( + "Rev. {}: {} by {}", + s.revision, + if s.certify.is_some() { "Certified" } else { "Signed" }, + s.signer.as_deref().unwrap_or("an unknown signer") + ) + } else { + format!("Unsigned signature field: {}", s.field) + }; + let resp = ui + .horizontal(|ui| { + let chevron = if open { "chevron-down" } else { "chevron-right" }; + let toggle = icons::button(ui, chevron, 20.0, false, if open { "Collapse" } else { "Expand" }).clicked(); + ui.add(icons::image(icon, 16.0, color)); + let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(12.5)).color(t.text)).sense(egui::Sense::click())); + toggle || l.clicked() + }) + .inner; + if resp { + if open { + expanded.retain(|f| f != &s.field); + } else { + expanded.push(s.field.clone()); + } + } + if !open { + continue; + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 3.0; + if !s.signed { + if widgets::pill_button(ui, "Sign this field…", true).clicked() { + action = Some(PanelAction::Sign(s.field.clone())); + } + return; + } + ui.label( + egui::RichText::new(match s.status { + SignatureStatus::Valid => "Signature is valid:", + SignatureStatus::Unknown => "Signature validity is UNKNOWN:", + SignatureStatus::Invalid => "Signature is INVALID:", + }) + .font(theme::semibold(12.0)), + ); + for line in &s.details { + ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); + } + ui.add_space(4.0); + ui.label(egui::RichText::new("Signature Details").font(theme::semibold(12.0))); + let row = |ui: &mut egui::Ui, k: &str, v: &str| { + ui.add(egui::Label::new(egui::RichText::new(format!("{k}: {v}")).font(theme::regular(11.5))).wrap()); + }; + if let Some(r) = &s.reason { + row(ui, "Reason", r); + } + if let Some(l) = &s.location { + row(ui, "Location", l); + } + if let Some(d) = &s.date { + row(ui, "Signing time", &sign::pdf::display_date(d)); + } + if let Some(a) = &s.algorithm { + row(ui, "Algorithm", a); + } + if let Some(c) = &s.certificate { + row(ui, "Signer", &c.subject.display()); + row(ui, "Issued by", &c.issuer.display()); + row(ui, "Valid", &format!("{} to {}", c.not_before, c.not_after)); + } + match s.page { + Some(p) => { + let link = ui.add( + egui::Label::new( + egui::RichText::new(format!("Field: {} on page {}", s.field, p + 1)) + .font(theme::regular(11.5)) + .color(t.accent_text) + .underline(), + ) + .sense(egui::Sense::click()), + ); + if link.clicked() { + action = Some(PanelAction::GoTo(p)); + } + } + None => row(ui, "Field", &s.field), + } + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + if let Some(c) = s.chain.last().or(s.certificate.as_ref()) + && s.status == SignatureStatus::Unknown + && ui.button("Add to trusted certificates").clicked() + { + action = Some(PanelAction::Trust(Box::new(c.clone()))); + } + if ui.button("View signed version").clicked() { + action = Some(PanelAction::ViewSigned(s.signed_len)); + } + if let Some(c) = &s.certificate + && ui.button("Export certificate…").clicked() + { + action = Some(PanelAction::ExportCertificate(Box::new(c.clone()))); + } + }); + }); + } + action +} + +/// The text of an exported certificate (`.cer`, PEM). +pub fn certificate_pem(c: &Certificate) -> String { + sign::x509::to_pem(c) +} diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs new file mode 100644 index 0000000000..d8900322a7 --- /dev/null +++ b/crates/ui-egui/tests/signing.rs @@ -0,0 +1,225 @@ +//! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate. + +use egui::{Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_engine::SignatureStatus; +use printcraft_ui_egui::{Dialog, PrintCraftApp, QuickTool, RightPanel, SignStep}; + +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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length 45 >> stream +BT /F1 14 Tf 20 150 Td (Please sign below) Tj ET +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn dir() -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("printcraft-signing-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn harness(dir: std::path::PathBuf) -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("contract.pdf", None, FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.export_dir_override = Some(dir.to_string_lossy().into_owned()); + app.save_override = Some(dir.join("contract_signed.pdf").to_string_lossy().into_owned()); + app + }); + h.run_steps(6); + h +} + +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()) +} + +fn drag(h: &mut Harness<'static, PrintCraftApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +fn field<'a>(h: &'a Harness<'static, PrintCraftApp>, label: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("no {label:?}")) +} + +#[test] +fn drawing_a_signature_creating_an_id_signing_and_trusting() { + let dir = dir(); + let mut h = harness(dir.clone()); + assert!(h.state_mut().execute("sign.digital")); + assert_eq!(h.state().quick_tool, QuickTool::SignArea { certify: false }); + let (a, b) = (at(&h, 40.0, 90.0), at(&h, 220.0, 40.0)); + drag(&mut h, a, b); + assert_eq!(h.state().dialog, Some(Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure), "no IDs yet: configure one"); + + // Create a new digital ID (P-256 keeps the test fast). + h.get_by_label("Configure a Digital ID for Signing"); + field(&h, "Name").click(); + h.run_steps(1); + field(&h, "Name").type_text("Grace Hopper"); + h.state_mut().sign_draft.as_mut().unwrap().new_id.key = 3; + for (label, text) in [("Password", "secret1"), ("Confirm Password", "secret1")] { + field(&h, label).click(); + h.run_steps(1); + field(&h, label).type_text(text); + h.run_steps(1); + } + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(h.state().digital_ids.len(), 1); + assert!(std::path::Path::new(&h.state().digital_ids[0].path).exists(), "saved as a .p12"); + h.get_by_label("Grace Hopper"); + h.get_by_label("Continue").click(); + h.run_steps(2); + h.get_by_label("Sign as \"Grace Hopper\""); + + // A wrong password says so. + field(&h, "Digital ID password").click(); + h.run_steps(1); + field(&h, "Digital ID password").type_text("nope"); + h.get_by_label("Sign").click(); + h.run_steps(3); + h.get_by_label("The password is incorrect."); + h.state_mut().sign_draft.as_mut().unwrap().password = "secret1".into(); + field(&h, "Reason").click(); + h.run_steps(1); + field(&h, "Reason").type_text("I approve"); + h.get_by_label("Sign").click(); + h.run_steps(6); + + let s = h.state(); + assert_eq!(s.dialog, None); + assert!(dir.join("contract_signed.pdf").exists()); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.name, "contract_signed.pdf"); + let sig = doc.signatures.iter().find(|x| x.signed).expect("signed"); + assert_eq!((sig.status, sig.reason.as_deref(), sig.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + assert_eq!(s.right, Some(RightPanel::Signatures)); + h.get_by_label("At least one signature has problems."); + + // Signatures panel: expand, trust the signer → valid. + h.get_by_label_contains("Signed by Grace Hopper").click(); + h.run_steps(2); + h.get_by_label("Signature validity is UNKNOWN:"); + h.get_by_label("Add to trusted certificates").click(); + h.run_steps(3); + h.get_by_label("Signed and all signatures are valid."); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid); + // View signed version opens the bytes the signature covers as a new document. + h.get_by_label("View signed version").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.views.len(), 2); + let v = s.session.get(s.views[1].id).unwrap(); + assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed)); + // Trusted certificates and the ID list persist. + let saved = s.persist(); + let mut fresh = PrintCraftApp::new(); + fresh.restore(&saved); + assert_eq!((fresh.digital_ids.len(), fresh.session.trusted_certificates().len()), (1, 1)); +} + +#[test] +fn certifying_without_a_visible_signature() { + let dir = dir().join("certify"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + // An ID from a file. + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + assert!(h.state_mut().execute("sign.certify_invisible")); + h.run_steps(2); + { + let d = h.state_mut().sign_draft.as_mut().unwrap(); + assert_eq!((d.rect, d.certify), (None, Some(2))); + d.new_id.create = false; + d.new_id.file = p12.into(); + d.new_id.file_password = "test".into(); + } + h.run_steps(1); + h.get_by_label("Continue").click(); + h.run_steps(2); + assert_eq!(h.state().digital_ids.len(), 1); + h.get_by_label("Continue").click(); + h.run_steps(2); + h.get_by_label("Certify as \"Test Signer EC\""); + h.get_by_label("Permitted actions after certifying"); + h.state_mut().sign_draft.as_mut().unwrap().password = "test".into(); + h.get_by_label("Sign").click(); + h.run_steps(6); + let s = h.state(); + assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); + let doc = s.session.get(s.views[0].id).unwrap(); + let sig = doc.signatures.iter().find(|x| x.signed).unwrap(); + assert_eq!((sig.certify, sig.visible), (Some(2), false)); + h.get_by_label_contains("Certified by Test Signer EC"); +} + +/// The fixture with an empty signature field across the bottom of the page. +const WITH_FIELD: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 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] /Annots [6 0 R] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 20 280 80] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn clicking_an_empty_signature_field_signs_it() { + let dir = dir().join("field"); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = harness(dir.clone()); + h.state_mut().open_bytes("form.pdf", None, WITH_FIELD.to_vec()).unwrap(); + h.state_mut().active = Some(1); + h.run_steps(6); + let p = { + let r = h.state().views[1].page_screen_rect(0).expect("on screen"); + pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height()) + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); + let entry = { + let id = printcraft_engine::sign::pkcs12::open( + &std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(), + "test", + ) + .unwrap(); + h.state_mut().add_digital_id(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"), &id.certificate) + }; + { + let d = h.state_mut().sign_draft.as_mut().unwrap(); + d.selected = Some(entry); + d.step = SignStep::SignAs; + d.password = "test".into(); + } + h.run_steps(2); + h.get_by_label("Sign").click(); + h.run_steps(6); + let s = h.state(); + let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); + assert!(sig.signed && sig.visible); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 530f4aad80..630c52937d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -719,11 +719,11 @@ title = "Knockout transparency groups" area = "B" tier = "P1" milestone = "M11" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_save"] +evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "full rewrites of signed documents are refused; saves stay incremental" [[feature]] id = "view.render-type3" @@ -5747,11 +5747,11 @@ title = "Sign with a PKCS#12 (.p12/.pfx) digital ID" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384" [[feature]] id = "sign.macos-keychain" @@ -5795,11 +5795,11 @@ title = "Create a self-signed digital ID" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_id_create"] +evidence = ["crates/sign/tests/crypto.rs::new_digital_ids_are_self_signed_and_survive_a_p12_round_trip", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "RSA 2048/3072/4096 or P-256, saved as a password-protected .p12" [[feature]] id = "sign.export-certificate" @@ -5807,11 +5807,11 @@ title = "Export a public certificate" area = "H" tier = "P2" milestone = "M9" -status = "planned" -commands = [] +status = "shipped" +commands = ["sign.panel"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/sign/tests/crypto.rs::certificates_load_from_pem_and_der_and_export_as_pem"] +notes = "Signatures panel ▸ Export certificate (.cer, PEM)" [[feature]] id = "sign.visible-signature" @@ -5819,11 +5819,11 @@ title = "Visible signature by drawing a rectangle" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "drag a rectangle; Acrobat's name-and-details appearance" [[feature]] id = "sign.sign-existing-field" @@ -5831,10 +5831,10 @@ title = "Sign an existing signature field" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it"] notes = "" [[feature]] @@ -5843,10 +5843,10 @@ title = "Invisible signature" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.certify_invisible"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/pdf.rs::invisible_signatures_and_refusals", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] notes = "" [[feature]] @@ -5855,11 +5855,11 @@ title = "Configure signature appearance (text, graphic, name)" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] +status = "partial" +commands = ["sign.digital"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +notes = "text items (name, date, reason, location, DN, labels); no graphics or saved appearances yet" [[feature]] id = "sign.reason-location" @@ -5867,10 +5867,10 @@ title = "Reason, location and contact info" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "" [[feature]] @@ -5891,11 +5891,11 @@ title = "PKCS#7 detached signatures" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.validate"] +tools = ["sign_list"] +evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl"] +notes = "validated; new signatures use CAdES" [[feature]] id = "sign.cades-pades-bb" @@ -5903,11 +5903,11 @@ title = "CAdES / PAdES B-B signatures" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust"] +notes = "ETSI.CAdES.detached with signing-certificate-v2; pdfsig reports them valid" [[feature]] id = "sign.hash-algorithms" @@ -5915,11 +5915,11 @@ title = "SHA-256/384/512 hashes; no SHA-1 for new signatures" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type"] +notes = "SHA-256, SHA-384 for P-384; SHA-1 refused for new signatures, validated in old ones" [[feature]] id = "sign.certify" @@ -5927,10 +5927,10 @@ title = "Certify a document (DocMDP)" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.certify", "sign.certify_invisible"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] notes = "" [[feature]] @@ -5939,11 +5939,11 @@ title = "Allowed changes after certifying" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.certify"] +tools = ["sign_document"] +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions"] +notes = "DocMDP P=1/2/3 enforced when validating" [[feature]] id = "sign.field-mdp" @@ -6035,10 +6035,10 @@ title = "Validate all signatures" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.validate"] +tools = ["sign_list"] +evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "" [[feature]] @@ -6047,11 +6047,11 @@ title = "Signatures panel with per-revision tree" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["sign.panel"] +tools = ["sign_list"] +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +notes = "per-signature tree with details and actions; no per-revision page outline yet" [[feature]] id = "sign.status-icons" @@ -6059,10 +6059,10 @@ title = "Signature status (valid, unknown, invalid)" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["sign.panel"] +tools = ["sign_list"] +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "" [[feature]] @@ -6071,11 +6071,11 @@ title = "Signed/certified document message bar" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] +status = "shipped" +commands = ["sign.panel"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] +notes = "signed/valid, problems, invalid and certified bars" [[feature]] id = "sign.signature-properties" @@ -6083,11 +6083,11 @@ title = "Signature properties (validity summary, signer, time)" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["sign.panel"] +tools = ["sign_list"] +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +notes = "validity summary, signer, issuer, time, algorithm in the panel; no separate properties dialog" [[feature]] id = "sign.certificate-viewer" @@ -6107,11 +6107,11 @@ title = "Trusted certificates store" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.panel"] +tools = ["sign_trust"] +evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "trusted certificates persist with the app's settings" [[feature]] id = "sign.trust-import" @@ -6119,11 +6119,11 @@ title = "Import trusted certificates and set trust" area = "H" tier = "P1" milestone = "M9" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["sign_trust"] +evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "import .cer/.pem/.der/.p12 through the tool; per-certificate trust settings not yet" [[feature]] id = "sign.eutl" @@ -6167,11 +6167,11 @@ title = "List changes made after signing (MDP-aware diff)" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.panel"] +tools = ["sign_list"] +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure" [[feature]] id = "sign.view-signed-version" @@ -6179,10 +6179,10 @@ title = "View the signed version" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] +status = "shipped" +commands = ["sign.panel"] tools = [] -evidence = [] +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "" [[feature]] @@ -6203,11 +6203,11 @@ title = "Verify signatures when a document opens" area = "H" tier = "P1" milestone = "M9" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["sign_list"] +evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"] +notes = "validated when opened and after every edit" [[feature]] id = "sign.clear-signature"