From 285d3dabc7f605ae1a765da0a7f6a87e988f40d5 Mon Sep 17 00:00:00 2001 From: William Yang Date: Tue, 9 May 2023 12:56:35 +0200 Subject: [PATCH] feat(tls-partial-chain): support CAcert renewal The listener could support two versions of CAcerts if partial_chain is set to `two_cacerts_from_cacertfile` --- src/emqx_const_v2.erl | 26 +++++- src/emqx_tls_lib.erl | 19 ++-- ...istener_tls_verify_partial_chain_SUITE.erl | 89 ++++++++++++++++++- test/emqx_test_tls_certs_helper.erl | 2 + 4 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/emqx_const_v2.erl b/src/emqx_const_v2.erl index 0c211ec5f..d692dd3b4 100644 --- a/src/emqx_const_v2.erl +++ b/src/emqx_const_v2.erl @@ -21,7 +21,25 @@ -export([ make_tls_root_fun/2 ]). -make_tls_root_fun(cacert_from_cacertfile, CADer) -> - fun(_InputChain) -> - {trusted_ca, CADer} - end. +%% @doc Build a root fun for verify TLS partial_chain. +%% The `InputChain' is composed by OTP SSL with local cert store +%% AND the cert (chain if any) from the client. +%% @end +make_tls_root_fun(cacert_from_cacertfile, [Trusted]) -> + %% Allow only one trusted ca cert, and just return the defined trusted CA cert, + fun(_InputChain) -> + %% Note, returing `trusted_ca` doesn't really mean it accepts the connection + %% OTP SSL app will do the path validation, signature validation subsequently. + {trusted_ca, Trusted} + end; +make_tls_root_fun(cacert_from_cacertfile, [TrustedOne, TrustedTwo]) -> + %% Allow two trusted CA certs in case of CA cert renewal + %% This is a little expensive call as it compares the binaries. + fun(InputChain) -> + case lists:member(TrustedOne, InputChain) of + true -> + {trusted_ca, TrustedOne}; + false -> + {trusted_ca, TrustedTwo} + end + end. diff --git a/src/emqx_tls_lib.erl b/src/emqx_tls_lib.erl index ad4e5c0c4..720c886a9 100644 --- a/src/emqx_tls_lib.erl +++ b/src/emqx_tls_lib.erl @@ -191,26 +191,31 @@ opt_partial_chain(SslOpts) -> false -> SslOpts; V when V =:= cacert_from_cacertfile orelse V == true -> - replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(SslOpts)) + replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(1, SslOpts)); + V when V =:= two_cacerts_from_cacertfile -> %% for certificate rotations + replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(2, SslOpts)) end. replace(Opts, Key, Value) -> [{Key, Value} | proplists:delete(Key, Opts)]. %% @doc Helper, make TLS root_fun -rootfun_trusted_ca_from_cacertfile(SslOpts) -> +rootfun_trusted_ca_from_cacertfile(NumOfCerts, SslOpts) -> Cacertfile = proplists:get_value(cacertfile, SslOpts, undefined), - try do_rootfun_trusted_ca_from_cacertfile(Cacertfile) + try do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, Cacertfile) catch _Error:_Info:ST -> %% The cacertfile will be checked by OTP SSL as well and OTP choice to be silent on this. %% We are touching security sutffs, don't leak extra info.. ?LOG(error, "Failed to look for trusted cacert from cacertfile. Stacktrace: ~p", [ST]), throw({error, ?FUNCTION_NAME}) end. -do_rootfun_trusted_ca_from_cacertfile(Cacertfile) -> +do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, Cacertfile) -> {ok, PemBin} = file:read_file(Cacertfile), - %% The last one should be the top parent in the chain if it is a chain - {'Certificate', CADer, _} = lists:last(public_key:pem_decode(PemBin)), - emqx_const_v2:make_tls_root_fun(cacert_from_cacertfile, CADer). + %% The last one or two should be the top parent in the chain if it is a chain + Certs = public_key:pem_decode(PemBin), + Pos = length(Certs) - NumOfCerts + 1, + Trusted = [ CADer || {'Certificate', CADer, _} <- + lists:sublist(public_key:pem_decode(PemBin), Pos, NumOfCerts)], + emqx_const_v2:make_tls_root_fun(cacert_from_cacertfile, Trusted). -if(?OTP_RELEASE > 22). -ifdef(TEST). diff --git a/test/emqx_listener_tls_verify_partial_chain_SUITE.erl b/test/emqx_listener_tls_verify_partial_chain_SUITE.erl index 4f1d613e5..224e1a8b6 100644 --- a/test/emqx_listener_tls_verify_partial_chain_SUITE.erl +++ b/test/emqx_listener_tls_verify_partial_chain_SUITE.erl @@ -115,7 +115,56 @@ t_conn_fail_with_renewed_intermediate_cacert_and_client_using_old_bundle(Config) fail_when_no_ssl_alert(Socket, unknown_ca), ssl:close(Socket). -%%@TODO limitation: EMQX is not able to check if the trusted CAcert and the old CAcert belongs to same CA. +t_conn_success_with_old_and_renewed_intermediate_cacert_and_client_provides_renewed_client_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {cacertfile, filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + , {partial_chain, two_cacerts_from_cacertfile} + | ?config(ssl_config, Config) + ]}], + emqx_listeners:start_listener(ssl, Port, Options), + {ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client2.key")}, + {certfile, filename:join(DataDir, "client2_renewed.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +%% Note, this is good to have for usecase coverage +t_conn_success_with_new_intermediate_cacert_and_client_provides_renewed_client_cert_signed_by_old_intermediate(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {cacertfile, filename:join(DataDir, "intermediate2_renewed.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + | ?config(ssl_config, Config) + ]}], + emqx_listeners:start_listener(ssl, Port, Options), + {ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client2.key")}, + {certfile, filename:join(DataDir, "client2_renewed.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +%% @doc server should build a partial_chain with old version of ca cert. +t_conn_success_with_old_and_renewed_intermediate_cacert_and_client_provides_client_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {cacertfile, filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + , {partial_chain, two_cacerts_from_cacertfile} + | ?config(ssl_config, Config) + ]}], + emqx_listeners:start_listener(ssl, Port, Options), + {ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client2.key")}, + {certfile, filename:join(DataDir, "client2.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +%% @doc verify when config does not allow two versions of certs from same trusted CA. t_conn_fail_with_renewed_and_old_intermediate_cacert_and_client_using_old_bundle(Config) -> Port = emqx_test_tls_certs_helper:select_free_port(ssl), DataDir = ?config(data_dir, Config), @@ -131,6 +180,44 @@ t_conn_fail_with_renewed_and_old_intermediate_cacert_and_client_using_old_bundle fail_when_no_ssl_alert(Socket, unknown_ca), ssl:close(Socket). +%% @doc verify when config (two_cacerts_from_cacertfile) allows two versions of certs from same trusted CA. +t_conn_success_with_old_and_renewed_intermediate_cacert_bundle_and_client_using_old_bundle(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {cacertfile, filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + , {partial_chain, two_cacerts_from_cacertfile} + | ?config(ssl_config, Config) + ]}], + emqx_listeners:start_listener(ssl, Port, Options), + {ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client2.key")}, + {certfile, filename:join(DataDir, "client2-intermediate2-bundle.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +%% @doc: verify even if listener has old/new intermediate2 certs, +%% client1 should not able to connect with old intermediate2 cert. +%% In this case, listener verify_fun returns {trusted_ca, Oldintermediate2Cert} but OTP should still fail the validation +%% since the client1 cert is not signed by Oldintermediate2Cert (trusted CA cert). +%% @end +t_fail_success_with_old_and_renewed_intermediate_cacert_bundle_and_client_using_all_CAcerts(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {cacertfile, filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + , {partial_chain, two_cacerts_from_cacertfile} + | ?config(ssl_config, Config) + ]}], + emqx_listeners:start_listener(ssl, Port, Options), + {ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client1.key")}, + {certfile, filename:join(DataDir, "all-CAcerts-bundle.pem")} + ], 1000), + fail_when_no_ssl_alert(Socket, unknown_ca), + ssl:close(Socket). + t_conn_fail_with_renewed_intermediate_cacert_other_client(Config) -> Port = emqx_test_tls_certs_helper:select_free_port(ssl), DataDir = ?config(data_dir, Config), diff --git a/test/emqx_test_tls_certs_helper.erl b/test/emqx_test_tls_certs_helper.erl index d527b6468..8882f9f40 100644 --- a/test/emqx_test_tls_certs_helper.erl +++ b/test/emqx_test_tls_certs_helper.erl @@ -214,6 +214,8 @@ generate_tls_certs(Config) -> gen_host_cert("client1", "intermediate1", DataDir), gen_host_cert("server2", "intermediate2", DataDir), gen_host_cert("client2", "intermediate2", DataDir), + + %% Build bundles below os:cmd(io_lib:format("cat ~p ~p ~p > ~p", [filename:join(DataDir, "client2.pem"), filename:join(DataDir, "intermediate2.pem"), filename:join(DataDir, "root.pem"),