mod cert_parser;
mod tls_extractor;
mod whois;
use clap::Parser;
use colored::*;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
#[derive(Parser)]
#[command(name = "crtin", author, version, about = "crtin", long_about = None)]
struct Cli {
#[arg(value_name = "TARGET", help = "URL, domain, or path to a local certificate file (PEM, DER, CRT, CER, P7B, etc.)")]
target: String,
#[arg(short, long, help = "Port to connect for TLS handshake (default: 443, ignored for local files)")]
port: Option<u16>,
#[arg(short = 'J', long, help = "Output in JSON format")]
json: bool,
#[arg(short = 'P', long, help = "Output the PEM of the certificate")]
pem: bool,
#[arg(short = 'o', long = "output", help = "Write output to a file instead of stdout")]
output: Option<String>,
#[arg(short = 'O', long = "output-as-original-file", visible_alias = "oaf", value_name = "name", help = "Save the raw certificate with the original extension")]
output_as_original: Option<String>,
#[arg(short = 'W', long = "whois", help = "Perform a WHOIS lookup on the domain")]
whois: bool,
}
enum TargetType {
Url(String),
LocalFile(String),
}
fn classify_target(raw: &str) -> TargetType {
let raw = raw.trim();
if raw.starts_with("http://") || raw.starts_with("https://") {
let domain = raw.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
.unwrap_or(raw);
let domain = domain.split('/').next().unwrap_or(domain).split(':').next().unwrap_or(domain);
return TargetType::Url(domain.to_string());
}
let path = Path::new(raw);
if path.exists() {
return TargetType::LocalFile(raw.to_string());
}
if path.extension().is_some() {
let ext = path.extension().unwrap().to_str().unwrap_or("").to_lowercase();
let cert_exts = ["pem", "crt", "cert", "der", "cer", "p7b", "p7c", "pfx", "p12", "ca-bundle"];
if cert_exts.contains(&ext.as_str()) {
return TargetType::LocalFile(raw.to_string());
}
}
if !raw.contains('.') && !raw.contains('/') && !raw.contains('\\') {
return TargetType::Url(raw.to_string());
}
TargetType::Url(raw.to_string())
}
fn section_header(w: &mut dyn Write, title: &str) -> io::Result<()> {
writeln!(w, "\n{}", format!("═══ {} ═══", title).cyan().bold())
}
fn field(w: &mut dyn Write, label: &str, value: &str) -> io::Result<()> {
writeln!(w, " {:<26} {}", format!("{}", label).yellow().bold(), value.white())
}
fn field_list(w: &mut dyn Write, label: &str, items: &[String]) -> io::Result<()> {
if items.is_empty() {
return field(w, label, "N/A");
}
writeln!(w, " {:<26} {}", format!("{}", label).yellow().bold(), items[0].white())?;
for item in &items[1..] {
writeln!(w, " {:<26} {}", "", item.white())?;
}
Ok(())
}
fn field_bool(w: &mut dyn Write, label: &str, value: bool) -> io::Result<()> {
let v = if value { "YES".green().bold() } else { "NO".red() };
writeln!(w, " {:<26} {}", format!("{}", label).yellow().bold(), v)
}
fn print_banner(w: &mut dyn Write, target: &str) -> io::Result<()> {
let display = if target.len() > 42 {
format!("{}...", &target[..39])
} else {
target.to_string()
};
let banner = format!(
r#"
╔══════════════════════════════════════════════════════════╗
║ crtin ║
║ {:<42} ║
╚══════════════════════════════════════════════════════════╝"#,
display
);
writeln!(w, "{}", banner.cyan().bold())
}
fn main() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let cli = Cli::parse();
if let Some(ref name) = cli.output_as_original {
handle_save_raw_cert(&cli.target, cli.port, name);
return;
}
let mut output_writer: Box<dyn Write> = if let Some(ref path) = cli.output {
match File::create(path) {
Ok(f) => Box::new(BufWriter::new(f)),
Err(e) => {
eprintln!("Error opening output file: {}", e);
return;
}
}
} else {
Box::new(io::stdout())
};
let result = match classify_target(&cli.target) {
TargetType::LocalFile(path) => handle_local_file(&path, &cli, &mut *output_writer),
TargetType::Url(domain) => handle_url(&domain, &cli, &mut *output_writer),
};
if let Err(e) = result {
eprintln!("Error: {}", e);
}
}
fn handle_save_raw_cert(target: &str, port: Option<u16>, name: &str) {
let target_type = classify_target(target);
let result = match target_type {
TargetType::LocalFile(ref path) => cert_parser::load_certificate(path),
TargetType::Url(ref domain) => {
let port = port.unwrap_or(443);
tls_extractor::extract_cert_info(domain, port)
}
};
match result {
Ok(info) => {
let (ext, data) = if info.format == cert_parser::CertFormat::Der {
("der", info.der.clone())
} else {
("pem", info.pem.as_bytes().to_vec())
};
let output_path = format!("{}.{}", name, ext);
match std::fs::write(&output_path, &data) {
Ok(_) => println!("Saved raw certificate to {}", output_path),
Err(e) => eprintln!("Error writing file {}: {}", output_path, e),
}
}
Err(e) => eprintln!("Error: {}", e),
}
}
fn handle_local_file(path: &str, cli: &Cli, w: &mut dyn Write) -> io::Result<()> {
if cli.json {
match cert_parser::load_certificate(path) {
Ok(info) => {
let json_output = cert_info_to_json(&info);
let json_str = serde_json::to_string_pretty(&json_output)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
writeln!(w, "{}", json_str)?;
}
Err(e) => {
let err = serde_json::json!({"error": e.to_string()});
let json_str = serde_json::to_string_pretty(&err)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
writeln!(w, "{}", json_str)?;
}
}
return Ok(());
}
print_banner(w, path)?;
w.flush()?;
match cert_parser::load_certificate(path) {
Ok(info) => print_cert_info(w, &info, cli)?,
Err(e) => {
let err_msg = format!("{} {}", "ERROR:".red().bold(), e);
writeln!(w, "{}", err_msg)?;
}
}
Ok(())
}
fn handle_url(domain: &str, cli: &Cli, w: &mut dyn Write) -> io::Result<()> {
let port = cli.port.unwrap_or(443);
if cli.json {
let mut output = serde_json::json!({
"domain": domain,
"port": port,
});
match dns_lookup::lookup_host(domain) {
Ok(addrs) => {
let ips: Vec<String> = addrs.iter().map(|a| a.to_string()).collect();
output["dns"] = serde_json::json!(ips);
}
Err(e) => {
output["dns_error"] = serde_json::json!(e.to_string());
}
}
match tls_extractor::extract_cert_info(domain, port) {
Ok(info) => {
output["certificate"] = cert_info_to_json(&info);
}
Err(e) => {
output["certificate_error"] = serde_json::json!(e.to_string());
}
}
if cli.whois {
match whois_lookup(domain) {
Ok(data) => {
let cleaned = data.replace('\r', "");
output["whois"] = serde_json::json!(cleaned);
}
Err(e) => {
output["whois_error"] = serde_json::json!(e.to_string());
}
}
}
let json_str = serde_json::to_string_pretty(&output)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
writeln!(w, "{}", json_str)?;
return Ok(());
}
print_banner(w, domain)?;
w.flush()?;
section_header(w, "DNS RESOLUTION")?;
match dns_lookup::lookup_host(domain) {
Ok(addrs) => {
for addr in &addrs {
field(w, "Resolved Address", &addr.to_string())?;
}
}
Err(e) => {
let dns_err = format!("{}", e).red().to_string();
field(w, "DNS Error", &dns_err)?;
}
}
section_header(w, "LIVE CERTIFICATE")?;
match tls_extractor::extract_cert_info(domain, port) {
Ok(info) => {
print_cert_info(w, &info, cli)?;
}
Err(e) => {
let err_msg = format!("{} {}", "ERROR:".red().bold(), e);
writeln!(w, "{}", err_msg)?;
}
}
if cli.whois {
section_header(w, "WHOIS")?;
match whois_lookup(domain) {
Ok(output) => {
let cleaned = output.replace('\r', "");
for line in cleaned.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
writeln!(w, " {}", trimmed.white())?;
}
}
Err(e) => {
let err_msg = format!("{} {}", "WHOIS Error:".red().bold(), e);
writeln!(w, "{}", err_msg)?;
}
}
} else {
section_header(w, "WHOIS LOOKUP HINT")?;
writeln!(w, " Add the flag {} for registry/ownership details", "-W or --whois".magenta())?;
}
Ok(())
}
fn print_cert_info(w: &mut dyn Write, info: &cert_parser::CertInfo, cli: &Cli) -> io::Result<()> {
section_header(w, "CERTIFICATE DETAILS")?;
field(w, "Source", &info.source_type)?;
field(w, "Subject", &info.subject)?;
field(w, "Issuer", &info.issuer)?;
field(w, "Serial Number", &info.serial_number)?;
field(w, "Version", &info.version)?;
field(w, "Not Before", &info.not_before)?;
field(w, "Not After", &info.not_after)?;
field(w, "Signature Algorithm", &info.signature_algorithm)?;
field(w, "Public Key Algorithm", &info.public_key_algorithm)?;
field(w, "Public Key Size", &format!("{} bits", info.public_key_size))?;
field(w, "SHA-256 Fingerprint", &info.fingerprint_sha256)?;
field(w, "SHA-1 Fingerprint", &info.fingerprint_sha1)?;
field(w, "MD5 Fingerprint", &info.fingerprint_md5)?;
field_bool(w, "Wildcard", info.is_wildcard)?;
field_bool(w, "EV Certificate", info.is_ev)?;
field_bool(w, "CA Certificate", info.is_ca)?;
field_bool(w, "Self-Signed", info.is_self_signed)?;
if let Some(bc) = &info.basic_constraints {
field(w, "Basic Constraints", bc)?;
}
field_list(w, "Subject Alt Names", &info.sans)?;
field_list(w, "Key Usages", &info.key_usages)?;
field_list(w, "Extended Key Usages", &info.extended_key_usages)?;
field_list(w, "OCSP Responder", &info.ocsp_responder)?;
field_list(w, "CA Issuers (AIA)", &info.ca_issuers)?;
field_list(w, "CRL Distribution", &info.crl_distribution)?;
field_list(w, "Certificate Policies", &info.policy_oids)?;
if cli.pem {
section_header(w, "PEM")?;
writeln!(w, "{}", info.pem.dimmed())?;
}
let expiry = chrono::NaiveDateTime::parse_from_str(
&info.not_after.replace("GMT", "").trim(),
"%a, %d %b %Y %H:%M:%S %z",
);
if let Ok(exp) = expiry {
let now = chrono::Utc::now().naive_utc();
let remaining = exp - now;
let days = remaining.num_days();
section_header(w, "EXPIRY STATUS")?;
let status = if days < 0 {
format!("EXPIRED {} days ago", days.abs()).red().bold()
} else if days < 30 {
format!("{} days remaining ⚠", days).yellow().bold()
} else {
format!("{} days remaining ✓", days).green().bold()
};
writeln!(w, " {}", status)?;
}
Ok(())
}
fn cert_info_to_json(info: &cert_parser::CertInfo) -> serde_json::Value {
let remaining_days = chrono::NaiveDateTime::parse_from_str(
&info.not_after.replace("GMT", "").trim(),
"%a, %d %b %Y %H:%M:%S %z",
).ok().map(|exp| {
let now = chrono::Utc::now().naive_utc();
(exp - now).num_days()
});
serde_json::json!({
"source": info.source_type,
"subject": info.subject,
"issuer": info.issuer,
"serial_number": info.serial_number,
"version": info.version,
"not_before": info.not_before,
"not_after": info.not_after,
"signature_algorithm": info.signature_algorithm,
"public_key_algorithm": info.public_key_algorithm,
"public_key_size_bits": info.public_key_size,
"fingerprint_sha256": info.fingerprint_sha256,
"fingerprint_sha1": info.fingerprint_sha1,
"fingerprint_md5": info.fingerprint_md5,
"is_wildcard": info.is_wildcard,
"is_ev": info.is_ev,
"is_ca": info.is_ca,
"is_self_signed": info.is_self_signed,
"basic_constraints": info.basic_constraints,
"subject_alt_names": info.sans,
"key_usages": info.key_usages,
"extended_key_usages": info.extended_key_usages,
"ocsp_responder": info.ocsp_responder,
"ca_issuers_aia": info.ca_issuers,
"crl_distribution_points": info.crl_distribution,
"certificate_policies": info.policy_oids,
"days_until_expiry": remaining_days,
})
}
fn extract_registrable(domain: &str) -> String {
let parts: Vec<&str> = domain.split('.').collect();
if parts.len() <= 2 {
return domain.to_string();
}
let tlds = ["co.uk", "com.au", "co.nz", "co.jp", "or.jp", "ne.jp", "ac.uk", "gov.uk", "org.uk", "net.au"];
let last2 = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
if tlds.contains(&last2.as_str()) && parts.len() > 2 {
return format!("{}.{}", parts[parts.len() - 3], last2);
}
format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
}
fn whois_lookup(domain: &str) -> Result<String, String> {
let registrable = extract_registrable(domain);
whois::lookup(®istrable)
}