Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/ssl/src/ssl.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@
{applications, [crypto, public_key, kernel, stdlib]},
{env, []},
{mod, {ssl_app, []}},
{runtime_dependencies, ["stdlib-7.0","public_key-1.18.3","kernel-10.3",
{runtime_dependencies, ["stdlib-7.0","public_key-@OTP-20042@","kernel-10.3",
"erts-16.0","crypto-5.8", "inets-5.10.7",
"runtime_tools-1.15.1"]}]}.
29 changes: 26 additions & 3 deletions lib/ssl/src/ssl.erl
Original file line number Diff line number Diff line change
Expand Up @@ -1512,9 +1512,32 @@ different semantics for the client and server.
> #### Note {: .info }
>
> Even if requested by the client, the OCSP response might not be
> provided by the server. In such event, SSL will proceed with
> the handshake and generate a `{missing, stapling_response}` logger
> event.
> provided by the server. In such event, the certificate validation
> will fail with reason `missing_ocsp_staple`. Capturing this
> failure with a custom `verify_fun` enables the user to implement
> their own fallback validation, for example by performing a direct
> OCSP query or a CRL check. Note however that accepting
> `{bad_cert, missing_ocsp_staple}` without performing alternative
> revocation checking is insecure, as it allows a MITM attacker to
> suppress revocation information by omitting the OCSP staple.
>
> ```erlang
> {verify_fun, {fun(_, _, {bad_cert, missing_ocsp_staple} = R, _St) ->
> %% Implement fallback revocation check here,
> %% e.g. a direct OCSP query or CRL check.
> %% Simply returning {valid, St} skips
> %% revocation checking entirely.
> {fail, R};
> (_, _, {bad_cert, _} = R, _) ->
> {fail, R};
> (_, _, {extension, _}, St) ->
> {unknown, St};
> (_, _, valid, St) ->
> {valid, St};
> (_, _, valid_peer, St) ->
> {valid, St}
> end, []}}
> ```

When `Stapling` is given as a map, boolean `ocsp_nonce` key can
indicate whether an OCSP nonce should be requested by the client
Expand Down
30 changes: 8 additions & 22 deletions lib/ssl/src/ssl_certificate.erl
Original file line number Diff line number Diff line change
Expand Up @@ -601,32 +601,19 @@ verify_hostname(Hostname, Customize, Cert, UserState) ->
verify_cert_extensions(Cert, #{cert_ext := CertExts} = UserState, LogLevel) ->
Id = public_key:pkix_subject_id(Cert),
Extensions = maps:get(Id, CertExts, []),
verify_cert_extensions(Cert, UserState, Extensions,
#{certificate_valid => false}, LogLevel).

verify_cert_extensions(_Cert, UserState = #{stapling_state := #{configured := true},
path_len := 0}, [],
_Context = #{certificate_valid := false}, LogLevel) ->
%% RFC6066 section 8
%% Servers that receive a client hello containing the "status_request"
%% extension MAY return a suitable certificate status response to the
%% client along with their certificate.
Desc = "Certificate Status - stapling response not provided by the server",
ssl_logger:log(notice, LogLevel, #{description => Desc,
reason => [{missing, stapling_response}]},
?LOCATION),
{valid, UserState};
verify_cert_extensions(Cert, UserState, [], _, _) ->
verify_cert_extensions(Cert, UserState, Extensions, LogLevel).

verify_cert_extensions(Cert, UserState, [], _) ->
{valid, UserState#{issuer => Cert}};
verify_cert_extensions(_, #{stapling_state := #{configured := false}},
[#certificate_status{} | _], _, _) ->
[#certificate_status{} | _], _) ->
{fail, unexpected_certificate_status};
verify_cert_extensions(Cert, #{stapling_state := StaplingState,
issuer := Issuer,
certdb := CertDbHandle,
certdb_ref := CertDbRef} = UserState,
[#certificate_status{response = OcspResponseDer} | Exts],
Context, LogLevel) ->
LogLevel) ->
#{ocsp_nonce := Nonce} = StaplingState,
IsTrustedResponderFun =
fun(#cert{der = DerResponderCert, otp = OtpCert}) ->
Expand Down Expand Up @@ -658,14 +645,13 @@ verify_cert_extensions(Cert, #{stapling_state := StaplingState,
H(Rest);
H([]) -> ok end,
HandleOcspDetails(Details),
verify_cert_extensions(Cert, UserState, Exts,
Context#{certificate_valid => true}, LogLevel);
verify_cert_extensions(Cert, UserState, Exts, LogLevel);
{error, {bad_cert, _} = Reason} ->
{fail, Reason}
end;
verify_cert_extensions(Cert, UserState, [_|Exts], Context, LogLevel) ->
verify_cert_extensions(Cert, UserState, [_|Exts], LogLevel) ->
%% Skip unknown extensions!
verify_cert_extensions(Cert, UserState, Exts, Context, LogLevel).
verify_cert_extensions(Cert, UserState, Exts, LogLevel).

verify_sign_support(_, #{version := Version})
when ?TLS_LT(Version, ?TLS_1_2) ->
Expand Down
20 changes: 14 additions & 6 deletions lib/ssl/src/ssl_handshake.erl
Original file line number Diff line number Diff line change
Expand Up @@ -2025,9 +2025,14 @@ extension_value(#psk_key_exchange_modes{ke_modes = Modes}) ->
extension_value(#cookie{cookie = Cookie}) ->
Cookie.

%% Extension value mapping (from decode_extensions/4):
%% 'false' - extension absent from ServerHello (maps:get default)
%% 'undefined' - extension present with empty body (RFC 6066: server
%% MUST include status_request with empty extension_data
%% to indicate willingness to send CertificateStatus)
handle_cert_status_extension(#{stapling := _Stapling}, Extensions) ->
case maps:get(status_request, Extensions, false) of
undefined -> %% status_request received in server hello
undefined ->
#{configured => true,
status => negotiated};
false ->
Expand Down Expand Up @@ -2327,11 +2332,14 @@ cert_status_check(_OtpCert,
status := StaplingStatus}},
_VerifyResult, _CertPath, _LogLevel)
when StaplingStatus == not_negotiated; StaplingStatus == not_received ->
%% RFC6066 section 8
%% Servers that receive a client hello containing the "status_request"
%% extension MAY return a suitable certificate status response to the
%% client along with their certificate.
valid.
%% Hard-fail (TLS 1.2 and TLS 1.3): client requested OCSP stapling
%% but server did not provide a staple. Erlang/OTP has no fallback
%% to direct OCSP queries or CRL checking when stapling is
%% configured, so accepting a missing staple would skip revocation
%% checking entirely.
%% Note: {bad_cert, _} tuple is required for apply_user_fun/8 to
%% deliver the failure to a custom verify_fun.
{bad_cert, missing_ocsp_staple}.


maybe_check_crl(_, #{crl_check := false}, _, _, _) ->
Expand Down
17 changes: 12 additions & 5 deletions lib/ssl/src/tls_dtls_client_connection.erl
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ wait_stapling(internal, #certificate_status{} = CertStatus,
stapling_state =
StaplingState#{status => received_staple,
staple => CertStatus}}}};
%% Server did not send OCSP staple message
%% TLS 1.2 only: server negotiated stapling (included status_request
%% in ServerHello) but sent a different message instead of
%% CertificateStatus. Mark as not_received and postpone the message
%% for the certify state. Hard-fail is enforced later by
%% cert_status_check/5 in ssl_handshake.
wait_stapling(internal, Msg,
#state{static_env = #static_env{protocol_cb = _Connection},
handshake_env = #handshake_env{
Expand Down Expand Up @@ -178,9 +182,11 @@ certify(internal, #certificate{asn1_certificates = DerCerts},
connection_env = #connection_env{
negotiated_version = Version},
ssl_options = Opts} = State)
when StaplingStatus == not_negotiated; StaplingStatus == received_staple ->
%% this clause handles also scenario with stapling disabled, so
%% 'not_negotiated' appears in guard
when StaplingStatus == not_negotiated; StaplingStatus == received_staple;
StaplingStatus == not_received ->
%% not_negotiated covers two cases: stapling disabled (configured=false)
%% and stapling enabled but server did not include status_request in
%% ServerHello. Hard-fail for the latter is enforced by cert_status_check/5.
Certs = try [#cert{der=DerCert, otp=public_key:pkix_decode_cert(DerCert, otp)}
|| DerCert <- DerCerts]
catch
Expand Down Expand Up @@ -873,6 +879,7 @@ ext_info(#{status := received_staple, staple := CertStatus} = StaplingState,
#cert{otp = PeerCert}) ->
#{cert_ext => #{public_key:pkix_subject_id(PeerCert) => [CertStatus]},
stapling_state => StaplingState};
ext_info(#{status := not_negotiated} = StaplingState, #cert{otp = PeerCert}) ->
ext_info(#{status := StaplingStatus} = StaplingState, #cert{otp = PeerCert})
when StaplingStatus == not_negotiated; StaplingStatus == not_received ->
#{cert_ext => #{public_key:pkix_subject_id(PeerCert) => []},
stapling_state => StaplingState}.
34 changes: 26 additions & 8 deletions lib/ssl/src/tls_handshake_1_3.erl
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,9 @@ validate_certificate_chain(CertEntries, CertDbHandle, CertDbRef,
ssl_handshake:certify(Certs, CertDbHandle,
CertDbRef, SslOptions, CRLDbHandle, Role, Host, ?TLS_1_3,
ExtInfo)
catch error:{_,{error, {asn1, Asn1Reason}}}=Reason:ST ->
catch throw:#alert{} = Alert ->
Alert;
error:{_,{error, {asn1, Asn1Reason}}}=Reason:ST ->
%% ASN-1 decode of certificate somehow failed
?SSL_LOG(info, asn1_decode, [Reason, {stacktrace, ST}]),
?ALERT_REC(?FATAL, ?CERTIFICATE_UNKNOWN, {failed_to_decode_certificate, Asn1Reason})
Expand All @@ -828,13 +830,29 @@ split_cert_entries([#certificate_entry{data = DerCert,

Id = public_key:pkix_subject_id(DerCert),
Extensions = [ExtValue || {_, ExtValue} <- maps:to_list(Extensions0)],
StaplingState = case {maps:get(status_request, Extensions0, undefined),
StaplingConfigured} of
{undefined, _} ->
StaplingState0;
{_, true} ->
StaplingState0#{status => received_staple}
end,
StaplingState =
case {maps:get(status_request, Extensions0, undefined), StaplingConfigured} of
{undefined, _} ->
%% No OCSP response in this cert entry.
%% For intermediate CA certs this is normal.
%% For the peer cert, state stays not_negotiated
%% and cert_status_check/5 will hard-fail when
%% stapling is configured.
StaplingState0;
{#certificate_status{}, true} ->
%% Server provided OCSP staple and client
%% requested it — mark as received. The
%% response will be verified later by
%% ssl_certificate:verify_cert_extensions/4.
StaplingState0#{status => received_staple};
{#certificate_status{}, false} ->
%% Unsolicited OCSP staple — client did not
%% configure stapling. Protocol violation per
%% RFC 8446 4.2: server MUST NOT include
%% extensions not offered in ClientHello.
throw(?ALERT_REC(?FATAL, ?UNSUPPORTED_EXTENSION,
unexpected_certificate_status))
end,
split_cert_entries(CertEntries, StaplingState, [Cert | Chain],
CertExt#{Id => Extensions}).

Expand Down
68 changes: 57 additions & 11 deletions lib/ssl/test/openssl_stapling_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
staple_with_nonce/0, staple_with_nonce/1,
cert_status_revoked/0, cert_status_revoked/1,
cert_status_undetermined/0, cert_status_undetermined/1,
staple_missing/0, staple_missing/1
staple_missing/0, staple_missing/1,
staple_missing_atom/0, staple_missing_atom/1,
staple_missing_verify_fun/0, staple_missing_verify_fun/1
]).

%% spawn export
Expand Down Expand Up @@ -85,7 +87,9 @@ negative() ->
staple_wrong_issuer,
cert_status_revoked,
cert_status_undetermined,
staple_missing].
staple_missing,
staple_missing_atom,
staple_missing_verify_fun].

%%--------------------------------------------------------------------
init_per_suite(Config0) ->
Expand Down Expand Up @@ -198,17 +202,13 @@ staple_with_nonce(Config)
when is_list(Config) ->
stapling_helper(Config, #{ocsp_nonce => true}).

staple_missing() ->
[{doc, "Verify OCSP stapling works with a missing OCSP response."}].
staple_missing(Config)
when is_list(Config) ->
%% Start a server that will not include an OCSP response.
stapling_helper(Config, openssl, #{ocsp_nonce => true}).

stapling_helper(Config, StaplingOpt) ->
stapling_helper(Config, openssl_ocsp, StaplingOpt).

stapling_helper(Config, ServerType, StaplingOpt) ->
stapling_helper(Config, ServerType, StaplingOpt, []).

stapling_helper(Config, ServerType, StaplingOpt, ExtraClientOpts) ->
%% ok = logger:set_application_level(ssl, debug),
PrivDir = proplists:get_value(priv_dir, Config),
CACertsFile = filename:join(PrivDir, "a.server/cacerts.pem"),
Expand All @@ -222,7 +222,7 @@ stapling_helper(Config, ServerType, StaplingOpt) ->
ClientOpts = ssl_test_lib:ssl_options([{verify, verify_peer},
{cacertfile, CACertsFile},
{server_name_indication, disable},
{stapling, StaplingOpt}],
{stapling, StaplingOpt}] ++ ExtraClientOpts,
Config),
Client = ssl_test_lib:start_client(erlang,
[{port, Port},
Expand All @@ -234,6 +234,49 @@ stapling_helper(Config, ServerType, StaplingOpt) ->
ssl_test_lib:close(Client).

%%--------------------------------------------------------------------
staple_missing() ->
[{doc, "Verify that missing OCSP staple causes handshake failure."}].
staple_missing(Config)
when is_list(Config) ->
%% Start a server (openssl) that will not include an OCSP response
stapling_negative_helper(Config, "a.server/cacerts.pem",
openssl, handshake_failure).

staple_missing_atom() ->
[{doc, "Verify that missing OCSP staple causes handshake failure "
"with {stapling, staple} atom shorthand."}].
staple_missing_atom(Config)
when is_list(Config) ->
stapling_negative_helper(Config, "a.server/cacerts.pem",
openssl, handshake_failure, staple).

staple_missing_verify_fun() ->
[{doc, "Verify that a custom verify_fun can be used for accepting missing OCSP staple."}].
staple_missing_verify_fun(Config)
when is_list(Config) ->
VerifyFun =
{fun(_, _, {bad_cert, missing_ocsp_staple}, UserState) ->
?CT_LOG("verify_fun: got missing_ocsp_staple, accepting", []),
{valid, [staple_missing_seen | UserState]};
(_, _, {bad_cert, _} = Reason, _) ->
?CT_LOG("verify_fun: got ~p, rejecting", [Reason]),
{fail, Reason};
(_, _, {extension, _} = Ext, UserState) ->
?CT_LOG("verify_fun: got extension ~p", [Ext]),
{unknown, UserState};
(_, _, valid, UserState) ->
?CT_LOG("verify_fun: got valid", []),
{valid, UserState};
(_, _, valid_peer, UserState) ->
?CT_LOG("verify_fun: got valid_peer, state=~p", [UserState]),
case lists:member(staple_missing_seen, UserState) of
true -> {valid, UserState};
false -> {fail, {bad_cert, missing_staple_not_reported}}
end
end, []},
stapling_helper(Config, openssl, #{ocsp_nonce => false},
[{verify_fun, VerifyFun}]).

staple_not_designated() ->
[{doc,"Verify OCSP stapling works without nonce."
"Response signed with certificate issued directly by issuer of server "
Expand Down Expand Up @@ -268,6 +311,9 @@ cert_status_undetermined(Config)
openssl_ocsp_undetermined, bad_certificate).

stapling_negative_helper(Config, CACertsPath, ServerVariant, ExpectedError) ->
stapling_negative_helper(Config, CACertsPath, ServerVariant, ExpectedError, #{ocsp_nonce => true}).

stapling_negative_helper(Config, CACertsPath, ServerVariant, ExpectedError, StaplingOpt) ->
PrivDir = proplists:get_value(priv_dir, Config),
CACertsFile = filename:join(PrivDir, CACertsPath),
GroupName = undefined,
Expand All @@ -280,7 +326,7 @@ stapling_negative_helper(Config, CACertsPath, ServerVariant, ExpectedError) ->
ClientOpts = ssl_test_lib:ssl_options([{verify, verify_peer},
{server_name_indication, disable},
{cacertfile, CACertsFile},
{stapling, #{ocsp_nonce => true}}],
{stapling, StaplingOpt}],
Config),
Client = ssl_test_lib:start_client_error([{node, ClientNode},{port, Port},
{host, Hostname}, {from, self()},
Expand Down
Loading