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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 02:59:19 -04:00
co-authored by Claude Opus 5.5
parent 64e900f270
commit bb1d9a0d8a
17 changed files with 1315 additions and 140 deletions
+1
View File
@@ -508,6 +508,7 @@ pub fn tools() -> Vec<ToolDef> {
&["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(),
+5 -4
View File
@@ -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),
],
}],
},
+5
View File
@@ -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"),
+11 -16
View File
@@ -737,7 +737,7 @@ pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8
// Write with the placeholders, then patch them.
let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, ..SaveOptions::default() };
let mut out = printcraft_cos::write_incremental(&doc, &save)?;
let (br_at, gap) = locate(&out, sig.num, reserve)?;
let (br_at, gap) = locate(&out, reserve)?;
let (start, end) = gap;
let ranges = [0usize, start, end, out.len() - end];
let mut br_text = format!("0 {} {} {}", ranges[1], ranges[2], ranges[3]).into_bytes();
@@ -757,26 +757,21 @@ pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8
Ok(out)
}
/// Find the placeholders of signature object `num` in the written file: the offset of the
/// ByteRange numbers, and the `<…>` 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<usize> = 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<usize> = 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.
+18
View File
@@ -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);
}
}
+71 -10
View File
@@ -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<crate::fill_sign::TypeBox>,
/// 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<Notice> {
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.
+3
View File
@@ -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];
+15
View File
@@ -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;
+2
View File
@@ -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.")),
+7
View File
@@ -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);
+1 -1
View File
@@ -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<Vec<u8>>)], title: &str) -> usize {
pub(crate) fn write_files(&mut self, named: &[(String, Arc<Vec<u8>>)], title: &str) -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
let dir = match &self.export_dir_override {
+3 -2
View File
@@ -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()) });
+31 -1
View File
@@ -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<SignDraft>,
/// Digital ID files the user has created or added.
pub digital_ids: Vec<DigitalIdEntry>,
/// Signatures panel: expanded entries (field names).
pub sig_expanded: Vec<String>,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option<RecoveryStore>,
/// 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<String> = 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::<Vec<DigitalIdEntry>>(v["digital_ids"].clone()) {
self.digital_ids = ids;
}
if let Ok(pems) = serde_json::from_value::<Vec<String>>(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()
+21
View File
@@ -325,6 +325,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
let mut bm_action: Option<BmAction> = None;
let mut panel_edit: Option<printcraft_engine::Edit> = None;
let mut panel_command: Option<&'static str> = None;
let mut sig_action: Option<crate::sign_ui::PanelAction> = 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);
}
+790
View File
@@ -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<String>,
/// Certify (DocMDP) with these permissions: 1 none, 2 form fill and signing, 3 also comments.
pub certify: Option<u8>,
pub step: SignStep,
pub selected: Option<usize>,
pub password: String,
pub reason: String,
pub location: String,
pub appearance: Appearance,
pub new_id: NewIdDraft,
pub error: Option<String>,
}
impl SignDraft {
fn new(page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>, 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<String>,
}
/// 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<PathBuf> {
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<String>, certify: Option<u8>) {
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<usize, String> {
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<usize, String> {
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<String>) {
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<Certificate>),
ViewSigned(usize),
Sign(String),
ExportCertificate(Box<Certificate>),
}
/// The Signatures panel body.
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec<String>) -> Option<PanelAction> {
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)
}
+225
View File
@@ -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);
}
+106 -106
View File
@@ -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"