From 067678c9ac2194a0b9cf1fd2c79e0f2d5ce85635 Mon Sep 17 00:00:00 2001 From: Guilherme Andrade Date: Wed, 1 Sep 2021 21:49:46 +0100 Subject: [PATCH] Prepare for DST Root CA X3 expiration and the woes that come with it References: * https://blog.voltone.net/post/29 * https://blog.voltone.net/post/30 * https://github.com/elixir-mint/mint/pull/328 * http://erlang.org/pipermail/erlang-questions/2021-July/101251.html * http://erlang.org/pipermail/erlang-questions/2021-July/101252.html --- CHANGELOG.md | 4 + Makefile | 1 + src/tls_certificate_check_shared_state.erl | 47 +++++- test/cross_signing/.gitignore | 3 + test/cross_signing/Makefile | 141 ++++++++++++++++++ test/cross_signing/install_faketime.sh | 25 ++++ test/cross_signing/intermediate_ca.ext | 8 + ..._certificate_check_cross_signing_SUITE.erl | 135 +++++++++++++++++ 8 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 test/cross_signing/.gitignore create mode 100644 test/cross_signing/Makefile create mode 100755 test/cross_signing/install_faketime.sh create mode 100644 test/cross_signing/intermediate_ca.ext create mode 100644 test/tls_certificate_check_cross_signing_SUITE.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb58e5..f47c279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- partial chain validation to prepare for DST Root CA X3 expiration + ### Removed - unnecessary handling of partial chains on OTP 23.3.4.5+ or OTP 24.0.4+ diff --git a/Makefile b/Makefile index f5d577d..c3bce71 100644 --- a/Makefile +++ b/Makefile @@ -53,6 +53,7 @@ xref: $(REBAR3) @$(REBAR3) as hardcoded_authorities_update xref test: $(REBAR3) + @make -C test/cross_signing/ @$(REBAR3) do eunit, ct, cover cover: test diff --git a/src/tls_certificate_check_shared_state.erl b/src/tls_certificate_check_shared_state.erl index e9bd312..4d3b3d3 100644 --- a/src/tls_certificate_check_shared_state.erl +++ b/src/tls_certificate_check_shared_state.erl @@ -158,7 +158,8 @@ needs_partial_chain_handler() -> find_trusted_authority(EncodedCertificates) -> SharedState = get_latest_shared_state(), TrustedPublicKeys = #{} = SharedState#shared_state.trusted_public_keys, - find_trusted_authority_recur(EncodedCertificates, TrustedPublicKeys). + Now = universal_time_in_certificate_format(), + find_trusted_authority_recur(EncodedCertificates, Now, TrustedPublicKeys). -spec maybe_update_shared_state(binary()) -> ok | {error, term()}. maybe_update_shared_state(EncodedAuthorities) -> @@ -391,16 +392,50 @@ latest_shared_state_key() -> throw({application_either_not_started_or_not_ready, tls_certificate_check}) end. -find_trusted_authority_recur([EncodedCertificate | NextEncodedCertificates], TrustedPublicKeys) -> +universal_time_in_certificate_format() -> + % http://erlang.org/doc/apps/public_key/public_key_records.html + % * {utcTime, "YYMMDDHHMMSSZ" + % * {generalTime, "YYYYMMDDHHMMSSZ"} + + {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:universal_time(), + IoData = io_lib:format("~4..0B~2..0B~2..0B" "~2..0B~2..0B~2..0BZ", + [Year, Month, Day, Hour, Minute, Second]), + lists:flatten(IoData). + +find_trusted_authority_recur([EncodedCertificate | NextEncodedCertificates], Now, TrustedPublicKeys) -> Certificate = public_key:pkix_decode_cert(EncodedCertificate, otp), #'OTPCertificate'{tbsCertificate = TbsCertificate} = Certificate, - #'OTPTBSCertificate'{subjectPublicKeyInfo = PublicKeyInfo} = TbsCertificate, + #'OTPTBSCertificate'{subjectPublicKeyInfo = PublicKeyInfo, + validity = Validity} = TbsCertificate, - case maps:is_key(PublicKeyInfo, TrustedPublicKeys) of + case is_certificate_valid(Validity, Now) + andalso maps:is_key(PublicKeyInfo, TrustedPublicKeys) + of true -> {trusted_ca, EncodedCertificate}; false -> - find_trusted_authority_recur(NextEncodedCertificates, TrustedPublicKeys) + find_trusted_authority_recur(NextEncodedCertificates, Now, TrustedPublicKeys) end; -find_trusted_authority_recur([], _TrustedPublicKeys) -> +find_trusted_authority_recur([], _Now, _TrustedPublicKeys) -> unknown_ca. + +is_certificate_valid(Validity, Now) -> + #'Validity'{notBefore = NotBefore, notAfter = NotAfter} = Validity, + compare_certificate_timestamps(NotAfter, Now) =/= lesser + andalso compare_certificate_timestamps(NotBefore, Now) =/= greater. + +compare_certificate_timestamps({utcTime, String}, Now) -> + compare_certificate_timestamps_("20" ++ String, Now); +compare_certificate_timestamps({generalTime, String}, Now) -> + compare_certificate_timestamps_(String, Now). + +compare_certificate_timestamps_([X|A], [Y|B]) -> + if X < Y -> + lesser; + X > Y -> + greater; + true -> + compare_certificate_timestamps_(A, B) + end; +compare_certificate_timestamps_([], []) -> + equal. diff --git a/test/cross_signing/.gitignore b/test/cross_signing/.gitignore new file mode 100644 index 0000000..1f272ba --- /dev/null +++ b/test/cross_signing/.gitignore @@ -0,0 +1,3 @@ +*.csr +*.pem +*.srl diff --git a/test/cross_signing/Makefile b/test/cross_signing/Makefile new file mode 100644 index 0000000..49ed4ad --- /dev/null +++ b/test/cross_signing/Makefile @@ -0,0 +1,141 @@ + +all: good_ca_store_for_expiry +all: bad_ca_store_for_expiry +all: ca_store1_for_cross_signing +all: ca_store2_for_cross_signing +all: localhost_chain_for_expiry +all: localhost_chain_for_cross_signing + +good_ca_store_for_expiry: new_ca +good_ca_store_for_expiry: expired_ca +good_ca_store_for_expiry: + cat expired_ca.pem new_ca.pem >good_ca_store_for_expiry.pem +.PHONY: good_ca_store_for_expiry + +bad_ca_store_for_expiry: expired_ca +bad_ca_store_for_expiry: + cat expired_ca.pem >bad_ca_store_for_expiry.pem +.PHONY: bad_ca_store_for_expiry + +ca_store1_for_cross_signing: third_ca + cat new_ca.pem >ca_store1_for_cross_signing.pem + +ca_store2_for_cross_signing: third_ca + cat third_ca.pem >ca_store2_for_cross_signing.pem + +localhost_chain_for_expiry: localhost +localhost_chain_for_expiry: regular_intermediate_cert +localhost_chain_for_expiry: cross_signed_bad_intermediate_cert + cat \ + localhost.pem \ + regular_intermediate_cert.pem \ + cross_signed_bad_intermediate_cert.pem \ + >localhost_chain_for_expiry.pem +.PHONY: localhost_chain + +localhost_chain_for_cross_signing: localhost +localhost_chain_for_cross_signing: regular_intermediate_cert +localhost_chain_for_cross_signing: cross_signed_good_intermediate_cert + cat \ + localhost.pem \ + regular_intermediate_cert.pem \ + cross_signed_good_intermediate_cert.pem \ + >localhost_chain_for_cross_signing.pem +.PHONY: localhost_chain + +localhost: regular_intermediate_cert +localhost: localhost.csr +localhost: + faketime -f '-5y' openssl x509 \ + -req \ + -in localhost.csr \ + -CA regular_intermediate_cert.pem \ + -CAkey regular_intermediate_cert_key.pem \ + -CAcreateserial \ + -out localhost.pem \ + -days 3600 \ + -sha256 +.PHONY: localhost + +regular_intermediate_cert: new_ca +regular_intermediate_cert: regular_intermediate_cert.csr + faketime -f '-5y' openssl x509 \ + -req \ + -in regular_intermediate_cert.csr \ + -extfile intermediate_ca.ext \ + -extensions v3_intermediate_ca \ + -CA new_ca.pem \ + -CAkey new_ca_key.pem \ + -CAcreateserial \ + -out regular_intermediate_cert.pem \ + -days 3600 \ + -sha256 +.PHONY: regular_intermediate_cert + +cross_signed_bad_intermediate_cert: expired_ca +cross_signed_bad_intermediate_cert: new_ca.csr + faketime -f '-5y' openssl x509 \ + -req \ + -in new_ca.csr \ + -extfile intermediate_ca.ext \ + -extensions v3_intermediate_ca \ + -CA expired_ca.pem \ + -CAkey expired_ca_key.pem \ + -CAcreateserial \ + -out cross_signed_bad_intermediate_cert.pem \ + -days 3600 \ + -sha256 +.PHONY: cross_signed_bad_intermediate_cert + +cross_signed_good_intermediate_cert: third_ca +cross_signed_good_intermediate_cert: new_ca.csr + faketime -f '-5y' openssl x509 \ + -req \ + -in new_ca.csr \ + -extfile intermediate_ca.ext \ + -extensions v3_intermediate_ca \ + -CA third_ca.pem \ + -CAkey third_ca_key.pem \ + -CAcreateserial \ + -out cross_signed_good_intermediate_cert.pem \ + -days 3600 \ + -sha256 +.PHONY: cross_signed_good_intermediate_cert + +expired_ca: faketime +expired_ca: expired_ca_key.pem + faketime -f '-5y' openssl req -x509 \ + -new -nodes \ + -key expired_ca_key.pem \ + -sha256 \ + -days 1800 \ + -subj "/CN=Expired CA" \ + -out expired_ca.pem +.PHONY: expired_ca + +faketime: + ./install_faketime.sh +.PHONY: faketime + +.PRECIOUS: %_ca # prevens removal of what is considered an intermediate file (FIXME untested on macos) +%_ca: %_ca_key.pem + faketime -f '-5y' openssl req -x509 \ + -new -nodes \ + -key $*_ca_key.pem \ + -sha256 \ + -days 3600 \ + -subj "/CN=$*_ca" \ + -out $*_ca.pem +.PHONY: %_ca + +.PRECIOUS: %_key.pem # prevens removal of what is considered an intermediate file (FIXME untested on macos) +%.csr: %_key.pem + openssl req \ + -new \ + -key $*_key.pem \ + -subj "/CN=$*" \ + -out $@ + +.PRECIOUS: %_key.pem # prevens removal of what is considered an intermediate file (FIXME untested on macos) +%_key.pem: + openssl genrsa -out $@ 2048 diff --git a/test/cross_signing/install_faketime.sh b/test/cross_signing/install_faketime.sh new file mode 100755 index 0000000..e91bfb5 --- /dev/null +++ b/test/cross_signing/install_faketime.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -eu + +function is_installed { + which $1 >/dev/null; +} + + +if is_installed faketime; then + exit +fi + +if is_installed brew; then + brew install faketime # FIXME untested + exit +fi + +if is_installed apt-get; then + DEBIAN_FRONTEND=noninteractive sudo apt-get --yes install faketime + exit +fi + +>&2 echo "I don't know how to install faketime in your system" +exit 1 diff --git a/test/cross_signing/intermediate_ca.ext b/test/cross_signing/intermediate_ca.ext new file mode 100644 index 0000000..5e65157 --- /dev/null +++ b/test/cross_signing/intermediate_ca.ext @@ -0,0 +1,8 @@ +# From: https://stackoverflow.com/questions/52500165/problem-verifying-a-self-created-openssl-root-intermediate-and-end-user-certifi + +[ v3_intermediate_ca ] +# Extensions for a typical intermediate CA (`man x509v3_config`). +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:true, pathlen:1 +keyUsage = critical, digitalSignature, cRLSign, keyCertSign diff --git a/test/tls_certificate_check_cross_signing_SUITE.erl b/test/tls_certificate_check_cross_signing_SUITE.erl new file mode 100644 index 0000000..cb56e4f --- /dev/null +++ b/test/tls_certificate_check_cross_signing_SUITE.erl @@ -0,0 +1,135 @@ +%% Copyright (c) 2021 Guilherme Andrade +%% +%% Permission is hereby granted, free of charge, to any person obtaining a +%% copy of this software and associated documentation files (the "Software"), +%% to deal in the Software without restriction, including without limitation +%% the rights to use, copy, modify, merge, publish, distribute, sublicense, +%% and/or sell copies of the Software, and to permit persons to whom the +%% Software is furnished to do so, subject to the following conditions: +%% +%% The above copyright notice and this permission notice shall be included in +%% all copies or substantial portions of the Software. +%% +%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +%% DEALINGS IN THE SOFTWARE. + +-module(tls_certificate_check_cross_signing_SUITE). +-compile(export_all). + +-include_lib("stdlib/include/assert.hrl"). + +%% ------------------------------------------------------------------ +%% Macros +%% ------------------------------------------------------------------ + +-define(PEMS_PATH, "../../../../test/cross_signing"). + +%% ------------------------------------------------------------------ +%% Setup +%% ------------------------------------------------------------------ + +all() -> + [good_chain_with_expired_root_test, + bad_chain_with_expired_root_test, + cross_signing_with_one_recognized_ca_test, + cross_signing_with_one_other_recognized_ca_test]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(tls_certificate_check), + Config. + +end_per_suite(_Config) -> + ok = application:stop(tls_certificate_check). + +%% ------------------------------------------------------------------ +%% Test Cases +%% ------------------------------------------------------------------ + +good_chain_with_expired_root_test(_Config) -> + connect("good_ca_store_for_expiry.pem", + "localhost_chain_for_expiry.pem", + fun ({ok, Socket}) -> + ssl:close(Socket) + end). + +bad_chain_with_expired_root_test(_Config) -> + connect("bad_ca_store_for_expiry.pem", + "localhost_chain_for_expiry.pem", + fun ({error, {tls_alert, {certificate_expired, _}}}) -> + ok + end). + +cross_signing_with_one_recognized_ca_test(_Config) -> + connect("ca_store1_for_cross_signing.pem", + "localhost_chain_for_cross_signing.pem", + fun ({ok, Socket}) -> + ssl:close(Socket) + end). + +cross_signing_with_one_other_recognized_ca_test(_Config) -> + connect("ca_store2_for_cross_signing.pem", + "localhost_chain_for_cross_signing.pem", + fun ({ok, Socket}) -> + ssl:close(Socket) + end). + +%% ------------------------------------------------------------------ +%% Internal +%% ------------------------------------------------------------------ + +connect(AuthoritiesFilename, ChainFilename, Fun) -> + AuthoritiesPath = filename:join(?PEMS_PATH, AuthoritiesFilename), + {ok, EncodedAuthorities} = file:read_file(AuthoritiesPath), + ok = tls_certificate_check_shared_state:maybe_update_shared_state(EncodedAuthorities), + + {ListenSocket, Port, AcceptorPid} = start_server_with_chain(ChainFilename), + try + Hostname = "localhost", + Options = tls_certificate_check:options(Hostname), + Timeout = timer:seconds(5), + _ = Fun( ssl:connect(Hostname, Port, Options, Timeout) ), + ok + after + stop_ssl_acceptor(AcceptorPid), + _ = ssl:close(ListenSocket) + end. + +start_server_with_chain(ChainFilename) -> + CertsPath = filename:join(?PEMS_PATH, ChainFilename), + KeyPath = filename:join(?PEMS_PATH, "localhost_key.pem"), + Options = [{ip, {127, 0, 0, 1}}, + {certfile, CertsPath}, + {keyfile, KeyPath}, + {reuseaddr, true}], + + {ok, ListenSocket} = ssl:listen(_Port = 0, Options), + {ok, {_Address, Port}} = ssl:sockname(ListenSocket), + AcceptorPid = start_ssl_acceptor(ListenSocket), + {ListenSocket, Port, AcceptorPid}. + +start_ssl_acceptor(ListenSocket) -> + spawn_link(fun () -> run_ssl_acceptor(ListenSocket) end). + +run_ssl_acceptor(ListenSocket) -> + receive + stop -> exit(normal) + after + 0 -> ok + end, + + case ssl:transport_accept(ListenSocket, _Timeout = 100) of + {ok, Transportsocket} -> + _ = ssl:handshake(Transportsocket), + run_ssl_acceptor(ListenSocket); + {error, Reason} + when Reason =:= timeout; Reason =:= closed -> + run_ssl_acceptor(ListenSocket) + end. + +stop_ssl_acceptor(AcceptorPid) -> + AcceptorPid ! stop.