/* * This file is part of cert-tools * * Copyright (C) 2025 the original author or authors. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #![windows_subsystem = "windows"] use cert_tools::{Chain, PrivateKey}; use iced::border::Radius; use iced::widget::text_editor::{default, Content, Status}; use iced::widget::{ self, button, column, container, horizontal_rule, horizontal_space, row, text, text_editor, text_input, Container, Scrollable, }; use iced::{ alignment, application, clipboard, color, window, Background, Border, Color, Element, Font, Length, Pixels, Settings, Size, Task, }; use std::fs; use std::path::PathBuf; use std::time::SystemTime; fn main() -> iced::Result { application(Ui::title, Ui::update, Ui::view) .settings(Settings { default_text_size: Pixels::from(13), ..Settings::default() }) .window(window::Settings { icon: window::icon::from_file_data(include_bytes!("../../resources/icon.ico"), None).ok(), ..window::Settings::default() }) .resizable(false) .window_size(Size::new(1020.0, 800.0)) .run_with(Ui::new) } enum File { None, Invalid(PathBuf), Certificates(PathBuf, Box), PrivateKey(PathBuf, Box), } impl File { fn is_some(&self) -> bool { !matches!(self, Self::None) } } enum UiMode { CertList, Output, } struct Ui { cert_file: File, ca_file: File, key_file: File, mode: UiMode, chain: Option, fixed_chain: Option, output: Content, status: String, chain_indicator_state: IndicatorState, key_indicator_state: IndicatorState, } impl Ui { fn new() -> (Self, Task) { ( Self { cert_file: File::None, ca_file: File::None, key_file: File::None, mode: UiMode::CertList, chain: None, fixed_chain: None, output: Content::default(), status: String::new(), chain_indicator_state: IndicatorState::Unknown, key_indicator_state: IndicatorState::Unknown, }, Task::none(), ) } fn title(&self) -> String { "CertTools".into() } fn update(&mut self, message: Message) -> Task { self.mode = UiMode::CertList; match message { Message::PickCertFile => Task::perform(pick_file(), Message::SetCertFile), Message::PickCaFile => Task::perform(pick_file(), Message::SetCaFile), Message::PickKeyFile => Task::perform(pick_file(), Message::SetKeyFile), Message::ClearCertFile => { self.cert_file = File::None; self.ca_file = File::None; self.key_file = File::None; self.chain = None; self.fixed_chain = None; self.chain_indicator_state = IndicatorState::Unknown; self.key_indicator_state = IndicatorState::Unknown; Task::done(Message::Print) } Message::ClearCaFile => { self.ca_file = File::None; self.chain = match self.load_chain() { Ok(chain) => Some(chain), _ => None, }; self.fixed_chain = match &self.chain { Some(chain) => match Chain::create_fixed(chain.certs()) { Ok(chain) => Some(chain), _ => None, }, _ => None, }; self.chain_indicator_state = self.chain_indicator_state(); Task::done(Message::Print) } Message::ClearKeyFile => { self.key_file = File::None; self.key_indicator_state = IndicatorState::Unknown; Task::done(Message::Print) } Message::SetCertFile(file) => { match file { Ok(file) => { self.cert_file = match Chain::read(&file) { Ok(chain) => File::Certificates(file, Box::new(chain)), Err(_) => File::Invalid(file), }; self.chain = match self.load_chain() { Ok(chain) => Some(chain), _ => None, }; self.fixed_chain = match &self.chain { Some(chain) => match Chain::create_fixed(chain.certs()) { Ok(chain) => Some(chain), _ => None, }, _ => None, }; self.output = Content::default(); self.mode = UiMode::CertList; } _ => self.cert_file = File::None, }; self.chain_indicator_state = self.chain_indicator_state(); Task::done(Message::Print) } Message::SetCaFile(file) => { match file { Ok(file) => { self.ca_file = match Chain::read(&file) { Ok(chain) => File::Certificates(file, Box::new(chain)), Err(_) => File::Invalid(file), }; self.chain = match self.load_chain() { Ok(chain) => Some(chain), _ => None, }; self.fixed_chain = match &self.chain { Some(chain) => match Chain::create_fixed(chain.certs()) { Ok(chain) => Some(chain), _ => None, }, _ => None, }; self.output = Content::default(); } _ => self.ca_file = File::None, }; self.chain_indicator_state = self.chain_indicator_state(); Task::done(Message::Print) } Message::SetKeyFile(file) => { match file { Ok(file) => { self.key_file = match PrivateKey::read(&file) { Ok(key) => File::PrivateKey(file, Box::new(key)), Err(_) => File::Invalid(file), }; } _ => self.key_file = File::None, }; self.key_indicator_state = self.key_indicator_state(); Task::done(Message::Print) } Message::Print => { match self.print_output() { Ok(output) => { self.output = Content::with_text(output.as_str()); self.status = String::new(); self.mode = UiMode::CertList; } Err(err) => { self.output = Content::default(); self.status = err } }; Task::none() } Message::PrintPem => { match self.pem_output() { Ok(output) => { self.output = Content::with_text(output.as_str()); self.status = String::new(); self.mode = UiMode::Output; } Err(err) => { self.output = Content::default(); self.status = err } }; Task::none() } Message::CopyValue(value) => clipboard::write::(value), Message::Cleanup => { if let Some(chain) = self.fixed_chain.take() { self.chain = Some(chain); self.mode = UiMode::CertList; } self.chain_indicator_state = IndicatorState::Cleaned; Task::none() } Message::PickExportFile => Task::perform(export_file(), Message::ExportToFile), Message::ExportToFile(file) => { match file { Ok(file) => match self.pem_output() { Ok(output) => match fs::write(&file, output) { Ok(_) => self.status = format!("Exported to {}", file.display()), Err(err) => self.status = format!("{:?}", err), }, Err(err) => self.status = err, }, Err(err) => { self.status = format!("{:?}", err); } } Task::none() } } } fn view(&self) -> Element { fn filename_text<'a>( placeholder: &'a str, file: &'a File, ) -> text_input::TextInput<'a, Message> { let text = match file { File::Invalid(ref file) | File::Certificates(ref file, _) | File::PrivateKey(ref file, _) => file.display().to_string(), _ => String::new(), }; match file { File::Certificates(_, _) | File::PrivateKey(_, _) => text_input(placeholder, &text), File::Invalid(_) => text_input(placeholder, &text), _ => text_input(placeholder, &text), } .width(Length::Fill) .style(move |theme, status| text_input::Style { background: Background::Color(Color::WHITE), placeholder: color!(0x888888), value: match file { File::Certificates(_, _) | File::PrivateKey(_, _) => Color::BLACK, File::Invalid(_) => color!(0xaa0000), File::None => color!(0x888888), }, ..text_input::default(theme, status) }) } let cert_file_input = { row![ text("Certificate: ").width(100), filename_text("No certificate file", &self.cert_file), if self.cert_file.is_some() { button("x") .on_press(Message::ClearCertFile) .style(button::danger) } else { button("x").style(button::danger) }, button("..") .on_press(Message::PickCertFile) .style(button::secondary) ] .spacing(2) .align_y(alignment::Vertical::Center) }; let ca_file_input = { row![ text("CA: ").width(100), filename_text("No CA file", &self.ca_file), if self.ca_file.is_some() { button("x") .on_press(Message::ClearCaFile) .style(button::danger) } else { button("x").style(button::danger) }, if self.cert_file.is_some() { button("..") .on_press(Message::PickCaFile) .style(button::secondary) } else { button("..").style(button::secondary) } ] .spacing(2) .align_y(alignment::Vertical::Center) }; let key_file_input = { row![ text("Key: ").width(100), filename_text("No key file", &self.key_file), if self.key_file.is_some() { button("x") .on_press(Message::ClearKeyFile) .style(button::danger) } else { button("x").style(button::danger) }, if self.cert_file.is_some() { button("..") .on_press(Message::PickKeyFile) .style(button::secondary) } else { button("..").style(button::secondary) } ] .spacing(2) .align_y(alignment::Vertical::Center) }; let export_button = if !(self.chain_indicator_state == IndicatorState::Success || self.chain_indicator_state == IndicatorState::Cleaned) { button("Export PEM").style(button::primary) } else { button("Export PEM") .on_press(Message::PickExportFile) .style(button::primary) }; let clip_button = if self.output.text().trim().is_empty() { button("Copy to Clipboard").style(button::secondary) } else { button("Copy to Clipboard") .on_press(Message::CopyValue(self.output.text().trim().to_string())) .style(button::secondary) }; let cleanup_button = if self.fixed_chain.is_none() || self.chain_indicator_state == IndicatorState::Success { button("Cleanup").style(button::secondary) } else { button("Cleanup") .on_press(Message::Cleanup) .style(button::secondary) }; let buttons = if self.cert_file.is_some() { row![ button("Print information") .on_press(Message::Print) .style(button::primary), button("Print PEM") .on_press(Message::PrintPem) .style(button::primary), export_button, text(" "), clip_button, cleanup_button, horizontal_space(), ] } else { row![ button("Print information").style(button::primary), button("Print PEM").style(button::primary), export_button, text(" "), clip_button, cleanup_button, horizontal_space(), ] } .spacing(2); let output = { Scrollable::new( text_editor(&self.output) .style(|theme, _| text_editor::Style { background: Background::Color(Color::BLACK), value: Color::WHITE, ..default(theme, Status::Disabled) }) .font(Font::MONOSPACE), ) .height(Length::Fill) }; let certs = { let mut result = column![]; if let Some(chain) = &self.chain { fn monospace_text<'a>(s: String) -> widget::Text<'a> { text(s) .shaping(text::Shaping::Advanced) .font(Font::MONOSPACE) .size(12) } for (idx, cert) in chain.certs().iter().enumerate() { result = result.push( Container::new( column![ text(cert.name().to_string()).size(18), horizontal_rule(1), row![text("Issuer: ").width(160), text(cert.issuer().to_string())], row![ text("Gültigkeit: ").width(160), text("Gültig von "), if cert.is_valid_not_before(&SystemTime::now()) { text(cert.not_before().to_string()) } else { text(cert.not_before().to_string()).color(color!(0xaa0000)) }, text(" bis "), if cert.is_valid_not_after(&SystemTime::now()) { text(cert.not_after().to_string()) } else { text(cert.not_after().to_string()).color(color!(0xaa0000)) } ], row![ text("SHA-1-Fingerprint: ").width(160), monospace_text(cert.fingerprint().sha1.to_string()), horizontal_space(), button("Copy to Clipboard") .style(button::secondary) .padding(1) .on_press(Message::CopyValue( cert.fingerprint().sha1.to_string() )), ] .align_y(alignment::Vertical::Center), row![ text("SHA-256-Fingerprint: ").width(160), monospace_text(cert.fingerprint().sha256.to_string()), horizontal_space(), button("Copy to Clipboard") .style(button::secondary) .padding(1) .on_press(Message::CopyValue( cert.fingerprint().sha1.to_string() )), ] .align_y(alignment::Vertical::Center), row![ text("Subject-Key-Id: ").width(160), monospace_text(cert.subject_key_id().to_string()), text(" "), if idx == 0 { container(text("")) } else { container(text(format!("{}", idx)).size(10)).padding(1).center_x(24).center_y(14).style(move |_| self.get_cert_key_number_style(idx as u8 - 1, false)) } ], row![ text("Authority-Key-Id: ").width(160), monospace_text(cert.authority_key_id().to_string()), text(" "), if idx >= chain.certs().len() - 1 { container(text("")) } else { container(text(format!("{}", idx+1)).size(10)).padding(1).center_x(24).center_y(14).style(move |_| self.get_cert_key_number_style(idx as u8, true)) } ], if cert.dns_names().is_empty() { row![] } else { row![ text("DNS-Names: ").width(160), text(cert.dns_names().join(", ")) ] }, ] .spacing(2), ) .padding(8) .style(|_| container::Style { background: Some(Background::Color(Color::WHITE)), ..container::Style::default() }) .width(Length::Fill), ) } }; let content = Container::new(result.spacing(4)) .padding(4) .style(|_| container::Style { background: Some(Background::Color(color!(0xeeeeee))), ..container::Style::default() }) .width(Length::Fill); Scrollable::new(content).height(Length::Fill) }; let chain_info = { let mut result = column![]; result = result.push(if let Some(chain) = &self.chain { if chain.has_missing_tail() { column![ Container::new(text("Last Certificate points to another one that should be contained in chain.")).style(|_| container::Style { background: Some(Background::Color(color!(0xeeaa00))), text_color: Some(Color::WHITE), ..container::Style::default() }).padding(2).width(Length::Fill), Container::new(text("Self signed (CA-) Certificate? It might be required to import a self signed Root-CA manually for applications to use it.")) .padding(2) ] } else { column![] } } else { column![] }); result = result.push(if let Some(chain) = &self.chain { if chain.is_valid() { column![Container::new(text("Chain is valid")) .style(|_| container::Style { background: Some(Background::Color(color!(0x00aa00))), text_color: Some(Color::WHITE), ..container::Style::default() }) .padding(2) .width(Length::Fill)] } else if !chain.certs().is_empty() { column![Container::new(text( "Chain or some of its parts is not valid (anymore)" )) .style(|_| container::Style { background: Some(Background::Color(color!(0xaa0000))), text_color: Some(Color::WHITE), ..container::Style::default() }) .padding(2) .width(Length::Fill)] } else { column![] } } else { column![] }); result = result.push(if let File::PrivateKey(_, private_key) = &self.key_file { if let Some(chain) = &self.chain { if let Some(first) = chain.certs().first() { if first.public_key_matches(private_key) { column![Container::new(text( "Private Key matches first Cert Public Key" )) .style(|_| container::Style { background: Some(Background::Color(color!(0x00aa00))), text_color: Some(Color::WHITE), ..container::Style::default() }) .padding(2) .width(Length::Fill)] } else { column![Container::new(text( "Private Key does not match the first Cert Public Key" )) .style(|_| container::Style { background: Some(Background::Color(color!(0xaa0000))), text_color: Some(Color::WHITE), ..container::Style::default() }) .padding(2) .width(Length::Fill)] } } else { column![] } } else { column![] } } else { column![] }); result }; let indicator = { let chain_content = match self.chain_indicator_state { IndicatorState::Unknown => ("No Chain", color!(0xaaaaaa, 0.2), color!(0xaaaaaa)), IndicatorState::Success => ("Chain OK", color!(0x00aa00, 0.2), color!(0x00aa00)), IndicatorState::Error => ("Chain not OK", color!(0xaa0000, 0.2), color!(0xaa0000)), IndicatorState::Cleaned => { ("Chain cleaned", color!(0x00aa88, 0.2), color!(0x00aa88)) } }; let key_content = match self.key_indicator_state { IndicatorState::Success => ("Key OK", color!(0x00aa00, 0.2), color!(0x00aa00)), IndicatorState::Error => ("Key not OK", color!(0xaa0000, 0.2), color!(0xaa0000)), _ => ("No Key", color!(0xaaaaaa, 0.2), color!(0xaaaaaa)), }; container( column![ container(text(chain_content.0)) .style(move |_| container::Style { background: Some(Background::Color(chain_content.1)), text_color: Some(chain_content.2), border: Border { color: chain_content.2, width: 1.0, radius: Radius::from(4) }, ..container::Style::default() }) .center_x(160) .center_y(40), container(text(key_content.0)) .style(move |_| container::Style { background: Some(Background::Color(key_content.1)), text_color: Some(key_content.2), border: Border { color: key_content.2, width: 1.0, radius: Radius::from(4) }, ..container::Style::default() }) .center_x(160) .center_y(40), ] .spacing(4), ) .center_x(160) .center_y(80) }; column![ row![ container(column![cert_file_input, ca_file_input, key_file_input].spacing(2)) .center_y(96), indicator.center_y(96), ] .spacing(40), horizontal_rule(1), buttons, horizontal_rule(1), match self.mode { UiMode::CertList => column![certs, chain_info], UiMode::Output => column![output], }, horizontal_rule(1), text(&self.status) ] .padding(4) .spacing(2) .into() } fn print_output(&self) -> Result { let mut output = vec![]; if let File::Certificates(_, chain) = &self.cert_file { let mut certs = vec![]; for cert in chain.certs() { certs.push(cert); } if let File::Certificates(_, ca_chain) = &self.ca_file { for ca_cert in ca_chain.certs() { certs.push(ca_cert); } } for cert in chain.certs() { let s = format!( "Name: {} Issuer: {} Gültigkeit: Gültig von: {} bis: {} SHA-1-Fingerprint: {} SHA-256-Fingerprint: {} Subject-Key-Id: {} Authority-Key-Id: {} {}", cert.name(), cert.issuer(), cert.not_before(), cert.not_after(), cert.fingerprint().sha1, cert.fingerprint().sha256, cert.subject_key_id(), cert.authority_key_id(), if cert.dns_names().is_empty() { "\n".to_string() } else { format!("DNS-Names: {}\n", cert.dns_names().join(", ")) } ); output.push(s); } if chain.has_missing_tail() { output.push( "! Last Certificate points to another one that should be contained in chain." .to_string(), ); output.push(" Self signed (CA-) Certificate? It might be required to import a self signed Root-CA manually for applications to use it.".to_string()); } if chain.is_valid() { output.push("✓ Chain is valid".to_string()); } else { output.push("! Chain or some of its parts is not valid (anymore)".to_string()); } if let File::PrivateKey(_, private_key) = &self.key_file { if let Some(cert) = chain.certs().first() { if cert.public_key_matches(private_key) { output.push("✓ Private Key matches first Cert Public Key".to_string()); } else { output.push( "! Private Key does not match the first Cert Public Key".to_string(), ); } } } } Ok(output.join("\n")) } fn pem_output(&self) -> Result { let mut result = String::new(); let chain = self.chain.as_ref(); if let Some(chain) = chain { for cert in chain.certs() { match cert.to_pem() { Ok(plain) => result.push_str(&plain), Err(_) => { return Err("Cannot merge files to valid chain - Cert error!".to_string()); } } } } Ok(result) } fn load_chain(&self) -> Result { if let File::Certificates(_, chain) = &self.cert_file { let mut certs = vec![]; for cert in chain.certs() { certs.push(cert.clone()); } if let File::Certificates(_, ca_chain) = &self.ca_file { for ca_cert in ca_chain.certs() { certs.push(ca_cert.clone()); } } return Ok(Chain::from(certs)); } Ok(Chain::from(vec![])) } fn chain_indicator_state(&self) -> IndicatorState { if let Some(chain) = &self.chain { if chain.is_valid() { IndicatorState::Success } else { IndicatorState::Error } } else { IndicatorState::Unknown } } fn key_indicator_state(&self) -> IndicatorState { if let Some(chain) = &self.chain { if let File::PrivateKey(_, private_key) = &self.key_file { if let Some(cert) = chain.certs().first() { return if cert.public_key_matches(private_key) { IndicatorState::Success } else { IndicatorState::Error }; } } } IndicatorState::Unknown } fn wrong_chain_certificate_indexes(&self) -> Vec { if let Some(chain) = &self.chain { let authority_key_ids = chain.certs().iter() .map(|cert| cert.authority_key_id().to_string()) .collect::>(); let x = chain.certs()[1..].iter() .map(|cert| cert.subject_key_id().to_string()) .enumerate() .filter_map(|(idx, key_id)| { if authority_key_ids.get(idx) == Some(&key_id) { None } else { Some(idx as u8) } }).collect::>(); return x; } vec![] } fn get_cert_key_number_style(&self, idx: u8, fill: bool) -> container::Style { let background = if self.wrong_chain_certificate_indexes().contains(&idx) { color!(0xaa0000, 0.2) } else { color!(0x00aa00, 0.2) }; let background = if !fill { Color::WHITE } else { background }; let color = if self.wrong_chain_certificate_indexes().contains(&idx) { color!(0xaa0000) } else { color!(0x00aa00) }; container::Style { background: Some(Background::Color(background)), text_color: Some(color), border: Border { color, width: 1.0, radius: Radius::from(4), }, ..container::Style::default() } } } #[derive(Debug, Clone)] enum Message { PickCertFile, PickCaFile, PickKeyFile, ClearCertFile, ClearCaFile, ClearKeyFile, SetCertFile(Result), SetCaFile(Result), SetKeyFile(Result), Print, PrintPem, CopyValue(String), Cleanup, PickExportFile, ExportToFile(Result), } #[derive(Debug, Clone)] enum Error { Undefined, } #[derive(PartialEq)] enum IndicatorState { Unknown, Success, Error, Cleaned, } async fn pick_file() -> Result { let path = rfd::AsyncFileDialog::new() .set_title("Open file...") .pick_file() .await .ok_or(Error::Undefined)?; Ok(path.into()) } async fn export_file() -> Result { let path = rfd::AsyncFileDialog::new() .set_title("Export file...") .add_filter("PEM-File", &["crt", "pem"]) .save_file() .await .ok_or(Error::Undefined)?; Ok(path.into()) }