From 82121c0ceea709f23f01fb6899951696792ee019 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 10 Aug 2026 11:58:22 +0200 Subject: [PATCH 01/17] ssl: Improve format_status handling Add missing format_status function to avoid unwanted logging. Also use new format_staus/1 instead of deprecated format_status/2. --- lib/ssl/src/dtls_client_connection.erl | 6 +-- lib/ssl/src/dtls_packet_demux.erl | 14 +++++- lib/ssl/src/dtls_server_connection.erl | 6 +-- lib/ssl/src/ssl_gen_statem.erl | 44 +++++++++++-------- lib/ssl/src/ssl_manager.erl | 41 +++++++++++------ lib/ssl/src/ssl_server_session_cache.erl | 18 ++++++-- lib/ssl/src/tls_client_connection.erl | 6 +-- lib/ssl/src/tls_client_connection_1_3.erl | 6 +-- lib/ssl/src/tls_client_ticket_store.erl | 14 +++--- lib/ssl/src/tls_sender.erl | 16 +++++++ lib/ssl/src/tls_server_connection.erl | 6 +-- lib/ssl/src/tls_server_connection_1_3.erl | 6 +-- lib/ssl/src/tls_server_session_ticket.erl | 20 ++++++--- lib/ssl/src/tls_socket.erl | 11 +++++ .../test/tls_server_session_ticket_SUITE.erl | 10 +++-- 15 files changed, 154 insertions(+), 70 deletions(-) diff --git a/lib/ssl/src/dtls_client_connection.erl b/lib/ssl/src/dtls_client_connection.erl index cc66e263e5ff..eb4d354b5f5a 100644 --- a/lib/ssl/src/dtls_client_connection.erl +++ b/lib/ssl/src/dtls_client_connection.erl @@ -147,7 +147,7 @@ -export([callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% Tracing -export([handle_trace/3]). @@ -573,8 +573,8 @@ terminate(Reason, StateName, State) -> code_change(_OldVsn, StateName, State, _Extra) -> {ok, StateName, State}. -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). gen_state(StateName, Type, Event, State) -> try tls_dtls_client_connection:StateName(Type, Event, State) diff --git a/lib/ssl/src/dtls_packet_demux.erl b/lib/ssl/src/dtls_packet_demux.erl index 34f8c8f4165d..a0995c94dbae 100644 --- a/lib/ssl/src/dtls_packet_demux.erl +++ b/lib/ssl/src/dtls_packet_demux.erl @@ -51,7 +51,8 @@ handle_cast/2, handle_info/2, terminate/2, - code_change/3]). + code_change/3, + format_status/1]). -record(state, {active_n, @@ -279,6 +280,17 @@ terminate(_Reason, _State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, #state{dtls_options = Options} = State) -> + State#state{dtls_options = + ssl_gen_statem:format_options(Options), + dtls_msq_queues = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). + %%%=================================================================== %%% Internal functions %%%=================================================================== diff --git a/lib/ssl/src/dtls_server_connection.erl b/lib/ssl/src/dtls_server_connection.erl index a7668272d93d..185b9934be1b 100644 --- a/lib/ssl/src/dtls_server_connection.erl +++ b/lib/ssl/src/dtls_server_connection.erl @@ -147,7 +147,7 @@ -export([callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% Tracing -export([handle_trace/3]). @@ -547,8 +547,8 @@ terminate(Reason, StateName, State) -> code_change(_OldVsn, StateName, State, _Extra) -> {ok, StateName, State}. -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). %%-------------------------------------------------------------------- %% Internal functions diff --git a/lib/ssl/src/ssl_gen_statem.erl b/lib/ssl/src/ssl_gen_statem.erl index 5695066db14e..a6099961717b 100644 --- a/lib/ssl/src/ssl_gen_statem.erl +++ b/lib/ssl/src/ssl_gen_statem.erl @@ -97,7 +97,8 @@ terminate/3]). %% Log handling --export([format_status/2]). +-export([format_status/1, + format_options/1]). %% Tracing -export([handle_trace/3]). @@ -1268,24 +1269,29 @@ terminate(Reason, _StateName, #state{static_env = #static_env{transport_cb = Tra %%==================================================================== %% Log handling %%==================================================================== -format_status(normal, [_, StateName, State]) -> - [{data, [{"State", {StateName, State}}]}]; -format_status(terminate, [_, StateName, State]) -> - SslOptions = (State#state.ssl_options), - NewOptions = SslOptions#{ - certs_keys => ?SECRET_PRINTOUT, - cacerts => ?SECRET_PRINTOUT, - dh => ?SECRET_PRINTOUT, - psk_identity => ?SECRET_PRINTOUT, - srp_identity => ?SECRET_PRINTOUT}, - [{data, [{"State", {StateName, State#state{connection_states = ?SECRET_PRINTOUT, - protocol_buffers = ?SECRET_PRINTOUT, - user_data_buffer = ?SECRET_PRINTOUT, - handshake_env = ?SECRET_PRINTOUT, - connection_env = ?SECRET_PRINTOUT, - session = ?SECRET_PRINTOUT, - ssl_options = NewOptions} - }}]}]. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(data, State) -> + TLSOptions = (State#state.ssl_options), + NewOptions = format_options(TLSOptions), + State#state{connection_states = ?SECRET_PRINTOUT, + protocol_buffers = ?SECRET_PRINTOUT, + user_data_buffer = ?SECRET_PRINTOUT, + handshake_env = ?SECRET_PRINTOUT, + connection_env = ?SECRET_PRINTOUT, + session = ?SECRET_PRINTOUT, + ssl_options = NewOptions}; + (_,Value) -> + Value + end, Status). + +format_options(TLSOptions) -> + TLSOptions#{certs_keys => ?SECRET_PRINTOUT, + cacerts => ?SECRET_PRINTOUT, + dh => ?SECRET_PRINTOUT, + psk_identity => ?SECRET_PRINTOUT, + srp_identity => ?SECRET_PRINTOUT}. %%-------------------------------------------------------------------- %%% Internal functions diff --git a/lib/ssl/src/ssl_manager.erl b/lib/ssl/src/ssl_manager.erl index 0e644601999e..1ff2a1c0bde3 100644 --- a/lib/ssl/src/ssl_manager.erl +++ b/lib/ssl/src/ssl_manager.erl @@ -46,8 +46,13 @@ -export([init_session_validator/1]). %% gen_server callbacks --export([init/1, handle_call/3, handle_cast/2, handle_info/2, - terminate/2, code_change/3]). +-export([init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + format_status/1, + code_change/3]). -include("ssl_handshake.hrl"). -include("ssl_internal.hrl"). @@ -63,16 +68,17 @@ -type serialnumber() :: pos_integer(). -record(state, { - session_cache_client :: db_handle(), - session_cache_client_cb :: atom(), - session_lifetime :: integer(), - certificate_db :: db_handle(), - session_validation_timer :: reference(), - session_cache_client_max :: integer(), - session_client_invalidator :: undefined | pid(), - options :: list(), - client_session_order :: gb_trees:tree() - }). + session_cache_client, + session_cache_client_cb, + session_lifetime, + certificate_db, + session_validation_timer, + session_cache_client_max, + session_client_invalidator, + options, + client_session_order, + max_crl_cace_size + }). -define(GEN_UNIQUE_ID_MAX_TRIES, 10). -define(SESSION_VALIDATION_INTERVAL, 60000). @@ -408,8 +414,17 @@ terminate(_Reason, #state{certificate_db = Db, catch CacheCb:terminate(ClientSessionCache), ok. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, State) -> + State#state{client_session_order = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). + %%-------------------------------------------------------------------- --spec code_change(term(), #state{}, list()) -> {ok, #state{}}. +-spec code_change(term(), #state{}, list()) -> {ok, #state{}}. %% %% Description: Convert process state when code is changed %%-------------------------------------------------------------------- diff --git a/lib/ssl/src/ssl_server_session_cache.erl b/lib/ssl/src/ssl_server_session_cache.erl index f2b32f78bb48..67369260dd7f 100644 --- a/lib/ssl/src/ssl_server_session_cache.erl +++ b/lib/ssl/src/ssl_server_session_cache.erl @@ -46,9 +46,9 @@ handle_call/3, handle_cast/2, handle_info/2, - terminate/2 + terminate/2, %%code_change/3, - %%format_status/2 + format_status/1 ]). -record(state, {store_cb, @@ -152,7 +152,7 @@ handle_call({reuse_session, SessionId}, _From, #state{store_cb = Cb, end. -spec handle_cast(Request :: term(), State :: term()) -> - {noreply, NewState :: term()}. + {noreply, NewState :: term()}. handle_cast({register_session, #session{session_id = SessionId, time_stamp = TimeStamp} = Session0}, #state{store_cb = Cb, db = Store0, @@ -194,7 +194,8 @@ handle_cast({register_session, #session{session_id = SessionId, time_stamp = Tim {noreply, State}. -spec handle_info(Info :: timeout() | term(), State :: term()) -> - {noreply, NewState :: term()}. + {noreply, NewState :: term()} | + {stop, Reason :: term(), NewState :: term()}. handle_info({'DOWN', Monitor, _, _, _}, #state{listener = Monitor} = State) -> {stop, normal, State}; handle_info(_, State) -> @@ -203,6 +204,15 @@ handle_info(_, State) -> terminate(_, _) -> ok. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, State) -> + State#state{db = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). + %%%=================================================================== %%% Internal functions %%%=================================================================== diff --git a/lib/ssl/src/tls_client_connection.erl b/lib/ssl/src/tls_client_connection.erl index e607c5011305..360674fdf9f4 100644 --- a/lib/ssl/src/tls_client_connection.erl +++ b/lib/ssl/src/tls_client_connection.erl @@ -130,7 +130,7 @@ -export([callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% Tracing -export([handle_trace/3]). @@ -487,8 +487,8 @@ callback_mode() -> terminate(Reason, StateName, State) -> ssl_gen_statem:terminate(Reason, StateName, State). -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). code_change(_OldVsn, StateName, State, _) -> {ok, StateName, State}. diff --git a/lib/ssl/src/tls_client_connection_1_3.erl b/lib/ssl/src/tls_client_connection_1_3.erl index 693580e45d34..0105e4cdbaa4 100644 --- a/lib/ssl/src/tls_client_connection_1_3.erl +++ b/lib/ssl/src/tls_client_connection_1_3.erl @@ -84,7 +84,7 @@ callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% gen_statem state functions -export([config_error/3, @@ -135,8 +135,8 @@ init([?CLIENT_ROLE, Sender, Tab, Host, Port, Socket, Options, User, CbInfo]) -> terminate(Reason, StateName, State) -> ssl_gen_statem:terminate(Reason, StateName, State). -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). code_change(_OldVsn, StateName, State, _) -> {ok, StateName, State}. diff --git a/lib/ssl/src/tls_client_ticket_store.erl b/lib/ssl/src/tls_client_ticket_store.erl index bbf597805d8d..2fbae15d9c3a 100644 --- a/lib/ssl/src/tls_client_ticket_store.erl +++ b/lib/ssl/src/tls_client_ticket_store.erl @@ -43,7 +43,7 @@ %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, - terminate/2, code_change/3, format_status/2]). + terminate/2, code_change/3, format_status/1]). -define(SERVER, ?MODULE). @@ -158,11 +158,15 @@ terminate(_Reason, _State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, State) -> + State#state{db = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). --spec format_status(Opt :: normal | terminate, - Status :: list()) -> Status :: term(). -format_status(_Opt, Status) -> - Status. %%%=================================================================== %%% Internal functions %%%=================================================================== diff --git a/lib/ssl/src/tls_sender.erl b/lib/ssl/src/tls_sender.erl index 990c790806b4..d0e810e5f7b6 100644 --- a/lib/ssl/src/tls_sender.erl +++ b/lib/ssl/src/tls_sender.erl @@ -54,6 +54,10 @@ async_wait/3, handshake/3, death_row/3]). + +%% Log handling +-export([format_status/1]). + %% Tracing -export([handle_trace/3]). @@ -475,6 +479,18 @@ death_row_shutdown(Reason, StateData) -> %%-------------------------------------------------------------------- terminate(_Reason, _State, _Data) -> void. +%%==================================================================== +%% Log handling +%%==================================================================== +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(data, StateData) -> + StateData#data{connection_states = ?SECRET_PRINTOUT, + buff = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). %%-------------------------------------------------------------------- -spec code_change( diff --git a/lib/ssl/src/tls_server_connection.erl b/lib/ssl/src/tls_server_connection.erl index dae196a3a234..fa0cf2eea1e4 100644 --- a/lib/ssl/src/tls_server_connection.erl +++ b/lib/ssl/src/tls_server_connection.erl @@ -129,7 +129,7 @@ -export([callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% Tracing -export([handle_trace/3]). @@ -417,8 +417,8 @@ callback_mode() -> terminate(Reason, StateName, State) -> ssl_gen_statem:terminate(Reason, StateName, State). -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). code_change(_OldVsn, StateName, State, _) -> {ok, StateName, State}. diff --git a/lib/ssl/src/tls_server_connection_1_3.erl b/lib/ssl/src/tls_server_connection_1_3.erl index df98ce83755d..f17ea98f0588 100644 --- a/lib/ssl/src/tls_server_connection_1_3.erl +++ b/lib/ssl/src/tls_server_connection_1_3.erl @@ -85,7 +85,7 @@ callback_mode/0, terminate/3, code_change/4, - format_status/2]). + format_status/1]). %% gen_statem state functions -export([config_error/3, @@ -126,8 +126,8 @@ init([?SERVER_ROLE, Sender, Tab, Host, Port, Socket, Options, User, CbInfo]) -> terminate(Reason, StateName, State) -> ssl_gen_statem:terminate(Reason, StateName, State). -format_status(Type, Data) -> - ssl_gen_statem:format_status(Type, Data). +format_status(Data) -> + ssl_gen_statem:format_status(Data). code_change(_OldVsn, StateName, State, _) -> {ok, StateName, State}. diff --git a/lib/ssl/src/tls_server_session_ticket.erl b/lib/ssl/src/tls_server_session_ticket.erl index 9ac57f550889..4a2b469bd28b 100644 --- a/lib/ssl/src/tls_server_session_ticket.erl +++ b/lib/ssl/src/tls_server_session_ticket.erl @@ -41,7 +41,7 @@ %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, - terminate/2, code_change/3, format_status/2]). + terminate/2, code_change/3, format_status/1]). %% Tracing -export([handle_trace/3]). @@ -166,11 +166,19 @@ terminate(_Reason, _State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. - --spec format_status(Opt :: normal | terminate, - Status :: list()) -> Status :: term(). -format_status(_Opt, Status) -> - Status. +%%==================================================================== +%% Log handling +%%==================================================================== +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, State) -> + State#state{stateful = ?SECRET_PRINTOUT, + stateless = ?SECRET_PRINTOUT, + nonce = ?SECRET_PRINTOUT}; + (_,Value) -> + Value + end, Status). %%%=================================================================== %%% Internal functions diff --git a/lib/ssl/src/tls_socket.erl b/lib/ssl/src/tls_socket.erl index f5b387281082..8cc7b8c5eb5f 100644 --- a/lib/ssl/src/tls_socket.erl +++ b/lib/ssl/src/tls_socket.erl @@ -59,6 +59,7 @@ handle_call/3, handle_cast/2, handle_info/2, + format_status/1, code_change/3]). -export([update_active_n/2]). @@ -431,6 +432,16 @@ handle_info({'DOWN', Monitor, _, _, _}, #state{listen_monitor = Monitor} = State terminate(_Reason, _State) -> ok. +-spec format_status(map()) -> map(). +format_status(Status) -> + maps:map( + fun(state, #state{ssl_opts = TLSOptions} = State) -> + State#state{ssl_opts = + ssl_gen_statem:format_options(TLSOptions)}; + (_,Value) -> + Value + end, Status). + %%-------------------------------------------------------------------- -spec code_change(term(), #state{}, list()) -> {ok, #state{}}. %% diff --git a/lib/ssl/test/tls_server_session_ticket_SUITE.erl b/lib/ssl/test/tls_server_session_ticket_SUITE.erl index 2b7b22acce1a..9fea4df3c123 100644 --- a/lib/ssl/test/tls_server_session_ticket_SUITE.erl +++ b/lib/ssl/test/tls_server_session_ticket_SUITE.erl @@ -75,9 +75,12 @@ all() -> groups() -> [{stateful, [], [main_test, expired_ticket_test, invalid_ticket_test]}, {stateful_with_cert, [], [main_test, expired_ticket_test, invalid_ticket_test]}, - {stateless, [], [expired_ticket_test, invalid_ticket_test, main_test, certificate_encoding_test]}, - {stateless_with_cert, [], [expired_ticket_test, invalid_ticket_test, main_test, certificate_encoding_test]}, - {stateless_antireplay, [], [main_test, misc_test, valid_ticket_older_than_windowsize_test, certificate_encoding_test]} + {stateless, [], [expired_ticket_test, invalid_ticket_test, main_test, + certificate_encoding_test]}, + {stateless_with_cert, [], [expired_ticket_test, invalid_ticket_test, main_test, + certificate_encoding_test]}, + {stateless_antireplay, [], [main_test, misc_test, valid_ticket_older_than_windowsize_test, + certificate_encoding_test]} ]. init_per_suite(Config0) -> @@ -223,7 +226,6 @@ misc_test(Config) when is_list(Config) -> Pid ! rotate_bloom_filters, Pid ! general_handle_info, {ok, state} = tls_server_session_ticket:code_change(old_version, state, extra), - Pid = tls_server_session_ticket:format_status(not_relevant, Pid), true = is_process_alive(Pid). certificate_encoding_test() -> From dced6eb08936fb2db409722470f517b7598e4204 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 10 Aug 2026 12:03:36 +0200 Subject: [PATCH 02/17] ssl: Allow better optimization --- lib/ssl/src/tls_handshake.erl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ssl/src/tls_handshake.erl b/lib/ssl/src/tls_handshake.erl index 9d46b13c2264..a12698959870 100644 --- a/lib/ssl/src/tls_handshake.erl +++ b/lib/ssl/src/tls_handshake.erl @@ -303,7 +303,8 @@ encode_handshake(Package, Version) -> get_tls_handshakes(Version, Data, <<>>, Options) -> get_tls_handshakes_aux(Version, Data, Options, []); get_tls_handshakes(Version, Data, Buffer, Options) -> - get_tls_handshakes_aux(Version, list_to_binary([Buffer, Data]), Options, []). + get_tls_handshakes_aux(Version, <>, + Options, []). %%-------------------------------------------------------------------- %%% Handshake helper From f360a59bc0b027e13021b533d9b45d8e7fea85bb Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 10 Aug 2026 16:09:40 +0200 Subject: [PATCH 03/17] ssl: Improve CRL cache handling Add sanity checks. Correct documentation for logging. Ignore invalid CRL input. --- lib/ssl/src/ssl.erl | 11 + lib/ssl/src/ssl_config.erl | 22 +- lib/ssl/src/ssl_crl.erl | 23 +- lib/ssl/src/ssl_crl_cache.erl | 201 +++++++++--- lib/ssl/src/ssl_crl_cache_api.erl | 7 +- lib/ssl/src/ssl_internal.hrl | 1 + lib/ssl/src/ssl_manager.erl | 33 +- lib/ssl/src/ssl_pkix_db.erl | 24 +- lib/ssl/test/Makefile | 1 + lib/ssl/test/ssl_crl_SUITE.erl | 370 ++++++++++++----------- lib/ssl/test/ssl_crl_white_box_SUITE.erl | 196 ++++++++++++ 11 files changed, 646 insertions(+), 243 deletions(-) create mode 100644 lib/ssl/test/ssl_crl_white_box_SUITE.erl diff --git a/lib/ssl/src/ssl.erl b/lib/ssl/src/ssl.erl index 60fc34edb952..ce6c3d34c8d1 100644 --- a/lib/ssl/src/ssl.erl +++ b/lib/ssl/src/ssl.erl @@ -1242,6 +1242,17 @@ There are two implementations available: extensions](`e:public_key:public_key_records.md`). Requires the [Inets](`e:inets:introduction.md`) application. +- **`{allowed_hosts, [string()]}`** + + If http fetching is allowed, a list of allowed hosts can be + specified as a hardening option. The entries should be + "Host:Port". If ":Port" is left out the default port is 80. If the + allowed_hosts is not specified only an external hosts using port + 80 or 8080 will be allowed. + + > #### Note {: .info } + Putting the local host on the allow list will of course make the local host allowed. + - **`ssl_crl_hash_dir`** - Implementation 2 This module makes use of a directory where CRLs are diff --git a/lib/ssl/src/ssl_config.erl b/lib/ssl/src/ssl_config.erl index ba56c417c607..9a2c3bfe0e3d 100644 --- a/lib/ssl/src/ssl_config.erl +++ b/lib/ssl/src/ssl_config.erl @@ -34,6 +34,7 @@ -define(DEFAULT_MAX_SESSION_CACHE, 1000). -define(TWO_HOURS, 7200). -define(SEVEN_DAYS, 604800). +-define(DEFAULT_MAX_CRL_CACHE_SIZE, 100000). %% Connection parameter configuration -export([init/2, @@ -46,6 +47,7 @@ %% Application configuration -export([pre_1_3_session_opts/1, get_max_early_data_size/0, + get_max_crl_cache_size/0, get_ticket_lifetime/0, get_ticket_store_size/0, get_internal_active_n/0, @@ -146,6 +148,9 @@ get_internal_active_n(true) -> get_internal_active_n(false) -> application_int(internal_active_n, ?INTERNAL_ACTIVE_N). +get_max_crl_cache_size() -> + application_int(crl_cache_max_size, ?DEFAULT_MAX_CRL_CACHE_SIZE). + %%==================================================================== %% Certificate and Key configuration %%==================================================================== @@ -1497,9 +1502,22 @@ opt_psk_groups(#supported_groups{supported_groups = [First| _] = SupportedGroups end. opt_crl(UserOpts, Opts, _Env) -> + ManagerType = case maps:get(erl_dist, Opts, false) of + false -> + normal; + true -> + dist + end, {_, Check} = get_opt_of(crl_check, [best_effort, peer, true, false], false, UserOpts, Opts), - Cache = case get_opt(crl_cache, {ssl_crl_cache, {internal, []}}, UserOpts, Opts) of - {_, {Cb, {_Handle, Options}} = Value} when is_atom(Cb), is_list(Options) -> + Cache = case get_opt(crl_cache, {ssl_crl_cache, {internal, [{owner, ManagerType}]}}, + UserOpts, Opts) of + {default, {ssl_crl_cache, {_Handle, _Options}} = Value} -> + Value; + {old, {ssl_crl_cache, {_Handle, _Options}} = Value} -> + Value; + {new, {ssl_crl_cache, {Handle, Options}}} when is_list(Options) -> + {ssl_crl_cache, {Handle, [{owner, ManagerType} | Options]}}; + {_, {Cb, {_Handle, Options}} = Value} when is_atom(Cb), is_list(Options) -> Value; {_, Err} -> option_error(crl_cache, Err) diff --git a/lib/ssl/src/ssl_crl.erl b/lib/ssl/src/ssl_crl.erl index fb5c9277e6e0..fb90df0f8ae1 100644 --- a/lib/ssl/src/ssl_crl.erl +++ b/lib/ssl/src/ssl_crl.erl @@ -111,13 +111,18 @@ find_issuer(IsIssuerFun, Db, _) -> verify_crl_issuer(CRL, #cert{otp = OTPCertCandidate}, Issuer, NotIssuer) -> TBSCert = OTPCertCandidate#'OTPCertificate'.tbsCertificate, case public_key:pkix_normalize_name(TBSCert#'OTPTBSCertificate'.subject) of - Issuer -> - case public_key:pkix_crl_verify(CRL, OTPCertCandidate) of - true -> - throw({ok, OTPCertCandidate}); - false -> - NotIssuer - end; - _ -> - NotIssuer + Issuer -> + try public_key:pkix_crl_verify(CRL, OTPCertCandidate) of + true -> + throw({ok, OTPCertCandidate}); + false -> + NotIssuer + catch _:_ -> + %% Fail gracefully unlikely to happen in valid use cases + ?LOG_WARNING("SSL WARNING: Ignoring CRL " + "unexpected signature algorithm", [CRL]), + NotIssuer + end; + _ -> + NotIssuer end. diff --git a/lib/ssl/src/ssl_crl_cache.erl b/lib/ssl/src/ssl_crl_cache.erl index cbe826458d24..4474debd09b9 100644 --- a/lib/ssl/src/ssl_crl_cache.erl +++ b/lib/ssl/src/ssl_crl_cache.erl @@ -19,7 +19,7 @@ %% %% %CopyrightEnd% -%---------------------------------------------------------------------- +%%---------------------------------------------------------------------- %% Purpose: Simple default CRL cache %%---------------------------------------------------------------------- @@ -47,6 +47,9 @@ A source to input CRLs -export([lookup/3, select/2, fresh_crl/2]). -export([insert/1, insert/2, delete/1]). +%% Exported for testing (ssl_crl_SUITE) +-export([is_internal_ip/1, validate_host/4, is_allowed/3]). + %%==================================================================== %% Cache callback API %%==================================================================== @@ -75,13 +78,19 @@ select(Issuer, {{_Cache, Mapping},_}) -> end. -doc false. +%% Might want to add possibility to provide argument +%% Future improvement, needs public_key feature too. fresh_crl(#'DistributionPoint'{distributionPoint = {fullName, Names}}, CRL) -> - case get_crls(Names, undefined) of + case get_crls(Names, {undefined, [{http, 1000}]}) of not_available -> CRL; NewCRL -> NewCRL - end. + end; +fresh_crl(#'DistributionPoint'{}, CRL) -> + %% nameRelativeToCRLIssuer or asn1_NOVALUE — cannot fetch via HTTP, + %% return the current CRL unchanged. + CRL. %%==================================================================== %% API @@ -174,9 +183,12 @@ get_crls([], _) -> not_available; get_crls([{uniformResourceIdentifier, "http"++_ = URL} | Rest], CRLDbInfo) -> - case cache_lookup(URL, CRLDbInfo) of - [] -> - handle_http(URL, Rest, CRLDbInfo); + URI = #{scheme := Scheme} = uri_string:normalize(URL, [return_map]), + case cache_lookup(URI, CRLDbInfo) of + [] when Scheme == "http" -> + handle_http(URL, URI, Rest, CRLDbInfo); + [] -> + get_crls(Rest, CRLDbInfo); CRLs -> CRLs end; @@ -184,31 +196,112 @@ get_crls([ _| Rest], CRLDbInfo) -> %% unsupported CRL location get_crls(Rest, CRLDbInfo). -http_lookup(URL, Rest, CRLDbInfo, Timeout) -> +http_lookup(URL, #{host := Host} = URI, Rest, {_, Args} = CRLDbInfo, Timeout) -> case application:ensure_started(inets) of ok -> - http_get(URL, Rest, CRLDbInfo, Timeout); + Allowed = proplists:get_value(allowed_hosts, Args, []), + {IP, Family} = host_family(Host), + case validate_host(URI, Allowed, IP, Family) of + ok -> + http_get(URL, IP, Family, Rest, CRLDbInfo, Timeout); + {disallowed, Reason} -> + ?LOG_WARNING("CRL fetch ignored, host disallowed: ~p ~n", [Reason]), + get_crls(Rest, CRLDbInfo) + end; _ -> get_crls(Rest, CRLDbInfo) end. -http_get(URL, Rest, CRLDbInfo, Timeout) -> +-doc false. +-spec validate_host(map(), [string()], inet:ip_address(), inet | inet6| unknown) -> + ok | {disallowed, term()}. +validate_host(_, _, _, unknown) -> + {disallowed, host_not_found}; +validate_host(#{host := Host} = URI, Allowed, IP, _) -> + Port = maps:get(port, URI, 80), + case Allowed of + [] when Port == 80; + Port == 8080 -> + allow_external_ip(IP); + [_ | _] -> + case is_allowed(Host, Port, Allowed) of + true -> + ok; + false -> + {disallowed, not_in_allowed_list} + end; + _ -> + {disallowed, not_standard_port} + end. + +-doc false. +-spec is_allowed(string(), non_neg_integer(), [string()]) -> boolean(). + +is_allowed(_, _, []) -> + false; +is_allowed(Host, Port, [Allowed| Rest] = List) -> + try string:tokens(Allowed, [$:]) of + [Host] when Port == 80 -> + true; + [Host, StrPort]-> + case length(StrPort) of + N when N =< 5 -> + try Port == list_to_integer(StrPort) of + true = Result-> + Result; + false -> + is_allowed(Host, Port, Rest) + catch _:_ -> + false + end; + _ -> + false + end; + _ -> + is_allowed(Host, Port, Rest) + catch _:_ -> + ?LOG_WARNING("CRL fetch ignored, invalid host allow list ~p ~n", [List]), + false + end. + +host_family(Host) -> + case inet:getaddr(Host, inet) of + {ok, IP} -> + {IP, inet}; + {error, _} -> + case inet:getaddr(Host, inet6) of + {ok, IP} -> + {IP, inet6}; + {error, _} -> + unknown + end + end. + +allow_external_ip(IP) -> + case is_external_ip(IP) of + true -> + ok; + false -> + {disallowed, {local_ip_for_host, IP}} + end. + +is_external_ip(IP) -> + not is_internal_ip(IP). + +http_get(URL, IP, Family, Rest, CRLDbInfo, Timeout) -> case httpc:request(get, {URL, [{"connection", "close"}]}, - [{timeout, Timeout}], [{body_format, binary}]) of - {ok, {_Status, _Headers, Body}} -> + [{autoredirect, false},{timeout, Timeout}], + [{socket_opts, [Family, {ip, IP}]},{body_format, binary}]) of + {ok, {{_,200,_}, _Headers, Body}} when byte_size(Body) =< (?MAX_CRL_SIZE)-> case Body of <<"-----BEGIN", _/binary>> -> - try - Pem = public_key:pem_decode(Body), - lists:filtermap(fun({'CertificateList', - CRL, not_encrypted}) -> - {true, CRL}; - (_) -> - false - end, Pem) - catch _:_ -> - get_crls(Rest, CRLDbInfo) - end; + Pem = public_key:pem_decode(Body), + lists:filtermap(fun({'CertificateList', + CRL, not_encrypted}) -> + {true, CRL}; + (_) -> + false + end, Pem); _ -> try public_key:der_decode('CertificateList', Body) of _ -> @@ -217,17 +310,23 @@ http_get(URL, Rest, CRLDbInfo, Timeout) -> _:_ -> get_crls(Rest, CRLDbInfo) end - end; + end; + {ok, {Status, Headers, Body}} -> + ?LOG_WARNING("CRL fetch ignored: ~n" + "HTTP status: ~p ~n" + "HTTP headers: ~p ~n" + "HTTP Body size ~p ~n", + [Status, Headers, byte_size(Body)]), + get_crls(Rest, CRLDbInfo); {error, _Reason} -> get_crls(Rest, CRLDbInfo) end. cache_lookup(_, undefined) -> []; -cache_lookup(URL, {{Cache, _}, _}) -> - #{path := Path, - host := Host} = Map = uri_string:normalize(URL, [return_map]), - Port = maps:get(port, Map, 80), +cache_lookup(#{path := Path, + host := Host} = URI, {{Cache, _}, _}) -> + Port = maps:get(port, URI, 80), Key = make_key(Host, Port, Path), case ssl_pkix_db:lookup(Key, Cache) of undefined -> @@ -236,16 +335,44 @@ cache_lookup(URL, {{Cache, _}, _}) -> CRLs end. -handle_http(URI, Rest, {_, [{http, Timeout}]} = CRLDbInfo) -> - CRLs = http_lookup(URI, Rest, CRLDbInfo, Timeout), - %% Uncomment to improve performance, but need to - %% implement cache limit and or cleaning to prevent - %% DoS attack possibilities - %%insert(URI, {der, CRLs}), - CRLs; -handle_http(_, Rest, CRLDbInfo) -> - get_crls(Rest, CRLDbInfo). - +handle_http(URL, #{path := Path, + host := Host} = URI, Rest, {_, Args} = CRLDbInfo) -> + case proplists:get_value(http, Args, undefined) of + undefined -> + get_crls(Rest, CRLDbInfo); + Timeout -> + case http_lookup(URL, URI, Rest, CRLDbInfo, Timeout) of + not_available -> + not_available; + CRLs -> + case proplists:get_value(owner, Args, undefined) of + undefined -> + CRLs; + CacheOwner -> + Port = maps:get(port, URI, 80), + Key = make_key(Host, Port, Path), + ssl_manager:async_insert_crls(Key, CRLs, CacheOwner), + CRLs + end + end + end. make_key(Host, Port, Path) -> Host ++ ":" ++ integer_to_list(Port) ++ Path. + +-doc false. +-spec is_internal_ip(inet:ip_address()) -> boolean(). + +%% IPv4 +is_internal_ip({127, _, _, _}) -> true; %% loopback +is_internal_ip({10, _, _, _}) -> true; %% RFC 1918 +is_internal_ip({172, B, _, _}) when B >= 16, B =< 31 -> true; %% RFC 1918 +is_internal_ip({192, 168, _, _}) -> true; %% RFC 1918 +is_internal_ip({169, 254, _, _}) -> true; %% link-local +is_internal_ip({0, 0, 0, 0}) -> true; %% unspecified +%% IPv6 +is_internal_ip({0,0,0,0,0,0,0,1}) -> true; %% ::1 loopback +is_internal_ip({0,0,0,0,0,0,0,0}) -> true; %% :: unspecified +is_internal_ip({16#fe80,_,_,_,_,_,_,_}) -> true; %% link-local fe80::/10 +is_internal_ip({W,_,_,_,_,_,_,_}) when W >= 16#fc00, W =< 16#fdff -> true; %% unique-local +is_internal_ip(_) -> false. diff --git a/lib/ssl/src/ssl_crl_cache_api.erl b/lib/ssl/src/ssl_crl_cache_api.erl index bbcd0baa200c..0d3fecd0c3bb 100644 --- a/lib/ssl/src/ssl_crl_cache_api.erl +++ b/lib/ssl/src/ssl_crl_cache_api.erl @@ -58,9 +58,7 @@ Backwards compatibility, replaced by lookup/3 """. -callback lookup(DistPoint::dist_point(), CacheRef::crl_cache_ref()) -> not_available | [public_key:der_encoded()] | - {{logger, logger_info()}, [public_key:der_encoded()]}. - - + {logger, logger_info(), [public_key:der_encoded()]}. -doc(#{since => <<"OTP 19.0">>}). -doc """ Lookup the CRLs belonging to the distribution point `Distributionpoint`. This @@ -82,8 +80,7 @@ produce log events. """. -callback lookup(Distpoint::dist_point(), Issuer::public_key:issuer_name(), CacheRef::crl_cache_ref()) -> not_available | [public_key:der_encoded()] | - {{logger, logger_info()}, [public_key:der_encoded()]}. - + {logger, logger_info(), [public_key:der_encoded()]}. -doc(#{since => <<"OTP 18.0">>}). -doc """ diff --git a/lib/ssl/src/ssl_internal.hrl b/lib/ssl/src/ssl_internal.hrl index a0640319f4ff..a52b377cc8c6 100644 --- a/lib/ssl/src/ssl_internal.hrl +++ b/lib/ssl/src/ssl_internal.hrl @@ -58,6 +58,7 @@ -define(DEFAULT_TIMEOUT, 5000). -define(NO_DIST_POINT, "http://dummy/no_distribution_point"). -define(NO_DIST_POINT_PATH, "dummy/no_distribution_point"). +-define(MAX_CRL_SIZE, 10485760). %% 10 * 1024 * 1024 ~ 10 MB %% Common enumerate values in for SSL-protocols -define(NULL, 0). diff --git a/lib/ssl/src/ssl_manager.erl b/lib/ssl/src/ssl_manager.erl index 1ff2a1c0bde3..095b35ec029f 100644 --- a/lib/ssl/src/ssl_manager.erl +++ b/lib/ssl/src/ssl_manager.erl @@ -40,7 +40,8 @@ clean_cert_db/2, refresh_trusted_db/1, refresh_trusted_db/2, register_session/4, invalidate_session/2, - insert_crls/2, insert_crls/3, delete_crls/1, delete_crls/2, + insert_crls/2, insert_crls/3, async_insert_crls/3, + delete_crls/1, delete_crls/2, invalidate_session/3, name/1]). -export([init_session_validator/1]). @@ -77,7 +78,7 @@ session_client_invalidator, options, client_session_order, - max_crl_cace_size + max_crl_cache_size }). -define(GEN_UNIQUE_ID_MAX_TRIES, 10). @@ -229,6 +230,10 @@ insert_crls(Path, CRLs, ManagerType)-> put(ssl_manager, name(ManagerType)), call({insert_crls, Path, CRLs}). +async_insert_crls(Path, CRLs, ManagerType)-> + put(ssl_manager, name(ManagerType)), + cast({insert_crls, Path, CRLs}). + delete_crls(Path)-> delete_crls(Path, normal). delete_crls(?NO_DIST_POINT_PATH = Path, ManagerType)-> @@ -277,7 +282,8 @@ init([ManagerName, PemCacheName, Opts]) -> session_cache_client_max = ClientSessMax, session_client_invalidator = undefined, options = Opts, - client_session_order = gb_trees:empty() + client_session_order = gb_trees:empty(), + max_crl_cache_size = ssl_config:get_max_crl_cache_size() }}. %%-------------------------------------------------------------------- @@ -353,12 +359,12 @@ handle_cast({invalidate_session, Host, Port, #state{session_cache_client = Cache, session_cache_client_cb = CacheCb} = State) -> invalidate_session(Cache, CacheCb, {{Host, Port}, ID}, Session, State); -handle_cast({insert_crls, Path, CRLs}, - #state{certificate_db = Db} = State) -> - ssl_pkix_db:add_crls(Db, Path, CRLs), +handle_cast({insert_crls, Path, CRLs}, + #state{certificate_db = Db, + max_crl_cache_size = Max} = State) -> + do_insert_crls(Path, CRLs, Db, Max), {noreply, State}; - -handle_cast({delete_crls, CRLsOrPath}, +handle_cast({delete_crls, CRLsOrPath}, #state{certificate_db = Db} = State) -> ssl_pkix_db:remove_crls(Db, CRLsOrPath), {noreply, State}. @@ -623,3 +629,14 @@ load_mitigation() -> MSec -> continue end. + +do_insert_crls(Path, CRLs, [_,_,_, {Cache, Mapping}|_] = Db, Max) -> + CRLSize = lists:foldl(fun(CRL, Sum) -> byte_size(CRL) + Sum end, 0, CRLs), + case CRLSize + ssl_pkix_db:db_size(Cache) of + Size when Size >= Max -> + ssl_pkix_db:clear(Mapping), + ssl_pkix_db:clear(Cache); + _ -> + ok + end, + ssl_pkix_db:add_crls(Db, Path, CRLs). diff --git a/lib/ssl/src/ssl_pkix_db.erl b/lib/ssl/src/ssl_pkix_db.erl index d0a6022a2858..acbc3d0bdc13 100644 --- a/lib/ssl/src/ssl_pkix_db.erl +++ b/lib/ssl/src/ssl_pkix_db.erl @@ -372,7 +372,12 @@ add_crls([_,_,_, {Cache, Mapping} | _], Path, CRLs) -> [add_crls(CRL, Mapping) || CRL <- CRLs]. add_crls(CRL, Mapping) -> - insert(crl_issuer(CRL), CRL, Mapping). + case crl_issuer(CRL) of + bad_crl -> + ok; + Issuer -> + insert(Issuer, CRL, Mapping) + end. remove_crls([_,_,_, {_, Mapping} | _], {?NO_DIST_POINT, CRLs}) -> [rm_crls(CRL, Mapping) || CRL <- CRLs]; @@ -387,12 +392,21 @@ remove_crls([_,_,_, {Cache, Mapping} | _], Path) -> end. rm_crls(CRL, Mapping) -> - remove(crl_issuer(CRL), CRL, Mapping). + case crl_issuer(CRL) of + bad_crl -> + ok; + Issuer -> + remove(Issuer, CRL, Mapping) + end. crl_issuer(DerCRL) -> - CRL = public_key:der_decode('CertificateList', DerCRL), - TBSCRL = CRL#'CertificateList'.tbsCertList, - TBSCRL#'TBSCertList'.issuer. + try public_key:der_decode('CertificateList', DerCRL) of + CRL -> + TBSCRL = CRL#'CertificateList'.tbsCertList, + TBSCRL#'TBSCertList'.issuer + catch _:_ -> + bad_crl + end. update_certs(Ref, CertList, KeyList, CertsDb) -> {Insert, Delete} = insert_delete_lists(Ref, CertList, CertsDb, [], KeyList), diff --git a/lib/ssl/test/Makefile b/lib/ssl/test/Makefile index 215d7ac42896..35b7ab3406b8 100644 --- a/lib/ssl/test/Makefile +++ b/lib/ssl/test/Makefile @@ -68,6 +68,7 @@ MODULES = \ openssl_server_cert_SUITE\ openssl_client_cert_SUITE\ ssl_crl_SUITE\ + ssl_crl_white_box_SUITE\ ssl_dist_SUITE \ ssl_dist_bench_SUITE \ ssl_engine_SUITE\ diff --git a/lib/ssl/test/ssl_crl_SUITE.erl b/lib/ssl/test/ssl_crl_SUITE.erl index 9eca84eb3ada..8507552567f1 100644 --- a/lib/ssl/test/ssl_crl_SUITE.erl +++ b/lib/ssl/test/ssl_crl_SUITE.erl @@ -62,16 +62,14 @@ %%-------------------------------------------------------------------- %% Common Test interface functions ----------------------------------- %%-------------------------------------------------------------------- -all() -> - [ - {group, check_true}, +all() -> + [{group, check_true}, {group, check_peer}, {group, check_best_effort} ]. groups() -> - [ - {check_true, [], [{group, v2_crl}, + [{check_true, [], [{group, v2_crl}, {group, v1_crl}, {group, idp_crl}, {group, crl_hash_dir}, @@ -81,7 +79,7 @@ groups() -> {group, idp_crl}, {group, crl_hash_dir}]}, {check_best_effort, [], [{group, v2_crl}, - {group, v1_crl}, + {group, v1_crl}, {group, idp_crl}, {group, crl_hash_dir}]}, {v2_crl, [], basic_tests()}, @@ -105,34 +103,33 @@ crl_hash_dir_tests() -> init_per_suite(Config) -> case os:find_executable("openssl") of - false -> - {skip, "Openssl not found"}; - _ -> - OpenSSL_version = (catch os:cmd("openssl version")), - case ssl_test_lib:enough_openssl_crl_support(OpenSSL_version) of - false -> - {skip, io_lib:format("Bad openssl version: ~p",[OpenSSL_version])}; - _ -> - end_per_suite(Config), - case application:ensure_started(crypto) of - ok -> - {ok, Hostname0} = inet:gethostname(), - IPfamily = - case lists:member(list_to_atom(Hostname0), ct:get_config(ipv6_hosts,[])) of - true -> inet6; - false -> inet - end, - [{ipfamily,IPfamily}, {openssl_version,OpenSSL_version} | Config]; + false -> + {skip, "Openssl not found"}; + _ -> + OpenSSL_version = (catch os:cmd("openssl version")), + case ssl_test_lib:enough_openssl_crl_support(OpenSSL_version) of + false -> + {skip, io_lib:format("Bad openssl version: ~p",[OpenSSL_version])}; + _ -> + end_per_suite(Config), + case application:ensure_started(crypto) of + ok -> + {ok, Hostname0} = inet:gethostname(), + IPfamily = + case lists:member(list_to_atom(Hostname0), + ct:get_config(ipv6_hosts,[])) of + true -> inet6; + false -> inet + end, + [{ipfamily,IPfamily}, {openssl_version,OpenSSL_version} | Config]; _ -> - {skip, "Crypto did not start"} - end - end + {skip, "Crypto did not start"} + end + end end. - end_per_suite(_Config) -> ssl:stop(), application:stop(crypto). - init_per_group(check_true, Config) -> [{crl_check, true} | Config]; init_per_group(check_peer, Config) -> @@ -140,66 +137,65 @@ init_per_group(check_peer, Config) -> init_per_group(check_best_effort, Config) -> [{crl_check, best_effort} | Config]; init_per_group(Group, Config0) -> - try - case is_idp(Group) of - true -> - [{idp_crl, true} | Config0]; - false -> - DataDir = proplists:get_value(data_dir, Config0), - CertDir = filename:join(proplists:get_value(priv_dir, Config0), Group), - {CertOpts, Config} = init_certs(CertDir, Group, Config0), - {ok, _} = make_certs:all(DataDir, CertDir, CertOpts), - CrlCacheOpts = case need_hash_dir(Group) of - true -> - CrlDir = filename:join(CertDir, "crls"), - %% Copy CRLs to their hashed filenames. - %% Find the hashes with 'openssl crl -noout -hash -in crl.pem'. - populate_crl_hash_dir(CertDir, CrlDir, - [{"erlangCA", "d6134ed3"}, - {"otpCA", "d4c8d7e5"}], + try + case is_idp(Group) of + true -> + [{idp_crl, true} | Config0]; + false -> + DataDir = proplists:get_value(data_dir, Config0), + CertDir = filename:join(proplists:get_value(priv_dir, Config0), Group), + {CertOpts, Config} = init_certs(CertDir, Group, Config0), + {ok, _} = make_certs:all(DataDir, CertDir, CertOpts), + CrlCacheOpts = case need_hash_dir(Group) of + true -> + CrlDir = filename:join(CertDir, "crls"), + %% Copy CRLs to their hashed filenames. + %% Find the hashes with 'openssl crl -noout -hash -in crl.pem'. + populate_crl_hash_dir(CertDir, CrlDir, + [{"erlangCA", "d6134ed3"}, + {"otpCA", "d4c8d7e5"}], replace), - [{crl_cache, - {ssl_crl_hash_dir, - {internal, [{dir, CrlDir}]}}}]; - _ -> - [] - end, - [{crl_cache_opts, CrlCacheOpts}, - {cert_dir, CertDir}, - {idp_crl, false} | Config] - end + [{crl_cache, + {ssl_crl_hash_dir, + {internal, [{dir, CrlDir}]}}}]; + _ -> + [] + end, + [{crl_cache_opts, CrlCacheOpts}, + {cert_dir, CertDir}, + {idp_crl, false} | Config] + end catch - _:_ -> - {skip, "Unable to create crls"} + _:_ -> + {skip, "Unable to create crls"} end. - end_per_group(_GroupName, Config) -> - Config. init_per_testcase(Case, Config0) -> case proplists:get_value(idp_crl, Config0) of - true -> - end_per_testcase(Case, Config0), - inets:start(), - ssl_test_lib:clean_start(), - ServerRoot = make_dir_path([proplists:get_value(priv_dir, Config0), idp_crl, tmp]), - %% start a HTTP server to serve the CRLs - {ok, Httpd} = inets:start(httpd, [{ipfamily, proplists:get_value(ipfamily, Config0)}, - {server_name, "localhost"}, {port, 0}, - {server_root, ServerRoot}, - {document_root, - filename:join(proplists:get_value(priv_dir, Config0), idp_crl)} - ]), - [{port,Port}] = httpd:info(Httpd, [port]), - Config = [{httpd_port, Port} | Config0], - DataDir = proplists:get_value(data_dir, Config), - CertDir = filename:join(proplists:get_value(priv_dir, Config0), idp_crl), - {CertOpts, Config} = init_certs(CertDir, idp_crl, Config), - case make_certs:all(DataDir, CertDir, CertOpts) of + true -> + end_per_testcase(Case, Config0), + inets:start(), + ssl_test_lib:clean_start(), + ServerRoot = make_dir_path([proplists:get_value(priv_dir, Config0), idp_crl, tmp]), + %% start a HTTP server to serve the CRLs + {ok, Httpd} = inets:start(httpd, [{ipfamily, proplists:get_value(ipfamily, Config0)}, + {server_name, "localhost"}, {port, 0}, + {server_root, ServerRoot}, + {document_root, + filename:join(proplists:get_value(priv_dir, Config0), + idp_crl)} + ]), + [{port,Port}] = httpd:info(Httpd, [port]), + Config = [{httpd_port, Port} | Config0], + DataDir = proplists:get_value(data_dir, Config), + CertDir = filename:join(proplists:get_value(priv_dir, Config0), idp_crl), + {CertOpts, Config} = init_certs(CertDir, idp_crl, Config), + case make_certs:all(DataDir, CertDir, CertOpts) of {ok, _} -> ct:timetrap(?TIMEOUT), - [{cert_dir, CertDir} | Config]; + [{cert_dir, CertDir}, {http_server, {"localhost", Port}} | Config]; _ -> end_per_testcase(Case, Config0), ssl_test_lib:clean_start(), @@ -207,20 +203,18 @@ init_per_testcase(Case, Config0) -> end; false -> ct:timetrap(?TIMEOUT), - end_per_testcase(Case, Config0), - ssl_test_lib:clean_start(), - Config0 + end_per_testcase(Case, Config0), + ssl_test_lib:clean_start(), + Config0 end. - end_per_testcase(_, Config) -> - case proplists:get_value(idp_crl, Config) of + case proplists:get_value(idp_crl, Config, false) of true -> ssl:stop(), inets:stop(); false -> ssl:stop() end. - %%%================================================================ %%% Test cases %%%================================================================ @@ -237,37 +231,47 @@ crl_verify_valid(Config) when is_list(Config) -> [_, _, _, {CRLCache,_}] = element(5, State), ServerOpts = [{keyfile, filename:join([PrivDir, "server", "key.pem"])}, - {certfile, filename:join([PrivDir, "server", "cert.pem"])}, + {certfile, filename:join([PrivDir, "server", "cert.pem"])}, {cacertfile, filename:join([PrivDir, "server", "cacerts.pem"])}], - ClientOpts = case proplists:get_value(idp_crl, Config) of - true -> - [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, - {crl_check, Check}, - {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}]}}}, - {verify, verify_peer}]; - false -> - proplists:get_value(crl_cache_opts, Config) ++ - [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, - {crl_check, Check}, - {verify, verify_peer}] - end, + ClientOpts = case proplists:get_value(idp_crl, Config) of + true -> + {Host, Port} = proplists:get_value(http_server, Config), + Allowed = Host ++ ":" ++ integer_to_list(Port), + [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, + {crl_check, Check}, + {crl_cache, {ssl_crl_cache, + {internal, [{http, 5000}, {allowed_hosts, [Allowed]}]}}}, + {verify, verify_peer}]; + false -> + proplists:get_value(crl_cache_opts, Config) ++ + [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, + {crl_check, Check}, + {verify, verify_peer}] + end, {ClientNode, ServerNode, Hostname} = ssl_test_lib:run_where(Config), - ssl_crl_cache:insert("http://localhost/erlangCA/crl.pem", {file, filename:join([PrivDir, "erlangCA", "crl.pem"])}), - ssl_crl_cache:insert("http://localhost/otpCA/crl.pem", {file, filename:join([PrivDir, "otpCA", "crl.pem"])}), + ssl_crl_cache:insert("http://localhost/erlangCA/crl.pem", + {file, filename:join([PrivDir, "erlangCA", "crl.pem"])}), + ssl_crl_cache:insert("http://localhost/otpCA/crl.pem", + {file, filename:join([PrivDir, "otpCA", "crl.pem"])}), ssl_crl_cache:insert({file, filename:join([PrivDir, "erlangCA", "crl.pem"])}), ssl_crl_cache:insert({file, filename:join([PrivDir, "otpCA", "crl.pem"])}), crl_verify_valid(Hostname, ServerNode, ServerOpts, ClientNode, ClientOpts), - ssl_crl_cache:insert("http://foobar/erlangCA/crl.pem", {file, filename:join([PrivDir, "otpCA", "crl.pem"])}), - - R1 = ssl_crl_cache:lookup(#'DistributionPoint'{distributionPoint = - {fullName, [{uniformResourceIdentifier, "http://foobar/erlangCA/crl.pem"}]}}, - undefined, {{CRLCache, internal_dummy}, internal_dummy}), - R2 = ssl_crl_cache:lookup(#'DistributionPoint'{distributionPoint = - {fullName, [{uniformResourceIdentifier, "http://localhost/erlangCA/crl.pem"}]}}, - undefined, {{CRLCache, internal_dummy}, internal_dummy}), + ssl_crl_cache:insert("http://foobar/erlangCA/crl.pem", + {file, filename:join([PrivDir, "otpCA", "crl.pem"])}), + + R1 = ssl_crl_cache:lookup( + #'DistributionPoint'{distributionPoint = + {fullName, [{uniformResourceIdentifier, + "http://foobar/erlangCA/crl.pem"}]}}, + undefined, {{CRLCache, internal_dummy}, internal_dummy}), + R2 = ssl_crl_cache:lookup( + #'DistributionPoint'{distributionPoint = + {fullName, [{uniformResourceIdentifier, + "http://localhost/erlangCA/crl.pem"}]}}, + undefined, {{CRLCache, internal_dummy}, internal_dummy}), %% Check that same path in URI does not evaluate to same result true = R1 =/= R2, @@ -287,22 +291,26 @@ crl_verify_revoked(Config) when is_list(Config) -> ssl_crl_cache:insert({file, filename:join([PrivDir, "erlangCA", "crl.pem"])}), ssl_crl_cache:insert({file, filename:join([PrivDir, "otpCA", "crl.pem"])}), - - ClientOpts = case proplists:get_value(idp_crl, Config) of - true -> - [{cacertfile, filename:join([PrivDir, "revoked", "cacerts.pem"])}, - {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}]}}}, - {crl_check, Check}, - {verify, verify_peer}]; - false -> - proplists:get_value(crl_cache_opts, Config) ++ - [{cacertfile, filename:join([PrivDir, "revoked", "cacerts.pem"])}, - {crl_check, Check}, - {verify, verify_peer}] - end, - + + ClientOpts = case proplists:get_value(idp_crl, Config) of + true -> + {Host, Port} = proplists:get_value(http_server, Config), + Allowed = Host ++ ":" ++ integer_to_list(Port), + [{cacertfile, filename:join([PrivDir, "revoked", "cacerts.pem"])}, + {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}, + {allowed_hosts, [Allowed]}]}}}, + {crl_check, Check}, + {verify, verify_peer}]; + false -> + proplists:get_value(crl_cache_opts, Config) ++ + [{cacertfile, filename:join([PrivDir, "revoked", "cacerts.pem"])}, + {crl_check, Check}, + {verify, verify_peer}] + end, + crl_verify_error(Hostname, ServerNode, ServerOpts, ClientNode, ClientOpts, certificate_revoked). + crl_verify_valid_derCAs() -> [{doc,"Verify a simple valid CRL chain"}]. crl_verify_valid_derCAs(Config) when is_list(Config) -> @@ -317,16 +325,19 @@ crl_verify_valid_derCAs(Config) when is_list(Config) -> ], ClientOpts = case proplists:get_value(idp_crl, Config) of true -> - [{cacerts, CaCerts}, - {crl_check, Check}, - {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}]}}}, - {verify, verify_peer}]; - false -> - proplists:get_value(crl_cache_opts, Config) ++ - [{cacerts, CaCerts}, - {crl_check, Check}, - {verify, verify_peer}] - end, + {Host, Port} = proplists:get_value(http_server, Config), + Allowed = Host ++ ":" ++ integer_to_list(Port), + [{cacerts, CaCerts}, + {crl_check, Check}, + {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}, + {allowed_hosts, [Allowed]}]}}}, + {verify, verify_peer}]; + false -> + proplists:get_value(crl_cache_opts, Config) ++ + [{cacerts, CaCerts}, + {crl_check, Check}, + {verify, verify_peer}] + end, {ClientNode, ServerNode, Hostname} = ssl_test_lib:run_where(Config), ssl_crl_cache:insert({file, filename:join([PrivDir, "erlangCA", "crl.pem"])}), @@ -352,17 +363,20 @@ crl_verify_revoked_derCAs(Config) when is_list(Config) -> ssl_crl_cache:insert({file, filename:join([PrivDir, "otpCA", "crl.pem"])}), ClientOpts = case proplists:get_value(idp_crl, Config) of - true -> - [{cacerts, CaCerts}, - {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}]}}}, - {crl_check, Check}, - {verify, verify_peer}]; - false -> - proplists:get_value(crl_cache_opts, Config) ++ - [{cacerts, CaCerts}, - {crl_check, Check}, - {verify, verify_peer}] - end, + true -> + {Host, Port} = proplists:get_value(http_server, Config), + Allowed = Host ++ ":" ++ integer_to_list(Port), + [{cacerts, CaCerts}, + {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}, + {allowed_hosts, [Allowed]}]}}}, + {crl_check, Check}, + {verify, verify_peer}]; + false -> + proplists:get_value(crl_cache_opts, Config) ++ + [{cacerts, CaCerts}, + {crl_check, Check}, + {verify, verify_peer}] + end, crl_verify_error(Hostname, ServerNode, ServerOpts, ClientNode, ClientOpts, certificate_revoked). @@ -376,19 +390,21 @@ crl_verify_no_crl(Config) when is_list(Config) -> ServerOpts = [{keyfile, filename:join([PrivDir, "server", "key.pem"])}, {certfile, filename:join([PrivDir, "server", "cert.pem"])}, {cacertfile, filename:join([PrivDir, "server", "cacerts.pem"])}], - ClientOpts = case proplists:get_value(idp_crl, Config) of - true -> - [{cacertfile, filename:join([PrivDir, "server", "cacerts.pem"])}, - {crl_check, Check}, - {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}]}}}, - {verify, verify_peer}]; - false -> - [{cacertfile, filename:join([PrivDir, "server", "cacerts.pem"])}, - {crl_check, Check}, - {verify, verify_peer}] - end, + ClientOpts = case proplists:get_value(idp_crl, Config) of + true -> + {Host, Port} = proplists:get_value(http_server, Config), + Allowed = Host ++ ":" ++ integer_to_list(Port), + [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, + {crl_check, Check}, + {crl_cache, {ssl_crl_cache, {internal, [{http, 5000}, + {allowed_hosts, [Allowed]}]}}}, + {verify, verify_peer}]; + false -> + [{cacertfile, filename:join([PrivDir, "client", "cacerts.pem"])}, + {crl_check, Check}, + {verify, verify_peer}] + end, {ClientNode, ServerNode, Hostname} = ssl_test_lib:run_where(Config), - %% In case we're running an HTTP server that serves CRLs, let's %% rename those files, so the CRL is absent when we try to verify %% it. @@ -444,19 +460,19 @@ crl_hash_dir_collision(Config) when is_list(Config) -> populate_crl_hash_dir(PrivDir, CrlDir, [{CA1, "b68fc624"}, {CA2, "b68fc624"}], - replace), + replace), NewCA = new_ca(filename:join([PrivDir, "new_ca"]), filename:join([PrivDir, "erlangCA", "cacerts.pem"]), filename:join([PrivDir, "server", "cacerts.pem"])), - + ClientOpts = proplists:get_value(crl_cache_opts, Config) ++ [{cacertfile, NewCA}, {crl_check, Check}, {verify, verify_peer}], - + {ClientNode, ServerNode, Hostname} = ssl_test_lib:run_where(Config), - + %% Neither certificate revoked; both succeed. crl_verify_valid(Hostname, ServerNode, ServerOpts1, ClientNode, ClientOpts), crl_verify_valid(Hostname, ServerNode, ServerOpts2, ClientNode, ClientOpts), @@ -465,7 +481,7 @@ crl_hash_dir_collision(Config) when is_list(Config) -> populate_crl_hash_dir(PrivDir, CrlDir, [{CA1, "b68fc624"}, {CA2, "b68fc624"}], - replace), + replace), %% First certificate revoked; first fails, second succeeds. crl_verify_error(Hostname, ServerNode, ServerOpts1, ClientNode, ClientOpts, @@ -476,7 +492,7 @@ crl_hash_dir_collision(Config) when is_list(Config) -> populate_crl_hash_dir(PrivDir, CrlDir, [{CA1, "b68fc624"}, {CA2, "b68fc624"}], - replace), + replace), %% Second certificate revoked; both fail. crl_verify_error(Hostname, ServerNode, ServerOpts1, ClientNode, ClientOpts, @@ -551,19 +567,19 @@ crl_hash_dir_expired(Config) when is_list(Config) -> ok. crl_verify_valid(Hostname, ServerNode, ServerOpts, ClientNode, ClientOpts) -> - Server = ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, - {from, self()}, - {mfa, {ssl_test_lib, - send_recv_result_active, []}}, - {options, ServerOpts}]), - Port = ssl_test_lib:inet_port(Server), - Client = ssl_test_lib:start_client([{node, ClientNode}, {port, Port}, - {host, Hostname}, - {from, self()}, - {mfa, {ssl_test_lib, - send_recv_result_active, []}}, - {options, ClientOpts}]), - + Server = ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, + {from, self()}, + {mfa, {ssl_test_lib, + send_recv_result_active, []}}, + {options, ServerOpts}]), + Port = ssl_test_lib:inet_port(Server), + Client = ssl_test_lib:start_client([{node, ClientNode}, {port, Port}, + {host, Hostname}, + {from, self()}, + {mfa, {ssl_test_lib, + send_recv_result_active, []}}, + {options, ClientOpts}]), + ssl_test_lib:check_result(Client, ok, Server, ok), ssl_test_lib:close(Server), @@ -626,16 +642,16 @@ need_hash_dir(crl_verify_crldp_crlissuer) -> need_hash_dir(_) -> false. -init_certs(_,v1_crl, Config) -> +init_certs(_,v1_crl, Config) -> {[{v2_crls, false}], Config}; init_certs(_,crl_verify_crldp_crlissuer , Config) -> {[{crldp_crlissuer, true}], Config}; -init_certs(_, idp_crl, Config) -> +init_certs(_, idp_crl, Config) -> Port = proplists:get_value(httpd_port, Config), {[{crl_port,Port}, {issuing_distribution_point, true}], Config }; -init_certs(_,_,Config) -> +init_certs(_,_,Config) -> {[], Config}. make_dir_path(PathComponents) -> diff --git a/lib/ssl/test/ssl_crl_white_box_SUITE.erl b/lib/ssl/test/ssl_crl_white_box_SUITE.erl new file mode 100644 index 000000000000..78d302022a7b --- /dev/null +++ b/lib/ssl/test/ssl_crl_white_box_SUITE.erl @@ -0,0 +1,196 @@ +%% +%% %CopyrightBegin% +%% +%% SPDX-License-Identifier: Apache-2.0 +%% +%% Copyright Ericsson AB 2026-2026. 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. +%% +%% %CopyrightEnd% +%% +%% + +-module(ssl_crl_white_box_SUITE). + +-behaviour(ct_suite). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("public_key/include/public_key.hrl"). + +%% Common test +-export([all/0]). + +-export([crl_fetch_internal_ip_blocked/0, + crl_fetch_internal_ip_blocked/1, + crl_fetch_allowed_hosts/0, + crl_fetch_allowed_hosts/1, + crl_fetch_oversized_rejected/0, + crl_fetch_oversized_rejected/1, + crl_malformed_der_ignored/0, + crl_malformed_der_ignored/1, + crl_fetch_non_http_scheme_blocked/0, + crl_fetch_non_http_scheme_blocked/1]). + +all() -> + [crl_fetch_internal_ip_blocked, + crl_fetch_allowed_hosts, + crl_fetch_oversized_rejected, + crl_malformed_der_ignored, + crl_fetch_non_http_scheme_blocked + ]. + +%%-------------------------------------------------------------------- +%% CRL Cache White Box Tests +%%-------------------------------------------------------------------- +crl_fetch_internal_ip_blocked() -> + [{doc, "Verify that CRL fetch to internal/loopback IPs is blocked (SSRF protection)"}]. +crl_fetch_internal_ip_blocked(_Config) -> + %% Test the internal IP detection directly + true = ssl_crl_cache:is_internal_ip({127, 0, 0, 1}), + true = ssl_crl_cache:is_internal_ip({10, 0, 0, 1}), + true = ssl_crl_cache:is_internal_ip({172, 16, 0, 1}), + true = ssl_crl_cache:is_internal_ip({172, 31, 255, 255}), + true = ssl_crl_cache:is_internal_ip({192, 168, 1, 1}), + true = ssl_crl_cache:is_internal_ip({169, 254, 0, 1}), + true = ssl_crl_cache:is_internal_ip({0, 0, 0, 0}), + %% IPv6 + true = ssl_crl_cache:is_internal_ip({0,0,0,0,0,0,0,1}), + true = ssl_crl_cache:is_internal_ip({0,0,0,0,0,0,0,0}), + true = ssl_crl_cache:is_internal_ip({16#fe80,0,0,0,0,0,0,1}), + true = ssl_crl_cache:is_internal_ip({16#fc00,0,0,0,0,0,0,1}), + true = ssl_crl_cache:is_internal_ip({16#fdff,0,0,0,0,0,0,1}), + %% External IPs should pass + false = ssl_crl_cache:is_internal_ip({8, 8, 8, 8}), + false = ssl_crl_cache:is_internal_ip({172, 15, 0, 1}), + false = ssl_crl_cache:is_internal_ip({172, 32, 0, 1}), + false = ssl_crl_cache:is_internal_ip({192, 167, 1, 1}), + false = ssl_crl_cache:is_internal_ip({1, 2, 3, 4}), + %% Test via valid_host with a URL resolving to loopback + URI = uri_string:normalize("http://localhost/crl.pem", [return_map]), + {disallowed, _} = ssl_crl_cache:validate_host(URI, [], {127,0,0,1},inet), + ok. + +crl_fetch_allowed_hosts() -> + [{doc, "Verify that allowed_hosts allowlist permits configured hosts " + "and blocks others"}]. +crl_fetch_allowed_hosts(_Config) -> + %% is_allowed with matching host and default port + true = ssl_crl_cache:is_allowed("crl.example.com", 80, + ["crl.example.com"]), + false = ssl_crl_cache:is_allowed("crl.example.com", 8080, + ["crl.example.com"]), + %% is_allowed with explicit port + true = ssl_crl_cache:is_allowed("crl.example.com", 8080, + ["crl.example.com:8080"]), + %% Not in allowlist + false = ssl_crl_cache:is_allowed("evil.com", 80, + ["crl.example.com"]), + %% Port mismatch (non-default port, no explicit port in allowlist) + false = ssl_crl_cache:is_allowed("crl.example.com", 9090, + ["crl.example.com", "crl.example.com:8080"]), + %% Empty allowlist — always false (falls through to external IP check) + false = ssl_crl_cache:is_allowed("anything.com", 80, []), + URI0 = uri_string:normalize("http://crl.example.com/crl.pem", [return_map]), + URI1 = uri_string:normalize("http://crl.example.com:8080/crl.pem", [return_map]), + URI2 = uri_string:normalize("http://crl.example.com:9090/crl.pem", [return_map]), + {ok, IP} = inet:getaddr("www.example.com", inet), + ok = ssl_crl_cache:validate_host(URI0, [], IP, inet), + ok = ssl_crl_cache:validate_host(URI1, [], IP, inet), + {disallowed, not_standard_port} = ssl_crl_cache:validate_host(URI2, [], IP, inet), + ok. + +crl_fetch_oversized_rejected() -> + [{doc, "Verify that CRL responses exceeding MAX_CRL_SIZE are rejected"}]. +crl_fetch_oversized_rejected(Config) -> + %% Start a local HTTP server that serves an oversized response + PrivDir = proplists:get_value(priv_dir, Config), + ServerRoot = filename:join(PrivDir, "oversized_crl_server"), + DocRoot = filename:join(ServerRoot, "docs"), + ok = filelib:ensure_dir(filename:join(DocRoot, "dummy")), + %% Create a file larger than 10MB + OversizedFile = filename:join(DocRoot, "huge.crl"), + OversizedData = binary:copy(<<0>>, 10 * 1024 * 1024 + 1), + ok = file:write_file(OversizedFile, OversizedData), + application:ensure_started(inets), + {ok, Httpd} = inets:start(httpd, [{server_name, "localhost"}, + {port, 0}, + {server_root, ServerRoot}, + {document_root, DocRoot}, + {ipfamily, inet}]), + [{port, Port}] = httpd:info(Httpd, [port]), + URL = "http://127.0.0.1:" ++ integer_to_list(Port) ++ "/huge.crl", + %% Use a temporary ets table as a valid cache to avoid badarg + %% on cache lookup. + PortStr = integer_to_list(Port), + Cache = ets:new(test_crl_cache, [set, public]), + Mapping = ets:new(test_crl_mapping, [set, public]), + CRLDbInfo = {{Cache, Mapping}, + [{http, 5000}, {owner, normal}, + {allowed_hosts, ["127.0.0.1:" ++ PortStr]}]}, + DP = #'DistributionPoint'{ + distributionPoint = + {fullName, [{uniformResourceIdentifier, URL}]}}, + Result = ssl_crl_cache:lookup(DP, undefined, CRLDbInfo), + %% Should not return a CRL (oversized body rejected) + not_available = Result, + ets:delete(Cache), + ets:delete(Mapping), + inets:stop(httpd, Httpd), + ok. + +crl_malformed_der_ignored() -> + [{doc, "Verify that malformed CRL DER data does not crash ssl_manager"}]. +crl_malformed_der_ignored(_Config) -> + %% Insert a malformed CRL via the documented API — previously this + %% would crash ssl_manager via unguarded public_key:der_decode + ssl_test_lib:clean_start(), + MalformedDER = <<"not a valid DER CertificateList">>, + %% This should not crash ssl_manager + ok = ssl_crl_cache:insert({der, [MalformedDER]}), + %% Verify ssl_manager is still alive + true = is_pid(whereis(ssl_manager)), + %% Also test via URI insertion + ok = ssl_crl_cache:insert("http://example.com/bad.crl", + {der, [MalformedDER]}), + true = is_pid(whereis(ssl_manager)), + ok. + +crl_fetch_non_http_scheme_blocked() -> + [{doc, "Verify that non-HTTP schemes (ftp, file, https) in CRL DPs " + "are silently skipped — only http:// triggers a fetch"}]. +crl_fetch_non_http_scheme_blocked(_Config) -> + %% CRL Distribution Points with non-http schemes should result + %% in not_available — get_crls only matches "http"++_ URLs. + %% Use undefined as CRLDbInfo to avoid needing a live cache for + %% scheme filtering (cache_lookup(_, undefined) -> []). + CRLDbInfo = undefined, + %% ftp scheme + DP_ftp = #'DistributionPoint'{ + distributionPoint = + {fullName, [{uniformResourceIdentifier, + "ftp://evil.com/crl.pem"}]}}, + not_available = ssl_crl_cache:lookup(DP_ftp, undefined, CRLDbInfo), + %% file scheme + DP_file = #'DistributionPoint'{ + distributionPoint = + {fullName, [{uniformResourceIdentifier, + "file:///etc/passwd"}]}}, + not_available = ssl_crl_cache:lookup(DP_file, undefined, CRLDbInfo), + %% https scheme (would cause recursive TLS, not supported) + DP_https = #'DistributionPoint'{ + distributionPoint = + {fullName, [{uniformResourceIdentifier, + "https://ca.example.com/crl"}]}}, + not_available = ssl_crl_cache:lookup(DP_https, undefined, CRLDbInfo), + ok. From 070b2db5c3139af8ff0fbeb5bf97a8c2cd864b12 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Tue, 11 Aug 2026 08:06:15 +0200 Subject: [PATCH 04/17] ssl: Optimize TLS-1.3 ticket handling Avoid bignums and floats. --- lib/ssl/src/tls_bloom_filter.erl | 54 ++++++++++++------------- lib/ssl/src/tls_client_ticket_store.erl | 4 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/lib/ssl/src/tls_bloom_filter.erl b/lib/ssl/src/tls_bloom_filter.erl index ff127bc5f0fc..3b442824a563 100644 --- a/lib/ssl/src/tls_bloom_filter.erl +++ b/lib/ssl/src/tls_bloom_filter.erl @@ -80,37 +80,35 @@ rotate(#{m := M, %%-------------------------------------------------------------------- %% Internal functions ------------------------------------------------ %%-------------------------------------------------------------------- -bit_is_set(<<1:1,_/bitstring>>, 0) -> - true; -bit_is_set(BitField, N) -> - case BitField of - <<_:N,1:1,_/bitstring>> -> - true; - _ -> - false - end. +%% Kirsch-Mitzenmacher-Optimization +%% Compute the two base hashes once, derive K positions arithmetically. +hash(Elem, K, M) -> + H1 = erlang:phash2({Elem, 0}, M), + H2 = erlang:phash2({Elem, 1}, M), + hash(H1, H2, K, M, []). + +hash(_, _, 0, _, Acc) -> + Acc; +hash(H1, H2, K, M, Acc) -> + H = (H1 + (K - 1) * H2) rem M, + hash(H1, H2, K - 1, M, [H | Acc]). + +%% Convert bit position to {ByteOffset, BitWithinByte} and operate on +%% whole bytes — avoids bignum creation from bit-offset binary matching. +bit_is_set(BitField, N) -> + ByteOffset = N bsr 3, %% N div 8 + BitOffset = 7 - (N band 7), %% bit 0 = MSB of byte (matching original semantics) + <<_:ByteOffset/binary, Byte:8, _/binary>> = BitField, + (Byte bsr BitOffset) band 1 =:= 1. set_bits(BitField, []) -> BitField; -set_bits(BitField, [H|T]) -> +set_bits(BitField, [H | T]) -> set_bits(set_bit(BitField, H), T). - -set_bit(BitField, 0) -> - <<_:1,Rest/bitstring>> = BitField, - <<1:1,Rest/bitstring>>; -set_bit(BitField, B) -> - <> = BitField, - <>. - - -%% Kirsch-Mitzenmacher-Optimization -hash(Elem, K, M) -> - hash(Elem, K, M, []). -%% -hash(_, 0, _, Acc) -> - Acc; -hash(Elem, K, M, Acc) -> - H = (erlang:phash2({Elem, 0}, M) + (K - 1) * erlang:phash2({Elem, 1}, M)) rem M, - hash(Elem, K - 1, M, [H|Acc]). +set_bit(BitField, N) -> + ByteOffset = N bsr 3, + BitOffset = 7 - (N band 7), + <> = BitField, + <>. diff --git a/lib/ssl/src/tls_client_ticket_store.erl b/lib/ssl/src/tls_client_ticket_store.erl index 2fbae15d9c3a..608f156dcf25 100644 --- a/lib/ssl/src/tls_client_ticket_store.erl +++ b/lib/ssl/src/tls_client_ticket_store.erl @@ -310,7 +310,9 @@ get_tickets(#state{db = Db} = State, Pid, [Key|T], Acc) -> %% "ticket_age_add" value that was included with the ticket %% (see Section 4.6.1), modulo 2^32. obfuscate_ticket_age(TicketAge, AgeAdd) -> - (TicketAge + AgeAdd) rem round(math:pow(2,32)). + %% Optimization: band 16#ffffffff is the canonical way to do + %% unsigned modulo 2^32 in Erlang, also avoid floats. + (TicketAge + AgeAdd) band 16#ffffffff. remove_tickets(State, []) -> From 049fe6ee61dbc912d843b79c359af6a717653bc5 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Tue, 11 Aug 2026 08:09:00 +0200 Subject: [PATCH 05/17] ssl: Improve TLS-1.3 client ticket handling Handle termination of clients that locked a ticket for PSK resumption attempt when using automated client ticket store. --- lib/ssl/doc/ssl_app.md | 4 +- lib/ssl/src/tls_client_ticket_store.erl | 70 ++++++++++++++++++------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/lib/ssl/doc/ssl_app.md b/lib/ssl/doc/ssl_app.md index 0badf972a12a..ec328a8ffb8e 100644 --- a/lib/ssl/doc/ssl_app.md +++ b/lib/ssl/doc/ssl_app.md @@ -148,11 +148,11 @@ The environment parameters can be set on the command line, for example: early_data_indication extension. Defaults to 16384. Size limit is enforced by both client and server. -- **`client_session_ticket_lifetime = integer() `** - Lifetime of +- **`client_session_ticket_lifetime = pos_integer() `** - Lifetime of session tickets in the client ticket store. Expired tickets are automatically removed. Defaults to 7200 seconds (2 hours). -- **`client_session_ticket_store_size = integer() `** - Sets the +- **`client_session_ticket_store_size = pos_integer() `** - Sets the maximum size of the client session ticket store. Defaults to 1000. Size limit is enforced by dropping old tickets. diff --git a/lib/ssl/src/tls_client_ticket_store.erl b/lib/ssl/src/tls_client_ticket_store.erl index 608f156dcf25..db35e4ae9ce7 100644 --- a/lib/ssl/src/tls_client_ticket_store.erl +++ b/lib/ssl/src/tls_client_ticket_store.erl @@ -45,12 +45,11 @@ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3, format_status/1]). --define(SERVER, ?MODULE). - -record(state, { db, lifetime, - max + max, + user_monitors = #{} %% #{Pid => MonitorRef} }). -record(data, { @@ -143,6 +142,15 @@ handle_cast(_Request, State) -> handle_info(remove_invalid_tickets, State0) -> State = remove_invalid_tickets(State0), {noreply, State}; +handle_info({'DOWN', Ref, process, Pid, _Reason}, #state{user_monitors = Monitors} = State0) -> + case maps:get(Pid, Monitors, undefined) of + undefined -> + {noreply, State0}; + Ref -> + %% Locker terminated without releasing its ticket locks + State = unlock_all_tickets(State0, Pid), + {noreply, State#state{user_monitors = maps:remove(Pid, Monitors)}} + end; handle_info(_Info, State) -> {noreply, State}. @@ -260,11 +268,11 @@ verify_ticket_sni(_, _) -> %% Get tickets that are not locked by another process get_tickets(State, Pid, Keys) -> get_tickets(State, Pid, Keys, []). -%% + get_tickets(_, _, [], []) -> - undefined; %% No tickets found + undefined; %% No tickets found, fallback on full handshake get_tickets(_, _, [], Acc) -> - Acc; + Acc; %% If empty will result in illegal parameter get_tickets(#state{db = Db} = State, Pid, [Key|T], Acc) -> try gb_trees:get(Key, Db) of #data{pos = Pos, @@ -336,24 +344,23 @@ remove_invalid_tickets(#state{db = Db, collect_invalid_tickets(Iter, Lifetime) -> collect_invalid_tickets(Iter, Lifetime, []). -%% + collect_invalid_tickets(Iter0, Lifetime, Acc) -> + %% Tickets that have expired are invalid + %% regardless if a process has locked them + %% for potential usage or not. case gb_trees:next(Iter0) of - {Key, #data{timestamp = Timestamp, - lock = undefined}, Iter} -> + {Key, #data{timestamp = Timestamp}, Iter} -> Age = erlang:monotonic_time(millisecond) - Timestamp, if Age < Lifetime * 1000 -> collect_invalid_tickets(Iter, Lifetime, Acc); true -> collect_invalid_tickets(Iter, Lifetime, [Key|Acc]) end; - {_, _, Iter} -> %% Skip locked tickets - collect_invalid_tickets(Iter, Lifetime, Acc); none -> Acc end. - store_ticket(#state{db = Db0, max = Max} = State, Ticket, CipherSuite, SNI, PSK) -> Timestamp = erlang:monotonic_time(millisecond), Size = gb_trees:size(Db0), @@ -393,14 +400,41 @@ delete_oldest(Db0) -> Db0 end. +lock_tickets(State0, Pid, Keys) -> + State = #state{user_monitors = Monitors0} = set_lock(State0, Pid, Keys, lock), + %% There will only one monitor as ssl:connect will not return until + %% the handshake is finished and the process has released its locks upon + %% successful connect or process dies and monitor DOWN message is received. + Ref = erlang:monitor(process, Pid), + State#state{user_monitors = Monitors0#{Pid => Ref}}. + + +unlock_tickets(State0, Pid, Keys) -> + State = #state{user_monitors = Monitors0} = set_lock(State0, Pid, Keys, unlock), + Ref = maps:get(Pid, Monitors0, undefined), + case Ref of + undefined -> + State; + _ -> + {Ref, Monitors} = maps:take(Pid, Monitors0), + erlang:demonitor(Ref, [flush]), + State#state{user_monitors = Monitors} + end. -lock_tickets(State, Pid, Keys) -> - set_lock(State, Pid, Keys, lock). - - -unlock_tickets(State, Pid, Keys) -> - set_lock(State, Pid, Keys, unlock). +unlock_all_tickets(#state{db = Db0} = State, Pid) -> + Db = unlock_ticket(gb_trees:iterator(Db0), Pid, Db0), + State#state{db = Db}. +unlock_ticket(Iter0, Pid, Db) -> + case gb_trees:next(Iter0) of + {Key, #data{lock = Pid} = Value, Iter} -> + unlock_ticket(Iter, Pid, + gb_trees:update(Key, Value#data{lock = undefined}, Db)); + {_, _, Iter} -> + unlock_ticket(Iter, Pid, Db); + none -> + Db + end. set_lock(State, _, [], _) -> State; From b247b219c7ffd015445c77897e617a8dd12737d2 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Tue, 11 Aug 2026 08:41:16 +0200 Subject: [PATCH 06/17] ssl: Make code more readable --- lib/ssl/src/tls_client_ticket_store.erl | 88 ++++++++++++++----------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/lib/ssl/src/tls_client_ticket_store.erl b/lib/ssl/src/tls_client_ticket_store.erl index db35e4ae9ce7..52cb68bf1a12 100644 --- a/lib/ssl/src/tls_client_ticket_store.erl +++ b/lib/ssl/src/tls_client_ticket_store.erl @@ -206,53 +206,63 @@ do_find_ticket(#state{db = Db, iterate_tickets(Iter0, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize) -> iterate_tickets(Iter0, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, []). -%% + iterate_tickets(Iter0, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc) -> case gb_trees:next(Iter0) of - {Key, #data{cipher_suite = {Cipher, Hash}, - sni = TicketSNI, - ticket = #new_session_ticket{ - extensions = Extensions}, - timestamp = Timestamp, - lock = Lock}, Iter} when Lock =:= undefined orelse - Lock =:= Pid -> - MaxEarlyData = tls_handshake_1_3:get_max_early_data(Extensions), - Age = erlang:monotonic_time(millisecond) - Timestamp, - if Age < Lifetime * 1000 -> - case verify_ticket_sni(SNI, TicketSNI) of - match -> - case lists:member(Cipher, Ciphers) of - true -> - Front = last_elem(Acc), - %% 'Key' can be used with early_data as both - %% block cipher and hash algorithm matches. - %% 'Front' can only be used for session - %% resumption. - case EarlyDataSize =:= undefined orelse - EarlyDataSize =< MaxEarlyData of - true -> - {Key, Front}; - false -> - %% 'Key' cannot be used for early_data as the data - %% to be sent exceeds the max limit for this ticket. - iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, - Lifetime, EarlyDataSize,[Key|Acc]) - end; - false -> - iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, [Key|Acc]) - end; - nomatch -> - iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc) - end; - true -> - iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc) - end; + {Key, #data{cipher_suite = {_,Hash}, + lock = Lock} = Data, Iter} when Lock =:= undefined orelse + Lock =:= Pid -> + handle_available_ticket(Key, Data, Iter, Pid, Ciphers, SNI, Lifetime, EarlyDataSize, Acc); {_, _, Iter} -> iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc); none -> {undefined, last_elem(Acc)} end. +handle_available_ticket(Key, #data{timestamp = Timestamp, + cipher_suite = {_, Hash}} = Data, Iter, Pid, + Ciphers, SNI, Lifetime, EarlyDataSize, Acc) -> + Age = erlang:monotonic_time(millisecond) - Timestamp, + if Age < Lifetime * 1000 -> + maybe_use_ticket(Key, Data, Iter, Pid, Ciphers, SNI, Lifetime, + EarlyDataSize, Acc); + true -> + iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc) + end. + +maybe_use_ticket(Key, #data{cipher_suite = {Cipher, Hash}, + sni = TicketSNI, + ticket = #new_session_ticket{ + extensions = Extensions}}, Iter, Pid, Ciphers, SNI, Lifetime, + EarlyDataSize, Acc) -> + MaxEarlyData = tls_handshake_1_3:get_max_early_data(Extensions), + case verify_ticket_sni(SNI, TicketSNI) of + match -> + case lists:member(Cipher, Ciphers) of + true -> + Front = last_elem(Acc), + %% 'Key' can be used with early_data as both + %% block cipher and hash algorithm matches. + %% 'Front' can only be used for session + %% resumption. + case EarlyDataSize =:= undefined orelse + EarlyDataSize =< MaxEarlyData of + true -> + {Key, Front}; + false -> + %% 'Key' cannot be used for early_data as the data + %% to be sent exceeds the max limit for this ticket. + iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, + Lifetime, EarlyDataSize,[Key|Acc]) + end; + false -> + iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, + EarlyDataSize, [Key|Acc]) + end; + nomatch -> + iterate_tickets(Iter, Pid, Ciphers, Hash, SNI, Lifetime, EarlyDataSize, Acc) + end. + last_elem([_|_] = L) -> lists:last(L); last_elem([]) -> From dff672ad16a7bcb87fd1c5a64f15f5d947cf0168 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Tue, 11 Aug 2026 15:50:33 +0200 Subject: [PATCH 07/17] ssl: Anti-replay clarification --- lib/ssl/src/ssl.erl | 10 +++++++++- lib/ssl/src/ssl_config.erl | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/ssl/src/ssl.erl b/lib/ssl/src/ssl.erl index ce6c3d34c8d1..ad084c080417 100644 --- a/lib/ssl/src/ssl.erl +++ b/lib/ssl/src/ssl.erl @@ -2074,9 +2074,17 @@ Options only relevant for TLS-1.3. Configures if the server accepts (`enabled`) or rejects (`disabled`) early data sent by a client. The default value is `disabled`. + + > #### Warning {: .warning } + > 0-RTT data is inherently replay-vulnerable by TLS 1.3 design. The + > mitigation is application-level idempotency or server-side anti-replay. + > The server side mechanisms for anti-replay are stateful tickets or stateless + > tickets with a configured Bloom filter. + """. -type server_option_tls13() :: {session_tickets, SessionTickets:: disabled | stateful | stateless | - stateful_with_cert | stateless_with_cert} | + stateful_with_cert | + stateless_with_cert} | {stateless_tickets_seed, TicketSeed::binary()} | {anti_replay, '10k' | '100k' | {BloomFilterWindowSize::pos_integer(), diff --git a/lib/ssl/src/ssl_config.erl b/lib/ssl/src/ssl_config.erl index 9a2c3bfe0e3d..bcc70580b69d 100644 --- a/lib/ssl/src/ssl_config.erl +++ b/lib/ssl/src/ssl_config.erl @@ -1025,6 +1025,13 @@ opt_tickets(UserOpts, #{versions := Versions} = Opts, #{role := server}) -> option_incompatible(STS =/= undefined andalso not Stateless, [stateless_tickets_seed, {session_tickets, SessionTickets}]), + case EarlyData =:= enabled andalso Stateless andalso AntiReplay =:= undefined of + true -> + ?LOG_WARNING("early_data enabled without anti_replay; " + "0-RTT data is replayable"); + false -> + ok + end, assert_client_only(use_ticket, UserOpts), Opts#{session_tickets => SessionTickets, early_data => EarlyData, anti_replay => AntiReplay, stateless_tickets_seed => STS}. From 8c6ea639872051ecec7c741ee04c635bad650578 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Tue, 11 Aug 2026 17:32:36 +0200 Subject: [PATCH 08/17] ssl dist: Harden tls dist By default run TLS-1.3 instead of TLS-1.2, but keep TLS-1.2 support for smoother upgrade. Make sure net_kernel allowed is honored in all cases. --- lib/ssl/doc/guides/ssl_distribution.md | 9 +++- lib/ssl/src/inet_epmd_tls_socket.erl | 68 +++++++++++++++++++++----- lib/ssl/src/inet_tls_dist.erl | 4 +- lib/ssl/src/tls_sender.erl | 3 +- lib/ssl/test/ssl_dist_SUITE.erl | 10 ++-- 5 files changed, 69 insertions(+), 25 deletions(-) diff --git a/lib/ssl/doc/guides/ssl_distribution.md b/lib/ssl/doc/guides/ssl_distribution.md index 09912d35972d..f94ceb92be61 100644 --- a/lib/ssl/doc/guides/ssl_distribution.md +++ b/lib/ssl/doc/guides/ssl_distribution.md @@ -206,13 +206,20 @@ the node name you are connecting to. This only works if the the server certificate is issued to the name [`atom_to_list(TargetNode)`](`atom_to_list/1`). -For the server it is also possible to use the option `{verify, verify_peer}` and +For the server it is also possible, and recommended, to use the option `{verify, verify_peer}` and the server will only accept client connections with certificates that are trusted by a root certificate that the server knows. A client that presents an untrusted certificate will be rejected. This option is preferably combined with `{fail_if_no_peer_cert, true}` or a client will still be accepted if it does not present any certificate. +> #### Note {: .note } +net_kernel:allow/1 node restrictions rely on verifying the peer +certificate. To enforce them, the server must set `{verify, +verify_peer}` and `{fail_if_no_peer_cert, true}` (default when +verify_peer is set). Without these options, any node can connect +regardless of the allowed list. + A node started in this way is fully functional, using TLS as the distribution protocol. diff --git a/lib/ssl/src/inet_epmd_tls_socket.erl b/lib/ssl/src/inet_epmd_tls_socket.erl index fbc84ab82b9e..5a28676c3b2c 100644 --- a/lib/ssl/src/inet_epmd_tls_socket.erl +++ b/lib/ssl/src/inet_epmd_tls_socket.erl @@ -102,16 +102,20 @@ listen_close(ListenSocket) -> %% ------------------------------------------------------------ accept_open(NetAddress, ListenSocket) -> - maybe - {ok, Socket} ?= - socket:accept(ListenSocket), - {ok, #{ addr := Ip }} ?= - socket:sockname(Socket), - {ok, #{ addr := PeerIp, port := PeerPort }} ?= - socket:peername(Socket), - inet_epmd_socket:check_ip(Ip, PeerIp), - accept_handshake(NetAddress, Socket, PeerIp, PeerPort) - else + case socket:accept(ListenSocket) of + {ok, Socket} -> + maybe + {ok, #{ addr := Ip }} ?= + socket:sockname(Socket), + {ok, #{ addr := PeerIp, port := PeerPort }} ?= + socket:peername(Socket), + inet_epmd_socket:check_ip(Ip, PeerIp), + accept_handshake(NetAddress, Socket, PeerIp, PeerPort) + else + {error, Reason} -> + _ = socket:close(Socket), + exit({?FUNCTION_NAME, Reason}) + end; {error, Reason} -> exit({?FUNCTION_NAME, Reason}) end. @@ -126,10 +130,18 @@ accept_handshake(#net_address{ family = Family }, Socket, PeerIp, PeerPort) -> net_kernel:connecttime()) of {ok, SslSocket} -> - {SslSocket, {PeerIp, PeerPort}}; + %% Verify peer cert matches host part of node names — mirrors + %% internal inet_tls_dist function allowed_nodes/2 which the classic path + %% runs post-handshake. Without this, any cert chaining + %% to the trusted CA can claim any node name. + case check_allowed_nodes(SslSocket, PeerIp) of + ok -> + {SslSocket, {PeerIp, PeerPort}}; + {error, Reason} -> + ssl:close(SslSocket), + exit({?FUNCTION_NAME, Reason}) + end; {error, {options, _} = Reason} = Error -> - %% Bad options: that's probably our fault. - %% Let's log that. ?LOG_ERROR( "Cannot accept TLS distribution connection: ~s~n", [ssl:format_error(Error)]), @@ -140,6 +152,36 @@ accept_handshake(#net_address{ family = Family }, Socket, PeerIp, PeerPort) -> exit({?FUNCTION_NAME, Reason}) end. +check_allowed_nodes(SslSocket, PeerIp) -> + {ok, Allowed} = net_kernel:allowed(), + case Allowed of + [] -> + %% No restriction configured — allow all + ok; + _ -> + case ssl:peercert(SslSocket) of + {ok, PeerCertDER} -> + PeerCert = public_key:pkix_decode_cert(PeerCertDER, otp), + AllowedHosts = inet_tls_dist:allowed_hosts(Allowed), + case + public_key:pkix_verify_hostname( + PeerCert, + [{ip, PeerIp} | [{dns_id, Host} || Host <- AllowedHosts]]) + of + true -> ok; + false -> + ?LOG_ERROR( + "** Connection attempt from " + "disallowed node ~p ** ~n", [PeerIp]), + {error, cert_no_hostname_nor_ip_match} + end; + {error, no_peercert} -> + %% No peer cert — allow (same as classic path) + ok; + {error, _} = Error -> + Error + end + end. %% ------------------------------------------------------------ accept_controller(_NetAddress, Controller, SslSocket) -> maybe diff --git a/lib/ssl/src/inet_tls_dist.erl b/lib/ssl/src/inet_tls_dist.erl index 798f94275fbd..e321bc74b41e 100644 --- a/lib/ssl/src/inet_tls_dist.erl +++ b/lib/ssl/src/inet_tls_dist.erl @@ -35,7 +35,7 @@ -export([fam_select/2, fam_address/1, fam_listen/3, fam_accept/2, fam_accept_connection/6, fam_setup/6]). --export([verify_client/3, cert_nodes/1, +-export([verify_client/3, cert_nodes/1, allowed_hosts/1, get_ssl_client_options/0, get_ssl_server_options/1]). %% kTLS helpers @@ -861,7 +861,7 @@ get_ssl_options(Type) -> dist_defaults(Opts) -> case proplists:get_value(versions, Opts, undefined) of undefined -> - [{versions, ['tlsv1.2']} | Opts]; + [{versions, ['tlsv1.3', 'tlsv1.2']} | Opts]; _ -> Opts end. diff --git a/lib/ssl/src/tls_sender.erl b/lib/ssl/src/tls_sender.erl index d0e810e5f7b6..df302498b835 100644 --- a/lib/ssl/src/tls_sender.erl +++ b/lib/ssl/src/tls_sender.erl @@ -370,8 +370,7 @@ connection(info, tick, #data{buff = Buff} = StateData) -> case Buff of undefined -> Data = [<<0:32>>], % encode_packet(4, <<>>) - From = {self(), undefined}, - send_application_data(Data, From, connection, StateData); + send_application_data(Data, dist_data, connection, StateData); _ -> %% No need to send tick, have outgoing data in buffer {keep_state_and_data, []} end; diff --git a/lib/ssl/test/ssl_dist_SUITE.erl b/lib/ssl/test/ssl_dist_SUITE.erl index ef861a03207e..9f68d9ddfd04 100644 --- a/lib/ssl/test/ssl_dist_SUITE.erl +++ b/lib/ssl/test/ssl_dist_SUITE.erl @@ -457,9 +457,9 @@ connect(Control, Host, Port) -> plain_options() -> [{doc,"Test specifying tls options not related to certificate verification"}]. plain_options(Config) when is_list(Config) -> - TLSOpts = "-ssl_dist_opt server_secure_renegotiate true " - "client_secure_renegotiate true " - "server_hibernate_after 500 client_hibernate_after 500", + TLSOpts = "-ssl_dist_opt " + "server_hibernate_after 500 " + "client_hibernate_after 500", gen_dist_test(plain_options_test, [{tls_only_basic_opts, TLSOpts} | Config]). @@ -468,12 +468,8 @@ plain_verify_options() -> [{doc,"Test specifying tls options including certificate verification options"}]. plain_verify_options(Config) when is_list(Config) -> TLSOpts = "-ssl_dist_opt " - "server_secure_renegotiate true " - "client_secure_renegotiate true " "server_hibernate_after 500 " "client_hibernate_after 500 " - "server_reuse_sessions true " - "client_reuse_sessions true " "server_depth 1 " "client_depth 1 ", gen_dist_test(plain_verify_options_test, [{tls_verify_opts, TLSOpts} | Config]). From 643fc4a4d37b47b249a49b78c3247cee3d531f82 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 10 Aug 2026 12:13:48 +0200 Subject: [PATCH 09/17] dtls: Correct missbehavior Add missing state update Could cause connection failure instead of DTLS alert ignoring. Fix fragment reassembling bug and handle unexpected epoch 0 message. Mitigate DoS attack possiblities. --- lib/ssl/src/dtls_gen_connection.erl | 8 ++++--- lib/ssl/src/dtls_handshake.erl | 34 +++++++++++++++++++---------- lib/ssl/src/dtls_record.erl | 17 ++++++++++----- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/lib/ssl/src/dtls_gen_connection.erl b/lib/ssl/src/dtls_gen_connection.erl index 6552acb7c4d3..2a2f2007e579 100644 --- a/lib/ssl/src/dtls_gen_connection.erl +++ b/lib/ssl/src/dtls_gen_connection.erl @@ -887,15 +887,17 @@ next_dtls_record(Data, StateName, #state{protocol_buffers = #protocol_buffers{ -decode_cipher_text(#state{protocol_buffers = #protocol_buffers{dtls_cipher_texts = [ CT | Rest]} = Buffers, +decode_cipher_text(#state{protocol_buffers = #protocol_buffers{dtls_cipher_texts = [ CT | Rest]} = + Buffers, connection_states = ConnStates0} = State) -> case dtls_record:decode_cipher_text(CT, ConnStates0) of - {Plain, ConnStates} -> + {Plain, ConnStates} -> {Plain, State#state{protocol_buffers = Buffers#protocol_buffers{dtls_cipher_texts = Rest}, connection_states = ConnStates}}; #alert{} = Alert -> - {Alert, State} + {Alert, State#state{protocol_buffers = + Buffers#protocol_buffers{dtls_cipher_texts = Rest}}} end. decode_alerts(Bin) -> diff --git a/lib/ssl/src/dtls_handshake.erl b/lib/ssl/src/dtls_handshake.erl index fc0a370ecf4b..61b85e248f06 100644 --- a/lib/ssl/src/dtls_handshake.erl +++ b/lib/ssl/src/dtls_handshake.erl @@ -318,24 +318,34 @@ address_to_bin({A,B,C,D,E,F,G,H}, Port) -> <>. %%-------------------------------------------------------------------- - handle_fragments(Version, FragmentData, Buffers0, Options, Acc) -> - Fragments = decode_handshake_fragments(FragmentData), - do_handle_fragments(Version, Fragments, Buffers0, Options, Acc). + try decode_handshake_fragments(FragmentData) of + Fragments -> + do_handle_fragments(Version, Fragments, Buffers0, Options, Acc) + catch + error:_Reason -> + throw(?ALERT_REC(?FATAL, ?DECODE_ERROR, malformed_handshake_fragment)) + end. do_handle_fragments(_, [], Buffers, _Options, Acc) -> {lists:reverse(Acc), Buffers}; -do_handle_fragments(Version, [Fragment | Fragments], Buffers0, #{log_level := LogLevel} = Options, Acc) -> - case reassemble(Version, Fragment, Buffers0) of - {more_data, Buffers} when Fragments == [] -> - {lists:reverse(Acc), Buffers}; - {more_data, Buffers} -> - do_handle_fragments(Version, Fragments, Buffers, Options, Acc); - {{Handshake, _} = HsPacket, Buffers} -> +do_handle_fragments(Version, [Fragment | Fragments], Buffers0, + #{log_level := LogLevel} = Options, Acc) -> + try reassemble(Version, Fragment, Buffers0) of + {more_data, Buffers} when Fragments == [] -> + {lists:reverse(Acc), Buffers}; + {more_data, Buffers} -> + do_handle_fragments(Version, Fragments, Buffers, Options, Acc); + {{Handshake, _} = HsPacket, Buffers} -> ssl_logger:debug(LogLevel, inbound, 'handshake', Handshake), - do_handle_fragments(Version, Fragments, Buffers, Options, [HsPacket | Acc]) + do_handle_fragments(Version, Fragments, Buffers, Options, [HsPacket | Acc]) + catch + error:Reason:ST -> + ?SSL_LOG(debug, reassemble_fragment_error, [{reason, Reason}, {stacktrace, ST}]), + throw(?ALERT_REC(?FATAL, ?DECODE_ERROR, malformed_handshake_fragment)) end. + decode_handshake(Version, <>) -> decode_handshake(Version, Type, Bin). @@ -379,7 +389,7 @@ decode_tls_handshake(Version, Tag, Msg) -> ssl_handshake:decode_handshake(TLSVersion, Tag, Msg). decode_handshake_fragments(<<>>) -> - [<<>>]; + []; decode_handshake_fragments(< - Saved. + Saved; +%% This can be an attack on read side so return undefined so we can trigger alert +%% on write side this would be a programming error, so let it crash. +get_connection_state_by_epoch(_, _, read) -> + undefined. set_connection_state_by_epoch(WriteState, Epoch, #{current_write := #{epoch := Epoch}} = States, write) -> @@ -253,10 +257,13 @@ encode_plain_text(Type, Version, Epoch, Data, ConnectionStates) -> %% Decoding %%==================================================================== -decode_cipher_text(#ssl_tls{epoch = Epoch} = CipherText, ConnnectionStates0) -> - ReadState = get_connection_state_by_epoch(Epoch, ConnnectionStates0, read), - decode_cipher_text(CipherText, ReadState, ConnnectionStates0). - +decode_cipher_text(#ssl_tls{epoch = Epoch} = CipherText, ConnectionStates0) -> + case get_connection_state_by_epoch(Epoch, ConnectionStates0, read) of + undefined -> + ?ALERT_REC(?FATAL, ?BAD_RECORD_MAC); + ReadState -> + decode_cipher_text(CipherText, ReadState, ConnectionStates0) + end. %%==================================================================== %% Protocol version handling From 6e5771b726f4855e02119ccd827293e8c7b0c0dd Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Thu, 13 Aug 2026 11:02:55 +0200 Subject: [PATCH 10/17] ssl: TLS-1.3 RFC compliance Correct TLS-1.3 ALPN check. Add missing client santity check. Various MUST alerts for extension handling. --- lib/ssl/src/ssl_handshake.erl | 18 +++++- lib/ssl/src/tls_client_connection_1_3.erl | 71 ++++++++++++++++++----- lib/ssl/src/tls_handshake_1_3.erl | 23 ++++++-- lib/ssl/src/tls_server_connection_1_3.erl | 3 +- 4 files changed, 90 insertions(+), 25 deletions(-) diff --git a/lib/ssl/src/ssl_handshake.erl b/lib/ssl/src/ssl_handshake.erl index 35b95acb377c..85564bf42e08 100644 --- a/lib/ssl/src/ssl_handshake.erl +++ b/lib/ssl/src/ssl_handshake.erl @@ -3160,12 +3160,15 @@ decode_extensions(<>, Version, MessageType, Acc) - when Len =:= 2, SelectedVersion =:= 16#0304 -> + ?BYTE(Major), ?BYTE(Minor), Rest/binary>>, Version, MessageType, Acc) + when Len =:= 2 -> assert_unique_extension(server_hello_selected_version, Acc), + %% Decode any version — RFC 8446 §4.2.1 requires the client to + %% abort with illegal_parameter if this is not TLS 1.3; that check + %% is performed downstream in validate_server_version/1. decode_extensions(Rest, Version, MessageType, Acc#{server_hello_selected_version => - #server_hello_selected_version{selected_version = ?TLS_1_3}}); + #server_hello_selected_version{selected_version = {Major, Minor}}}); decode_extensions(<>, @@ -3214,6 +3217,15 @@ decode_extensions(< <> = ExtData, assert_unique_extension(pre_shared_key, Acc), + %% RFC 8446 §4.2.11: pre_shared_key MUST be the last extension in + %% the ClientHello. Abort if trailing data follows. + case Rest of + <<>> -> + ok; + _ -> + throw(?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, + pre_shared_key_not_last_extension)) + end, decode_extensions(Rest, Version, MessageType, Acc#{pre_shared_key => #pre_shared_key_client_hello{ diff --git a/lib/ssl/src/tls_client_connection_1_3.erl b/lib/ssl/src/tls_client_connection_1_3.erl index 0105e4cdbaa4..32f9197c2085 100644 --- a/lib/ssl/src/tls_client_connection_1_3.erl +++ b/lib/ssl/src/tls_client_connection_1_3.erl @@ -290,8 +290,9 @@ wait_sh(internal, #server_hello{session_id = ?EMPTY_ID} = Hello, {State1, wait_ee} -> tls_gen_connection:next_event(wait_ee, no_record, State1) end; -wait_sh(internal, #server_hello{} = Hello, - #state{protocol_specific = PS, +wait_sh(internal, #server_hello{session_id = SessionId} = Hello, + #state{session = #session{session_id = SessionId}, + protocol_specific = PS, ssl_options = SSLOpts} = State0) when not is_map_key(middlebox_comp_mode, SSLOpts) -> IsRetry = maps:get(hello_retry, PS, false), @@ -307,6 +308,12 @@ wait_sh(internal, #server_hello{} = Hello, tls_gen_connection:next_event(hello_middlebox_assert, no_record, State1) end; +wait_sh(internal, #server_hello{}, + #state{ssl_options = SSLOpts} = State0) + when not is_map_key(middlebox_comp_mode, SSLOpts) -> + %% RFC 8446 §4.1.3: legacy_session_id_echo does not match — abort. + Alert = ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, session_id_echo_mismatch), + ssl_gen_statem:handle_own_alert(Alert, ?STATE(wait_sh), State0); wait_sh(internal, {protocol_record, #ssl_tls{type = ?APPLICATION_DATA}}, State) -> Alert = ?ALERT_REC(?FATAL, ?UNEXPECTED_MESSAGE, application_data_before_initial_handshake), ssl_gen_statem:handle_own_alert(Alert, ?STATE(wait_sh), State); @@ -426,8 +433,10 @@ wait_cv(internal, #certificate_verify_1_3{} = CertificateVerify, State0) -> {Ref,Maybe} = tls_gen_connection_1_3:do_maybe(), try + State1 = Maybe(tls_handshake_1_3:verify_signature_algorithm(State0, + CertificateVerify)), {State, NextState} - = Maybe(tls_handshake_1_3:verify_certificate_verify(State0, + = Maybe(tls_handshake_1_3:verify_certificate_verify(State1, CertificateVerify)), tls_gen_connection:next_event(NextState, no_record, State) catch @@ -686,7 +695,9 @@ do_handle_exlusive_1_3_hello_or_hello_retry_request( %% alert. case KeyShare of #key_share_hello_retry_request{} -> - Maybe(validate_selected_group(SelectedGroup, ClientGroups)); + OfferedKeyShareGroups = maps:get(psk_groups, SslOpts, [hd(ClientGroups)]), + Maybe(validate_selected_group(SelectedGroup, ClientGroups, + OfferedKeyShareGroups)); _ -> ok end, @@ -782,6 +793,10 @@ handle_server_hello(#server_hello{cipher_suite = SelectedCipherSuite, ServerKeyShare = server_share(maps:get(key_share, Extensions)), ServerPreSharedKey = maps:get(pre_shared_key, Extensions, undefined), + %% RFC 8446 §4.1.4: If the version in the second ServerHello + %% differs from the HRR, abort with illegal_parameter. + Maybe(validate_server_version(Extensions)), + %% Go to state 'start' if server replies with 'HelloRetryRequest'. Maybe(tls_handshake_1_3:maybe_hello_retry_request(ServerHello, State0)), @@ -835,7 +850,7 @@ handle_encrypted_extensions(Extensions, State0) -> {Ref, Maybe} = tls_gen_connection_1_3:do_maybe(), try ALPNProtocol0 = maps:get(alpn, Extensions, undefined), - ALPNProtocol = decode_alpn(ALPNProtocol0), + ALPNProtocol = Maybe(decode_alpn(ALPNProtocol0)), EarlyDataIndication = maps:get(early_data, Extensions, undefined), %% RFC 6066: handle received/expected maximum fragment length @@ -1019,10 +1034,14 @@ maybe_queue_cert_verify(_Certificate, end. decode_alpn(undefined) -> - undefined; + {ok, undefined}; decode_alpn(Encoded) -> - [Decoded] = ssl_handshake:decode_alpn(Encoded), - Decoded. + case ssl_handshake:decode_alpn(Encoded) of + [Decoded] -> + {ok, Decoded}; + _ -> + {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, invalid_alpn)} + end. %% Verify that selected group is offered by the client. validate_server_key_share([], _) -> @@ -1033,14 +1052,38 @@ validate_server_key_share([_|ClientGroups], #key_share_entry{} = ServerKeyShare) validate_server_key_share(ClientGroups, ServerKeyShare). -validate_selected_group(SelectedGroup, [SelectedGroup|_]) -> +%% RFC 8446 §4.1.4: supported_versions in the ServerHello must be TLS 1.3. +validate_server_version(Extensions) -> + case maps:get(server_hello_selected_version, Extensions, undefined) of + #server_hello_selected_version{selected_version = ?TLS_1_3} -> + ok; + undefined -> + %% Missing supported_versions — not a valid TLS 1.3 ServerHello + {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, + "ServerHello missing supported_versions extension")}; + _ -> + {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, + "ServerHello supported_versions changed after HelloRetryRequest")} + end. + +validate_selected_group(undefined, _, _) -> + %% HRR with no key_share and no cookie — RFC 8446 §4.1.4 violation {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, - "Selected group sent by the server shall not correspond to a group" - " which was provided in the key_share extension")}; -validate_selected_group(SelectedGroup, ClientGroups) -> - case lists:member(SelectedGroup, ClientGroups) of + "HelloRetryRequest contains neither key_share nor cookie")}; +validate_selected_group(SelectedGroup, SupportedGroups, OfferedKeyShareGroups) -> + %% RFC 8446 §4.2.8: + %% (1) selected_group MUST be in supported_groups + %% (2) selected_group MUST NOT already be in the offered key_share + case lists:member(SelectedGroup, SupportedGroups) of true -> - ok; + case lists:member(SelectedGroup, OfferedKeyShareGroups) of + true -> + {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, + "Selected group sent by the server shall not correspond to a group" + " which was provided in the key_share extension")}; + false -> + ok + end; false -> {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, "Selected group sent by the server shall correspond to a group" diff --git a/lib/ssl/src/tls_handshake_1_3.erl b/lib/ssl/src/tls_handshake_1_3.erl index 1ddfeb298b3d..88bf909fe910 100644 --- a/lib/ssl/src/tls_handshake_1_3.erl +++ b/lib/ssl/src/tls_handshake_1_3.erl @@ -82,6 +82,7 @@ get_pre_shared_key/4, get_pre_shared_key_early_data/2, get_supported_groups/1, + get_client_signatures/1, generate_kex_keys/1, hybrid_algs/1, calculate_client_early_traffic_secret/5, @@ -1193,6 +1194,14 @@ get_supported_groups(undefined = Groups) -> get_supported_groups(#supported_groups{supported_groups = Groups}) -> {ok, Groups}. +get_client_signatures(Extensions) -> + case maps:get(signature_algs, Extensions, undefined) of + undefined -> + {error, ?ALERT_REC(?FATAL, ?MISSING_EXTENSION, {signature_algs, Extensions})}; + Value -> + {ok, get_signature_scheme_list(Value)} + end. + generate_kex_keys(secp256r1) -> public_key:generate_key({namedCurve, secp256r1}); generate_kex_keys(secp384r1) -> @@ -1569,15 +1578,17 @@ verify_signature_algorithm(#state{ true -> {ok, maybe_update_selected_sign_alg(State, PeerSignAlg, Role)}; false -> - {error, {?ALERT_REC(?FATAL, ?HANDSHAKE_FAILURE, + {error, {?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER, "CertificateVerify uses unsupported signature algorithm"), State}} end. - -maybe_update_selected_sign_alg(#state{session = Session} = State, SignAlg, client) -> - State#state{session = Session#session{sign_alg = SignAlg}}; -maybe_update_selected_sign_alg(State, _, _) -> - State. +maybe_update_selected_sign_alg(State, _, client) -> + %% Client verifies the server's scheme but doesn't record it — + %% session.sign_alg holds the client's own signature algorithm + %% for its CertificateVerify. + State; +maybe_update_selected_sign_alg(#state{session = Session} = State, SignAlg, server) -> + State#state{session = Session#session{sign_alg = SignAlg}}. context_string(server) -> <<"TLS 1.3, server CertificateVerify">>; diff --git a/lib/ssl/src/tls_server_connection_1_3.erl b/lib/ssl/src/tls_server_connection_1_3.erl index f17ea98f0588..1597c9d75920 100644 --- a/lib/ssl/src/tls_server_connection_1_3.erl +++ b/lib/ssl/src/tls_server_connection_1_3.erl @@ -443,8 +443,7 @@ do_handle_client_hello(#client_hello{cipher_suites = ClientCiphers, ClientALPN0 = maps:get(alpn, Extensions, undefined), ClientALPN = ssl_handshake:decode_alpn(ClientALPN0), - ClientSignAlgs = tls_handshake_1_3:get_signature_scheme_list( - maps:get(signature_algs, Extensions, undefined)), + ClientSignAlgs = Maybe(tls_handshake_1_3:get_client_signatures(Extensions)), ClientSignAlgsCert = tls_handshake_1_3:get_signature_scheme_list( maps:get(signature_algs_cert, Extensions, undefined)), CertAuths = tls_handshake_1_3:get_certificate_authorities(maps:get(certificate_authorities, From 993d5585bd35566e6ffc9c4b635e41c43ec15609 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Thu, 13 Aug 2026 11:42:56 +0200 Subject: [PATCH 11/17] ssl: Gracefulness Better safe guard, try of to narrow here. --- lib/ssl/src/ssl_session.erl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ssl/src/ssl_session.erl b/lib/ssl/src/ssl_session.erl index 057bd7d3ef20..57d1232fb1bd 100644 --- a/lib/ssl/src/ssl_session.erl +++ b/lib/ssl/src/ssl_session.erl @@ -146,9 +146,9 @@ valid_session(#session{time_stamp = TimeStamp}, LifeTime) -> do_client_select_session({_, _, #{reuse_session := {SessionId, SessionData}}}, _, _, NewSession, _) when is_binary(SessionId) andalso is_binary(SessionData) -> - try binary_to_term(SessionData, [safe]) of - Session -> - Session#session{is_resumable = true} + try + Session = binary_to_term(SessionData, [safe]), + Session#session{is_resumable = true} catch _:_ -> NewSession#session{session_id = ?EMPTY_ID} From a52135c13c343973b65afa5c1d56d94b6b8c475f Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Thu, 13 Aug 2026 14:50:02 +0200 Subject: [PATCH 12/17] ssl: TLS-1.2 RFC compliance --- lib/ssl/src/ssl_gen_statem.erl | 5 +++++ lib/ssl/src/ssl_handshake.erl | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ssl/src/ssl_gen_statem.erl b/lib/ssl/src/ssl_gen_statem.erl index a6099961717b..efb4e8704771 100644 --- a/lib/ssl/src/ssl_gen_statem.erl +++ b/lib/ssl/src/ssl_gen_statem.erl @@ -729,6 +729,11 @@ downgrade(Type, Event, State) -> %%==================================================================== %% Event/Msg handling %%==================================================================== +handle_common_event(internal, {handshake, {#hello_request{}, _Raw}}, StateName, + #state{static_env = #static_env{role = client}}) + when StateName =/= connection -> + %% %% RFC 5246 §7.4.1.1: Ignore this message client is already in negotiation + keep_state_and_data; handle_common_event(internal, {handshake, {Handshake, Raw}}, StateName, #state{handshake_env = #handshake_env{tls_handshake_history = Hist0} = HsEnv, connection_env = #connection_env{negotiated_version = _Version}} = State0) -> diff --git a/lib/ssl/src/ssl_handshake.erl b/lib/ssl/src/ssl_handshake.erl index 85564bf42e08..6e17eeab0c2f 100644 --- a/lib/ssl/src/ssl_handshake.erl +++ b/lib/ssl/src/ssl_handshake.erl @@ -3955,7 +3955,7 @@ handle_renegotiation_info(_, _RecordCB, _, #renegotiation_info{renegotiated_conn ConnectionStates, false, _) -> {ok, ssl_record:set_renegotiation_flag(true, ConnectionStates)}; -handle_renegotiation_info(_, _RecordCB, server, undefined, ConnectionStates, _, CipherSuites) -> +handle_renegotiation_info(_, _RecordCB, server, undefined, ConnectionStates, false, CipherSuites) -> case is_member(?TLS_EMPTY_RENEGOTIATION_INFO_SCSV, CipherSuites) of true -> {ok, ssl_record:set_renegotiation_flag(true, ConnectionStates)}; From 7db8ecf75dbef77129e862e6f6d2a8af09340dab Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Fri, 14 Aug 2026 17:08:46 +0200 Subject: [PATCH 13/17] ssl: RFC compliance Ensure signature algs are checked for intermediates. If user hade own verify fun these would previously be missed. Add missing extension assertion. --- lib/ssl/src/ssl_handshake.erl | 18 +++++++++++++----- lib/ssl/src/ssl_trace.erl | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/ssl/src/ssl_handshake.erl b/lib/ssl/src/ssl_handshake.erl index 6e17eeab0c2f..764c05220bef 100644 --- a/lib/ssl/src/ssl_handshake.erl +++ b/lib/ssl/src/ssl_handshake.erl @@ -2185,14 +2185,15 @@ path_validation_options(Opts, ValidationFunAndState) -> apply_user_fun(Fun, OtpCert, DerCert, VerifyResult0, UserState0, SslState, CertPath, LogLevel) when VerifyResult0 == valid; VerifyResult0 == valid_peer -> - VerifyResult = maybe_check_hostname(OtpCert, VerifyResult0, SslState, LogLevel), + VerifyResult = tls_verify_fun(OtpCert, VerifyResult0, SslState, LogLevel), case apply_fun(Fun, OtpCert, DerCert, VerifyResult, UserState0) of {Valid, UserState} when (Valid == valid) orelse (Valid == valid_peer) -> case cert_status_check(OtpCert, SslState, VerifyResult, CertPath, LogLevel) of valid -> {Valid, {SslState, UserState}}; Result -> - apply_user_fun(Fun, OtpCert, DerCert, Result, UserState, SslState, CertPath, LogLevel) + apply_user_fun(Fun, OtpCert, DerCert, Result, UserState, + SslState, CertPath, LogLevel) end; {fail, _} = Fail -> Fail @@ -2214,15 +2215,20 @@ apply_fun(Fun, OtpCert, DerCert, ExtensionOrError, UserState) -> Fun(OtpCert, ExtensionOrError, UserState) end. -maybe_check_hostname(OtpCert, valid_peer, SslState, LogLevel) -> +tls_verify_fun(OtpCert, valid_peer, SslState, LogLevel) -> case ssl_certificate:validate(OtpCert, valid_peer, SslState, LogLevel) of {valid, _} -> valid_peer; {fail, Reason} -> Reason end; -maybe_check_hostname(_, valid, _, _) -> - valid. +tls_verify_fun(OtpCert, valid, SslState, LogLevel) -> + case ssl_certificate:validate(OtpCert, valid, SslState, LogLevel) of + {valid, _} -> + valid; + {fail, Reason} -> + Reason + end. path_validation_alert({bad_cert, cert_expired}, _, _) -> ?ALERT_REC(?FATAL, ?CERTIFICATE_EXPIRED); @@ -3186,6 +3192,7 @@ decode_extensions(<> = ExtData, Group = tls_v1:enum_to_group(EnumGroup), KeyExchange = maybe_dec_server_hybrid_share(Group, KeyExchange0), + assert_unique_extension(key_share, Acc), decode_extensions(Rest, Version, MessageType, Acc#{key_share => #key_share_server_hello{ @@ -3198,6 +3205,7 @@ decode_extensions(<>, Version, MessageType = hello_retry_request, Acc) -> <> = ExtData, + assert_unique_extension(key_share, Acc), decode_extensions(Rest, Version, MessageType, Acc#{key_share => #key_share_hello_retry_request{ diff --git a/lib/ssl/src/ssl_trace.erl b/lib/ssl/src/ssl_trace.erl index cffcfedeb21d..2f2f582e5beb 100644 --- a/lib/ssl/src/ssl_trace.erl +++ b/lib/ssl/src/ssl_trace.erl @@ -477,7 +477,7 @@ trace_profiles() -> {ssl_handshake, [{path_validate, 11}, {path_validation, 10}, {select_hashsign, 5}, {get_cert_params, 1}, {cert_curve, 3}, - {maybe_check_hostname, 4}]}, + {tls_verify_fun, 4}]}, {ssl_pkix_db, [{decode_cert, 2}]}, {tls_handshake_1_3, [{path_validation, 10}]}, {tls_server_connection_1_3, [{init,1}]}, From 10553a6f1bc85129650bdc39e17d518cf7338da1 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 17 Aug 2026 08:43:06 +0200 Subject: [PATCH 14/17] ssl: Update TLS-1.3 compliance section Refocus on what parts are not yet implemented instead of trying to list everthing that is. Add note about importance of using lates patch version. --- lib/ssl/doc/guides/standards_compliance.md | 41 +++++++++++----------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/ssl/doc/guides/standards_compliance.md b/lib/ssl/doc/guides/standards_compliance.md index 6315bce63042..a55570926818 100644 --- a/lib/ssl/doc/guides/standards_compliance.md +++ b/lib/ssl/doc/guides/standards_compliance.md @@ -98,28 +98,27 @@ Not yet supported ## TLS 1.3 -OTP-22 introduces support for TLS 1.3. The current implementation supports a -selective set of cryptographic algorithms: - -- Key Exchange: ECDHE groups supported by default -- Groups: all standard groups supported for the Diffie-Hellman key exchange -- Groups: Support brainpool groups from RFC 8734 -- Ciphers: all mandatory cipher suites are supported -- Signature Algorithms: All algorithms form RFC 8446 -- Certificates: RSA, ECDSA and EDDSA keys - -Other notable features: - -- PSK and session resumption is supported (stateful and stateless tickets) -- Anti-replay protection using Bloom-filters with stateless tickets -- Early data and 0-RTT is supported -- Key and Initialization Vector Update is supported +TLS 1.3 support was first introduced in OTP 22. The "Since" column in the +table below indicates in which OTP release a feature was first implemented. +We always recommend running the latest patch level of any given release, as +compliance bugs may have been fixed in subsequent patch releases. + +The following features from [RFC 8446](https://tools.ietf.org/html/rfc8446) +(or mentioned by it) are **not yet implemented**: + +- PSK-only key exchange (without (EC)DHE) +- Post-Handshake Client Authentication (Section 4.6.2) +- OID Filters extension (Section 4.2.5) +- Record padding (sending, Section 5.4) +- Server-side OCSP stapling (status_request in Certificate, Section 4.4.2) +- Supported groups in Encrypted Extensions (Section 4.3.1) +- Heartbeat extension (RFC 6520) +- Signed Certificate Timestamp extension (RFC 6962) +- Raw Public Keys / client_certificate_type and server_certificate_type (RFC 7250) +- Padding extension (RFC 7685) For more detailed information see the -[Standards Compliance](standards_compliance.md#soc_table) below. - -The following table describes the current state of standards compliance for TLS -1.3. +[Standards Compliance](standards_compliance.md#soc_table) table below. (_C_ = Compliant, _NC_ = Non-Compliant, _PC_ = Partially-Compliant, _NA_ = Not Applicable) @@ -430,7 +429,7 @@ Applicable) | [C.5. Unauthenticated Operation](https://tools.ietf.org/html/rfc8446#section-C.5) | | C | 22 | | [D.1. Negotiating with an Older Server](https://tools.ietf.org/html/rfc8446#section-D.1) | | C | 22\.2 | | [D.2. Negotiating with an Older Client](https://tools.ietf.org/html/rfc8446#section-D.2) | | C | 22 | -| [D.3. 0-RTT Backward Compatibility](https://tools.ietf.org/html/rfc8446#section-D.3) | | NC | | +| [D.3. 0-RTT Backward Compatibility](https://tools.ietf.org/html/rfc8446#section-D.3) | | NA | | | [D.4. Middlebox Compatibility Mode](https://tools.ietf.org/html/rfc8446#section-D.4) | | C | 23 | | [D.5. Security Restrictions Related to Backward Compatibility](https://tools.ietf.org/html/rfc8446#section-D.5) | | C | 22 | From 7e03f3c516c6900f9462ee27b7663af2bfb2552a Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Mon, 17 Aug 2026 09:00:36 +0200 Subject: [PATCH 15/17] ssl: Add PQC section --- lib/ssl/doc/guides/standards_compliance.md | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/lib/ssl/doc/guides/standards_compliance.md b/lib/ssl/doc/guides/standards_compliance.md index a55570926818..3b7c9750e0a2 100644 --- a/lib/ssl/doc/guides/standards_compliance.md +++ b/lib/ssl/doc/guides/standards_compliance.md @@ -434,3 +434,59 @@ Applicable) | [D.5. Security Restrictions Related to Backward Compatibility](https://tools.ietf.org/html/rfc8446#section-D.5) | | C | 22 | _Table: Standards Compliance_ + +## Post-Quantum Cryptography (PQC) + +Post-quantum cryptography support was first introduced in OTP 28. PQC +algorithms are only available with TLS 1.3. + +### Key Exchange (ML-KEM) + +Hybrid key exchange groups combining ML-KEM (FIPS 203) with classical +ECDHE, as specified in [RFC 10024](https://www.rfc-editor.org/rfc/rfc10024.txt): + +| Group | Status | Since | +|-------|--------|-------| +| x25519mlkem768 | Default | 28.3 (default since 29.0) | +| secp256r1mlkem768 | Supported | 28.3 | +| secp384r1mlkem1024 | Supported | 28.3 | + +Plain ML-KEM groups (without classical hybrid): + +| Group | Status | Since | +|-------|--------|-------| +| mlkem768 | Supported | 28.0 | +| mlkem1024 | Supported | 28.0 | +| mlkem512 | Supported | 28.0 | + +### Signature Algorithms + +ML-DSA (FIPS 204) as specified in +[draft-ietf-tls-mldsa](https://www.ietf.org/archive/id/draft-ietf-tls-mldsa-01.html): + +| Algorithm | Status | Since | +|-----------|--------|-------| +| mldsa44 | Supported | 28.0 | +| mldsa65 | Supported | 28.0 | +| mldsa87 | Supported | 28.0 | + +SLH-DSA (FIPS 205): + +| Algorithm | Status | Since | +|-----------|--------|-------| +| slh_dsa_sha2_128s | Supported | 28.3 | +| slh_dsa_sha2_128f | Supported | 28.3 | +| slh_dsa_sha2_192s | Supported | 28.3 | +| slh_dsa_sha2_192f | Supported | 28.3 | +| slh_dsa_sha2_256s | Supported | 28.3 | +| slh_dsa_sha2_256f | Supported | 28.3 | +| slh_dsa_shake_128s | Supported | 28.3 | +| slh_dsa_shake_128f | Supported | 28.3 | +| slh_dsa_shake_192s | Supported | 28.3 | +| slh_dsa_shake_192f | Supported | 28.3 | +| slh_dsa_shake_256s | Supported | 28.3 | +| slh_dsa_shake_256f | Supported | 28.3 | + +### Not Yet Implemented + +- Composite ML-DSA signatures (ML-DSA + RSA/ECDSA in a single certificate) From 899ffec7a53f44e220ebc164107e177bf2d37331 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Thu, 25 Jun 2026 17:42:21 +0200 Subject: [PATCH 16/17] dtls: Avoid continuing event handling when reciving duplicte chang_ciphr_spec messages Found when looking into issue 11075 --- lib/ssl/src/dtls_server_connection.erl | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/ssl/src/dtls_server_connection.erl b/lib/ssl/src/dtls_server_connection.erl index 185b9934be1b..f989d99f27e2 100644 --- a/lib/ssl/src/dtls_server_connection.erl +++ b/lib/ssl/src/dtls_server_connection.erl @@ -296,17 +296,10 @@ hello(internal, {handshake, {#client_hello{cookie = <<>>} = Handshake, _}}, Stat {next_state, ?STATE(hello), State, [{next_event, internal, Handshake}]}; hello(internal, #change_cipher_spec{type = <<1>>}, State0) -> Epoch = dtls_gen_connection:retransmit_epoch(?STATE(hello), State0), - {State1, Actions0} = + {State, _} = dtls_gen_connection:send_handshake_flight(State0, Epoch), - %% This will reset the retransmission timer by repeating the enter state event - case dtls_gen_connection:next_event(?STATE(hello), no_record, State1, Actions0) of - {next_state, ?FUNCTION_NAME, State, Actions} -> - {repeat_state, State, Actions}; - {next_state, ?FUNCTION_NAME, State} -> - {repeat_state, State}; - {stop, _, _} = Stop -> - Stop - end; + %% Just retransmit and reset timer, don't process buffered records + {repeat_state, State}; hello(internal, {protocol_record, #ssl_tls{type = ?APPLICATION_DATA}}, #state{handshake_env = #handshake_env{renegotiation = {false, first}}} = State) -> Alert = ?ALERT_REC(?FATAL, ?UNEXPECTED_MESSAGE, application_data_before_initial_handshake), @@ -362,16 +355,9 @@ certify(enter, _, State0) -> {keep_state, State, Actions}; certify(internal, #change_cipher_spec{type = <<1>>}, State0) -> Epoch = dtls_gen_connection:retransmit_epoch(?STATE(certify), State0), - {State1, Actions0} = dtls_gen_connection:send_handshake_flight(State0, Epoch), - %% This will reset the retransmission timer by repeating the enter state event - case dtls_gen_connection:next_event(?STATE(certify), no_record, State1, Actions0) of - {next_state, ?FUNCTION_NAME, State, Actions} -> - {repeat_state, State, Actions}; - {next_state, ?FUNCTION_NAME, State} -> - {repeat_state, State, Actions0}; - {stop, _, _} = Stop -> - Stop - end; + {State, _} = dtls_gen_connection:send_handshake_flight(State0, Epoch), + %% Just retransmit and reset timer, don't process buffered records + {repeat_state, State}; certify(state_timeout, Event, State) -> dtls_gen_connection:handle_state_timeout(Event, ?STATE(certify), State); certify(internal, {protocol_record, #ssl_tls{type = ?APPLICATION_DATA}}, From b275ec52ae02414ce254a6c30ff5f2fa7a878535 Mon Sep 17 00:00:00 2001 From: Ingela Anderton Andin Date: Fri, 21 Aug 2026 08:23:35 +0200 Subject: [PATCH 17/17] ssl: TLS-1.2 session reuse and client certs Make session_reuse disabled by default when client certificates are requierd, until extend master secret can be implemented. But best option is to run TLS-1.3. --- lib/ssl/doc/guides/ssl_hardening.md | 29 +++++++++++++++++++++++++++++ lib/ssl/src/ssl_config.erl | 11 ++++++++++- lib/ssl/test/ssl_session_SUITE.erl | 26 +++++++++++++++----------- lib/ssl/test/ssl_test_lib.erl | 2 +- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/lib/ssl/doc/guides/ssl_hardening.md b/lib/ssl/doc/guides/ssl_hardening.md index d578710c1d12..d0151bd85437 100644 --- a/lib/ssl/doc/guides/ssl_hardening.md +++ b/lib/ssl/doc/guides/ssl_hardening.md @@ -174,6 +174,35 @@ avoid possible [DoS-attacks](https://en.wikipedia.org/wiki/Denial-of-service_att By default, the server mitigates renegotiation abuse by enforcing a 12-second delay between client initiated renegotiations. +### TLS 1.2 Session Resumption and Client Certificates + +> #### Warning {: .warning } +> TLS 1.2 session resumption (session ID and RFC 5077 tickets) does +> not bind the master secret to the handshake transcript. When client +> certificate authentication is required, this leaves the connection +> vulnerable to the Triple Handshake attack (see RFC 7627). This +> concern does not apply to TLS 1.3, which binds all session keys to +> the full transcript. + +If your TLS 1.2 server uses {verify, verify_peer} to require client +certificates make sure session resumption is disabled: + +```erlang +{reuse_sessions, false} +``` +This eliminates the Triple Handshake attack surface at the cost of a +full handshake for every connection. For deployments that need both +mutual authentication and session resumption over TLS 1.2, upgrading +to TLS 1.3 is the recommended solution — TLS 1.3 session tickets are +inherently safe. + +Note that this concern only applies to the server role. A client +setting `verify_peer` to verify the server is not affected. + +The default value for `reuse_sessions` in above described configuration +is false since OTP @OTP-20289@ + + ### Key Exchange Groups TLS-1.3 decouples key exchange algorithms from cipher suites. The key exchange algorithms are configured using the diff --git a/lib/ssl/src/ssl_config.erl b/lib/ssl/src/ssl_config.erl index bcc70580b69d..2234f1eb7069 100644 --- a/lib/ssl/src/ssl_config.erl +++ b/lib/ssl/src/ssl_config.erl @@ -1388,7 +1388,16 @@ opt_reuse_sessions(UserOpts, #{versions := Versions} = Opts, #{role := client}) assert_version_dep(Where2 =:= new, reuse_session, Versions, ['tlsv1','tlsv1.1','tlsv1.2']), Opts#{reuse_sessions => RUSS, reuse_session => RS}; opt_reuse_sessions(UserOpts, #{versions := Versions} = Opts, #{role := server}) -> - {Where1, RUSS} = get_opt_bool(reuse_sessions, true, UserOpts, Opts), + %% Disable reuse_sessions when client cert verify_peer + %% verification is required, until Extended Master Secret is implemented. + ReuseDefault = case maps:get(verify, Opts) of + verify_peer -> + false; + verify_none -> + true + end, + + {Where1, RUSS} = get_opt_bool(reuse_sessions, ReuseDefault, UserOpts, Opts), DefRS = fun(_, _, _, _) -> true end, {Where2, RS} = get_opt_fun(reuse_session, 4, DefRS, UserOpts, Opts), diff --git a/lib/ssl/test/ssl_session_SUITE.erl b/lib/ssl/test/ssl_session_SUITE.erl index 74a6f5c3fdf6..b5dd96a1eb7b 100644 --- a/lib/ssl/test/ssl_session_SUITE.erl +++ b/lib/ssl/test/ssl_session_SUITE.erl @@ -206,7 +206,7 @@ reuse_session_expired(Config) when is_list(Config) -> {from, self()}, {mfa, {ssl_test_lib, no_result, []}}, {tcp_options, [{active, false}]}, - {options, ServerOpts}]), + {options, [{reuse_sessions, true}| ServerOpts]}]), Port0 = ssl_test_lib:inet_port(Server0), Client0 = ssl_test_lib:start_client([{node, ClientNode}, @@ -338,7 +338,7 @@ explicit_session_reuse(Config) when is_list(Config) -> ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, {from, self()}, {mfa, {ssl_test_lib, no_result, []}}, - {options, ServerOpts}]), + {options, [{reuse_sessions, true} | ServerOpts]}]), Port = ssl_test_lib:inet_port(Server), {Client0, Client0Sock} = ssl_test_lib:start_client([{node, ClientNode}, @@ -380,7 +380,7 @@ explicit_session_reuse_expired(Config) when is_list(Config) -> ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, {from, self()}, {mfa, {ssl_test_lib, no_result, []}}, - {options, ServerOpts}]), + {options, [{reuse_sessions, true} | ServerOpts]}]), Port = ssl_test_lib:inet_port(Server), {Client0, Client0Sock} = ssl_test_lib:start_client([{node, ClientNode}, @@ -491,7 +491,8 @@ no_reuses_session_server_restart_new_cert() -> [{doc,"Check that a session is not reused if the server is restarted with a new cert."}]. no_reuses_session_server_restart_new_cert(Config) when is_list(Config) -> ClientOpts = ssl_test_lib:ssl_options(client_rsa_der_opts, Config), - ServerOpts = ssl_test_lib:ssl_options(server_rsa_der_verify_opts, Config), + ServerOpts = [{reuse_sessions, true} | + ssl_test_lib:ssl_options(server_rsa_der_verify_opts, Config)], POpts = proplists:get_value(group_opts, Config, []), #{client_config := NewCOpts, @@ -504,16 +505,17 @@ no_reuses_session_server_restart_new_cert(Config) when is_list(Config) -> {ClientNode, ServerNode, Hostname} = ssl_test_lib:run_where(Config), Server0 = - ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, - {from, self()}, + ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, + {from, self()}, {mfa, {ssl_test_lib, session_info_result, []}}, - {options, ServerOpts}]), + {options, ServerOpts}]), Port = ssl_test_lib:inet_port(Server0), Client0 = ssl_test_lib:start_client([{node, ClientNode}, {port, Port}, {host, Hostname}, {mfa, {ssl_test_lib, session_info_result, []}}, - {from, self()}, {options, [{reuse_sessions, save} | ClientOpts]}]), + {from, self()}, + {options, [{reuse_sessions, save} | ClientOpts]}]), Info0 = receive {Server0, Info00} -> Info00 end, Info0 = receive {Client0, Info01} -> Info01 end, @@ -620,7 +622,8 @@ client_max_session_table(Config) when is_list(Config)-> ClientOpts = ssl_test_lib:ssl_options(client_rsa_verify_opts, Config), ServerOpts = ssl_test_lib:ssl_options(server_rsa_verify_opts, Config), {ClientNode, ServerNode, HostName} = ssl_test_lib:run_where(Config), - test_max_session_limit(ClientOpts,ServerOpts,ClientNode, ServerNode, HostName), + test_max_session_limit(ClientOpts,[{reuse_sessions, true} | ServerOpts], + ClientNode, ServerNode, HostName), %% Explicit check table size {status, _, _, StatusInfo} = sys:get_status(whereis(ssl_manager)), [_, _,_, _, Prop] = StatusInfo, @@ -635,7 +638,8 @@ server_max_session_table(Config) when is_list(Config)-> ClientOpts = ssl_test_lib:ssl_options(client_rsa_verify_opts, Config), ServerOpts = ssl_test_lib:ssl_options(server_rsa_verify_opts, Config), {ClientNode, ServerNode, HostName} = ssl_test_lib:run_where(Config), - test_max_session_limit(ClientOpts,ServerOpts,ClientNode, ServerNode, HostName), + test_max_session_limit(ClientOpts,[{reuse_sessions, true} | ServerOpts], + ClientNode, ServerNode, HostName), %% Explicit check table size SupName = sup_name(ServerOpts), Sup = whereis(SupName), @@ -684,7 +688,7 @@ session_server_restart(Config) when is_list(Config) -> ssl_test_lib:start_server([{node, ServerNode}, {port, 0}, {from, self()}, {mfa, {?MODULE, accept_socket, [Test]}}, - {options, ServerOpts}]), + {options, [{reuse_sessions, true} | ServerOpts]}]), Port = ssl_test_lib:inet_port(Server), {Client0, Client0Sock} = ssl_test_lib:start_client([{node, ClientNode}, diff --git a/lib/ssl/test/ssl_test_lib.erl b/lib/ssl/test/ssl_test_lib.erl index 3a665b3aef40..0b8fc3b74fe5 100644 --- a/lib/ssl/test/ssl_test_lib.erl +++ b/lib/ssl/test/ssl_test_lib.erl @@ -4100,7 +4100,7 @@ reuse_session(ClientOpts, ServerOpts, Config) -> {from, self()}, {mfa, {ssl_test_lib, no_result, []}}, {tcp_options, [{active, false}]}, - {options, ServerOpts}]), + {options, [{reuse_sessions, true} | ServerOpts]}]), Port0 = inet_port(Server0), Client0 = start_client([{node, ClientNode},