From 9847ff322905ff3614c8edce5c3afec9895747da Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Sun, 5 Jan 2025 18:02:43 +0100 Subject: [PATCH] feat: add iced based GUI --- Cargo.toml | 3 + README.md | 6 +- ui/Cargo.toml | 10 + ui/src/main.rs | 507 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 ui/Cargo.toml create mode 100644 ui/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6d9b914..9598e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" license = "GPL-3.0-or-later" authors = ["Paul-Christian Volkmer"] +[workspace] +members = ["ui"] + [dependencies] openssl = { version = "0.10"} clap = { version = "4.5", features = ["std", "help", "usage", "derive", "error-context"], default-features = false } diff --git a/README.md b/README.md index 253613b..36b4878 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,8 @@ Options: ```shell cert-tools merge cert.pem ca.pem > chain.pem -``` \ No newline at end of file +``` + +## GUI + +In addition to the console-based application, a simple graphical user interface is available in (sub-)package `ui`. \ No newline at end of file diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..6edb36e --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cert-tools-ui" +version = "0.1.0" +edition = "2021" + +[dependencies] +cert-tools = { path = "..", version = "*" } +iced = { version = "0.13", features = ["tiny-skia"], default-features = false } +rfd = "0.15" +itertools = "0.14" \ No newline at end of file diff --git a/ui/src/main.rs b/ui/src/main.rs new file mode 100644 index 0000000..e712023 --- /dev/null +++ b/ui/src/main.rs @@ -0,0 +1,507 @@ +#![windows_subsystem = "windows"] + +use cert_tools::{Chain, PrivateKey}; +use iced::widget::text_editor::{default, Content, Status}; +use iced::widget::{ + button, column, container, horizontal_rule, horizontal_space, row, text, text_editor, + Scrollable, +}; +use iced::{application, clipboard, Background, Color, Element, Font, Length, Size, Task}; +use itertools::Itertools; +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; + +fn main() -> iced::Result { + application(Ui::title, Ui::update, Ui::view) + .resizable(false) + .window_size(Size::new(1024.0, 800.0)) + .scale_factor(|_| 0.8) + .run_with(Ui::new) +} + +struct Ui { + cert_file: Option, + ca_file: Option, + key_file: Option, + + output: Content, + status: String, +} + +impl Ui { + fn new() -> (Self, Task) { + ( + Self { + cert_file: None, + ca_file: None, + key_file: None, + output: Content::default(), + status: String::new(), + }, + Task::none(), + ) + } + + fn title(&self) -> String { + "CertTools".into() + } + + fn update(&mut self, message: Message) -> Task { + 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 = None; + self.output = Content::default(); + Task::none() + } + Message::ClearCaFile => { + self.ca_file = None; + self.output = Content::default(); + Task::none() + } + Message::ClearKeyFile => { + self.key_file = None; + self.output = Content::default(); + Task::none() + } + Message::SetCertFile(file) => { + match file { + Ok(file) => { + self.cert_file = Some(file); + self.output = Content::default(); + }, + _ => self.cert_file = None, + }; + Task::none() + } + Message::SetCaFile(file) => { + match file { + Ok(file) => { + self.ca_file = Some(file); + self.output = Content::default(); + }, + _ => self.ca_file = None, + }; + Task::none() + } + Message::SetKeyFile(file) => { + match file { + Ok(file) => { + self.key_file = Some(file); + self.output = Content::default(); + }, + _ => self.key_file = None, + }; + Task::none() + } + Message::Print => { + match self.print_output() { + Ok(output) => { + self.output = Content::with_text(output.as_str()); + self.status = String::new(); + } + Err(err) => { + self.output = Content::default(); + self.status = err + } + }; + Task::none() + } + Message::Merge => { + match self.merge_output() { + Ok(output) => { + self.output = Content::with_text(output.as_str()); + self.status = String::new(); + } + Err(err) => { + self.output = Content::default(); + self.status = err + } + }; + Task::none() + } + Message::Copy => clipboard::write::(self.output.text()), + } + } + + fn view(&self) -> Element { + fn grey_out_style(is_active: bool) -> text::Style { + text::Style { + color: if is_active { + Some(Color::BLACK) + } else { + Some(Color::parse("#888888").unwrap()) + }, + } + } + + let cert_file_input = { + row![ + text("Certificate: ").width(100), + text(match self.cert_file { + Some(ref file) => file.display().to_string(), + _ => "No certificate file".to_string(), + }) + .style(|_| grey_out_style(self.cert_file.is_some())), + horizontal_space(), + 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) + }; + + let ca_file_input = { + row![ + text("CA: ").width(100), + text(match self.ca_file { + Some(ref file) => file.display().to_string(), + _ => "No CA file".to_string(), + }) + .style(|_| grey_out_style(self.ca_file.is_some())), + horizontal_space(), + 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) + }; + + let key_file_input = { + row![ + text("Key: ").width(100), + text(match self.key_file { + Some(ref file) => file.display().to_string(), + _ => "No key file".to_string(), + }) + .style(|_| grey_out_style(self.key_file.is_some())), + horizontal_space(), + 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) + }; + + 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::Copy) + .style(button::secondary) + }; + let buttons = if self.cert_file.is_some() { + row![ + button("Print information") + .on_press(Message::Print) + .style(button::primary), + button("Merge into PEM") + .on_press(Message::Merge) + .style(button::primary), + text(" "), + clip_button, + horizontal_space(), + ] + } else { + row![ + button("Print information").style(button::primary), + button("Merge into PEM").style(button::primary), + text(" "), + clip_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 indicator = { + let content = match self.indicator_state() { + IndicatorState::Unknown => ("?", "#aaaaaa", "#ffffff"), + IndicatorState::Success => ("OK", "#00aa00", "#ffffff"), + IndicatorState::Error => ("Not OK", "#aa0000", "#ffffff"), + }; + + container( + container(text(content.0)) + .style(|_| container::Style { + background: Some(Background::Color(Color::parse(content.1).unwrap())), + text_color: Some(Color::parse(content.2).unwrap()), + ..container::Style::default() + }) + .center_x(80) + .center_y(80), + ) + .center_x(96) + .center_y(96) + }; + + column![ + row![ + column![cert_file_input, ca_file_input, key_file_input].spacing(2), + indicator, + ] + .spacing(96), + horizontal_rule(1), + buttons, + output, + horizontal_rule(1), + text(&self.status) + ] + .padding(4) + .spacing(2) + .into() + } + + fn print_output(&self) -> Result { + let mut output = vec![]; + if let Some(cert_file) = &self.cert_file { + let chain = Chain::read(cert_file); + + if let Ok(mut chain) = chain { + if let Some(ca_file) = &self.ca_file { + if let Ok(ca_chain) = Chain::read(ca_file) { + for ca_cert in ca_chain.into_vec() { + chain.push(ca_cert); + } + } else { + return Err("Cannot read CA file".to_string()); + } + } + + 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 Some(key) = &self.key_file { + match PrivateKey::read(Path::new(&key)) { + Ok(private_key) => { + 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(), + ); + } + } + } + _ => return Err("Could not read Private Key".to_string()), + } + } + } else { + return Err("Cannot read Certificate file".to_string()); + } + } + + Ok(output.join("\n")) + } + + fn merge_output(&self) -> Result { + let mut result = String::new(); + if let Some(cert_file) = &self.cert_file { + let chain = Chain::read(cert_file); + + if let Ok(mut chain) = chain { + if let Some(ca_file) = &self.ca_file { + if let Ok(ca_chain) = Chain::read(ca_file) { + for ca_cert in ca_chain.into_vec() { + chain.push(ca_cert); + } + } else { + return Err("Cannot read CA file".to_string()); + } + } + let mut certs = chain.into_vec(); + certs.sort_by(|cert1, cert2| { + if cert1.subject_key_id() == cert2.authority_key_id() { + Ordering::Greater + } else { + Ordering::Less + } + }); + let chain = Chain::from(certs.into_iter().unique().collect::>()); + if !chain.is_valid() { + return Err("Cannot merge files to valid chain - giving up!".to_string()); + } + 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() + ); + } + } + } + } else { + return Err("Cannot read Certificate file".to_string()); + } + } + Ok(result) + } + + fn indicator_state(&self) -> IndicatorState { + let mut result = IndicatorState::Unknown; + + if let Some(cert_file) = &self.cert_file { + let chain = Chain::read(cert_file); + + if let Ok(mut chain) = chain { + result = if chain.is_valid() { + IndicatorState::Success + } else { + IndicatorState::Error + }; + + if let Some(ca_file) = &self.ca_file { + if let Ok(ca_chain) = Chain::read(ca_file) { + for ca_cert in ca_chain.into_vec() { + chain.push(ca_cert); + } + result = if chain.is_valid() { + IndicatorState::Success + } else { + IndicatorState::Error + }; + } else { + result = IndicatorState::Error; + } + } + + if let Some(key) = &self.key_file { + match PrivateKey::read(Path::new(&key)) { + Ok(private_key) => { + if let Some(cert) = chain.certs().first() { + return if cert.public_key_matches(private_key) && chain.is_valid() { + result + } else { + IndicatorState::Error + }; + } + } + _ => return IndicatorState::Error, + } + } + } + } + + result + } +} + +#[derive(Debug, Clone)] +enum Message { + PickCertFile, + PickCaFile, + PickKeyFile, + ClearCertFile, + ClearCaFile, + ClearKeyFile, + SetCertFile(Result), + SetCaFile(Result), + SetKeyFile(Result), + Print, + Merge, + Copy, +} + +#[derive(Debug, Clone)] +enum Error { + Undefined, +} + +enum IndicatorState { + Unknown, + Success, + Error, +} + +async fn pick_file() -> Result { + let path = rfd::AsyncFileDialog::new() + .set_title("Open file...") + .pick_file() + .await + .ok_or(Error::Undefined)?; + + Ok(path.into()) +}