Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,12 @@ jobs:
with:
components: rustfmt
- run: cargo fmt --all -- --check

audit:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
105 changes: 104 additions & 1 deletion src/cert/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl fmt::Display for CertTime {
}

/// Convert days since Unix epoch to (year, month, day)
fn days_to_ymd(days: i32) -> (i32, u8, u8) {
pub(crate) fn days_to_ymd(days: i32) -> (i32, u8, u8) {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
Expand All @@ -140,3 +140,106 @@ fn days_to_ymd(days: i32) -> (i32, u8, u8) {
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}

#[cfg(test)]
mod tests {
use super::*;

// ── days_to_ymd ───────────────────────────────────────────────────────────

#[test]
fn test_days_to_ymd_epoch() {
// Day 0 = 1970-01-01
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}

#[test]
fn test_days_to_ymd_known_dates() {
// 2000-01-01 = 10957 days after epoch
assert_eq!(days_to_ymd(10957), (2000, 1, 1));
// 19796 days from epoch = 2024-03-14
assert_eq!(days_to_ymd(19796), (2024, 3, 14));
}

#[test]
fn test_days_to_ymd_leap_year_feb29() {
// 2000-02-29 (leap year)
assert_eq!(days_to_ymd(11016), (2000, 2, 29));
}

#[test]
fn test_days_to_ymd_end_of_year() {
// 2023-12-31 = 19722 days after epoch
assert_eq!(days_to_ymd(19722), (2023, 12, 31));
}

// ── CertTime::from_timestamp ──────────────────────────────────────────────

#[test]
fn test_certtime_from_timestamp_epoch() {
let t = CertTime::from_timestamp(0);
assert_eq!(t.timestamp, 0);
assert_eq!(t.year, 1970);
assert_eq!(t.month, 1);
assert_eq!(t.day, 1);
assert_eq!(t.hour, 0);
assert_eq!(t.min, 0);
assert_eq!(t.sec, 0);
}

#[test]
fn test_certtime_from_timestamp_known() {
// 1710502496 = 2024-03-15 11:34:56 UTC
let ts = 1710502496_i64;
let t = CertTime::from_timestamp(ts);
assert_eq!(t.year, 2024);
assert_eq!(t.month, 3);
assert_eq!(t.day, 15);
assert_eq!(t.hour, 11);
assert_eq!(t.min, 34);
assert_eq!(t.sec, 56);
}

#[test]
fn test_certtime_from_timestamp_midnight() {
// A known midnight: 2020-01-01 00:00:00 = 1577836800
let t = CertTime::from_timestamp(1577836800);
assert_eq!(t.year, 2020);
assert_eq!(t.month, 1);
assert_eq!(t.day, 1);
assert_eq!(t.hour, 0);
assert_eq!(t.min, 0);
assert_eq!(t.sec, 0);
}

// ── CertTime::format ──────────────────────────────────────────────────────

#[test]
fn test_certtime_format_iso() {
let t = CertTime::from_timestamp(0);
assert_eq!(t.format("%Y-%m-%d"), "1970-01-01");
assert_eq!(t.format("%H:%M:%S"), "00:00:00");
}

#[test]
fn test_certtime_format_combined() {
let t = CertTime::from_timestamp(1710502496);
assert_eq!(t.format("%Y-%m-%d %H:%M:%S"), "2024-03-15 11:34:56");
}

#[test]
fn test_certtime_format_zero_padding() {
// 946857903 = 2000-01-03 00:05:03 UTC
let ts = 946_857_903_i64;
let t = CertTime::from_timestamp(ts);
let formatted = t.format("%Y-%m-%d %H:%M:%S");
assert_eq!(&formatted[5..7], "01"); // month
assert_eq!(&formatted[8..10], "03"); // day
}

#[test]
fn test_certtime_display() {
let t = CertTime::from_timestamp(0);
assert_eq!(t.to_string(), "1970-01-01 00:00:00");
}
}
178 changes: 172 additions & 6 deletions src/cert/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,30 @@ fn format_hex(bytes: &[u8]) -> String {

pub fn parse_cert_file(path: &str) -> Result<Vec<CertInfo>> {
let data = std::fs::read(path).with_context(|| format!("can't read {}", path))?;
parse_cert_data_with_source(&data, path)
}

/// Parse certificate(s) from raw bytes (PEM or DER).
/// Use this when data is already in memory (e.g. read from stdin).
pub fn parse_cert_data(data: &[u8]) -> Result<Vec<CertInfo>> {
parse_cert_data_with_source(data, "stdin")
}

if is_pem(&data) {
parse_pem_certs(&data)
} else if is_der(&data) {
parse_der_cert(&data).map(|c| vec![c])
pub fn parse_cert_data_from(data: &[u8], source: &str) -> Result<Vec<CertInfo>> {
parse_cert_data_with_source(data, source)
}

fn parse_cert_data_with_source(data: &[u8], source: &str) -> Result<Vec<CertInfo>> {
if is_pem(data) {
parse_pem_certs(data)
} else if is_der(data) {
parse_der_cert(data).map(|c| vec![c])
} else {
bail!(
"Unrecognized certificate format in '{}'. Expected PEM or DER.\n\
Hint: PEM files start with '-----BEGIN CERTIFICATE-----'\n\
Hint: For PKCS12 (.p12/.pfx), use --pkcs12 flag",
path
Hint: For PKCS12 (.p12/.pfx), use the convert or extract command",
source
)
}
}
Expand Down Expand Up @@ -231,3 +244,156 @@ fn sha256_hex(data: &[u8]) -> String {
}

// TODO: support PKCS8 encrypted private keys

#[cfg(test)]
mod tests {
use super::*;

// ── helpers ──────────────────────────────────────────────────────────────

/// Build a minimal self-signed DER certificate using rcgen.
fn make_self_signed_der(cn: &str, sans: &[&str]) -> Vec<u8> {
use rcgen::{CertificateParams, DistinguishedName, DnType, SanType};

let mut params = CertificateParams::default();
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, cn);
params.distinguished_name = dn;
params.subject_alt_names = sans
.iter()
.map(|s| SanType::DnsName(s.to_string().try_into().unwrap()))
.collect();

let kp = rcgen::KeyPair::generate().unwrap();
let cert = params.self_signed(&kp).unwrap();
cert.der().to_vec()
}

/// Wrap DER bytes in a PEM block.
fn der_to_pem(der: &[u8]) -> Vec<u8> {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for chunk in b64.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(chunk).unwrap());
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
pem.into_bytes()
}

// ── parse_pem_certs ───────────────────────────────────────────────────────

#[test]
fn test_parse_pem_single_cert() {
let der = make_self_signed_der("example.com", &["example.com"]);
let pem = der_to_pem(&der);

let certs = parse_pem_certs(&pem).unwrap();
assert_eq!(certs.len(), 1);
assert!(certs[0].subject.contains("example.com"));
}

#[test]
fn test_parse_pem_bundle_multiple_certs() {
let der1 = make_self_signed_der("first.example.com", &["first.example.com"]);
let der2 = make_self_signed_der("second.example.com", &["second.example.com"]);
let mut bundle = der_to_pem(&der1);
bundle.extend(der_to_pem(&der2));

let certs = parse_pem_certs(&bundle).unwrap();
assert_eq!(certs.len(), 2);
}

#[test]
fn test_parse_pem_empty_returns_error() {
let result = parse_pem_certs(b"");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("No certificates"));
}

#[test]
fn test_parse_pem_invalid_base64_returns_error() {
let bad =
b"-----BEGIN CERTIFICATE-----\n!!!not-valid-base64!!!\n-----END CERTIFICATE-----\n";
let result = parse_pem_certs(bad);
assert!(result.is_err());
}

// ── parse_der_cert ────────────────────────────────────────────────────────

#[test]
fn test_parse_der_cert_roundtrip() {
let der = make_self_signed_der("der-test.example.com", &["der-test.example.com"]);
let cert = parse_der_cert(&der).unwrap();

assert!(cert.subject.contains("der-test.example.com"));
assert!(cert.issuer.contains("der-test.example.com")); // self-signed
assert!(cert.sans.contains(&"der-test.example.com".to_string()));
assert!(!cert.sha256_fingerprint.is_empty());
}

#[test]
fn test_parse_der_cert_invalid_returns_error() {
let garbage = b"\x30\x00\x00\x00junk-data-that-is-not-a-cert";
let result = parse_der_cert(garbage);
assert!(result.is_err());
}

// ── sha256_of ─────────────────────────────────────────────────────────────

#[test]
fn test_sha256_of_known_input() {
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb924...
let hash = sha256_of(b"");
assert!(hash.starts_with("E3:B0:C4:42:98:FC:1C:14"));
}

#[test]
fn test_sha256_of_hello_world() {
// SHA-256("hello world") starts with b94d27b9...
let hash = sha256_of(b"hello world");
assert!(hash.starts_with("B9:4D:27:B9"));
}

#[test]
fn test_sha256_format_is_colon_separated_uppercase_hex() {
let hash = sha256_of(b"test");
let parts: Vec<&str> = hash.split(':').collect();
assert_eq!(parts.len(), 32); // SHA-256 = 32 bytes
for part in parts {
assert_eq!(part.len(), 2);
assert!(part.chars().all(|c| c.is_ascii_hexdigit()));
}
}

// ── base64_decode_str ─────────────────────────────────────────────────────

#[test]
fn test_base64_decode_valid() {
// "hello" in base64
let decoded = base64_decode_str("aGVsbG8=").unwrap();
assert_eq!(decoded, b"hello");
}

#[test]
fn test_base64_decode_empty_string() {
let decoded = base64_decode_str("").unwrap();
assert!(decoded.is_empty());
}

#[test]
fn test_base64_decode_invalid_returns_error() {
let result = base64_decode_str("!!!not-valid!!!");
assert!(result.is_err());
}

#[test]
fn test_base64_decode_with_whitespace_padding() {
// Leading/trailing whitespace should be trimmed and still parse
let result = base64_decode_str(" aGVsbG8= ");
assert!(result.is_ok());
assert_eq!(result.unwrap(), b"hello");
}
}
19 changes: 13 additions & 6 deletions src/commands/decode.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
use anyhow::{Context, Result};
use std::io::Read;

use crate::output::{box_chars, colors};

pub fn run(input: &str, json: bool, no_color: bool) -> Result<i32> {
let use_color = !no_color && !json && colors::should_color();

// Check if input is a file path or inline string
let (data, source) = if std::path::Path::new(input).exists() {
// Check if input is stdin, a file path, or inline string
let (data, source) = if input == "-" {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
(buf, "<stdin>".to_string())
} else if std::path::Path::new(input).exists() {
let data = std::fs::read(input).with_context(|| format!("Failed to read: {}", input))?;
(data, input.to_string())
} else {
Expand All @@ -18,7 +23,7 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result<i32> {

// Try to detect the type
if text.starts_with("-----BEGIN CERTIFICATE") {
return decode_as_cert(input, json, no_color, use_color, "PEM Certificate");
return decode_as_cert(&data, &source, json, no_color, use_color, "PEM Certificate");
}

if text.starts_with("-----BEGIN CERTIFICATE REQUEST")
Expand Down Expand Up @@ -53,7 +58,7 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result<i32> {

// DER certificate
if !data.is_empty() && data[0] == 0x30 && source != "inline" {
return decode_as_cert(input, json, no_color, use_color, "DER Certificate");
return decode_as_cert(&data, &source, json, no_color, use_color, "DER Certificate");
}

if json {
Expand Down Expand Up @@ -83,7 +88,8 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result<i32> {
}

fn decode_as_cert(
path: &str,
data: &[u8],
source: &str,
json: bool,
no_color: bool,
use_color: bool,
Expand All @@ -94,7 +100,8 @@ fn decode_as_cert(
} else {
println!("\n Detected: {}\n", label);
}
crate::commands::inspect::run(path, json, no_color)
let certs = crate::cert::parser::parse_cert_data_from(data, source)?;
crate::commands::inspect::run_certs(&certs, json, no_color)
}
Comment thread
thegdsks marked this conversation as resolved.

fn decode_as_csr(text: &str, json: bool, use_color: bool) -> Result<i32> {
Expand Down
Loading
Loading