M9: Certificate Viewer (chain, Summary, Details, Trust; export, trust) from the Signatures panel; dialog button rows no longer stretch dialogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:00:56 -04:00
parent 183eda77ca
commit 80a8087ebc
8 changed files with 209 additions and 14 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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<sign_ui::CertViewer>,
/// The last space audit.
pub space_audit: Vec<printcraft_engine::optimize::SpaceUse>,
/// 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(),

View File

@ -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
}

View File

@ -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;

View File

@ -701,6 +701,8 @@ pub enum PanelAction {
ViewSigned(usize),
Sign(String),
ExportCertificate(Box<Certificate>),
/// Certificate Viewer for the signer's chain (signer first).
ViewCertificate(Vec<Certificate>),
}
/// 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<Certificate>,
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::<Vec<_>>().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<Certificate>),
Export(Box<Certificate>),
}
/// 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<CertAction>) {
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)

View File

@ -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);

View File

@ -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"