Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v4.4: feat(listener): TLS partial_chain validation #10553

Merged
Merged
4 changes: 4 additions & 0 deletions changes/v4.4.18-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@
The parameters of certain actions support using placeholder syntax to dynamically fill in the content of strings. The format of the placeholder syntax is `${key}`.
Prior to the improvement, the `key` in `${key}` could only contain letters, numbers, and underscores. Now the `key` supports any UTF8 character after the improvement.

- Adds a new feature to enable partial certificate chain validation for TLS listeners[#10553](https://github.com/emqx/emqx/pull/10553).
If partial_chain is set to 'true', the last certificate in cacertfile is treated as the terminal of the certificate trust-chain. That is, the TLS handshake does not require full trust-chain, and EMQX will not try to validate the chain all the way up to the root CA.

## Bug fixes

4 changes: 4 additions & 0 deletions changes/v4.4.18-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
某些动作的参数支持使用占位符语法,来动态的填充字符串的内容,占位符语法的格式为 `${key}`。
改进前,`${key}` 中的 `key` 只能包含字母、数字和下划线。改进后 `key` 支持任意的 UTF8 字符了。

- 增加了一个新的功能,为TLS监听器启用部分证书链验证[#10553](https://github.com/emqx/emqx/pull/10553)。
如果 partial_chain 设置为“true”,cacertfile 中的最后一个证书将被视为证书信任链的顶端证书。 也就是说,TLS 握手不需要完整的链,并且 EMQX 不会尝试一直验证链直到根 CA。
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

最后一个证书 ->
最后一个或两个证书



## 修复

5 changes: 5 additions & 0 deletions priv/emqx.schema
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,10 @@ end}.
{datatype, atom}
]}.

{mapping, "listener.ssl.$name.partial_chain", "emqx.listeners", [
{datatype, atom}
Copy link
Member

@HJianBo HJianBo May 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{datatype, {enum, [true, two_cacerts_from_cacertfile, cacert_from_cacertfile]}}

]}.

{mapping, "listener.ssl.$name.fail_if_no_peer_cert", "emqx.listeners", [
{datatype, {enum, [true, false]}}
]}.
Expand Down Expand Up @@ -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)},
Expand Down
149 changes: 111 additions & 38 deletions src/emqx.appup.src

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions src/emqx_const_v2.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
%%--------------------------------------------------------------------
%% 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
]).

%% @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.
3 changes: 2 additions & 1 deletion src/emqx_listeners.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
49 changes: 47 additions & 2 deletions src/emqx_tls_lib.erl
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@
, default_ciphers/1
, integral_ciphers/2
, drop_tls13_for_old_otp/1
, inject_root_fun/1
, opt_partial_chain/1
]).

-include("logger.hrl").

%% non-empty string
-define(IS_STRING(L), (is_list(L) andalso L =/= [] andalso is_integer(hd(L)))).
%% non-empty list of strings
Expand Down Expand Up @@ -170,8 +174,49 @@ 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;
false ->
SslOpts;
V when V =:= cacert_from_cacertfile orelse V == true ->
replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(1, SslOpts));
V when V =:= two_cacerts_from_cacertfile -> %% for certificate rotations
replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(2, SslOpts))
end.

replace(Opts, Key, Value) -> [{Key, Value} | proplists:delete(Key, Opts)].

%% @doc Helper, make TLS root_fun
rootfun_trusted_ca_from_cacertfile(NumOfCerts, SslOpts) ->
Cacertfile = proplists:get_value(cacertfile, SslOpts, undefined),
try do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, Cacertfile)
catch _Error:_Info:ST ->
%% The cacertfile will be checked by OTP SSL as well and OTP choice to be silent on this.
%% We are touching security sutffs, don't leak extra info..
?LOG(error, "Failed to look for trusted cacert from cacertfile. Stacktrace: ~p", [ST]),
throw({error, ?FUNCTION_NAME})
end.
do_rootfun_trusted_ca_from_cacertfile(NumOfCerts, Cacertfile) ->
{ok, PemBin} = file:read_file(Cacertfile),
%% The last one or two should be the top parent in the chain if it is a chain
Certs = public_key:pem_decode(PemBin),
Pos = length(Certs) - NumOfCerts + 1,
Trusted = [ CADer || {'Certificate', CADer, _} <-
lists:sublist(public_key:pem_decode(PemBin), Pos, NumOfCerts)],
emqx_const_v2:make_tls_root_fun(cacert_from_cacertfile, Trusted).

-if(?OTP_RELEASE > 22).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
Expand All @@ -194,5 +239,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
173 changes: 173 additions & 0 deletions test/emqx_listener_tls_verify_chain_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
%%--------------------------------------------------------------------
%% 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_chain_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, [ fail_when_ssl_error/1
, fail_when_no_ssl_alert/2
, generate_tls_certs/1
]).


all() -> emqx_ct: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_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_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_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_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_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_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_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_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_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),
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_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_conn_fail_with_server_partial_chain(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")} %% imcomplete at server side
, {certfile, filename:join(DataDir, "server2.pem")}
, {keyfile, filename:join(DataDir, "server2.key")}
| ?config(ssl_config, Config)
]}],
emqx_listeners:start_listener(ssl, Port, Options),
{ok, Socket} = ssl:connect({127, 0, 0, 1}, Port, [{keyfile, filename:join(DataDir, "client2.key")},
{certfile, filename:join(DataDir, "client2-complete-bundle.pem")}
], 1000),
fail_when_no_ssl_alert(Socket, unknown_ca),
ok = ssl:close(Socket).

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_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).

ssl_config_verify_peer() ->
[ {verify, verify_peer}
, {fail_if_no_peer_cert, true}
].