Merge pull request #10669 from qzhuyan/dev/william/tls-verify-keyusages

feat(TLS): veriy client cert keyusage
This commit is contained in:
William Yang 2023-05-17 10:59:36 +02:00 committed by GitHub
commit 4a476be5b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 372 additions and 2 deletions

View File

@ -20,6 +20,8 @@
- 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.
- Adds a new feature to enable client certificate extended key usage validation for TLS listeners[#10669](https://github.com/emqx/emqx/pull/10669).
## Bug fixes
- Fixed an issue where the rule engine was unable to access variables exported by `FOREACH` in the `DO` clause [#10620](https://github.com/emqx/emqx/pull/10620).

View File

@ -20,6 +20,8 @@
- 增加了一个新的功能,为 TLS 监听器启用部分证书链验证[#10553](https://github.com/emqx/emqx/pull/10553)。
如果 partial_chain 设置为“true”cacertfile 中的最后一个证书将被视为证书信任链的顶端证书。 也就是说TLS 握手不需要完整的链,并且 EMQX 不会尝试一直验证链直到根 CA。
- 增加了一个新功能,为 TLS 监听器启用客户端证书扩展密钥使用验证 [#10669](https://github.com/emqx/emqx/pull/10669)。
## 修复
- 修复规则引擎无法在 `DO` 子句中访问 `FOREACH` 导出的变量的问题 [#10620](https://github.com/emqx/emqx/pull/10620)。

View File

@ -1650,6 +1650,11 @@ end}.
{datatype, atom}
]}.
{mapping, "listener.ssl.$name.verify_peer_ext_key_usage", "emqx.listeners", [
{datatype, string},
{default, undefined}
]}.
{mapping, "listener.ssl.$name.fail_if_no_peer_cert", "emqx.listeners", [
{datatype, {enum, [true, false]}}
]}.
@ -2382,6 +2387,7 @@ end}.
{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)},
{verify_peer_ext_key_usage, cuttlefish:conf_get(Prefix ++ ".verify_peer_ext_key_usage", 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)},

View File

@ -19,8 +19,10 @@
-module(emqx_const_v2).
-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.
@ -43,3 +45,73 @@ make_tls_root_fun(cacert_from_cacertfile, [TrustedOne, TrustedTwo]) ->
{trusted_ca, TrustedTwo}
end
end.
make_tls_verify_fun(verify_cert_extKeyUsage, KeyUsages) ->
AllowedKeyUsages = ext_key_opts(KeyUsages),
{fun verify_fun_peer_extKeyUsage/3, AllowedKeyUsages}.
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, %% valid peer cert
AllowedKeyUsages) ->
%% 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, AllowedKeyUsages) of
true ->
%% pass the check,
%% fallback to OTP verify_peer default
{valid, AllowedKeyUsages};
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()];
(undefined) -> undefined.
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';
([$O,$I,$D,$: | OidStr]) ->
OidList = string:tokens(OidStr, "."),
list_to_tuple(lists:map(fun list_to_integer/1, OidList))
end, Usages).

View File

@ -139,7 +139,8 @@ start_listener(Proto, ListenOn, Options0) when Proto == ssl; Proto == tls ->
ListenerID = proplists:get_value(listener_id, Options0),
Options1 = proplists:delete(listener_id, Options0),
Options2 = emqx_ocsp_cache:inject_sni_fun(ListenerID, Options1),
Options = emqx_tls_lib:inject_root_fun(Options2),
Options3 = emqx_tls_lib:inject_root_fun(Options2),
Options = emqx_tls_lib:inject_verify_fun(Options3),
ok = maybe_register_crl_urls(Options),
start_mqtt_listener('mqtt:ssl', ListenOn, Options);

View File

@ -23,10 +23,13 @@
, integral_ciphers/2
, drop_tls13_for_old_otp/1
, inject_root_fun/1
, inject_verify_fun/1
, opt_partial_chain/1
, opt_verify_fun/1
]).
-include("logger.hrl").
-include_lib("public_key/include/public_key.hrl").
%% non-empty string
-define(IS_STRING(L), (is_list(L) andalso L =/= [] andalso is_integer(hd(L)))).
@ -182,6 +185,14 @@ inject_root_fun(Options) ->
replace(Options, ssl_options, opt_partial_chain(SslOpts))
end.
inject_verify_fun(Options) ->
case proplists:get_value(ssl_options, Options) of
undefined ->
Options;
SslOpts ->
replace(Options, ssl_options, emqx_tls_lib:opt_verify_fun(SslOpts))
end.
%% @doc enable TLS partial_chain validation if set.
-spec opt_partial_chain(SslOpts :: proplists:proplist()) -> NewSslOpts :: proplists:proplist().
opt_partial_chain(SslOpts) ->
@ -196,6 +207,17 @@ opt_partial_chain(SslOpts) ->
replace(SslOpts, partial_chain, rootfun_trusted_ca_from_cacertfile(2, SslOpts))
end.
-spec opt_verify_fun(SslOpts :: proplists:proplist()) -> NewSslOpts :: proplists:proplist().
opt_verify_fun(SslOpts) ->
case proplists:get_value(verify_peer_ext_key_usage, SslOpts, undefined) of
undefined ->
SslOpts;
V ->
VerifyFun = emqx_const_v2:make_tls_verify_fun(verify_cert_extKeyUsage, V),
replace(SslOpts, verify_fun, VerifyFun)
end.
replace(Opts, Key, Value) -> [{Key, Value} | proplists:delete(Key, Opts)].
%% @doc Helper, make TLS root_fun

View File

@ -0,0 +1,264 @@
%%--------------------------------------------------------------------
%% 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_keyusage_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
, gen_host_cert/4
]).
all() ->
[ {group, full_chain}
, {group, partial_chain}
].
all_tc() ->
emqx_ct: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_listeners:start_listener(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")}
], 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_listeners:start_listener(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")}
], 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_listeners:start_listener(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)}
], 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
Options = [{ssl_options, [ {verify_peer_ext_key_usage, "OID:1.3.6.1.5.5.7.3.2"} %% from OTP-PUB-KEY.hrl
| ?config(ssl_config, Config)
]}],
emqx_listeners:start_listener(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)}
], 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_listeners:start_listener(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)}
], 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_listeners:start_listener(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)}
], 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_listeners:start_listener(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)}
], 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_listeners:start_listener(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")}
], 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_listeners:start_listener(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)}
], 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_listeners:start_listener(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")}
], 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}
].

View File

@ -89,8 +89,9 @@ gen_host_cert(H, CaName, Path, Opts) ->
HEXT,
"keyUsage=digitalSignature,keyAgreement,keyCertSign\n"
"basicConstraints=CA:TRUE \n"
"~s \n"
"subjectAltName=DNS:~s\n",
[CN]
[maps:get(ext, Opts, ""), CN]
),
CSR_Cmd = csr_cmd(PasswordArg, ECKeyFile, HKey, HCSR, CN),