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