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