From a18018bee0fd2d4c5add8a6fff8cdd135b3bc18d Mon Sep 17 00:00:00 2001 From: William Yang Date: Tue, 9 May 2023 16:05:49 +0200 Subject: [PATCH] feat(tls-partial-chain): add partial_chain support for TLS listeners --- priv/emqx.schema | 5 + src/emqx_const_v2.erl | 30 +++ src/emqx_listeners.erl | 3 +- src/emqx_tls_lib.erl | 32 ++- test/emqx_listener_tls_verify_SUITE.erl | 339 ++++++++++++++++++++++++ test/emqx_test_tls_certs_helper.erl | 156 +++++++++++ 6 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 src/emqx_const_v2.erl create mode 100644 test/emqx_listener_tls_verify_SUITE.erl create mode 100644 test/emqx_test_tls_certs_helper.erl diff --git a/priv/emqx.schema b/priv/emqx.schema index a4b9daeef..b3846dc0d 100644 --- a/priv/emqx.schema +++ b/priv/emqx.schema @@ -1646,6 +1646,10 @@ end}. {datatype, atom} ]}. +{mapping, "listener.ssl.$name.partial_chain", "emqx.listeners", [ + {datatype, atom} +]}. + {mapping, "listener.ssl.$name.fail_if_no_peer_cert", "emqx.listeners", [ {datatype, {enum, [true, false]}} ]}. @@ -2377,6 +2381,7 @@ end}. {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined)}, {verify, cuttlefish:conf_get(Prefix ++ ".verify", Conf, undefined)}, + {partial_chain, cuttlefish:conf_get(Prefix ++ ".partial_chain", Conf, undefined)}, {fail_if_no_peer_cert, cuttlefish:conf_get(Prefix ++ ".fail_if_no_peer_cert", Conf, undefined)}, {secure_renegotiate, cuttlefish:conf_get(Prefix ++ ".secure_renegotiate", Conf, undefined)}, {reuse_sessions, cuttlefish:conf_get(Prefix ++ ".reuse_sessions", Conf, undefined)}, diff --git a/src/emqx_const_v2.erl b/src/emqx_const_v2.erl new file mode 100644 index 000000000..536b0215a --- /dev/null +++ b/src/emqx_const_v2.erl @@ -0,0 +1,30 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% @doc Never update this module, create a v3 instead. +%%-------------------------------------------------------------------- + +-module(emqx_const_v2). + +-export([ make_tls_root_fun/2 + ]). + +make_tls_root_fun(cacert_from_cacertfile, CADer) -> + fun(InputChain) -> + case lists:member(CADer, InputChain) of + true -> {trusted_ca, CADer}; + _ -> unknown_ca + end + end. diff --git a/src/emqx_listeners.erl b/src/emqx_listeners.erl index 94bb72136..19963e8b6 100644 --- a/src/emqx_listeners.erl +++ b/src/emqx_listeners.erl @@ -138,7 +138,8 @@ start_listener(tcp, ListenOn, Options) -> start_listener(Proto, ListenOn, Options0) when Proto == ssl; Proto == tls -> ListenerID = proplists:get_value(listener_id, Options0), Options1 = proplists:delete(listener_id, Options0), - Options = emqx_ocsp_cache:inject_sni_fun(ListenerID, Options1), + Options2 = emqx_ocsp_cache:inject_sni_fun(ListenerID, Options1), + Options = emqx_tls_lib:inject_root_fun(Options2), ok = maybe_register_crl_urls(Options), start_mqtt_listener('mqtt:ssl', ListenOn, Options); diff --git a/src/emqx_tls_lib.erl b/src/emqx_tls_lib.erl index c027634b5..e7383b05c 100644 --- a/src/emqx_tls_lib.erl +++ b/src/emqx_tls_lib.erl @@ -22,6 +22,8 @@ , default_ciphers/1 , integral_ciphers/2 , drop_tls13_for_old_otp/1 + , inject_root_fun/1 + , opt_partial_chain/1 ]). %% non-empty string @@ -170,8 +172,34 @@ drop_tls13(SslOpts0) -> Ciphers -> replace(SslOpts1, ciphers, Ciphers -- ?TLSV13_EXCLUSIVE_CIPHERS) end. +inject_root_fun(Options) -> + case proplists:get_value(ssl_options, Options) of + undefined -> + Options; + SslOpts -> + replace(Options, ssl_options, opt_partial_chain(SslOpts)) + end. + +%% @doc enable TLS partial_chain validation if set. +-spec opt_partial_chain(SslOpts :: proplists:proplist()) -> NewSslOpts :: proplists:proplist(). +opt_partial_chain(SslOpts) -> + case proplists:get_value(partial_chain, SslOpts, undefined) of + undefined -> + SslOpts; + cacert_from_cacertfile -> + replace(SslOpts, partial_chain, cacert_from_cacertfile(SslOpts)) + end. + replace(Opts, Key, Value) -> [{Key, Value} | proplists:delete(Key, Opts)]. +%% @doc Helper, make TLS root_fun +cacert_from_cacertfile(SslOpts) -> + Cacertfile = proplists:get_value(cacertfile, SslOpts, undefined), + {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). + -if(?OTP_RELEASE > 22). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). @@ -194,5 +222,5 @@ drop_tls13_no_versions_cipers_test() -> has_tlsv13_cipher(Ciphers) -> lists:any(fun(C) -> lists:member(C, Ciphers) end, ?TLSV13_EXCLUSIVE_CIPHERS). --endif. --endif. +-endif. %% TEST +-endif. %% OTP_RELEASE > 22 diff --git a/test/emqx_listener_tls_verify_SUITE.erl b/test/emqx_listener_tls_verify_SUITE.erl new file mode 100644 index 000000000..49ba18e6c --- /dev/null +++ b/test/emqx_listener_tls_verify_SUITE.erl @@ -0,0 +1,339 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_listener_tls_verify_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-import(emqx_test_tls_certs_helper, [ gen_ca/2 + , gen_host_cert/3 + ]). + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Config) -> + generate_tls_certs(Config), + application:ensure_all_started(esockd), + Config. + +end_per_suite(_Config) -> + application:stop(esockd). + +t_tls_conn_success_when_partial_chain_enabled_with_intermediate_ca_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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, "client1.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_enabled_with_intermediate_cacert_bundle(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "server1-intermediate1-bundle.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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, "client1.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ssl:close(Socket). + +t_tls_conn_fail_when_partial_chain_enabled_with_intermediate_cacert_bundle2(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate1-server1-bundle.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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, "client1.pem")} + ], 1000), + fail_when_no_ssl_alert(Socket, unknown_ca), + ssl:close(Socket). + +t_tls_conn_fail_when_partial_chain_disabled_with_intermediate_ca_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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, "client1.pem")} + ], 1000), + + fail_when_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + + +t_tls_conn_fail_when_partial_chain_disabled_with_other_intermediate_ca_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +t_tls_conn_fail_when_partial_chain_enabled_with_other_intermediate_ca_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_enabled_root_ca_with_complete_cert_chain(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "root.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + ]}], + 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-complete-bundle.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ok = ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_disabled_with_intermediate_ca_cert(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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, "client1.pem")} + ], 1000), + fail_when_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_disabled_with_broken_cert_chain_other_intermediate(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + %% Server has root ca cert + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "root.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + %% Client has complete chain + 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), + ok = ssl:close(Socket). + +t_tls_conn_fail_when_partial_chain_enabled_with_other_complete_cert_chain(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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-complete-bundle.pem")} + ], 1000), + fail_when_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_enabled_with_complete_cert_chain(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate2.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + ]}], + 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-complete-bundle.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ok = ssl:close(Socket). + +t_tls_conn_success_when_partial_chain_disabled_with_complete_cert_chain_client(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "root.pem")} + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + ]}], + 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-complete-bundle.pem")} + ], 1000), + fail_when_ssl_error(Socket), + ok = ssl:close(Socket). + + +t_tls_conn_fail_when_partial_chain_disabled_with_incomplete_cert_chain_server(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "intermediate2.pem")} %% imcomplete at server side + , {certfile, filename:join(DataDir, "server2.pem")} + , {keyfile, filename:join(DataDir, "server2.key")} + ]}], + 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-complete-bundle.pem")} + ], 1000), + fail_when_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + + +t_tls_conn_fail_when_partial_chain_enabled_with_imcomplete_cert_chain(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {partial_chain, cacert_from_cacertfile} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +t_tls_conn_fail_when_partial_chain_disabled_with_incomplete_cert_chain(Config) -> + Port = emqx_test_tls_certs_helper:select_free_port(ssl), + DataDir = ?config(data_dir, Config), + Options = [{ssl_options, [ {verify, verify_peer} + , {fail_if_no_peer_cert, true} + , {cacertfile, filename:join(DataDir, "intermediate1.pem")} + , {certfile, filename:join(DataDir, "server1.pem")} + , {keyfile, filename:join(DataDir, "server1.key")} + ]}], + 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_no_ssl_alert(Socket, unknown_ca), + ok = ssl:close(Socket). + +generate_tls_certs(Config) -> + DataDir = ?config(data_dir, Config), + gen_ca(DataDir, "root"), + gen_host_cert("intermediate1", "root", DataDir), + gen_host_cert("intermediate2", "root", DataDir), + gen_host_cert("server1", "intermediate1", DataDir), + gen_host_cert("client1", "intermediate1", DataDir), + gen_host_cert("server2", "intermediate2", DataDir), + gen_host_cert("client2", "intermediate2", DataDir), + 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"), + filename:join(DataDir, "client2-complete-bundle.pem") + ])), + os:cmd(io_lib:format("cat ~p ~p > ~p", [filename:join(DataDir, "client2.pem"), + filename:join(DataDir, "intermediate2.pem"), + filename:join(DataDir, "client2-intermediate2-bundle.pem") + ])), + os:cmd(io_lib:format("cat ~p ~p > ~p", [filename:join(DataDir, "server1.pem"), + filename:join(DataDir, "intermediate1.pem"), + filename:join(DataDir, "server1-intermediate1-bundle.pem") + ])), + os:cmd(io_lib:format("cat ~p ~p > ~p", [filename:join(DataDir, "intermediate1.pem"), + filename:join(DataDir, "server1.pem"), + filename:join(DataDir, "intermediate1-server1-bundle.pem") + ])). + +fail_when_ssl_error(Socket) -> + receive + {ssl_error, Socket, _} -> + ct:fail("Handshake failed!") + after 1000 -> + ok + end. + +fail_when_no_ssl_alert(Socket, Alert) -> + receive + {ssl_error, Socket, {tls_alert, {Alert, AlertInfo}}} -> + ct:pal("alert info: ~p~n", [AlertInfo]); + {ssl_error, Socket, Other} -> + ct:fail("recv unexpected ssl_error: ~p~n", [Other]) + after 1000 -> + ct:fail("No expected alert: ~p from Socket: ~p ", [Alert, Socket]) + end. diff --git a/test/emqx_test_tls_certs_helper.erl b/test/emqx_test_tls_certs_helper.erl new file mode 100644 index 000000000..36813074c --- /dev/null +++ b/test/emqx_test_tls_certs_helper.erl @@ -0,0 +1,156 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_test_tls_certs_helper). +-export([ gen_ca/2 + , gen_host_cert/3 + , gen_host_cert/4 + + , select_free_port/1 + ]). + +%%------------------------------------------------------------------------------- +%% TLS certs +%%------------------------------------------------------------------------------- +gen_ca(Path, Name) -> + %% Generate ca.pem and ca.key which will be used to generate certs + %% for hosts server and clients + ECKeyFile = eckey_name(Path), + filelib:ensure_dir(ECKeyFile), + os:cmd("openssl ecparam -name secp256r1 > " ++ ECKeyFile), + Cmd = lists:flatten( + io_lib:format( + "openssl req -new -x509 -nodes " + "-newkey ec:~s " + "-keyout ~s -out ~s -days 3650 " + "-addext basicConstraints=CA:TRUE " + "-subj \"/C=SE/O=Internet Widgits Pty Ltd CA\"", + [ + ECKeyFile, + ca_key_name(Path, Name), + ca_cert_name(Path, Name) + ] + ) + ), + os:cmd(Cmd). + +ca_cert_name(Path, Name) -> + filename(Path, "~s.pem", [Name]). +ca_key_name(Path, Name) -> + filename(Path, "~s.key", [Name]). + +eckey_name(Path) -> + filename(Path, "ec.key", []). + +gen_host_cert(H, CaName, Path) -> + gen_host_cert(H, CaName, Path, #{}). + +gen_host_cert(H, CaName, Path, Opts) -> + ECKeyFile = eckey_name(Path), + CN = str(H), + HKey = filename(Path, "~s.key", [H]), + HCSR = filename(Path, "~s.csr", [H]), + HPEM = filename(Path, "~s.pem", [H]), + HEXT = filename(Path, "~s.extfile", [H]), + PasswordArg = + case maps:get(password, Opts, undefined) of + undefined -> + " -nodes "; + Password -> + io_lib:format(" -passout pass:'~s' ", [Password]) + end, + CSR_Cmd = + lists:flatten( + io_lib:format( + "openssl req -new ~s -newkey ec:~s " + "-keyout ~s -out ~s " + "-addext \"subjectAltName=DNS:~s\" " + "-addext basicConstraints=CA:TRUE " + "-addext keyUsage=digitalSignature,keyAgreement,keyCertSign " + "-subj \"/C=SE/O=Internet Widgits Pty Ltd/CN=~s\"", + [PasswordArg, ECKeyFile, HKey, HCSR, CN, CN] + ) + ), + create_file( + HEXT, + "keyUsage=digitalSignature,keyAgreement,keyCertSign\n" + "basicConstraints=CA:TRUE \n" + "subjectAltName=DNS:~s\n", + [CN] + ), + CERT_Cmd = + lists:flatten( + io_lib:format( + "openssl x509 -req " + "-extfile ~s " + "-in ~s -CA ~s -CAkey ~s -CAcreateserial " + "-out ~s -days 500", + [ + HEXT, + HCSR, + ca_cert_name(Path, CaName), + ca_key_name(Path, CaName), + HPEM + ] + ) + ), + ct:pal(os:cmd(CSR_Cmd)), + ct:pal(os:cmd(CERT_Cmd)), + file:delete(HEXT). + +filename(Path, F, A) -> + filename:join(Path, str(io_lib:format(F, A))). + +str(Arg) -> + binary_to_list(iolist_to_binary(Arg)). + +create_file(Filename, Fmt, Args) -> + filelib:ensure_dir(Filename), + {ok, F} = file:open(Filename, [write]), + try + io:format(F, Fmt, Args) + after + file:close(F) + end, + ok. + +%% @doc get unused port from OS +-spec select_free_port(tcp | udp | ssl | quic) -> inets:port_number(). +select_free_port(tcp) -> + select_free_port(gen_tcp, listen); +select_free_port(udp) -> + select_free_port(gen_udp, open); +select_free_port(ssl) -> + select_free_port(tcp); +select_free_port(quic) -> + select_free_port(udp). + +select_free_port(GenModule, Fun) when + GenModule == gen_tcp orelse + GenModule == gen_udp +-> + {ok, S} = GenModule:Fun(0, [{reuseaddr, true}]), + {ok, Port} = inet:port(S), + ok = GenModule:close(S), + case os:type() of + {unix, darwin} -> + %% in MacOS, still get address_in_use after close port + timer:sleep(500); + _ -> + skip + end, + ct:pal("Select free OS port: ~p", [Port]), + Port.