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