Browse / github.com/storytold/pdfcraft / patch
M9: import certificates to trust from the Signatures panel
M9: import certificates to trust from the Signatures panel <!-- claude-projects-attribution: {"github_login":"ffishman"} --> _Requested by **Rodrigo**_ ## What this changes Before: a certificate could only be trusted from the app by pressing "Add to trusted certificates" on a signer the open document already showed. Importing a CA certificate or a `.p7b` bundle was possible only through the `sign_trust` automation tool. So a signature whose PDF embeds only the signer's certificate stayed *Unknown*. Many Brazilian (ICP-Brasil) signatures are like this. After: the Signatures panel has **Import certificates…** next to Validate all. - It reads `.cer`/`
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ffishman (imported from pull request #4999, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +354 −5 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a9433c4002fc73ad4b83640d
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/app.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/right.rs crates/ui-egui/src/state.rs crates/ui-egui/src/tools/sign_ui.rs crates/ui-egui/src/tools/sign_ui/panel.rs crates/ui-egui/tests/snapshots/panel_signatures.txt parity/acrobat-features.toml
@@ -164,6 +164,7 @@ impl PdfCraftApp { alt_draft: Default::default(), os_key_store_ids: false, cert_viewer: None,+ cert_import: None, space_audit: Vec::new(), space_audit_of: None, combine_draft: Vec::new(),@@ -925,6 +925,28 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } return; }+ Dialog::ImportCertificates => {+ ui.set_width(620.0);+ let trusted = app.session.trusted_certificates().to_vec();+ let Some(v) = app.cert_import.as_mut() else {+ close = true;+ return;+ };+ match crate::sign_ui::import_dialog(ui, v, &trusted, &t) {+ Some(crate::sign_ui::ImportAction::Trust(certs)) => {+ let added = app.trust_certificates(certs);+ app.notify_fmt("Added {n} to your trusted certificates.", &[("n", &added.to_string())]);+ app.cert_import = None;+ close = true;+ }+ Some(crate::sign_ui::ImportAction::Cancel) => {+ app.cert_import = None;+ close = true;+ }+ None => {}+ }+ return;+ } Dialog::AuditSpace => { if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { next = Dialog::Optimize;@@ -2842,3 +2842,13 @@ theme Appearance Aparência Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário Extract the selected pages… Extrair as páginas selecionadas… Delete the selected pages Excluir as páginas selecionadas+ Import certificates… Importar certificados…+ Couldn't import the certificates: {e} Não foi possível importar os certificados: {e}+ Import Certificates Importar certificados+ Certificates in {file} Certificados em {file}+ Signatures made under a trusted certificate are shown as valid. Trust only certificates from a source you trust, and compare each fingerprint with the one its authority publishes. Assinaturas feitas sob um certificado confiável aparecem como válidas. Confie apenas em certificados de uma fonte em que você confia e compare cada impressão digital com a publicada pela autoridade.+ Already trusted Já confiável+ Issued by {issuer} Emitido por {issuer}+ Valid from {from} to {to} Válido de {from} a {to}+ Trust selected ({n}) Confiar nos selecionados ({n})+ Added {n} to your trusted certificates. {n} adicionados aos seus certificados confiáveis.@@ -463,6 +463,8 @@ pub struct PdfCraftApp { /// List the OS key store's signing identities among the digital IDs (the desktop app). pub os_key_store_ids: bool, pub cert_viewer: Option<sign_ui::CertViewer>,+ /// The certificates read for Import certificates…, until trusted or cancelled.+ pub cert_import: Option<sign_ui::CertImport>, /// The last space audit. pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>, /// The document and edit generation `space_audit` was measured from.@@ -376,6 +376,10 @@ pub fn right_panel(app: &mut PdfCraftApp, 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),+ #[cfg(not(target_arch = "wasm32"))]+ Some(crate::sign_ui::PanelAction::Import) => app.pick_certificates_to_import(),+ #[cfg(target_arch = "wasm32")]+ Some(crate::sign_ui::PanelAction::Import) => {} 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);@@ -182,6 +182,8 @@ pub enum Dialog { AuditSpace, /// Signatures ▸ Show certificate. CertificateViewer,+ /// Signatures ▸ Import certificates…: choose which of a file's certificates to trust.+ ImportCertificates, } #[derive(Clone, Copy, Debug, PartialEq, Eq)]@@ -21,8 +21,11 @@ mod panel; use dialog::detail_lines; use dialog::graphic_of; pub(crate) use dialog::{appearance_labels, dialog};+#[cfg(not(target_arch = "wasm32"))]+pub use panel::CERTIFICATE_EXTENSIONS; pub(crate) use panel::{CertAction, banner, cert_viewer, panel, signature_status_style};-pub use panel::{CertTab, CertViewer, PanelAction, certificate_pem};+pub use panel::{CertImport, CertTab, CertViewer, PanelAction, certificate_pem};+pub(crate) use panel::{ImportAction, import_dialog}; /// 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.@@ -553,11 +556,41 @@ impl PdfCraftApp { /// Trust a certificate (Signatures panel ▸ Add to trusted certificates), revalidating. pub fn trust_certificate(&mut self, cert: Certificate) {+ self.trust_certificates(vec![cert]);+ }++ /// Trust `new` (those not trusted yet), revalidating once. Returns how many were added.+ pub fn trust_certificates(&mut self, new: Vec<Certificate>) -> usize { let mut certs = self.session.trusted_certificates().to_vec();- if !certs.iter().any(|c| c.raw == cert.raw) {- certs.push(cert);+ let before = certs.len();+ for cert in new {+ if !certs.iter().any(|c| c.raw == cert.raw) {+ certs.push(cert);+ } }+ let added = certs.len() - before; self.apply_trusted_certificates(certs);+ added+ }++ /// Signatures panel ▸ Import certificates…: pick certificate files (several at once, as the+ /// ICP-Brasil download unpacks to one file per authority), then confirm what to trust.+ #[cfg(not(target_arch = "wasm32"))]+ pub(crate) fn pick_certificates_to_import(&mut self) {+ let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Certificates"), CERTIFICATE_EXTENSIONS);+ self.ask(crate::pickers::Ask::Files(dialog), None, |app, paths| app.open_certificate_import(&paths));+ }++ /// Read the certificates in `paths` and show them for confirmation. Nothing is trusted yet.+ #[cfg(not(target_arch = "wasm32"))]+ pub(crate) fn open_certificate_import(&mut self, paths: &[PathBuf]) {+ match CertImport::from_files(paths) {+ Ok(v) => {+ self.cert_import = Some(v);+ self.dialog = Some(crate::Dialog::ImportCertificates);+ }+ Err(e) => self.notify_fmt("Couldn't import the certificates: {e}", &[("e", &e)]),+ } } /// Replace the trust store and redraw from the rebuilt display copy. One helper@@ -710,6 +743,130 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A self-signed certificate named `cn`, for the trust tests.+ fn test_cert(cn: &str) -> Certificate {+ let key = PrivateKey::generate_p256().unwrap();+ Certificate::self_signed(&Name::build(cn, "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap()+ }++ /// The Import Certificates dialog alone, with the app's fonts, over `v` and `trusted`.+ struct ImportState {+ v: CertImport,+ trusted: Vec<Certificate>,+ action: Option<ImportAction>,+ /// Fonts set with `set_fonts` take effect a frame later: draw from then on.+ fonts: bool,+ }++ fn import_harness(v: CertImport, trusted: Vec<Certificate>) -> egui_kittest::Harness<'static, ImportState> {+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 700.0)).build_ui_state(+ |ui, s: &mut ImportState| {+ if !s.fonts {+ return;+ }+ let t = Tokens::get(ui.ctx());+ if let Some(a) = import_dialog(ui, &mut s.v, &s.trusted, &t) {+ s.action = Some(a);+ }+ },+ ImportState { v, trusted, action: None, fonts: false },+ );+ h.ctx.set_fonts(theme::font_definitions());+ h.run_steps(1);+ h.state_mut().fonts = true;+ h.run_steps(2);+ h+ }++ /// Import certificates… (#2613): reading a file trusts nothing, a certificate repeated in it+ /// is listed once, and confirming hands over only the ticked ones.+ #[test]+ fn an_import_trusts_only_the_ticked_certificates_on_confirmation() {+ use egui_kittest::kittest::Queryable;+ let dir = scratch();+ let (root, other) = (test_cert("Import Root"), test_cert("Import Other"));+ let file = dir.join("bundle.pem");+ std::fs::write(&file, [&root, &other, &root].map(sign::x509::to_pem).concat()).unwrap();+ let mut app = PdfCraftApp::new();+ app.open_certificate_import(std::slice::from_ref(&file));+ assert_eq!(app.dialog, Some(crate::Dialog::ImportCertificates));+ assert!(app.session.trusted_certificates().is_empty(), "nothing is trusted before the user confirms");+ let v = app.cert_import.take().unwrap();+ assert_eq!(v.certs.len(), 2, "a certificate repeated in the file is listed once");+ let mut h = import_harness(v, Vec::new());+ h.get_by_label("Import Other").click();+ h.run_steps(2);+ h.get_by_label("Trust selected (1)").click();+ h.run_steps(2);+ let Some(ImportAction::Trust(certs)) = h.state_mut().action.take() else { panic!("confirming asks to trust") };+ assert_eq!(certs.iter().map(Certificate::display_name).collect::<Vec<_>>(), ["Import Root"]);+ assert_eq!(app.trust_certificates(certs), 1);+ assert_eq!(app.session.trusted_certificates().iter().map(Certificate::display_name).collect::<Vec<_>>(), ["Import Root"]);+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// A certificate already trusted can't be ticked again, and with nothing left to trust the+ /// confirm button does nothing; Cancel asks for nothing to be trusted.+ #[test]+ fn an_import_of_trusted_certificates_only_offers_cancel() {+ use egui_kittest::kittest::Queryable;+ let a = test_cert("Already There");+ let v = CertImport::read("a.cer", sign::x509::to_pem(&a).as_bytes()).unwrap();+ let mut h = import_harness(v, vec![a]);+ h.get_by_label("Trust selected (0)").click();+ h.run_steps(2);+ assert!(h.state().action.is_none(), "a disabled confirm trusts nothing");+ h.get_by_label("Cancel").click();+ h.run_steps(2);+ assert!(matches!(h.state().action, Some(ImportAction::Cancel)));+ }++ /// Several files picked at once (ICP-Brasil's download is one `.crt` per authority) are read+ /// into one list, each certificate once; one unreadable file refuses the whole import.+ #[test]+ fn several_certificate_files_import_as_one_list() {+ let dir = scratch();+ let (a, b) = (test_cert("File A"), test_cert("File B"));+ let (fa, fb, junk) = (dir.join("a.crt"), dir.join("b.crt"), dir.join("junk.crt"));+ std::fs::write(&fa, sign::x509::to_pem(&a)).unwrap();+ std::fs::write(&fb, [&b, &a].map(sign::x509::to_pem).concat()).unwrap();+ std::fs::write(&junk, b"nope").unwrap();+ let v = CertImport::from_files(&[fa.clone(), fb.clone()]).unwrap();+ assert_eq!(v.certs.iter().map(Certificate::display_name).collect::<Vec<_>>(), ["File A", "File B"]);+ let e = CertImport::from_files(&[fa, junk.clone(), fb]).unwrap_err();+ assert!(e.contains("junk.crt"), "{e}");+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn a_file_that_is_not_a_certificate_or_too_large_opens_no_import() {+ let dir = scratch();+ let junk = dir.join("junk.cer");+ std::fs::write(&junk, b"not a certificate").unwrap();+ // Sparse: the size is refused before a byte is read.+ let huge = dir.join("huge.p7b");+ std::fs::File::create(&huge).unwrap().set_len(panel::MAX_CERTIFICATE_FILE_BYTES + 1).unwrap();+ for file in [&junk, &huge] {+ assert!(CertImport::from_file(file).is_err(), "{}", file.display());+ let mut app = PdfCraftApp::new();+ app.open_certificate_import(std::slice::from_ref(file));+ assert!(app.cert_import.is_none() && app.dialog.is_none(), "{}", file.display());+ assert!(app.session.trusted_certificates().is_empty());+ }+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn an_import_offers_only_what_is_not_trusted_yet() {+ let (a, b) = (test_cert("A"), test_cert("B"));+ let pem = [&a, &b].map(sign::x509::to_pem).concat();+ let v = CertImport::read("bundle.pem", pem.as_bytes()).unwrap();+ assert_eq!(v.picked(std::slice::from_ref(&a)).iter().map(Certificate::display_name).collect::<Vec<_>>(), ["B"]);+ let mut app = PdfCraftApp::new();+ assert_eq!(app.trust_certificates(v.picked(&[])), 2);+ assert_eq!(app.trust_certificates(v.picked(&[])), 0, "trusting again adds nothing");+ }+ #[test] fn signature_status_style_gives_each_verdict_its_panel_icon() { assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check");@@ -57,6 +57,8 @@ pub enum PanelAction { Validate, GoTo(usize), Trust(Box<Certificate>),+ /// Import certificates…: pick a certificate file to trust from.+ Import, ViewSigned { len: usize, revision: usize,@@ -74,6 +76,11 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan if widgets::pill_button(ui, tl!("Validate all"), false).clicked() { action = Some(PanelAction::Validate); }+ // The web build has no file to read certificates from.+ #[cfg(not(target_arch = "wasm32"))]+ if widgets::pill_button(ui, tl!("Import certificates…"), false).clicked() {+ action = Some(PanelAction::Import);+ } }); ui.add_space(6.0); if sigs.is_empty() {@@ -352,6 +359,146 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert (close, action) } +/// The files Import certificates… offers: single certificates (DER or PEM) and PKCS #7 bundles.+#[cfg(not(target_arch = "wasm32"))]+pub const CERTIFICATE_EXTENSIONS: &[&str] = &["cer", "crt", "pem", "der", "p7b", "p7c"];++/// The largest file Import certificates… reads. A national PKI's whole bundle is a few hundred+/// kilobytes; anything far larger is not a certificate file.+#[cfg(not(target_arch = "wasm32"))]+pub const MAX_CERTIFICATE_FILE_BYTES: u64 = 8 << 20;++/// The most files one Import certificates… reads: ICP-Brasil's whole set is a few hundred.+#[cfg(not(target_arch = "wasm32"))]+pub const MAX_CERTIFICATE_FILES: usize = 2000;++/// Import certificates…: the certificates read from a file and which of them to trust. Nothing is+/// trusted until the user confirms, since a trusted root vouches for every signature under it.+#[derive(Clone, Debug)]+pub struct CertImport {+ /// The file they came from, as shown to the user.+ pub file: String,+ /// Each certificate once, in file order.+ pub certs: Vec<Certificate>,+ /// Whether each of `certs` is ticked to be trusted.+ pub chosen: Vec<bool>,+}++impl CertImport {+ /// The certificates in `bytes` (DER, PEM or a `.p7b`/`.p7c` bundle) from `file`, all ticked.+ pub fn read(file: &str, bytes: &[u8]) -> Result<CertImport, String> {+ let mut certs: Vec<Certificate> = Vec::new();+ for c in sign::x509::load_certificates(bytes).map_err(|e| e.to_string())? {+ if !certs.iter().any(|x| x.raw == c.raw) {+ certs.push(c);+ }+ }+ let chosen = vec![true; certs.len()];+ Ok(CertImport { file: file.to_string(), certs, chosen })+ }++ /// Read `path`, refusing a file too large to be a certificate file before reading it.+ #[cfg(not(target_arch = "wasm32"))]+ pub fn from_file(path: &std::path::Path) -> Result<CertImport, String> {+ Self::from_files(std::slice::from_ref(&path.to_path_buf()))+ }++ /// Read every file in `paths` into one list, each certificate once. A file that is too large+ /// or holds no readable certificate refuses the whole import, naming it.+ #[cfg(not(target_arch = "wasm32"))]+ pub fn from_files(paths: &[std::path::PathBuf]) -> Result<CertImport, String> {+ if paths.len() > MAX_CERTIFICATE_FILES {+ return Err(format!("{} files: choose at most {MAX_CERTIFICATE_FILES}", paths.len()));+ }+ let mut all = CertImport { file: String::new(), certs: Vec::new(), chosen: Vec::new() };+ for path in paths {+ let file = path.display().to_string();+ let len = std::fs::metadata(path).map_err(|e| format!("{file}: {e}"))?.len();+ if len > MAX_CERTIFICATE_FILE_BYTES {+ return Err(format!("{file}: too large for a certificate file"));+ }+ let bytes = std::fs::read(path).map_err(|e| format!("{file}: {e}"))?;+ let one = Self::read(&file, &bytes).map_err(|e| format!("{file}: {e}"))?;+ for c in one.certs {+ if !all.certs.iter().any(|x| x.raw == c.raw) {+ all.certs.push(c);+ all.chosen.push(true);+ }+ }+ }+ all.file = match paths {+ [one] => one.display().to_string(),+ _ => format!("{} files", paths.len()),+ };+ Ok(all)+ }++ /// The ticked certificates that `trusted` doesn't already hold.+ pub fn picked(&self, trusted: &[Certificate]) -> Vec<Certificate> {+ self.certs+ .iter()+ .zip(&self.chosen)+ .filter(|(c, chosen)| **chosen && !trusted.iter().any(|t| t.raw == c.raw))+ .map(|(c, _)| c.clone())+ .collect()+ }+}++/// What the Import certificates dialog asks for.+pub(crate) enum ImportAction {+ Trust(Vec<Certificate>),+ Cancel,+}++/// The Import certificates dialog: each certificate with who it names, who issued it, its validity+/// and SHA-256 fingerprint (to compare with the one the authority publishes), and a tick.+pub(crate) fn import_dialog(ui: &mut egui::Ui, v: &mut CertImport, trusted: &[Certificate], t: &Tokens) -> Option<ImportAction> {+ title(ui, "Import Certificates");+ ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Certificates in {file}"), &[("file", &v.file)])).color(t.text_muted));+ ui.add_space(4.0);+ ui.label(tl!("Signatures made under a trusted certificate are shown as valid. Trust only certificates from a source you trust, and compare each fingerprint with the one its authority publishes."));+ ui.add_space(8.0);+ let muted = |s: String| egui::RichText::new(s).font(theme::regular(11.5)).color(t.text_muted);+ egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {+ for (i, c) in v.certs.iter().enumerate() {+ let already = trusted.iter().any(|x| x.raw == c.raw);+ ui.horizontal(|ui| {+ if already {+ ui.add_enabled(false, widgets::checkbox(&mut true, c.display_name()));+ ui.label(muted(tl!("Already trusted").to_string()));+ } else if let Some(chosen) = v.chosen.get_mut(i) {+ ui.add(widgets::checkbox(chosen, c.display_name()));+ }+ ui.label(muted(tl!(if c.is_ca { "Certificate authority" } else { "End entity" }).to_string()));+ });+ ui.indent(("cert-import", i), |ui| {+ ui.label(muted(crate::i18n::fmt(tl!("Issued by {issuer}"), &[("issuer", &c.issuer.display())])));+ ui.label(muted(crate::i18n::fmt(+ tl!("Valid from {from} to {to}"),+ &[("from", &c.not_before.to_string()), ("to", &c.not_after.to_string())],+ )));+ ui.label(muted(format!("SHA-256 {}", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw])))));+ });+ ui.add_space(6.0);+ }+ });+ ui.add_space(10.0);+ let picked = v.picked(trusted);+ let mut action = None;+ ui.horizontal(|ui| {+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {+ let label = crate::i18n::fmt(tl!("Trust selected ({n})"), &[("n", &picked.len().to_string())]);+ if ui.add_enabled_ui(!picked.is_empty(), |ui| widgets::pill_button(ui, &label, true)).inner.clicked() {+ action = Some(ImportAction::Trust(picked.clone()));+ }+ if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {+ action = Some(ImportAction::Cancel);+ }+ });+ });+ action+}+ /// The text of an exported certificate (`.cer`, PEM). pub fn certificate_pem(c: &Certificate) -> String { sign::x509::to_pem(c)@@ -2,6 +2,7 @@ Label = "Signatures" Label = "0" Button "Close" [off] Button "Validate all"+Button "Import certificates…" Button "Expand" [off] Image Label = "Unsigned signature field: Approval"@@ -6348,8 +6348,11 @@ milestone = "M9" status = "partial" commands = [] tools = ["sign_trust"]-evidence = ["crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"]-notes = "import .cer/.pem/.der/.p7b/.p7c/.p12 through the tool; per-certificate trust settings not yet"+evidence = [+ "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools",+ "crates/ui-egui/src/tools/sign_ui.rs::an_import_trusts_only_the_ticked_certificates_on_confirmation",+]+notes = "import .cer/.pem/.der/.p7b/.p7c/.p12 through the tool, and .cer/.crt/.pem/.der/.p7b/.p7c in the app (Signatures panel ▸ Import certificates…, confirmed per certificate); per-certificate trust settings not yet" [[feature]] id = "sign.eutl"