#!/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";