From 3dcbd428ea46c9bd5602901d17bce99d0872e726 Mon Sep 17 00:00:00 2001 From: Jakub Witczak Date: Mon, 15 Jun 2026 16:26:49 +0200 Subject: [PATCH 1/2] public_key: Use constant-time comparison for OCSP hash checks Replace == with crypto:hash_equals/2 for issuerNameHash, issuerKeyHash, and byKey responder ID comparisons in pubkey_ocsp.erl. The == operator short-circuits on the first differing byte, creating a timing side-channel. Affected locations: - match_single_response/4: issuerNameHash and issuerKeyHash - is_responder_cert/2: byKey SHA-1 hash --- lib/public_key/src/pubkey_ocsp.erl | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/public_key/src/pubkey_ocsp.erl b/lib/public_key/src/pubkey_ocsp.erl index f7e5d7dac629..db9a2b71a7d5 100644 --- a/lib/public_key/src/pubkey_ocsp.erl +++ b/lib/public_key/src/pubkey_ocsp.erl @@ -113,9 +113,12 @@ match_single_response(IssuerName, IssuerKey, SerialNum, #'SingleResponse'{thisUpdate = ThisUpdate, nextUpdate = NextUpdate} = SingleResponse, HashType = public_key:pkix_hash_type(Algo#'CertID_hashAlgorithm'.algorithm), - case (SerialNum == CertID#'CertID'.serialNumber) andalso - (crypto:hash(HashType, IssuerName) == CertID#'CertID'.issuerNameHash) andalso - (crypto:hash(HashType, IssuerKey) == CertID#'CertID'.issuerKeyHash) andalso + SerialMatch = (SerialNum == CertID#'CertID'.serialNumber), + NameHashMatch = hash_equals(crypto:hash(HashType, IssuerName), + CertID#'CertID'.issuerNameHash), + KeyHashMatch = hash_equals(crypto:hash(HashType, IssuerKey), + CertID#'CertID'.issuerKeyHash), + case SerialMatch andalso NameHashMatch andalso KeyHashMatch andalso verify_past_timestamp(ThisUpdate) == ok andalso verify_next_update(NextUpdate) == ok of true -> @@ -214,7 +217,7 @@ verify_next_update(NextUpdate) -> is_responder_cert({byName, Name}, #cert{otp = Cert}) -> public_key:der_encode('Name', Name) == get_subject_name(Cert); is_responder_cert({byKey, Key}, #cert{otp = Cert}) -> - Key == crypto:hash(sha, get_public_key(Cert)). + hash_equals(Key, crypto:hash(sha, get_public_key(Cert))). is_authorized_responder(CombinedResponderCert = #cert{otp = ResponderCert}, IssuerCert, IsTrustedResponderFun) -> @@ -295,6 +298,16 @@ designated_for_ocsp_signing(OtpCert) -> lists:member(?'id-kp-OCSPSigning', KeyUses) end. +%% Constant-time comparison that handles mismatched sizes gracefully. +%% crypto:hash_equals/2 requires equal-length binaries. If sizes differ, +%% the CertID cannot match (hash algorithm mismatch). No timing concern: +%% the expected length is determined by the hashAlgorithm OID in the same +%% CertID, which the sender chose — not a secret. +hash_equals(A, B) when byte_size(A) =:= byte_size(B) -> + crypto:hash_equals(A, B); +hash_equals(_, _) -> + false. + %%%################################################################ %%%# %%%# Tracing From ff1cb704e0d7249944d9a653c1289db9c03e2219 Mon Sep 17 00:00:00 2001 From: Jakub Witczak Date: Mon, 15 Jun 2026 16:30:33 +0200 Subject: [PATCH 2/2] public_key: Reject oversized OCSP responses before decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 100 KB size guard in decode_response/1 to reject oversized input before calling der_decode. This prevents unbounded memory allocation from crafted OCSP responses. The 100 KB limit is aligned with OpenSSL's OSSL_HTTP_DEFAULT_MAX_RESP_LEN. Typical OCSP responses are 1–5 KB; multi-stapling with 10 certs reaches ~50 KB. Oversized input returns: {error, {ocsp_response_too_large, Size}} --- lib/public_key/src/pubkey_ocsp.erl | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/public_key/src/pubkey_ocsp.erl b/lib/public_key/src/pubkey_ocsp.erl index db9a2b71a7d5..4e1b35bac86e 100644 --- a/lib/public_key/src/pubkey_ocsp.erl +++ b/lib/public_key/src/pubkey_ocsp.erl @@ -34,6 +34,10 @@ %% Tracing -export([handle_trace/3]). +%% 100 KB — aligned with OpenSSL OSSL_HTTP_DEFAULT_MAX_RESP_LEN. +%% Typical OCSP responses are 1–5 KB. +-define(MAX_OCSP_RESPONSE_SIZE, 102400). + -spec get_nonce_extn(undefined | binary()) -> undefined | #'Extension'{}. get_nonce_extn(undefined) -> undefined; @@ -92,7 +96,8 @@ status({unknown, Reason}, _) -> status({revoked, Reason}, _) -> {error, {bad_cert, {revoked, Reason}}}. -decode_response(ResponseDer) -> +decode_response(ResponseDer) + when byte_size(ResponseDer) =< ?MAX_OCSP_RESPONSE_SIZE -> Resp = public_key:der_decode('OCSPResponse', ResponseDer), case Resp#'OCSPResponse'.responseStatus of successful -> @@ -101,7 +106,9 @@ decode_response(ResponseDer) -> ); Error -> {error, Error} - end. + end; +decode_response(ResponseDer) when is_binary(ResponseDer) -> + {error, {ocsp_response_too_large, byte_size(ResponseDer)}}. %%-------------------------------------------------------------------- match_single_response(_IssuerName, _IssuerKey, _SerialNum, []) ->