فهرست منبع

Merge pull request #12955 from qzhuyan/port/william/5/partial-chain

port: TLS partial chain
William Yang 1 سال پیش
والد
کامیت
8f780ae8bc

+ 125 - 0
apps/emqx/src/emqx_const_v2.erl

@@ -0,0 +1,125 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 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).
+-elvis([{elvis_style, atom_naming_convention, #{regex => "^([a-z][a-z0-9A-Z]*_?)*(_SUITE)?$"}}]).
+
+-export([
+    make_tls_root_fun/2,
+    make_tls_verify_fun/2
+]).
+
+-include_lib("public_key/include/public_key.hrl").
+%% @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.
+
+make_tls_verify_fun(verify_cert_extKeyUsage, KeyUsages) ->
+    RequiredKeyUsages = ext_key_opts(KeyUsages),
+    {fun verify_fun_peer_extKeyUsage/3, RequiredKeyUsages}.
+
+verify_fun_peer_extKeyUsage(_, {bad_cert, invalid_ext_key_usage}, UserState) ->
+    %% !! Override OTP verify peer default
+    %% OTP SSL is unhappy with the ext_key_usage but we will check on our own.
+    {unknown, UserState};
+verify_fun_peer_extKeyUsage(_, {bad_cert, _} = Reason, _UserState) ->
+    %% OTP verify_peer default
+    {fail, Reason};
+verify_fun_peer_extKeyUsage(_, {extension, _}, UserState) ->
+    %% OTP verify_peer default
+    {unknown, UserState};
+verify_fun_peer_extKeyUsage(_, valid, UserState) ->
+    %% OTP verify_peer default
+    {valid, UserState};
+verify_fun_peer_extKeyUsage(
+    #'OTPCertificate'{tbsCertificate = #'OTPTBSCertificate'{extensions = ExtL}},
+    %% valid peer cert
+    valid_peer,
+    RequiredKeyUsages
+) ->
+    %% override OTP verify_peer default
+    %% must have id-ce-extKeyUsage
+    case lists:keyfind(?'id-ce-extKeyUsage', 2, ExtL) of
+        #'Extension'{extnID = ?'id-ce-extKeyUsage', extnValue = VL} ->
+            case do_verify_ext_key_usage(VL, RequiredKeyUsages) of
+                true ->
+                    %% pass the check,
+                    %% fallback to OTP verify_peer default
+                    {valid, RequiredKeyUsages};
+                false ->
+                    {fail, extKeyUsage_unmatched}
+            end;
+        _ ->
+            {fail, extKeyUsage_not_set}
+    end.
+
+%% @doc check required extkeyUsages are presented in the cert
+do_verify_ext_key_usage(_, []) ->
+    %% Verify finished
+    true;
+do_verify_ext_key_usage(CertExtL, [Usage | T] = _Required) ->
+    case lists:member(Usage, CertExtL) of
+        true ->
+            do_verify_ext_key_usage(CertExtL, T);
+        false ->
+            false
+    end.
+
+%% @doc Helper tls cert extension
+-spec ext_key_opts(string()) -> [OidString :: string() | public_key:oid()].
+ext_key_opts(Str) ->
+    Usages = string:tokens(Str, ","),
+    lists:map(
+        fun
+            ("clientAuth") ->
+                ?'id-kp-clientAuth';
+            ("serverAuth") ->
+                ?'id-kp-serverAuth';
+            ("codeSigning") ->
+                ?'id-kp-codeSigning';
+            ("emailProtection") ->
+                ?'id-kp-emailProtection';
+            ("timeStamping") ->
+                ?'id-kp-timeStamping';
+            ("ocspSigning") ->
+                ?'id-kp-OCSPSigning';
+            ("OID:" ++ OidStr) ->
+                OidList = string:tokens(OidStr, "."),
+                list_to_tuple(lists:map(fun list_to_integer/1, OidList))
+        end,
+        Usages
+    ).

+ 25 - 3
apps/emqx/src/emqx_listeners.erl

@@ -610,7 +610,9 @@ esockd_opts(ListenerId, Type, Name, Opts0) ->
             ssl ->
                 OptsWithCRL = inject_crl_config(Opts0),
                 OptsWithSNI = inject_sni_fun(ListenerId, OptsWithCRL),
-                SSLOpts = ssl_opts(OptsWithSNI),
+                OptsWithRootFun = inject_root_fun(OptsWithSNI),
+                OptsWithVerifyFun = inject_verify_fun(OptsWithRootFun),
+                SSLOpts = ssl_opts(OptsWithVerifyFun),
                 Opts3#{ssl_options => SSLOpts, tcp_options => tcp_opts(Opts0)}
         end
     ).
@@ -634,8 +636,18 @@ ranch_opts(Type, Opts = #{bind := ListenOn}) ->
     MaxConnections = maps:get(max_connections, Opts, 1024),
     SocketOpts =
         case Type of
-            wss -> tcp_opts(Opts) ++ proplists:delete(handshake_timeout, ssl_opts(Opts));
-            ws -> tcp_opts(Opts)
+            wss ->
+                tcp_opts(Opts) ++
+                    lists:filter(
+                        fun
+                            ({partial_chain, _}) -> false;
+                            ({handshake_timeout, _}) -> false;
+                            (_) -> true
+                        end,
+                        ssl_opts(Opts)
+                    );
+            ws ->
+                tcp_opts(Opts)
         end,
     #{
         num_acceptors => NumAcceptors,
@@ -956,6 +968,16 @@ quic_listener_optional_settings() ->
         stateless_operation_expiration_ms
     ].
 
+inject_root_fun(#{ssl_options := SslOpts} = Opts) ->
+    Opts#{ssl_options := emqx_tls_lib:opt_partial_chain(SslOpts)};
+inject_root_fun(Opts) ->
+    Opts.
+
+inject_verify_fun(#{ssl_options := SslOpts} = Opts) ->
+    Opts#{ssl_options := emqx_tls_lib:opt_verify_fun(SslOpts)};
+inject_verify_fun(Opts) ->
+    Opts.
+
 inject_sni_fun(ListenerId, Conf = #{ssl_options := #{ocsp := #{enable_ocsp_stapling := true}}}) ->
     emqx_ocsp_cache:inject_sni_fun(ListenerId, Conf);
 inject_sni_fun(_ListenerId, Conf) ->

+ 16 - 0
apps/emqx/src/emqx_schema.erl

@@ -2118,6 +2118,22 @@ common_ssl_opts_schema(Defaults, Type) ->
                     desc => ?DESC(common_ssl_opts_schema_verify)
                 }
             )},
+        {"partial_chain",
+            sc(
+                hoconsc:enum([true, false, two_cacerts_from_cacertfile, cacert_from_cacertfile]),
+                #{
+                    default => Df(partial_chain, false),
+                    desc => ?DESC(common_ssl_opts_schema_partial_chain)
+                }
+            )},
+        {"verify_peer_ext_key_usage",
+            sc(
+                string(),
+                #{
+                    required => false,
+                    desc => ?DESC(common_ssl_opts_verify_peer_ext_key_usage)
+                }
+            )},
         {"reuse_sessions",
             sc(
                 boolean(),

+ 55 - 0
apps/emqx/src/emqx_tls_lib.erl

@@ -15,6 +15,7 @@
 %%--------------------------------------------------------------------
 
 -module(emqx_tls_lib).
+-elvis([{elvis_style, atom_naming_convention, #{regex => "^([a-z][a-z0-9A-Z]*_?)*(_SUITE)?$"}}]).
 
 %% version & cipher suites
 -export([
@@ -23,6 +24,8 @@
     default_ciphers/0,
     selected_ciphers/1,
     integral_ciphers/2,
+    opt_partial_chain/1,
+    opt_verify_fun/1,
     all_ciphers_set_cached/0
 ]).
 
@@ -679,3 +682,55 @@ ensure_ssl_file_key(SSL, RequiredKeyPaths) ->
         [] -> ok;
         Miss -> {error, #{reason => ssl_file_option_not_found, which_options => Miss}}
     end.
+
+%% @doc enable TLS partial_chain validation if set.
+-spec opt_partial_chain(SslOpts :: map()) -> NewSslOpts :: map().
+opt_partial_chain(#{partial_chain := false} = SslOpts) ->
+    maps:remove(partial_chain, SslOpts);
+opt_partial_chain(#{partial_chain := true} = SslOpts) ->
+    SslOpts#{partial_chain := rootfun_trusted_ca_from_cacertfile(1, SslOpts)};
+opt_partial_chain(#{partial_chain := cacert_from_cacertfile} = SslOpts) ->
+    SslOpts#{partial_chain := rootfun_trusted_ca_from_cacertfile(1, SslOpts)};
+opt_partial_chain(#{partial_chain := two_cacerts_from_cacertfile} = SslOpts) ->
+    SslOpts#{partial_chain := rootfun_trusted_ca_from_cacertfile(2, SslOpts)};
+opt_partial_chain(SslOpts) ->
+    SslOpts.
+
+%% @doc make verify_fun if set.
+-spec opt_verify_fun(SslOpts :: map()) -> NewSslOpts :: map().
+opt_verify_fun(#{verify_peer_ext_key_usage := V} = SslOpts) when V =/= undefined ->
+    SslOpts#{verify_fun => emqx_const_v2:make_tls_verify_fun(verify_cert_extKeyUsage, V)};
+opt_verify_fun(SslOpts) ->
+    SslOpts.
+
+%% @doc Helper, make TLS root_fun
+rootfun_trusted_ca_from_cacertfile(NumOfCerts, #{cacertfile := Cacertfile}) ->
+    case file:read_file(Cacertfile) of
+        {ok, PemBin} ->
+            try
+                do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, PemBin)
+            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..
+                    ?SLOG(error, #{
+                        msg => "trusted_cacert_not_found_in_cacertfile", stacktrace => ST
+                    }),
+                    throw({error, ?FUNCTION_NAME})
+            end;
+        {error, Reason} ->
+            throw({error, {read_cacertfile_error, Cacertfile, Reason}})
+    end;
+rootfun_trusted_ca_from_cacertfile(_NumOfCerts, _SslOpts) ->
+    throw({error, cacertfile_unset}).
+
+do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, PemBin) ->
+    %% 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).

+ 257 - 0
apps/emqx/test/emqx_listener_tls_verify_chain_SUITE.erl

@@ -0,0 +1,257 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 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_chain_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("common_test/include/ct.hrl").
+
+-import(
+    emqx_test_tls_certs_helper,
+    [
+        emqx_start_listener/4,
+        fail_when_ssl_error/1,
+        fail_when_no_ssl_alert/2,
+        generate_tls_certs/1
+    ]
+).
+
+all() -> emqx_common_test_helpers:all(?MODULE).
+
+init_per_suite(Config) ->
+    generate_tls_certs(Config),
+    application:ensure_all_started(esockd),
+    [{ssl_config, ssl_config_verify_peer()} | Config].
+
+end_per_suite(_Config) ->
+    application:stop(esockd).
+
+t_conn_fail_with_intermediate_ca_cert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+            {certfile, filename:join(DataDir, "server1.pem")},
+            {keyfile, filename:join(DataDir, "server1.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+
+    fail_when_no_ssl_alert(Socket, unknown_ca),
+    ok = ssl:close(Socket).
+
+t_conn_fail_with_other_intermediate_ca_cert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+            {certfile, filename:join(DataDir, "server1.pem")},
+            {keyfile, filename:join(DataDir, "server1.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+
+    fail_when_no_ssl_alert(Socket, unknown_ca),
+    ok = ssl:close(Socket).
+
+t_conn_success_with_server_client_composed_complete_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Server has root ca cert
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "root.pem")},
+            {certfile, filename:join(DataDir, "server2.pem")},
+            {keyfile, filename:join(DataDir, "server2.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    %% Client has complete chain
+    emqx_start_listener(?FUNCTION_NAME, 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")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_with_other_signed_client_composed_complete_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Server has root ca cert
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "root.pem")},
+            {certfile, filename:join(DataDir, "server1.pem")},
+            {keyfile, filename:join(DataDir, "server1.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    %% Client has partial_chain
+    emqx_start_listener(?FUNCTION_NAME, 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")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_with_renewed_intermediate_root_bundle(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Server has root ca cert
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "intermediate1_renewed-root-bundle.pem")},
+            {certfile, filename:join(DataDir, "server1.pem")},
+            {keyfile, filename:join(DataDir, "server1.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_with_client_complete_cert_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "root.pem")},
+            {certfile, filename:join(DataDir, "server2.pem")},
+            {keyfile, filename:join(DataDir, "server2.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_fail_with_server_partial_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% imcomplete at server side
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "intermediate2.pem")},
+            {certfile, filename:join(DataDir, "server2.pem")},
+            {keyfile, filename:join(DataDir, "server2.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-complete-bundle.pem")},
+            {versions, ['tlsv1.2']},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_without_root_cacert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options, [
+            {cacertfile, filename:join(DataDir, "intermediate2.pem")},
+            {certfile, filename:join(DataDir, "server2.pem")},
+            {keyfile, filename:join(DataDir, "server2.key")}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-intermediate2-bundle.pem")},
+            %% stick to tlsv1.2 for consistent error message
+            {versions, ['tlsv1.2']},
+            {cacertfile, filename:join(DataDir, "intermediate2.pem")}
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+ssl_config_verify_peer() ->
+    [
+        {verify, verify_peer},
+        {fail_if_no_peer_cert, true}
+    ].

+ 372 - 0
apps/emqx/test/emqx_listener_tls_verify_keyusage_SUITE.erl

@@ -0,0 +1,372 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 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_keyusage_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("common_test/include/ct.hrl").
+
+-import(
+    emqx_test_tls_certs_helper,
+    [
+        fail_when_ssl_error/1,
+        fail_when_no_ssl_alert/2,
+        generate_tls_certs/1,
+        gen_host_cert/4,
+        emqx_start_listener/4
+    ]
+).
+
+all() ->
+    [
+        {group, full_chain},
+        {group, partial_chain}
+    ].
+
+all_tc() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+groups() ->
+    [
+        {partial_chain, [], all_tc()},
+        {full_chain, [], all_tc()}
+    ].
+
+init_per_suite(Config) ->
+    generate_tls_certs(Config),
+    application:ensure_all_started(esockd),
+    Config.
+
+end_per_suite(_Config) ->
+    application:stop(esockd).
+
+init_per_group(full_chain, Config) ->
+    [{ssl_config, ssl_config_verify_peer_full_chain(Config)} | Config];
+init_per_group(partial_chain, Config) ->
+    [{ssl_config, ssl_config_verify_peer_partial_chain(Config)} | Config];
+init_per_group(_, Config) ->
+    Config.
+
+end_per_group(_, Config) ->
+    Config.
+
+t_conn_success_verify_peer_ext_key_usage_unset(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Given listener keyusage unset
+    Options = [{ssl_options, ?config(ssl_config, Config)}],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% when client connect with cert without keyusage ext
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_verify_peer_ext_key_usage_undefined(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is set to undefined
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, undefined}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% when client connect with cert without keyusages ext
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_verify_peer_ext_key_usage_matched_predefined(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is set to clientAuth
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "clientAuth"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+
+    %% When client cert has clientAuth that is matched
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "clientAuth"),
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_verify_peer_ext_key_usage_matched_raw_oid(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is set to raw OID
+
+    %% from OTP-PUB-KEY.hrl
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "OID:1.3.6.1.5.5.7.3.2"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client cert has keyusage and matched.
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "clientAuth"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_verify_peer_ext_key_usage_matched_ordered_list(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+
+    %% Give listener keyusage is clientAuth,serverAuth
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "clientAuth,serverAuth"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client cert has the same keyusage ext list
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "clientAuth,serverAuth"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_verify_peer_ext_key_usage_matched_unordered_list(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is clientAuth,serverAuth
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "serverAuth,clientAuth"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client cert has the same keyusage ext list but different order
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "clientAuth,serverAuth"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection success
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_fail_verify_peer_ext_key_usage_unmatched_raw_oid(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is using OID
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "OID:1.3.6.1.5.5.7.3.1"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+
+    %% When client cert has the keyusage but not matching OID
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "clientAuth"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+
+    %% Then connecion should fail.
+    fail_when_no_ssl_alert(Socket, handshake_failure),
+    ok = ssl:close(Socket).
+
+t_conn_fail_verify_peer_ext_key_usage_empty_str(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, ""}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    %% Give listener keyusage is empty string
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client connect with cert without keyusage
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connecion should fail.
+    fail_when_no_ssl_alert(Socket, handshake_failure),
+    ok = ssl:close(Socket).
+
+t_conn_fail_client_keyusage_unmatch(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+
+    %% Give listener keyusage is clientAuth
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage, "clientAuth"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client connect with mismatch cert keyusage = codeSigning
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "codeSigning"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, client_key_file(DataDir, ?FUNCTION_NAME)},
+            {certfile, client_pem_file(DataDir, ?FUNCTION_NAME)},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connecion should fail.
+    fail_when_no_ssl_alert(Socket, handshake_failure),
+    ok = ssl:close(Socket).
+
+t_conn_fail_client_keyusage_incomplete(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% Give listener keyusage is codeSigning,clientAuth
+    Options = [
+        {ssl_options, [
+            {verify_peer_ext_key_usage,
+                "serverAuth,clientAuth,codeSigning,emailProtection,timeStamping,ocspSigning"}
+            | ?config(ssl_config, Config)
+        ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    %% When client connect with cert keyusage = clientAuth
+    gen_client_cert_ext_keyusage(?FUNCTION_NAME, "intermediate1", DataDir, "codeSigning"),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")},
+            {verify, verify_none}
+        ],
+        1000
+    ),
+    %% Then connection should fail
+    fail_when_no_ssl_alert(Socket, handshake_failure),
+    ok = ssl:close(Socket).
+
+%%%
+%%% Helpers
+%%%
+gen_client_cert_ext_keyusage(Name, CA, DataDir, Usage) when is_atom(Name) ->
+    gen_client_cert_ext_keyusage(atom_to_list(Name), CA, DataDir, Usage);
+gen_client_cert_ext_keyusage(Name, CA, DataDir, Usage) ->
+    gen_host_cert(Name, CA, DataDir, #{ext => "extendedKeyUsage=" ++ Usage}).
+
+client_key_file(DataDir, Name) ->
+    filename:join(DataDir, Name) ++ ".key".
+
+client_pem_file(DataDir, Name) ->
+    filename:join(DataDir, Name) ++ ".pem".
+
+ssl_config_verify_peer_full_chain(Config) ->
+    [
+        {cacertfile, filename:join(?config(data_dir, Config), "intermediate1-root-bundle.pem")}
+        | ssl_config_verify_peer(Config)
+    ].
+ssl_config_verify_peer_partial_chain(Config) ->
+    [
+        {cacertfile, filename:join(?config(data_dir, Config), "intermediate1.pem")},
+        {partial_chain, true}
+        | ssl_config_verify_peer(Config)
+    ].
+
+ssl_config_verify_peer(Config) ->
+    DataDir = ?config(data_dir, Config),
+    [
+        {verify, verify_peer},
+        {fail_if_no_peer_cert, true},
+        {keyfile, filename:join(DataDir, "server1.key")},
+        {certfile, filename:join(DataDir, "server1.pem")}
+        %% , {log_level, debug}
+    ].

+ 708 - 0
apps/emqx/test/emqx_listener_tls_verify_partial_chain_SUITE.erl

@@ -0,0 +1,708 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 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_partial_chain_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("common_test/include/ct.hrl").
+
+-import(
+    emqx_test_tls_certs_helper,
+    [
+        emqx_start_listener/4,
+        fail_when_ssl_error/1,
+        fail_when_no_ssl_alert/2,
+        generate_tls_certs/1
+    ]
+).
+
+all() -> emqx_common_test_helpers:all(?MODULE).
+
+init_per_suite(Config) ->
+    generate_tls_certs(Config),
+    application:ensure_all_started(esockd),
+    [{ssl_config, ssl_config_verify_partial_chain()} | Config].
+
+end_per_suite(_Config) ->
+    application:stop(esockd).
+
+t_conn_success_with_server_intermediate_cacert_and_client_cert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ssl:close(Socket).
+
+t_conn_success_with_intermediate_cacert_bundle(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "server1-intermediate1-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ssl:close(Socket).
+
+t_conn_success_with_renewed_intermediate_cacert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1_renewed.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ssl:close(Socket).
+
+t_conn_fail_with_renewed_intermediate_cacert_and_client_using_old_complete_bundle(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2_renewed.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-complete-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_with_renewed_intermediate_cacert_and_client_using_old_bundle(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2_renewed.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-intermediate2-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_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,
+            ?config(ssl_config, Config) ++
+                [
+                    {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}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        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,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2_renewed.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        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,
+            ?config(ssl_config, Config) ++
+                [
+                    {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}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2.pem")}
+            | client_default_tls_opts()
+        ],
+        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),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-intermediate2-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+%% @doc verify when config (two_cacerts_from_cacertfile) allows two versions of certs from same trusted CA.
+t_001_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,
+            ?config(ssl_config, Config) ++
+                [
+                    {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}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        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_conn_fail_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,
+            ?config(ssl_config, Config) ++
+                [
+                    {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}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "all-CAcerts-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+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),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1_renewed.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_with_intermediate_cacert_bundle_but_incorrect_order(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1-server1-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_when_singed_by_other_intermediate_ca(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_success_with_complete_chain_that_server_root_cacert_and_client_complete_cert_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "root.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_fail_with_other_client_complete_cert_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-complete-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_with_server_intermediate_and_other_client_complete_cert_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1-root-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_success_with_server_intermediate_cacert_and_client_complete_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, 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")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_fail_with_server_intermediate_chain_and_client_other_incomplete_cert_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-intermediate2-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_with_server_intermediate_and_other_client_root_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-root-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_success_with_server_intermediate_and_client_root_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate2.pem")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-root-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+%% @doc once rootCA cert present in cacertfile, sibling CA signed Client cert could connect.
+t_conn_success_with_server_all_CA_bundle_and_client_root_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "all-CAcerts-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    {ok, Socket} = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-root-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_ssl_error(Socket),
+    ok = ssl:close(Socket).
+
+t_conn_fail_with_server_two_IA_bundle_and_client_root_chain(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "two-intermediates-bundle.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client2.key")},
+            {certfile, filename:join(DataDir, "client2-root-bundle.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_conn_fail_with_server_partial_chain_false_intermediate_cacert_and_client_cert(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "intermediate1.pem")},
+                    {certfile, filename:join(DataDir, "server1.pem")},
+                    {keyfile, filename:join(DataDir, "server1.key")},
+                    {partial_chain, false}
+                ]}
+    ],
+    emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options),
+    Res = ssl:connect(
+        {127, 0, 0, 1},
+        Port,
+        [
+            {keyfile, filename:join(DataDir, "client1.key")},
+            {certfile, filename:join(DataDir, "client1.pem")}
+            | client_default_tls_opts()
+        ],
+        1000
+    ),
+    fail_when_no_ssl_alert(Res, unknown_ca).
+
+t_error_handling_invalid_cacertfile(Config) ->
+    Port = emqx_test_tls_certs_helper:select_free_port(ssl),
+    DataDir = ?config(data_dir, Config),
+    %% trigger error
+    Options = [
+        {ssl_options,
+            ?config(ssl_config, Config) ++
+                [
+                    {cacertfile, filename:join(DataDir, "server2.key")},
+                    {certfile, filename:join(DataDir, "server2.pem")},
+                    {keyfile, filename:join(DataDir, "server2.key")}
+                ]}
+    ],
+    ?assertException(
+        throw,
+        {error, rootfun_trusted_ca_from_cacertfile},
+        emqx_start_listener(?FUNCTION_NAME, ssl, Port, Options)
+    ).
+
+ssl_config_verify_partial_chain() ->
+    [
+        {verify, verify_peer},
+        {fail_if_no_peer_cert, true},
+        {partial_chain, true}
+    ].
+
+client_default_tls_opts() ->
+    [
+        {versions, ['tlsv1.2']},
+        {verify, verify_none}
+    ].

+ 319 - 0
apps/emqx/test/emqx_test_tls_certs_helper.erl

@@ -0,0 +1,319 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 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,
+    generate_tls_certs/1,
+
+    fail_when_ssl_error/1,
+    fail_when_ssl_error/2,
+    fail_when_no_ssl_alert/2,
+    fail_when_no_ssl_alert/3,
+
+    emqx_start_listener/4
+]).
+
+-include_lib("common_test/include/ct.hrl").
+
+%%-------------------------------------------------------------------------------
+%% Start Listener
+%%-------------------------------------------------------------------------------
+emqx_start_listener(Name, Type, Port, Opts) when is_list(Opts) ->
+    emqx_start_listener(Name, Type, Port, maps:from_list(Opts));
+emqx_start_listener(Name, ssl, Port, #{ssl_options := SslOptions} = Opts0) ->
+    Opts = Opts0#{
+        enable => true,
+        bind => {{127, 0, 0, 1}, Port},
+        mountpoint => <<>>,
+        zone => default,
+        ssl_options => maps:from_list(SslOptions)
+    },
+    ct:pal("start listener with ~p ~p", [Name, Opts]),
+    emqx_listeners:start_listener(ssl, Name, Opts).
+
+%%-------------------------------------------------------------------------------
+%% 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=TEST 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]),
+    HCSR2 = filename(Path, "~s.csr", [H]),
+    HPEM = filename(Path, "~s.pem", [H]),
+    HPEM2 = filename(Path, "~s_renewed.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,
+
+    create_file(
+        HEXT,
+        "keyUsage=digitalSignature,keyAgreement,keyCertSign\n"
+        "basicConstraints=CA:TRUE \n"
+        "~s \n"
+        "subjectAltName=DNS:~s\n",
+        [maps:get(ext, Opts, ""), CN]
+    ),
+
+    CSR_Cmd = csr_cmd(PasswordArg, ECKeyFile, HKey, HCSR, CN),
+    CSR_Cmd2 = csr_cmd(PasswordArg, ECKeyFile, HKey, HCSR2, CN),
+
+    CERT_Cmd = cert_sign_cmd(
+        HEXT, HCSR, ca_cert_name(Path, CaName), ca_key_name(Path, CaName), HPEM
+    ),
+    %% 2nd cert for testing renewed cert.
+    CERT_Cmd2 = cert_sign_cmd(
+        HEXT, HCSR2, ca_cert_name(Path, CaName), ca_key_name(Path, CaName), HPEM2
+    ),
+    ct:pal(os:cmd(CSR_Cmd)),
+    ct:pal(os:cmd(CSR_Cmd2)),
+    ct:pal(os:cmd(CERT_Cmd)),
+    ct:pal(os:cmd(CERT_Cmd2)),
+    file:delete(HEXT).
+
+cert_sign_cmd(ExtFile, CSRFile, CACert, CAKey, OutputCert) ->
+    lists:flatten(
+        io_lib:format(
+            "openssl x509 -req "
+            "-extfile ~s "
+            "-in ~s -CA ~s -CAkey ~s -CAcreateserial "
+            "-out ~s -days 500",
+            [
+                ExtFile,
+                CSRFile,
+                CACert,
+                CAKey,
+                OutputCert
+            ]
+        )
+    ).
+
+csr_cmd(PasswordArg, ECKeyFile, HKey, HCSR, CN) ->
+    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=TEST/CN=~s\"",
+            [PasswordArg, ECKeyFile, HKey, HCSR, CN, CN]
+        )
+    ).
+
+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.
+
+%% @doc fail the test if ssl_error recvd
+%%      post check for success conn establishment
+fail_when_ssl_error(Socket) ->
+    fail_when_ssl_error(Socket, 1000).
+fail_when_ssl_error(Socket, Timeout) ->
+    receive
+        {ssl_error, Socket, _} ->
+            ct:fail("Handshake failed!")
+    after Timeout ->
+        ok
+    end.
+
+%% @doc fail the test if no ssl_error
+fail_when_no_ssl_alert(Res, Alert) ->
+    fail_when_no_ssl_alert(Res, Alert, 1000).
+
+fail_when_no_ssl_alert({error, {tls_alert, {Alert, _}}}, Alert, _Timeout) ->
+    ok;
+fail_when_no_ssl_alert({error, _} = Other, Alert, _Timeout) ->
+    ct:fail("returned unexpected ssl_error: ~p, expected ~n", [Other, Alert]);
+fail_when_no_ssl_alert({ok, Socket}, Alert, Timeout) ->
+    fail_when_no_ssl_alert(Socket, Alert, Timeout);
+fail_when_no_ssl_alert(Socket, Alert, Timeout) ->
+    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 Timeout ->
+        ct:fail("No expected alert: ~p from Socket: ~p ", [Alert, Socket])
+    end.
+
+%% @doc Generate TLS cert chain for tests
+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),
+
+    %% 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"),
+            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, "client2.pem"),
+            filename:join(DataDir, "root.pem"),
+            filename:join(DataDir, "client2-root-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")
+        ])
+    ),
+    os:cmd(
+        io_lib:format("cat ~p ~p > ~p", [
+            filename:join(DataDir, "intermediate1_renewed.pem"),
+            filename:join(DataDir, "root.pem"),
+            filename:join(DataDir, "intermediate1_renewed-root-bundle.pem")
+        ])
+    ),
+    os:cmd(
+        io_lib:format("cat ~p ~p > ~p", [
+            filename:join(DataDir, "intermediate2.pem"),
+            filename:join(DataDir, "intermediate2_renewed.pem"),
+            filename:join(DataDir, "intermediate2_renewed_old-bundle.pem")
+        ])
+    ),
+    os:cmd(
+        io_lib:format("cat ~p ~p > ~p", [
+            filename:join(DataDir, "intermediate1.pem"),
+            filename:join(DataDir, "root.pem"),
+            filename:join(DataDir, "intermediate1-root-bundle.pem")
+        ])
+    ),
+    os:cmd(
+        io_lib:format("cat ~p ~p ~p > ~p", [
+            filename:join(DataDir, "root.pem"),
+            filename:join(DataDir, "intermediate2.pem"),
+            filename:join(DataDir, "intermediate1.pem"),
+            filename:join(DataDir, "all-CAcerts-bundle.pem")
+        ])
+    ),
+    os:cmd(
+        io_lib:format("cat ~p ~p > ~p", [
+            filename:join(DataDir, "intermediate2.pem"),
+            filename:join(DataDir, "intermediate1.pem"),
+            filename:join(DataDir, "two-intermediates-bundle.pem")
+        ])
+    ).

+ 8 - 0
apps/emqx_gateway/src/emqx_gateway_utils.erl

@@ -559,6 +559,8 @@ ssl_opts(Name, Opts) ->
         [
             fun ssl_opts_crl_config/2,
             fun ssl_opts_drop_unsupported/2,
+            fun ssl_partial_chain/2,
+            fun ssl_verify_fun/2,
             fun ssl_server_opts/2
         ],
         SSLOpts,
@@ -586,6 +588,12 @@ ssl_server_opts(SSLOpts, ssl_options) ->
 ssl_server_opts(SSLOpts, dtls_options) ->
     emqx_tls_lib:to_server_opts(dtls, SSLOpts).
 
+ssl_partial_chain(SSLOpts, _Options) ->
+    emqx_tls_lib:opt_partial_chain(SSLOpts).
+
+ssl_verify_fun(SSLOpts, _Options) ->
+    emqx_tls_lib:opt_verify_fun(SSLOpts).
+
 ranch_opts(Type, ListenOn, Opts) ->
     NumAcceptors = maps:get(acceptors, Opts, 4),
     MaxConnections = maps:get(max_connections, Opts, 1024),

+ 1 - 0
apps/emqx_management/test/emqx_mgmt_api_configs_SUITE.erl

@@ -469,6 +469,7 @@ t_create_webhook_v1_bridges_api(Config) ->
                                     <<"enable">> => true,
                                     <<"hibernate_after">> => <<"5s">>,
                                     <<"log_level">> => <<"notice">>,
+                                    <<"partial_chain">> => false,
                                     <<"reuse_sessions">> => true,
                                     <<"secure_renegotiate">> => true,
                                     <<"user_lookup_fun">> =>

+ 5 - 0
changes/ce/feat-11721.en.md

@@ -0,0 +1,5 @@
+Port two TLS handshake validation features from emqx 4.4
+
+- partial_chain support
+- Certificate KeyUsage Validation
+

+ 12 - 0
rel/i18n/emqx_schema.hocon

@@ -684,6 +684,18 @@ common_ssl_opts_schema_verify.desc:
 common_ssl_opts_schema_verify.label:
 """Verify peer"""
 
+common_ssl_opts_schema_partial_chain.desc:
+"""Enable or disable peer verification with partial_chain"""
+
+common_ssl_opts_schema_partial_chain.label:
+"""Partial chain"""
+
+common_ssl_opts_verify_peer_ext_key_usage.desc:
+"""Verify Extended Key Usage in Peer's certificate"""
+
+common_ssl_opts_verify_peer_ext_key_usage.label:
+"""Verify KeyUsage in cert"""
+
 fields_listeners_ssl.desc:
 """SSL listeners."""