Skip to content

Instantly share code, notes, and snippets.

@dtonhofer
Last active June 18, 2024 10:05
Show Gist options
  • Save dtonhofer/01018844971235b511d241b537c332ee to your computer and use it in GitHub Desktop.
Save dtonhofer/01018844971235b511d241b537c332ee to your computer and use it in GitHub Desktop.
Split a certificate bundle like "/etc/pki/tls/certs/ca-bundle.crt" into individual certificates labeled by issuer
#!/usr/bin/perl
use strict;
use warnings;
# ===
# Gist:
#
# https://gist.github.com/dtonhofer/01018844971235b511d241b537c332ee
#
# Synopsis:
#
# split_ca_bundle.pl /etc/pki/tls/certs/ca-bundle.crt
# split_ca_bundle.pl /etc/pki/tls/certs/ca-bundle.crt
#
# The result of splitting the CA bundle goes to a new directory created in the
# current working directory, which then looks like this:
#
# explode_bundle_L15w/
# 'certificate.000 (ACCVRAIZ1)'
# 'certificate.001 (AC RAIZ FNMT-RCM)'
# 'certificate.002 (AC RAIZ FNMT-RCM SERVIDORES SEGUROS)'
# 'certificate.003 (ANF Secure Server Root CA)'
# 'certificate.004 (Actalis Authentication Root CA)'
# 'certificate.005 (AffirmTrust Commercial)'
# 'certificate.006 (AffirmTrust Networking)'
# 'certificate.007 (AffirmTrust Premium)'
#
# ===
# Description:
#
# Split "certificate bundles" like those found in /etc/pki/tls/certs into
# individual files and append the X509 cleartext description to each file.
#
# The file to split is given on the command line or piped via STDIN.
#
# Files are written to a newly created directory. This directory is created
# in the current working directory.
#
# Created files are named "certificate.XXX" or "trusted-certificate.XXX",
# with XX an index value. The issuer/subject name (which should be the same
# as these are self-signed certificates) is append to the name.
#
# This works for bundles of both trusted and non-trusted certificates.
#
# See http://tygerclan.net/?q=node/49 for another program of this kind,
# which sets the name of the split-off files in function of the subject
#
# 2024-06-18: De-badified bad code for calling openssl, too large
# procedures made smaller, and lazy programming using
# references replaced. Fixed problem with "/" in CN.
#
# -------
# Author: David Tonhofer
# License: Public Domain
# -------
use File::Temp qw(tempdir); # Perl core module
use File::Spec::Functions qw(catfile); # Perl core module
sub extractCnBestEffort {
my($rest) = @_;
if ($rest =~ /\bCN\s*=\s*(.+?)\s*(,|$)/) {
return $1
}
else {
# Sometimes there is no CN, let's use the OU then
if ($rest =~ /\bOU\s*=\s*(.+?)\s*(,|$)/) {
return $1
}
else {
# Sometimes there is no OU, let's use the C then
# This is the case of Taiwan
my $country = '?';
my $org = '?';
if ($rest =~ /\bC\s*=\s*(.+?)\s*(,|$)/) {
$country = $1
}
if ($rest =~ /\bO\s*=\s*(.+?)\s*(,|$)/) {
$org = $1
}
return "$country $org"
}
}
}
sub analyzeLineOfFileWithCertText {
my($line,$curIssuer,$curSubject,$fn_text) = @_;
my $newIssuer = $curIssuer;
my $newSubject = $curSubject;
my $rest; # "rest of the line"
my $what; # select subject or issuer
# We see things like: "Issuer: C = PL, O = Krajowa Izba Rozliczeniowa S.A., CN = SZAFIR ROOT CA2"
# Issuer: C=CZ, O=První certifikační autorita, a.s., CN=I.CA Root CA/RSA, serialNumber=NTRCZ-26439395
if ($line =~ /^\s+Issuer:\s+(.+)$/) {
die "There are two 'Issuer' lines in $fn_text'\n" if $curIssuer;
$rest = $1;
$what = 'issuer';
}
elsif ($line =~ /^\s+Subject:\s+(.+)$/) {
die "There are two 'Subject' lines in fn'\n" if $curSubject;
$rest = $1;
$what = 'subject';
}
if ($rest) {
my $cnBestEffort = extractCnBestEffort($rest);
if ($what eq 'subject') {
$newSubject = $cnBestEffort;
}
else {
$newIssuer = $cnBestEffort;
}
}
return [ $newIssuer, $newSubject ];
}
sub analyzeFileWithCertText {
my($fn,$fn_text) = @_;
my $issuer;
my $subject;
# Read back from $fn_text and for each line:
# - append line to $fn
# - find subjetc or issuer
open(my $fh_app,'>>:encoding(UTF-8)',$fn) or die "Could not open file '$fn' for appending: $!";
open(my $fh_read, '<:encoding(UTF-8)', $fn_text) || die "Could not open file '$fn_text' for reading: $!\n";
while (my $line = <$fh_read>) {
print $fh_app $line;
my $ret = analyzeLineOfFileWithCertText($line,$issuer,$subject,$fn_text);
$issuer = $$ret[0];
$subject = $$ret[1];
}
close($fh_read) or warn "Could not close file '$fn_text': $!\n";
close($fh_app) or warn "Could not close file '$fn': $!";
return [$issuer,$subject];
}
sub runOpensslForDecodingAndAppendToFile {
my($fn) = @_;
my $fn_text = $fn . '.txt';
# See https://perldoc.perl.org/functions/system
my @openssl = ('openssl','x509','-noout','-text','-in',$fn,'-out',$fn_text);
my $retval = system(@openssl);
if ($retval == -1) {
print STDERR "Failed to execute 'openssl x509' command: $!\n";
}
elsif ($retval & 127) {
my $sig = ($? & 127); # the lowest 7 bits
my $cdw = ($? & 128) ? 'with' : 'without'; # the 8th bit
print STDERR "'openssl x509' died with signal $sig, $cdw coredump\n";
}
elsif ($retval != 0) {
my $bailout_code = $retval >> 8; # need to drop 8 bits
print STDERR "'openssl x509' failed with code $bailout_code\n";
}
else {
my $ret = analyzeFileWithCertText($fn,$fn_text);
unlink($fn_text) or warn "Could not unlink $fn_text: $!";
return $ret;
}
}
# ---
# Create new file to accept certificate data
# ---
sub createCertFile {
my($line,$count) = @_;
$line =~ /^(-----BEGIN (TRUSTED )?CERTIFICATE-----)\s*$/ || die "Not-matching line '$line'\n";
my $marker = $1;
my $trusted = $2;
my $prefix = "";
$prefix = "trusted-" if ($trusted);
my $xcount = sprintf("%03d",$count); #prefix count with 0s
my $fn = "${prefix}certificate.$xcount";
die "File '$fn' exists!" if (-e $fn);
print STDERR "Certificate data goes to file '$fn'\n";
open(my $fh, '>:encoding(UTF-8)', $fn) || die "Could not create file '$fn': $!\n";
print $fh "$marker\n";
return ($fn,$fh)
}
# ---
# Read the file with certificates in a single slurp (from STDIN or else from
# the file whose filename has been given on the command line)
# If a filename with a relative path is given on the cmdline, Perl will look
# for it in the current working directory and so may not find it.
# ---
# use Cwd qw(cwd);
# my $mycwd = cwd;
# print "Current working directory is ${mycwd}\n";
my @lines = <> or die "Could not 'slurp' input (file or STDIN): $!\n";
# ---
# Try to create a temporary directory to be filled with certificates;
# The directory is created in tmpdir(); use "DIR => $dir" as additional
# argument to change that.
# ---
# my $tgdir = tempdir("explode_bundle_XXXX", TMPDIR => 1);
my $tgdir = tempdir("explode_bundle_XXXX"); # explode in current directory
if (!$tgdir) {
die "Could not create temporary directory: $!\n"
}
else {
print STDERR "Created temporary directory '$tgdir' into which result will be written.\n"
}
chdir $tgdir || die "Could not change working directory to '$tgdir': $!\n";
# ---
# Read and split the slurped file-of-certificates using a simple state machine
# ---
my $state = "outside"; # reader state machine state
my $count = 0; # index of the certificate file we create
my $fh; # file handle of the certificate file we create
my $fn; # file name of the certificate file we create
my $trusted; # either undef or "TRUSTED" depend on type of certificate
# Certificates marked as "trusted" have "extended validation" fields, that is all.
for my $line (@lines) {
chomp $line;
if ($state eq "outside") {
if ($line =~ /^(-----BEGIN (TRUSTED )?CERTIFICATE-----)\s*$/) {
$trusted = $2;
($fn,$fh) = createCertFile($line,$count);
$state = "inside";
$count++
}
else {
print STDERR "Skipping line '$line'\n"
}
}
else {
die unless $state eq "inside";
if ($line =~ /^(-----END (TRUSTED )?CERTIFICATE-----)\s*$/) {
my $marker = $1;
my $trustedCheck = $2;
if (!((($trusted && $trustedCheck) || (!$trusted && !$trustedCheck)))) {
die "Trusted flag difference detected\n"
}
$state = "outside";
print $fh "$marker\n";
print STDERR "Closing file '$fn'\n";
close $fh or die "Could not close file '$fn': $!\n";
my $ret = runOpensslForDecodingAndAppendToFile($fn);
my $issuer = $$ret[0];
my $subject = $$ret[1];
my $selfsigned = ($issuer eq $subject);
my $fn_newname;
if ($selfsigned) {
$fn_newname = "$fn ($subject)" # can leave out the issuer
}
else {
$fn_newname = "$fn ($issuer ---> $subject)"
}
# in some cases, the new filename can contain '/'
$fn_newname =~ s/\//./g;
rename($fn,$fn_newname) or warn "Could not rename '$fn' to '$fn_newname': $!\n";
}
else {
# Write the line to the target file
print $fh "$line\n"
}
}
}
if ($state eq "inside") {
die "Last certificate was not properly terminated\n"
}
print STDERR "Done. Everything can be found in temporary directory '$tgdir'.\n";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment