diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 283c7125..4d518193 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -129,13 +129,15 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Token ui.checkbox(&mut o.show_dialog, "Show this dialog when the Checker starts"); ui.add_space(10.0); let (mut start, mut cancel) = (false, false); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Start Checking", true).clicked() { - start = true; - } - if widgets::pill_button(ui, "Cancel", false).clicked() { - cancel = true; - } + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Start Checking", true).clicked() { + start = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }) }); (start, cancel) } @@ -433,7 +435,7 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) - if n == 0 { ui.label(egui::RichText::new("This document has no tagged figures.").color(t.text_muted)); let mut cancel = false; - ui.with_layout(Layout::right_to_left(Align::Center), |ui| cancel = widgets::pill_button(ui, "Close", true).clicked()); + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| cancel = widgets::pill_button(ui, "Close", true).clicked())); return (false, cancel); } d.index = d.index.min(n - 1); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b99427eb..27c912ee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -657,6 +657,25 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { } return; } + Dialog::CertificateViewer => { + ui.set_width(700.0); + let trusted = app.session.trusted_certificates().to_vec(); + let Some(v) = app.cert_viewer.as_mut() else { + close = true; + return; + }; + let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); + close = done; + match action { + Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), + Some(crate::sign_ui::CertAction::Export(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 => {} + } + return; + } Dialog::AuditSpace => { if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { next = Dialog::Optimize; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 69c38707..c62c6ee1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -189,6 +189,8 @@ pub enum Dialog { AltText, /// PDF Optimizer ▸ Audit space usage (then back to the optimizer). AuditSpace, + /// Signatures ▸ Show certificate. + CertificateViewer, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -294,6 +296,7 @@ pub struct PrintCraftApp { pub alt_draft: a11y_ui::AltDraft, /// List the macOS Keychain's signing identities among the digital IDs (the desktop app). pub keychain_ids: bool, + pub cert_viewer: Option, /// The last space audit. pub space_audit: Vec, /// Combine files: the files staged so far. @@ -425,6 +428,7 @@ impl PrintCraftApp { a11y_skipped: Default::default(), alt_draft: Default::default(), keychain_ids: false, + cert_viewer: None, space_audit: Vec::new(), combine_draft: Vec::new(), custom_stamps: Vec::new(), diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 56204cdd..ffbf2537 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -176,7 +176,7 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[printcraft_engine::optimize: }); ui.add_space(12.0); let mut ok = false; - ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, "OK", true).clicked()); + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, "OK", true).clicked())); ok } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 427f4451..32cd1048 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -529,6 +529,10 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { 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::ViewCertificate(chain)) => { + app.cert_viewer = Some(crate::sign_ui::CertViewer { chain, selected: 0, tab: crate::sign_ui::CertTab::Summary }); + app.dialog = Some(crate::Dialog::CertificateViewer); + } 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; diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 58481324..abc8b7df 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -701,6 +701,8 @@ pub enum PanelAction { ViewSigned(usize), Sign(String), ExportCertificate(Box), + /// Certificate Viewer for the signer's chain (signer first). + ViewCertificate(Vec), } /// The Signatures panel body. @@ -824,6 +826,13 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan if ui.button("View signed version").clicked() { action = Some(PanelAction::ViewSigned(s.signed_len)); } + if let Some(c) = &s.certificate + && ui.button("Show certificate…").clicked() + { + let mut chain = vec![c.clone()]; + chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned()); + action = Some(PanelAction::ViewCertificate(chain)); + } if let Some(c) = &s.certificate && ui.button("Export certificate…").clicked() { @@ -835,6 +844,145 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan action } +/// Certificate Viewer: a chain (the end certificate first) and the tab shown. +#[derive(Clone, Debug)] +pub struct CertViewer { + pub chain: Vec, + pub selected: usize, + pub tab: CertTab, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CertTab { + Summary, + Details, + Trust, +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect::>().join(" ") +} + +fn key_usage(bits: u16) -> String { + const NAMES: [&str; 9] = [ + "Digital Signature", + "Non-Repudiation", + "Key Encipherment", + "Data Encipherment", + "Key Agreement", + "Certificate Signing", + "CRL Signing", + "Encipher Only", + "Decipher Only", + ]; + let used: Vec<&str> = NAMES.iter().enumerate().filter(|(i, _)| bits & (1 << i) != 0).map(|(_, n)| *n).collect(); + if used.is_empty() { "None".into() } else { used.join(", ") } +} + +/// What the viewer asks for. +pub(crate) enum CertAction { + Trust(Box), + Export(Box), +} + +/// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs. +pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option) { + title(ui, "Certificate Viewer"); + ui.label(egui::RichText::new("This dialog shows the details of a certificate and its chain.").color(t.text_muted)); + ui.add_space(8.0); + let mut action = None; + v.selected = v.selected.min(v.chain.len().saturating_sub(1)); + ui.horizontal_top(|ui| { + // The chain, root at the top (as Acrobat shows it). + ui.vertical(|ui| { + ui.set_width(200.0); + for (depth, i) in (0..v.chain.len()).rev().enumerate() { + let c = &v.chain[i]; + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 12.0); + if ui.selectable_label(v.selected == i, c.display_name()).clicked() { + v.selected = i; + } + }); + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_width(440.0); + let Some(c) = v.chain.get(v.selected).cloned() else { return }; + ui.horizontal(|ui| { + for (tab, label) in [(CertTab::Summary, "Summary"), (CertTab::Details, "Details"), (CertTab::Trust, "Trust")] { + if widgets::pill_button(ui, label, v.tab == tab).clicked() { + v.tab = tab; + } + } + }); + ui.add_space(8.0); + let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| { + egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| { + for (k, val) in rows { + ui.label(egui::RichText::new(k).color(t.text_muted)); + ui.add(egui::Label::new(val).wrap()); + ui.end_row(); + } + }); + }; + match v.tab { + CertTab::Summary => grid( + ui, + vec![ + ("Issued to", c.subject.display()), + ("Issued by", c.issuer.display()), + ("Valid from", c.not_before.to_string()), + ("Valid to", c.not_after.to_string()), + ("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| "Any".into())), + ], + ), + CertTab::Details => grid( + ui, + vec![ + ("Version", "3".into()), + ("Serial number", c.serial_hex()), + ("Issuer", c.issuer.display()), + ("Subject", c.subject.display()), + ("Validity starts", c.not_before.to_string()), + ("Validity ends", c.not_after.to_string()), + ("Public key", c.public_key.describe()), + ("Basic constraints", if c.is_ca { "Certificate authority".into() } else { "End entity".into() }), + ("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| "Not present".into())), + ("Self-signed", if c.is_self_signed() { "Yes".into() } else { "No".into() }), + ("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))), + ("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))), + ], + ), + CertTab::Trust => { + let is_trusted = trusted.iter().any(|x| x.raw == c.raw); + let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw)); + ui.label(if is_trusted { + "This certificate is in your list of trusted certificates." + } else if anchored { + "This certificate is trusted through a certificate above it in the chain." + } else { + "This certificate is not trusted. Signatures made with it show an unknown identity." + }); + ui.add_space(8.0); + if !is_trusted && widgets::pill_button(ui, "Add to Trusted Certificates", false).clicked() { + action = Some(CertAction::Trust(Box::new(c.clone()))); + } + } + } + ui.add_space(8.0); + if widgets::pill_button(ui, "Export…", false).clicked() { + action = Some(CertAction::Export(Box::new(c.clone()))); + } + }); + }); + ui.add_space(10.0); + let mut close = false; + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, "OK", true).clicked())); + (close, 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 index d8900322..761031e4 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -124,6 +124,24 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { 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); + // Show certificate: the Certificate Viewer. + h.get_by_label("Show certificate…").click(); + h.run_steps(3); + h.get_by_label("Certificate Viewer"); + assert!(h.get_all_by_label_contains("CN=Grace Hopper").count() >= 2, "issued to and by (self-signed)"); + h.run_steps(2); + h.get_by_label("Details").click(); + h.run_steps(2); + h.get_by_label("SHA-256 digest"); + h.run_steps(2); + h.get_by_label("Trust").click(); + h.run_steps(2); + + h.get_by_label("This certificate is in your list of trusted certificates."); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); // 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); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 18bfcd28..84e7aa55 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6095,11 +6095,11 @@ title = "Certificate viewer" area = "H" tier = "P1" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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 = "Signatures panel ▸ Show certificate: the chain (root on top) and Summary, Details (serial, key, constraints, usage, SHA-1/SHA-256 digests) and Trust tabs; add to trusted certificates; export" [[feature]] id = "sign.trust-store"