#!/usr/bin/env bash
# Check the X.509 fullchain of a remote TLS server. Requires openssl.
#
# (c) 2021 - Xavier G.
# This program is free software. It comes without any warranty, to
# the extent permitted by applicable law. You can redistribute it
# and/or modify it under the terms of the Do What The Fuck You Want
# To Public License, Version 2, as published by Sam Hocevar. See
# http://www.wtfpl.net/ for more details.
function make_tmpdir {
tmp_dir=$(mktemp --directory --suffix .check-fullchain)
[ -z "${tmp_dir}" ] && _exit 99 "Cannot create temporary directory."
cd "${tmp_dir}" || _exit 98 "Cannot enter temporary directory ${tmp_dir}."
}
function cleanup {
[ -d "${tmp_dir}" ] || return
rm -f "${tmp_dir}"/*.pem "${tmp_dir}/verify_out"
rmdir "${tmp_dir}"
}
function _exit {
rc="${1}"; shift
[ "${rc}" -ne 0 ] && echo "$@";
exit "${rc}"
}
function extract_pem {
perl -nle '
open($fh, q[>], sprintf(q[%d.pem], $i++)) if (m#^-+BEGIN CERTIFICATE-+$#);
print $fh $_ if $fh;
close($fh) if m#^-+END CERTIFICATE-+$#;
# Require at least one cert to exit(0):
END { exit(!$i); }
'
}
function extract_identifier {
perl -nle 'print $1 if m#((?:[0-9A-F]{2}:)+[0-9A-F]{2})#'
}
function x509_prop {
local cert_file="${1}"; shift
openssl x509 -in "${cert_file}" -noout "$@"
}
function x509_prop_ext {
# Does openssl support the -ext option?
if [ -z "${openssl_ext_support}" ]; then
openssl_ext_support='yes'
openssl x509 -in /dev/null -ext test 2>&1 | grep -qx 'unknown option -ext' && openssl_ext_support='no'
fi
local cert_file="${1}"; shift
local extension="${1}"; shift
if [ "${openssl_ext_support}" == 'yes' ]; then
x509_prop "${cert_file}" -ext "${extension}"
else
# Simulate -ext by parsing the output of -text:
[ "${extension}" == 'authorityKeyIdentifier' ] && title='Authority Key Identifier'
[ "${extension}" == 'subjectAltName' ] && title='Subject Alternative Name'
[ "${extension}" == 'subjectKeyIdentifier' ] && title='Subject Key Identifier'
x509_prop "${cert_file}" -text | extract_extension "${title}"
[ "${extension}" == 'authorityKeyIdentifier' ] && echo
fi
}
function extract_extension {
title="${1}" perl -nle '
$p = ($1 eq $ENV{q[title]}) ? 100000 : 0 if m#^ {12}X509v3 ([^:]+)#;
-- $p if m#^ {12}#;
s#^ {12}## && print if $p > 0'
}
function x509_prop_val {
x509_prop "$@" | perl -ple 's/^[^=]+=//'
}
function indent {
sed 's/^/\t/' "$@"
}
function pem_show_properties {
local pem="${1}"
echo 'Properties:'
{
x509_prop_ext "${pem}" subjectKeyIdentifier
x509_prop "${pem}" -serial -subject
x509_prop_ext "${pem}" subjectAltName
x509_prop "${pem}" -dates -fingerprint
} | indent
}
function pem_check_chain {
local pem="${1}"
local prev_pem="${2}"
echo ''
echo 'Chain check:'
{
local subject prev_issuer
subject=$(x509_prop_val "${pem}" -subject)
prev_issuer=$(x509_prop_val "${prev_pem}" -issuer)
if [ "${prev_issuer}" == "${subject}" ]; then
echo "[ok] This certificate's subject matches the previous certificate's issuer."
else
echo "[!!] This certificate's subject does NOT match the previous certificate's issuer."
fi
local serial prev_auth_serial
prev_auth_serial=$(x509_prop_ext "${prev_pem}" authorityKeyIdentifier | grep 'serial:' | extract_identifier)
if [ -n "${prev_auth_serial}" ]; then
serial=$(x509_prop_val "${pem}" -serial)
if [ "${prev_auth_serial//:/}" == "${serial}" ]; then
echo "[ok] This certificate's serial matches the previous certificate's authority serial."
else
echo "[!!] This certificate's serial does NOT match the previous certificate's authority serial."
fi
fi
local subj_key_id prev_auth_key_id
subj_key_id=$(x509_prop_ext "${pem}" subjectKeyIdentifier | extract_identifier)
prev_auth_key_id=$(x509_prop_ext "${prev_pem}" authorityKeyIdentifier | grep 'keyid:' | extract_identifier)
if [ "${prev_auth_key_id}" == "${subj_key_id}" ]; then
echo "[ok] This certificate's subject key identifier matches the previous certificate's authority key identifier."
else
echo "[!!] This certificate's subject key identifier does NOT match the previous certificate's authority key identifier."
fi
# Use -CAfile instead of -trusted to ensure openssl will trust ONLY the
# current certificate; this is useful is the previous certificate
# happens to be in openssl's system-wide CA store.
local verify_cmd=(openssl verify -partial_chain -CAfile "${pem}" "${prev_pem}")
if "${verify_cmd[@]}" > verify_out 2>&1; then
echo "[ok] ${verify_cmd[*]} succeeded"
else
echo "[!!] ${verify_cmd[*]} failed with exit code $?"
indent verify_out
fi
} | indent
}
function pem_show_issuer {
local pem="${1}"
echo ''
echo 'Issuer:'
{
x509_prop "${pem}" -issuer
x509_prop_ext "${pem}" authorityKeyIdentifier
} | indent
}
function analyse_pem {
local pem="${1}"
local prev_pem="${2}"
pem_show_properties "${pem}"
[ -n "${prev_pem}" ] && pem_check_chain "${pem}" "${prev_pem}"
pem_show_issuer "${pem}"
}
function pem_is_self_signed {
local pem="${1}"
local subject issuer
subject=$(x509_prop_val "${pem}" -subject)
issuer=$(x509_prop_val "${pem}" -issuer)
[ "${subject}" == "${issuer}" ]
}
function get_ca_path {
ca_path="${CA_PATH:-/etc/ssl/certs}"
[ -d "${ca_path}" ] || _exit 50 "${ca_path} is not a directory; please set CA_PATH."
}
function lookup_ca {
get_ca_path
local pem
local issuer="${1}"; shift
local issuer_hash="${1}"; shift
for pem in "${ca_path}/${issuer_hash}".*; do
[ "$(x509_prop_val "${pem}" -subject)" != "${issuer}" ] && continue
# Found it:
found_ca_path="${pem}"
return 0
done
return 1
}
function pem_lookup_ca {
local pem="${1}"
lookup_ca "$(x509_prop_val "${pem}" -issuer)" "$(x509_prop_val "${pem}" -issuer_hash)"
}
# Usual boilerplate:
target="${1}"; shift
[ -z "${target}" ] && _exit 100 "Usage: ${0} host:port [s_client args...]"
[[ "${target}" =~ :[0-9]{1,5}$ ]] || target="${target}:${DEFAULT_PORT:-443}"
trap cleanup EXIT
make_tmpdir
# Get X.509 certificates (as PEM files) from the provided network target:
openssl s_client -connect "${target}" "$@" -showcerts 2> /dev/null < /dev/null | extract_pem || _exit 90
# Iterate over those certificates:
while read -r pem; do
echo "${pem/.pem/}:"
analyse_pem "${pem}" "${prev_pem}" | indent
prev_pem="${pem}"
done < <(find . -type f -name '*.pem' -printf '%P\n' | sort -n)
# Handle the lack of root CA certificate at the end of the delivered fullchain:
if ! pem_is_self_signed "${prev_pem}"; then
# The last certificate is not self-signed, i.e. it is not the root CA certificate.
# Look up the root CA certificate within the local CA path:
if pem_lookup_ca "${prev_pem}"; then
# Found it -- now analyse it as if it had been provided by the remote server:
pem="${found_ca_path}"
echo "$(readlink -f "${pem}"):"
analyse_pem "${pem}" "${prev_pem}" | indent
fi
fi
_exit 0