Project: Certificate Inspector
Prerequisites: Lesson 7 (Certificates), Lesson 14 (tokio-rustls). Connect to real websites and inspect their TLS certificates.
Why inspect certificates?
Certificates are the backbone of internet trust. Being able to inspect them is a fundamental skill:
┌──────────────────────────────────────────────────────────────┐
│ When you need to inspect certificates │
│ │
│ DevOps / SRE: │
│ "Our HTTPS is broken" → is the cert expired? │
│ "Users see a warning" → hostname mismatch? wrong chain? │
│ "Cert renew failed" → what's the current expiry? │
│ │
│ Security: │
│ "Is this site legit?" → who issued the cert? trusted CA? │
│ "MITM detection" → did the cert fingerprint change? │
│ "CT monitoring" → was a cert issued for my domain? │
│ │
│ Development: │
│ "mTLS isn't working" → is the client cert valid? │
│ "Self-signed setup" → are SANs configured correctly? │
│ "Testing TLS code" → what does the server actually send?│
└──────────────────────────────────────────────────────────────┘
What you’re building
A CLI tool that connects to any website, downloads its certificate chain, and shows everything — like a mini openssl s_client but with cleaner output.
cargo run -p tls --bin p3-cert-inspector -- google.com
google.com:443
──────────────
Protocol: TLS 1.3
Cipher: TLS_AES_256_GCM_SHA384
Certificate chain:
[0] *.google.com
Issuer: GTS CA 1C3
Valid: 2024-10-21 to 2025-01-13
Expires in: 42 days
Key: ECDSA (P-256)
SANs: *.google.com, google.com, *.youtube.com, ...
[1] GTS CA 1C3
Issuer: GTS Root R1
Valid: 2020-08-13 to 2027-09-30
Key: RSA (2048 bits)
Fingerprint (SHA-256): a1b2c3d4e5f6...
The reference tools
Before building our own, see what the standard tools show:
# === openssl s_client — the classic ===
# Full connection info:
echo | openssl s_client -connect google.com:443 2>/dev/null | head -25
# CONNECTED(00000003)
# depth=2 ...
# depth=1 ...
# depth=0 ...
# ---
# Certificate chain
# 0 s:CN = *.google.com
# i:CN = GTS CA 1C3
# 1 s:CN = GTS CA 1C3
# i:CN = GTS Root R1
# Just the certificate details:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
openssl x509 -text -noout | head -30
# Just the dates:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
openssl x509 -noout -dates
# notBefore=Oct 21 08:22:04 2024 GMT
# notAfter=Jan 13 08:22:03 2025 GMT
# Just the SANs:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
openssl x509 -noout -ext subjectAltName
# Just the fingerprint:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
openssl x509 -noout -fingerprint -sha256
# === Test sites with intentional cert problems ===
# Expired cert:
echo | openssl s_client -connect expired.badssl.com:443 2>/dev/null | \
openssl x509 -noout -dates
# notAfter is in the past!
# Wrong hostname:
echo | openssl s_client -connect wrong.host.badssl.com:443 2>/dev/null | \
openssl x509 -noout -subject -ext subjectAltName
# Subject doesn't match the hostname
# Self-signed:
echo | openssl s_client -connect self-signed.badssl.com:443 2>/dev/null | head -5
# "verify error:num=18:self-signed certificate"
Implementation guide
Step 0: Project setup
touch tls/src/bin/p3-cert-inspector.rs
Add to tls/Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "0.26"
x509-parser = "0.16"
clap = { version = "4", features = ["derive"] }
CLI skeleton:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "cert-inspector", about = "Inspect TLS certificates of any website")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Inspect a site's certificate chain
Inspect {
/// Domain name (e.g., google.com)
host: String,
/// Port (default: 443)
#[arg(long, default_value = "443")]
port: u16,
},
/// Check certificate expiry for multiple domains
CheckExpiry {
/// Domain names
hosts: Vec<String>,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Inspect { host, port } => todo!(),
Command::CheckExpiry { hosts } => todo!(),
}
}
Step 1: Connect over TLS
#![allow(unused)]
fn main() {
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use rustls::{ClientConfig, RootCertStore};
async fn tls_connect(host: &str, port: u16)
-> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error>>
{
// Load the system's trusted root CAs
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let tcp = TcpStream::connect(format!("{host}:{port}")).await?;
let server_name = host.try_into()?;
let tls = connector.connect(server_name, tcp).await?;
Ok(tls)
}
}
Test it:
#[tokio::main]
async fn main() {
let tls = tls_connect("google.com", 443).await.unwrap();
println!("Connected to google.com over TLS!");
// Extract negotiated parameters:
let (_, conn) = tls.get_ref();
println!("Protocol: {:?}", conn.protocol_version().unwrap());
println!("Cipher: {:?}", conn.negotiated_cipher_suite().unwrap());
}
cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# Connected to google.com over TLS!
# Protocol: TLSv1_3
# Cipher: TLS13_AES_256_GCM_SHA384
Step 2: Extract the certificate chain
After the TLS handshake, the peer’s certificates are available:
#![allow(unused)]
fn main() {
let (_, conn) = tls.get_ref();
let certs = conn.peer_certificates()
.expect("server didn't send certificates");
println!("Certificate chain: {} certificates", certs.len());
}
Each cert is DER-encoded bytes. Let’s parse them.
Step 3: Parse certificates with x509-parser
#![allow(unused)]
fn main() {
use x509_parser::prelude::*;
fn print_cert(index: usize, der: &[u8]) {
let (_, cert) = X509Certificate::from_der(der)
.expect("failed to parse certificate");
println!(" [{}] {}", index, cert.subject());
println!(" Issuer: {}", cert.issuer());
println!(" Valid: {} to {}",
cert.validity().not_before,
cert.validity().not_after);
// Check if it's a CA certificate
if let Some(bc) = cert.basic_constraints().ok().flatten() {
if bc.value.ca {
println!(" Type: CA certificate");
}
}
// Self-signed?
if cert.subject() == cert.issuer() {
println!(" Note: Self-signed (root CA or self-signed cert)");
}
}
}
Test it:
cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# Certificate chain: 3 certificates
# [0] CN=*.google.com
# Issuer: CN=GTS CA 1C3
# Valid: 2024-10-21 to 2025-01-13
# [1] CN=GTS CA 1C3
# Issuer: CN=GTS Root R1
# Valid: 2020-08-13 to 2027-09-30
# Type: CA certificate
# Compare with openssl:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep -E "s:|i:"
Step 4: Extract Subject Alternative Names
SANs tell you which domains the certificate covers:
#![allow(unused)]
fn main() {
fn print_sans(cert: &X509Certificate) {
if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
let names: Vec<String> = san_ext.value.general_names.iter()
.filter_map(|name| match name {
x509_parser::extensions::GeneralName::DNSName(dns) => {
Some(dns.to_string())
}
x509_parser::extensions::GeneralName::IPAddress(ip) => {
Some(format!("IP:{:?}", ip))
}
_ => None,
})
.collect();
if !names.is_empty() {
println!(" SANs: {}", names.join(", "));
}
}
}
}
cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# ...
# SANs: *.google.com, google.com, *.youtube.com, youtube.com, ...
Step 5: Compute days until expiry
#![allow(unused)]
fn main() {
fn days_until_expiry(cert: &X509Certificate) -> i64 {
let not_after = cert.validity().not_after.timestamp();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
(not_after - now) / 86400
}
}
#![allow(unused)]
fn main() {
let days = days_until_expiry(&cert);
if days < 0 {
println!(" ⚠ EXPIRED {} days ago!", -days);
} else if days < 30 {
println!(" ⚠ Expires in {} days (renew soon!)", days);
} else {
println!(" Expires in {} days", days);
}
}
Step 6: Certificate fingerprint
The SHA-256 fingerprint uniquely identifies a certificate (used for pinning):
#![allow(unused)]
fn main() {
use sha2::{Sha256, Digest};
fn cert_fingerprint(der: &[u8]) -> String {
let hash = Sha256::digest(der);
hash.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(":")
}
}
# Compare with openssl:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
openssl x509 -noout -fingerprint -sha256
# SHA256 Fingerprint=A1:B2:C3:D4:...
Step 7: Batch expiry checker
Check multiple domains at once:
#![allow(unused)]
fn main() {
async fn check_expiry(hosts: Vec<String>) {
for host in &hosts {
match tls_connect(host, 443).await {
Ok(tls) => {
let (_, conn) = tls.get_ref();
if let Some(certs) = conn.peer_certificates() {
let (_, cert) = X509Certificate::from_der(&certs[0]).unwrap();
let days = days_until_expiry(&cert);
let status = if days < 0 { "EXPIRED" }
else if days < 30 { "⚠ RENEW SOON" }
else { "✓" };
println!("{:<30} {:>4} days {}", host, days, status);
}
}
Err(e) => {
println!("{:<30} ERROR: {}", host, e);
}
}
}
}
}
cargo run -p tls --bin p3-cert-inspector -- check-expiry \
google.com github.com cloudflare.com expired.badssl.com
# google.com 62 days ✓
# github.com 198 days ✓
# cloudflare.com 150 days ✓
# expired.badssl.com ERROR: certificate has expired
Test targets
badssl.com provides certificates with every kind of problem — perfect for testing:
# Working:
cargo run -p tls --bin p3-cert-inspector -- inspect sha256.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect tls-v1-2.badssl.com
# Broken (your tool should show useful errors):
cargo run -p tls --bin p3-cert-inspector -- inspect expired.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect wrong.host.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect self-signed.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect untrusted-root.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect revoked.badssl.com
Tip: for sites with invalid certs, you’ll need to configure rustls to accept them (for inspection only). Create a custom ServerCertVerifier that accepts everything:
#![allow(unused)]
fn main() {
// DANGEROUS — for inspection only, never in production
struct NoVerify;
impl rustls::client::danger::ServerCertVerifier for NoVerify {
fn verify_server_cert(&self, ...) -> Result<...> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
// ... implement other required methods
}
}
This lets you connect to expired/self-signed sites to inspect their certs.
Exercises
Exercise 1: Basic inspector
Connect, extract chain, print subject/issuer/validity/SANs for each cert. Compare your output with openssl s_client.
Exercise 2: Batch expiry checker
cargo run -p tls --bin p3-cert-inspector -- check-expiry \
google.com github.com example.com expired.badssl.com
Color-code output: green for >30 days, yellow for <30 days, red for expired.
Exercise 3: Certificate pinning
Download a site’s cert, compute SHA-256 fingerprint, save to a JSON file. On subsequent runs, compare the current fingerprint with the saved one. Alert if it changed (possible MITM or cert rotation).
# First run — saves the pin:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# Fingerprint: SHA-256:A1:B2:C3...
# Saved to pins.json
# Later run — checks the pin:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# Fingerprint: SHA-256:A1:B2:C3... ✓ matches saved pin
# After cert rotation:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# ⚠ FINGERPRINT CHANGED!
# Old: SHA-256:A1:B2:C3...
# New: SHA-256:D4:E5:F6...
Exercise 4: JSON output
Add --json flag for machine-readable output:
cargo run -p tls --bin p3-cert-inspector -- inspect --json google.com
{
"host": "google.com",
"port": 443,
"protocol": "TLS 1.3",
"cipher": "TLS_AES_256_GCM_SHA384",
"chain": [
{
"subject": "CN=*.google.com",
"issuer": "CN=GTS CA 1C3",
"not_before": "2024-10-21",
"not_after": "2025-01-13",
"days_until_expiry": 62,
"sans": ["*.google.com", "google.com", "*.youtube.com"],
"fingerprint": "SHA-256:A1:B2:C3:D4..."
}
]
}
This is useful for monitoring scripts that parse the output programmatically.
Extension: turn your inspector into a scanner
Once the inspector works, it’s one step from the tools security teams actually use (testssl.sh, SSL Labs). Every commercial scanner is built from the same primitives you already have: protocol/cipher introspection from rustls, expiry and SAN checks from x509-parser, plus a handful of security-header checks.
┌──────────────────────────────────────────────────────────┐
│ What a TLS scanner checks │
│ │
│ ✓ Protocol version — TLS 1.3? 1.2? Old 1.0? (bad!) │
│ ✓ Cipher suite — modern AEAD? or weak RC4? (bad!) │
│ ✓ Certificate — expired? wrong hostname? │
│ ✓ Key exchange — ephemeral DH (forward secrecy)? │
│ ✓ HSTS header — does the site force HTTPS? │
│ ✓ Certificate chain — complete? trusted root? │
└──────────────────────────────────────────────────────────┘
Try with existing tools first to see the shape of the data:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
grep -E "Protocol|Cipher|Server Temp Key"
curl -sI https://google.com | grep -i strict-transport
# Known-broken test sites (badssl.com):
echo | openssl s_client -connect expired.badssl.com:443 2>/dev/null | \
openssl x509 -noout -dates
echo | openssl s_client -connect tls-v1-0.badssl.com:1010 2>/dev/null | grep Protocol
Extract the negotiated parameters after your TLS connect:
#![allow(unused)]
fn main() {
let tls = tls_connect(host, port).await?;
let (_, conn) = tls.get_ref();
let protocol = conn.protocol_version().unwrap();
let cipher = conn.negotiated_cipher_suite().unwrap();
let certs = conn.peer_certificates().unwrap();
}
After the handshake, reuse the same TLS stream to fetch HTTP headers:
#![allow(unused)]
fn main() {
let request = format!("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
tls.write_all(request.as_bytes()).await?;
let mut response = vec![0u8; 8192];
let n = tls.read(&mut response).await.unwrap_or(0);
let response = String::from_utf8_lossy(&response[..n]);
let has_hsts = response.lines()
.any(|l| l.to_lowercase().starts_with("strict-transport-security"));
}
Exercise 5: Full scanner output
Combine everything into one report:
TLS Scan: google.com:443
─────────────────────────
Protocol: TLS 1.3 ✓
Cipher: TLS_AES_256_GCM_SHA384 ✓
Key exchange: X25519 (forward secrecy ✓)
Certificate:
Subject: *.google.com
Issuer: GTS CA 1C3
Expires in: 62 days ✓
SANs: *.google.com, google.com, *.youtube.com, ...
HTTP headers:
HSTS: max-age=31536000 ✓
Grade: A
Assign a grade with a simple rubric:
#![allow(unused)]
fn main() {
fn grade(protocol_ok: bool, cipher_ok: bool, cert_days: i64, has_hsts: bool) -> &'static str {
if !protocol_ok || cert_days < 0 { return "F"; }
if !cipher_ok { return "C"; }
if cert_days < 30 { return "B"; }
if !has_hsts { return "B+"; }
"A"
}
}
Run it against expired.badssl.com, self-signed.badssl.com, tls-v1-0.badssl.com:1010 — each should drop to a different grade for a different reason.
Exercise 6: Comparison table
Scan multiple hosts and emit a side-by-side table. This is the shape most monitoring dashboards want:
Domain Protocol Cipher Expires HSTS Grade
google.com TLS 1.3 AES-256-GCM 62 days ✓ A
example.com TLS 1.3 AES-128-GCM 90 days ✗ B+
old-site.com TLS 1.2 AES-128-CBC EXPIRED ✗ F