diff options
| author | Franck Cuny <fcuny@roblox.com> | 2025-08-29 09:53:59 -0700 |
|---|---|---|
| committer | Franck Cuny <fcuny@roblox.com> | 2025-08-29 09:53:59 -0700 |
| commit | 5047b92050bba0fd5fddddf6db6256524a6d925a (patch) | |
| tree | 9c8c1103bb5165fabc7a3a5903df08b34c184a35 | |
| parent | don't check errors (diff) | |
| download | x-5047b92050bba0fd5fddddf6db6256524a6d925a.tar.gz | |
add a tool to check validity of SSH certificates
Diffstat (limited to '')
| -rw-r--r-- | Cargo.lock | 4 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/ssh-cert-info/Cargo.toml | 6 | ||||
| -rw-r--r-- | src/ssh-cert-info/README.org | 1 | ||||
| -rw-r--r-- | src/ssh-cert-info/src/main.rs | 225 |
5 files changed, 237 insertions, 1 deletions
@@ -847,6 +847,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] +name = "ssh-cert-info" +version = "0.1.0" + +[[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1,3 +1,3 @@ [workspace] -members = ["src/apple_silicon/", "src/x509-info/"] +members = ["src/apple_silicon/", "src/ssh-cert-info", "src/x509-info/"] resolver = "3" diff --git a/src/ssh-cert-info/Cargo.toml b/src/ssh-cert-info/Cargo.toml new file mode 100644 index 0000000..f00f2c5 --- /dev/null +++ b/src/ssh-cert-info/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ssh-cert-info" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/src/ssh-cert-info/README.org b/src/ssh-cert-info/README.org new file mode 100644 index 0000000..9d10f5f --- /dev/null +++ b/src/ssh-cert-info/README.org @@ -0,0 +1 @@ +A quick way to check information about various SSH certificates on my machine. diff --git a/src/ssh-cert-info/src/main.rs b/src/ssh-cert-info/src/main.rs new file mode 100644 index 0000000..4c0c1ff --- /dev/null +++ b/src/ssh-cert-info/src/main.rs @@ -0,0 +1,225 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let ssh_dir = get_ssh_directory(); + + match fs::read_dir(&ssh_dir) { + Ok(entries) => { + let mut cert_files = Vec::new(); + + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if let Some(extension) = path.extension() { + if extension == "pub" { + // I use the following convention for my certificates: <identificator>-cert.pub + if let Some(filename) = path.file_name() { + if filename.to_string_lossy().contains("-cert.pub") { + cert_files.push(path); + } + } + } + } + } + } + + if cert_files.is_empty() { + println!("No SSH certificates found in {}", ssh_dir.display()); + return; + } + + println!("SSH Certificate Report"); + println!("======================"); + println!(); + + for cert_path in cert_files { + check_certificate(&cert_path); + println!(); + } + } + Err(e) => { + eprintln!("Error reading SSH directory {}: {}", ssh_dir.display(), e); + std::process::exit(1); + } + } +} + +fn get_ssh_directory() -> PathBuf { + let home = env::var("HOME").unwrap_or_else(|_| { + eprintln!("HOME environment variable not set"); + std::process::exit(1); + }); + + PathBuf::from(home).join(".ssh") +} + +fn check_certificate(cert_path: &PathBuf) { + println!("Certificate: {}", cert_path.display()); + + // Use ssh-keygen to get certificate information + // TODO: maybe consider https://docs.rs/ssh-key/latest/ssh_key/certificate/struct.Certificate.html ? + let output = Command::new("ssh-keygen") + .arg("-L") + .arg("-f") + .arg(cert_path) + .output(); + + match output { + Ok(output) => { + if output.status.success() { + let cert_info = String::from_utf8_lossy(&output.stdout); + parse_and_display_cert_info(&cert_info); + } else { + let error = String::from_utf8_lossy(&output.stderr); + println!(" Error: Failed to read certificate: {}", error.trim()); + } + } + Err(e) => { + println!(" Error: Failed to execute ssh-keygen: {}", e); + } + } +} + +fn parse_and_display_cert_info(cert_info: &str) { + let mut type_found = false; + let mut public_key = String::new(); + let mut signing_ca = String::new(); + let mut key_id = String::new(); + let mut serial = String::new(); + let mut valid_from = String::new(); + let mut valid_to = String::new(); + let mut cert_type = String::new(); + let mut principals = Vec::new(); + let mut critical_options = Vec::new(); + let mut extensions = Vec::new(); + + for line in cert_info.lines() { + let line = line.trim(); + + if line.starts_with("Type:") { + cert_type = line.replace("Type:", "").trim().to_string(); + type_found = true; + } else if line.starts_with("Public key:") { + public_key = line.replace("Public key:", "").trim().to_string(); + } else if line.starts_with("Signing CA:") { + signing_ca = line.replace("Signing CA:", "").trim().to_string(); + } else if line.starts_with("Key ID:") { + key_id = line.replace("Key ID:", "").trim().to_string(); + } else if line.starts_with("Serial:") { + serial = line.replace("Serial:", "").trim().to_string(); + } else if line.starts_with("Valid: from") { + // Format: "Valid: from 2023-01-01T00:00:00 to 2024-01-01T00:00:00" + let valid_line = line.replace("Valid: from", "").trim().to_string(); + if let Some(to_pos) = valid_line.find(" to ") { + valid_from = valid_line[..to_pos].trim().to_string(); + valid_to = valid_line[to_pos + 4..].trim().to_string(); + } + } else if line.starts_with("Principals:") { + // Principals might be on the same line or following lines + let principal_text = line.replace("Principals:", "").trim().to_string(); + if !principal_text.is_empty() { + principals.push(principal_text); + } + } else if line.starts_with("Critical Options:") { + let options_text = line.replace("Critical Options:", "").trim().to_string(); + if !options_text.is_empty() { + critical_options.push(options_text); + } + } else if line.starts_with("Extensions:") { + let extensions_text = line.replace("Extensions:", "").trim().to_string(); + if !extensions_text.is_empty() { + extensions.push(extensions_text); + } + } else if !type_found { + // Skip lines before we find the certificate type + continue; + } else if line.len() > 0 && (line.starts_with(" ") || line.starts_with("\t\t")) { + // This might be a continuation of principals, critical options, or extensions + let trimmed = line.trim(); + if !trimmed.is_empty() { + if !principals.is_empty() { + principals.push(trimmed.to_string()); + } else if !critical_options.is_empty() { + critical_options.push(trimmed.to_string()); + } else if !extensions.is_empty() { + extensions.push(trimmed.to_string()); + } + } + } + } + + if !cert_type.is_empty() { + println!(" Type: {}", cert_type); + } + + if !public_key.is_empty() { + println!(" Public key: {}", public_key); + } + + if !signing_ca.is_empty() { + println!(" Signing CA: {}", signing_ca); + } + + if !key_id.is_empty() { + println!(" Key ID: {}", key_id); + } + + if !serial.is_empty() { + println!(" Serial: {}", serial); + } + + if !valid_from.is_empty() && !valid_to.is_empty() { + println!(" Valid from: {}", valid_from); + println!(" Valid to: {}", valid_to); + + check_expiration(&valid_to); + } + + if !principals.is_empty() { + println!(" Principals:"); + for principal in &principals { + if !principal.is_empty() { + println!(" {}", principal); + } + } + } + + if !critical_options.is_empty() { + println!(" Critical Options:"); + for option in &critical_options { + if !option.is_empty() { + println!(" {}", option); + } + } + } + + if !extensions.is_empty() { + println!(" Extensions:"); + for extension in &extensions { + if !extension.is_empty() { + println!(" {}", extension); + } + } + } +} + +fn check_expiration(valid_to: &str) { + // TODO: maybe use chrono for more robust date handling ? + let current_date = std::process::Command::new("date") + .arg("+%Y-%m-%dT%H:%M:%S") + .output(); + + if let Ok(output) = current_date { + if let Ok(current) = String::from_utf8(output.stdout) { + let current = current.trim(); + if valid_to < current { + println!(" ⚠️ WARNING: Certificate has EXPIRED!"); + } else { + println!(" ✓ Certificate is currently valid"); + } + } + } +} |
