1 | 1 |
new file mode 100755 |
... | ... |
@@ -0,0 +1,194 @@ |
1 |
+#!/usr/bin/env bash |
|
2 |
+ |
|
3 |
+# Check the X.509 fullchain of a remote TLS server. Requires openssl. |
|
4 |
+# |
|
5 |
+# (c) 2021 - Xavier G. |
|
6 |
+# This program is free software. It comes without any warranty, to |
|
7 |
+# the extent permitted by applicable law. You can redistribute it |
|
8 |
+# and/or modify it under the terms of the Do What The Fuck You Want |
|
9 |
+# To Public License, Version 2, as published by Sam Hocevar. See |
|
10 |
+# http://www.wtfpl.net/ for more details. |
|
11 |
+ |
|
12 |
+ |
|
13 |
+function make_tmpdir { |
|
14 |
+ tmp_dir=$(mktemp --directory --suffix .check-fullchain) |
|
15 |
+ [ -z "${tmp_dir}" ] && _exit 99 "Cannot create temporary directory." |
|
16 |
+ cd "${tmp_dir}" || _exit 98 "Cannot enter temporary directory ${tmp_dir}." |
|
17 |
+} |
|
18 |
+ |
|
19 |
+function cleanup { |
|
20 |
+ [ -d "${tmp_dir}" ] || return |
|
21 |
+ rm -f "${tmp_dir}"/*.pem "${tmp_dir}/verify_out" |
|
22 |
+ rmdir "${tmp_dir}" |
|
23 |
+} |
|
24 |
+ |
|
25 |
+function _exit { |
|
26 |
+ rc="${1}"; shift |
|
27 |
+ [ "${rc}" -ne 0 ] && echo "$@"; |
|
28 |
+ exit "${rc}" |
|
29 |
+} |
|
30 |
+ |
|
31 |
+function extract_pem { |
|
32 |
+ perl -nle ' |
|
33 |
+ open($fh, q[>], sprintf(q[%d.pem], $i++)) if (m#^-+BEGIN CERTIFICATE-+$#); |
|
34 |
+ print $fh $_ if $fh; |
|
35 |
+ close($fh) if m#^-+END CERTIFICATE-+$#; |
|
36 |
+ # Require at least one cert to exit(0): |
|
37 |
+ END { exit(!$i); } |
|
38 |
+ ' |
|
39 |
+} |
|
40 |
+ |
|
41 |
+function extract_identifier { |
|
42 |
+ perl -nle 'print $1 if m#((?:[0-9A-F]{2}:)+[0-9A-F]{2})#' |
|
43 |
+} |
|
44 |
+ |
|
45 |
+function x509_prop { |
|
46 |
+ local cert_file="${1}"; shift |
|
47 |
+ openssl x509 -in "${cert_file}" -noout "$@" |
|
48 |
+} |
|
49 |
+ |
|
50 |
+function x509_prop_val { |
|
51 |
+ x509_prop "$@" | perl -ple 's/^[^=]+=//' |
|
52 |
+} |
|
53 |
+ |
|
54 |
+function indent { |
|
55 |
+ sed 's/^/\t/' "$@" |
|
56 |
+} |
|
57 |
+ |
|
58 |
+function pem_show_properties { |
|
59 |
+ local pem="${1}" |
|
60 |
+ |
|
61 |
+ echo 'Properties:' |
|
62 |
+ { |
|
63 |
+ x509_prop "${pem}" -ext subjectKeyIdentifier -serial |
|
64 |
+ x509_prop "${pem}" -subject -ext subjectAltName -dates -fingerprint |
|
65 |
+ } | indent |
|
66 |
+} |
|
67 |
+ |
|
68 |
+function pem_check_chain { |
|
69 |
+ local pem="${1}" |
|
70 |
+ local prev_pem="${2}" |
|
71 |
+ |
|
72 |
+ echo '' |
|
73 |
+ echo 'Chain check:' |
|
74 |
+ { |
|
75 |
+ local subject prev_issuer |
|
76 |
+ subject=$(x509_prop_val "${pem}" -subject) |
|
77 |
+ prev_issuer=$(x509_prop_val "${prev_pem}" -issuer) |
|
78 |
+ if [ "${prev_issuer}" == "${subject}" ]; then |
|
79 |
+ echo "[ok] This certificate's subject matches the previous certificate's issuer." |
|
80 |
+ else |
|
81 |
+ echo "[!!] This certificate's subject does NOT match the previous certificate's issuer." |
|
82 |
+ fi |
|
83 |
+ |
|
84 |
+ local serial prev_auth_serial |
|
85 |
+ prev_auth_serial=$(x509_prop "${prev_pem}" -ext authorityKeyIdentifier | grep 'serial:' | extract_identifier) |
|
86 |
+ if [ -n "${prev_auth_serial}" ]; then |
|
87 |
+ serial=$(x509_prop_val "${pem}" -serial) |
|
88 |
+ if [ "${prev_auth_serial//:/}" == "${serial}" ]; then |
|
89 |
+ echo "[ok] This certificate's serial matches the previous certificate's authority serial." |
|
90 |
+ else |
|
91 |
+ echo "[!!] This certificate's serial does NOT match the previous certificate's authority serial." |
|
92 |
+ fi |
|
93 |
+ fi |
|
94 |
+ |
|
95 |
+ local subj_key_id prev_auth_key_id |
|
96 |
+ subj_key_id=$(x509_prop "${pem}" -ext subjectKeyIdentifier | extract_identifier) |
|
97 |
+ prev_auth_key_id=$(x509_prop "${prev_pem}" -ext authorityKeyIdentifier | grep 'keyid:' | extract_identifier) |
|
98 |
+ if [ "${prev_auth_key_id}" == "${subj_key_id}" ]; then |
|
99 |
+ echo "[ok] This certificate's subject key identifier matches the previous certificate's authority key identifier." |
|
100 |
+ else |
|
101 |
+ echo "[!!] This certificate's subject key identifier does NOT match the previous certificate's authority key identifier." |
|
102 |
+ fi |
|
103 |
+ |
|
104 |
+ local verify_cmd=(openssl verify -partial_chain -trusted "${pem}" "${prev_pem}") |
|
105 |
+ if "${verify_cmd[@]}" > verify_out 2>&1; then |
|
106 |
+ echo "[ok] ${verify_cmd[*]} succeeded" |
|
107 |
+ else |
|
108 |
+ echo "[!!] ${verify_cmd[*]} failed with exit code $?" |
|
109 |
+ indent verify_out |
|
110 |
+ fi |
|
111 |
+ } | indent |
|
112 |
+} |
|
113 |
+ |
|
114 |
+function pem_show_issuer { |
|
115 |
+ local pem="${1}" |
|
116 |
+ |
|
117 |
+ echo '' |
|
118 |
+ echo 'Issuer:' |
|
119 |
+ { |
|
120 |
+ x509_prop "${pem}" -issuer |
|
121 |
+ x509_prop "${pem}" -ext authorityKeyIdentifier |
|
122 |
+ } | indent |
|
123 |
+} |
|
124 |
+ |
|
125 |
+function analyse_pem { |
|
126 |
+ local pem="${1}" |
|
127 |
+ local prev_pem="${2}" |
|
128 |
+ |
|
129 |
+ pem_show_properties "${pem}" |
|
130 |
+ [ -n "${prev_pem}" ] && pem_check_chain "${pem}" "${prev_pem}" |
|
131 |
+ pem_show_issuer "${pem}" |
|
132 |
+} |
|
133 |
+ |
|
134 |
+function pem_is_self_signed { |
|
135 |
+ local pem="${1}" |
|
136 |
+ local subject issuer |
|
137 |
+ subject=$(x509_prop_val "${pem}" -subject) |
|
138 |
+ issuer=$(x509_prop_val "${pem}" -issuer) |
|
139 |
+ [ "${subject}" == "${issuer}" ] |
|
140 |
+} |
|
141 |
+ |
|
142 |
+function get_ca_path { |
|
143 |
+ ca_path="${CA_PATH:-/etc/ssl/certs}" |
|
144 |
+ [ -d "${ca_path}" ] || _exit 50 "${ca_path} is not a directory; please set CA_PATH." |
|
145 |
+} |
|
146 |
+ |
|
147 |
+function lookup_ca { |
|
148 |
+ get_ca_path |
|
149 |
+ local pem |
|
150 |
+ local issuer="${1}"; shift |
|
151 |
+ local issuer_hash="${1}"; shift |
|
152 |
+ for pem in "${ca_path}/${issuer_hash}".*; do |
|
153 |
+ [ "$(x509_prop_val "${pem}" -subject)" != "${issuer}" ] && continue |
|
154 |
+ # Found it: |
|
155 |
+ found_ca_path="${pem}" |
|
156 |
+ return 0 |
|
157 |
+ done |
|
158 |
+ return 1 |
|
159 |
+} |
|
160 |
+ |
|
161 |
+function pem_lookup_ca { |
|
162 |
+ local pem="${1}" |
|
163 |
+ lookup_ca "$(x509_prop_val "${pem}" -issuer)" "$(x509_prop_val "${pem}" -issuer_hash)" |
|
164 |
+} |
|
165 |
+ |
|
166 |
+# Usual boilerplate: |
|
167 |
+target="${1}"; shift |
|
168 |
+[ -z "${target}" ] && _exit 100 "Usage: ${0} host:port [s_client args...]" |
|
169 |
+[[ "${target}" =~ :[0-9]{1,5}$ ]] || target="${target}:${DEFAULT_PORT:-443}" |
|
170 |
+trap cleanup EXIT |
|
171 |
+make_tmpdir |
|
172 |
+ |
|
173 |
+# Get X.509 certificates (as PEM files) from the provided network target: |
|
174 |
+openssl s_client -connect "${target}" "$@" -showcerts 2> /dev/null < /dev/null | extract_pem || _exit 90 |
|
175 |
+ |
|
176 |
+# Iterate over those certificates: |
|
177 |
+while read -r pem; do |
|
178 |
+ echo "${pem/.pem/}:" |
|
179 |
+ analyse_pem "${pem}" "${prev_pem}" | indent |
|
180 |
+ prev_pem="${pem}" |
|
181 |
+done < <(find . -type f -name '*.pem' -printf '%P\n' | sort -n) |
|
182 |
+ |
|
183 |
+# Handle the lack of root CA certificate at the end of the delivered fullchain: |
|
184 |
+if ! pem_is_self_signed "${prev_pem}"; then |
|
185 |
+ # The last certificate is not self-signed, i.e. it is not the root CA certificate. |
|
186 |
+ # Look up the root CA certificate within the local CA path: |
|
187 |
+ if pem_lookup_ca "${prev_pem}"; then |
|
188 |
+ # Found it -- now analyse it as if it had been provided by the remote server: |
|
189 |
+ pem="${found_ca_path}" |
|
190 |
+ echo "$(readlink -f "${pem}"):" |
|
191 |
+ analyse_pem "${pem}" "${prev_pem}" | indent |
|
192 |
+ fi |
|
193 |
+fi |
|
194 |
+_exit 0 |