Perl Certificate-Path Analyser for keytool Verbose Output

A generic, read-only utility for turning one or more keytool -list -v streams or saved dumps into structural certification paths. It is implemented entirely in Perl and does not invoke OpenSSL, keytool or any other external command.

What it does

Prerequisites

Direct pipeline

Pipe keytool's verbose output directly into the analyser. Keytool can still prompt securely on the terminal because only standard output is connected to the pipe. Do not place the keystore password in the command line.

keytool -list -v \
  -keystore /path/to/store.jks \
  | perl analyse_keytool_chains.pl \
  | less

Saved dump input

To retain the decoded public-certificate report for later analysis, save it first. It contains no private keys, but aliases and subjects may reveal internal infrastructure, so protect it appropriately.

keytool -list -v \
  -keystore /path/to/store.jks \
  >"$HOME/tmp/store-keytool-verbose.txt"

Analyse one saved dump:

perl analyse_keytool_chains.pl \
  "$HOME/tmp/store-keytool-verbose.txt" \
  | less

Several saved dumps can be analysed together. Normal Perl diamond behaviour is retained: filenames are read in order, '-' means standard input, and no filenames means standard input.

perl analyse_keytool_chains.pl \
  "$HOME/tmp/identity-keytool-verbose.txt" \
  "$HOME/tmp/trust-keytool-verbose.txt" \
  | less

For diagnostic inventory details as well as the compact paths:

perl analyse_keytool_chains.pl --details \
  "$HOME/tmp/store-keytool-verbose.txt" \
  | less

Reading the output

Scope and limitations

  1. The paths are structural. The script does not cryptographically verify certificate signatures, certificate policies or revocation status.
  2. Exact DN matching can be ambiguous when several different certificates share a Subject DN. Each matching route is reported; inspect ambiguous results before drawing conclusions.
  3. The result covers only certificates present in the supplied input. It cannot discover a certificate held by a remote peer or another unexamined store.
  4. A self-issued CA:true terminal is a plausible root in this dump, not independent proof that an organisation or application accepts it as a trust anchor.
  5. The script itself is organisation-neutral. Its input and output may contain private organisational PKI metadata and should remain within the appropriate security boundary.

Script

#!/usr/bin/perl

use strict;
use warnings;

use Getopt::Long qw(GetOptions);

my ($details, $help);
GetOptions(
    'details' => \$details,
    'help'    => \$help,
) or usage(2);
usage(0) if $help;

# Retain normal Perl diamond behaviour: read named files in order, use '-' for
# standard input, and read standard input when no filenames were supplied.
@ARGV = ('-') unless @ARGV;

my @occurrences = read_keytool_verbose();
die "No certificates found. Supply keytool -list -v output (not -rfc output).\n"
    unless @occurrences;

my (%certs, @ids);
my $sequence = 0;

for my $occurrence (@occurrences) {
    ++$sequence;
    my $id = $occurrence->{fingerprint};
    $id = sprintf('occurrence-%06d', $sequence)
        unless defined $id && length $id;

    my $cert = $certs{$id};
    if (!$cert) {
        $cert = {
            id          => $id,
            subject     => $occurrence->{subject},
            issuer      => $occurrence->{issuer},
            valid_from  => $occurrence->{valid_from},
            valid_until => $occurrence->{valid_until},
            ca          => $occurrence->{ca},
            aliases     => {},
            occurrences => [],
            issuers     => [],
        };
        $certs{$id} = $cert;
        push @ids, $id;
    }

    $cert->{aliases}->{ $occurrence->{alias} } = 1;
    push @{ $cert->{occurrences} }, $occurrence;
}

# This is deliberately structural analysis. Match the Issuer DN reported by
# keytool to Subject DNs in the same dump; do not invoke a crypto executable.
for my $child_id (@ids) {
    my $child = $certs{$child_id};
    @{ $child->{issuers} } = grep {
        $certs{$_}->{subject} eq $child->{issuer}
    } @ids;
}

my @paths = collect_paths(\%certs, \@ids);
print_inventory(\%certs, \@ids) if $details;
print_paths(\%certs, \@paths);

exit 0;

sub usage {
    my ($status) = @_;
    print STDERR <<'USAGE';
Usage:
  keytool -list -v -keystore STORE | analyse_keytool_chains.pl [options]
  analyse_keytool_chains.pl [options] KEYTOOL_VERBOSE_OUTPUT [...]

Input must contain verbose output from:
  keytool -list -v -keystore STORE

Options:
  --details   also print the certificate inventory
  --help      show this help

The script is read-only. It parses supplied text and invokes no external
command. It never opens or changes a keystore.

The paths are structural: Issuer DNs are matched to Subject DNs. The report
checks whether a terminal certificate is self-issued and records the CA flag,
but it does not cryptographically verify signatures or revocation status.

Extended documentation:
  INFORMATION/java-keystore-certificate-path-analyser
USAGE
    exit $status;
}

sub read_keytool_verbose {
    my @found;
    my ($alias, $entry_type, $chain_index, $current);
    my $in_basic_constraints = 0;

    while (<>) {
        s/\r\n?/\n/g;

        if (/^Alias name:\s*(.*?)\s*$/) {
            finish_certificate(\@found, \$current);
            $alias = $1;
            $entry_type = '(entry type unavailable)';
            $chain_index = undef;
            $in_basic_constraints = 0;
            next;
        }
        if (/^Entry type:\s*(.*?)\s*$/) {
            $entry_type = $1;
            next;
        }
        if (/^Certificate\[(\d+)\]:\s*$/) {
            finish_certificate(\@found, \$current);
            $chain_index = 0 + $1;
            $in_basic_constraints = 0;
            next;
        }
        if (/^Owner:\s*(.*?)\s*$/) {
            finish_certificate(\@found, \$current);
            $current = {
                alias       => defined($alias) ? $alias : '(alias unavailable)',
                entry_type  => defined($entry_type) ? $entry_type : '(entry type unavailable)',
                chain_index => $chain_index,
                source      => $ARGV,
                subject     => $1,
                issuer      => undef,
                valid_from  => undef,
                valid_until => undef,
                fingerprint => undef,
                ca          => undef,
            };
            $in_basic_constraints = 0;
            next;
        }

        next unless $current;

        if (/^Issuer:\s*(.*?)\s*$/) {
            $current->{issuer} = $1;
        } elsif (/^Valid from:\s*(.*?)\s+until:\s*(.*?)\s*$/) {
            $current->{valid_from} = $1;
            $current->{valid_until} = $2;
        } elsif (/^\s*SHA256:\s*(.*?)\s*$/) {
            $current->{fingerprint} = normalise_fingerprint($1);
        } elsif (/BasicConstraints:\s*\[/) {
            $in_basic_constraints = 1;
        } elsif ($in_basic_constraints && /^\s*CA:(true|false)\s*$/i) {
            $current->{ca} = lc($1);
        } elsif ($in_basic_constraints && /^\s*\]\s*$/) {
            $in_basic_constraints = 0;
        }
    }

    finish_certificate(\@found, \$current);
    return @found;
}

sub finish_certificate {
    my ($found, $current_ref) = @_;
    my $current = $$current_ref;
    return unless $current;
    die "Certificate for alias $current->{alias} has no Issuer line\n"
        unless defined $current->{issuer} && length $current->{issuer};
    push @$found, $current;
    $$current_ref = undef;
}

sub normalise_fingerprint {
    my ($value) = @_;
    $value =~ s/[^0-9A-Fa-f]//g;
    $value = uc $value;
    die "Invalid SHA-256 fingerprint in keytool output\n"
        unless length($value) == 64;
    return $value;
}

sub collect_paths {
    my ($certs, $ids) = @_;
    my %is_issuer;

    for my $id (@$ids) {
        for my $issuer (@{ $certs->{$id}->{issuers} }) {
            $is_issuer{$issuer} = 1 if $issuer ne $id;
        }
    }

    my @starts = grep { !$is_issuer{$_} } @$ids;
    @starts = @$ids unless @starts;

    my (@paths, %seen_path);
    for my $start (@starts) {
        for my $path (walk_paths($certs, $start, [], {})) {
            my $key = join('>', @$path);
            next if $seen_path{$key}++;
            push @paths, $path;
        }
    }
    return @paths;
}

sub walk_paths {
    my ($certs, $current, $path, $seen) = @_;
    my @next_path = (@$path, $current);
    return [ @next_path, '[CYCLE]' ] if $seen->{$current};

    my %next_seen = (%$seen, $current => 1);
    my @issuers = @{ $certs->{$current}->{issuers} };
    my @self = grep { $_ eq $current } @issuers;

    if (@self) {
        my $ca = defined $certs->{$current}->{ca}
            ? $certs->{$current}->{ca}
            : 'not reported';
        return [ @next_path, "[SELF-ISSUED; CA:$ca]" ];
    }

    @issuers = grep { $_ ne $current } @issuers;
    return [ @next_path, '[ISSUER NOT IN DUMP]' ] unless @issuers;

    my @paths;
    for my $issuer (@issuers) {
        push @paths, walk_paths($certs, $issuer, \@next_path, \%next_seen);
    }
    return @paths;
}

sub print_inventory {
    my ($certs, $ids) = @_;
    print "CERTIFICATE INVENTORY\n";
    print "=====================\n";
    print "Unique certificates: " . scalar(@$ids) . "\n\n";

    for my $id (@$ids) {
        my $cert = $certs->{$id};
        my @aliases = sort keys %{ $cert->{aliases} };
        print "Aliases:    " . join(', ', @aliases) . "\n";
        print "Subject:    $cert->{subject}\n";
        print "Issuer:     $cert->{issuer}\n";
        print "CA:         " . (defined $cert->{ca} ? $cert->{ca} : 'not reported') . "\n";
        print "Validity:   $cert->{valid_from} to $cert->{valid_until}\n"
            if defined $cert->{valid_from} || defined $cert->{valid_until};
        print "Issuer matches in dump: " . scalar(@{ $cert->{issuers} }) . "\n\n";
    }
}

sub print_paths {
    my ($certs, $paths) = @_;
    print "CERTIFICATION PATHS (STRUCTURAL; SIGNATURES NOT VERIFIED)\n";
    print "========================================================\n";
    print "Distinct leaf/standalone paths: " . scalar(@$paths) . "\n\n";

    my $number = 0;
    for my $path (@$paths) {
        ++$number;
        print "PATH $number\n";
        my $depth = 0;
        for my $id (@$path) {
            my $indent = '  ' x ($depth + 1);
            if ($id =~ /^\[/) {
                print "$indent$id\n";
                next;
            }
            my $cert = $certs->{$id};
            my @aliases = sort keys %{ $cert->{aliases} };
            print "$indent" . join(', ', @aliases) . "\n";
            print "$indent  subject: $cert->{subject}\n";
            if ($cert->{subject} eq $cert->{issuer}) {
                print "$indent  validity: $cert->{valid_from} to $cert->{valid_until}\n"
                    if defined $cert->{valid_from} || defined $cert->{valid_until};
            }
            ++$depth;
        }
        print "\n";
    }
}

Related technique

For the reasoning behind checking whether a self-signed certificate still issues anything in a Java keystore, including the limits of an Owner/Issuer comparison, see Java Keystore — Verifying Whether a CA Is Used By Anything Else.

version 4  ·  created 2026-09-16  ·  updated 2026-09-16  ·  tags java, keytool, keystore, pki, perl, certificate-chain, tool