From 49033a6d93a5be0ee0dce04e1fb8b4ae7de1e0c0 Mon Sep 17 00:00:00 2001
From: Jakub Witczak <kuba@erlang.org>
Date: Tue, 17 Mar 2026 18:38:07 +0100
Subject: [PATCH] public_key: Verify designated OCSP responder certificate
 signature
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A designated OCSP responder (Case 2 in RFC 6960 §4.2.2.2) must hold
a certificate issued directly by the CA. The existing check verified
the issuer DN match and id-kp-OCSPSigning EKU but did not verify the
CA's cryptographic signature on the responder certificate.

An attacker could forge a self-signed certificate with the CA's
subject DN and OCSPSigning EKU, and it would be accepted as a valid
designated responder.

Add public_key:pkix_verify/2 call to Case 2 in is_authorized_responder/3
to verify the responder certificate was actually signed by the CA.

Add designated_responder test case covering both legitimate (CA-signed)
and forged (self-signed) designated responder certificates.

GHSA-gxrm-pf64-99xm
---
 lib/public_key/src/pubkey_ocsp.erl        |   4 +-
 lib/public_key/test/pubkey_ocsp_SUITE.erl | 147 +++++++++++++++++++++-
 2 files changed, 149 insertions(+), 2 deletions(-)

diff --git a/lib/public_key/src/pubkey_ocsp.erl b/lib/public_key/src/pubkey_ocsp.erl
index 8e343f3ab3bb..89355e893734 100644
--- a/lib/public_key/src/pubkey_ocsp.erl
+++ b/lib/public_key/src/pubkey_ocsp.erl
@@ -229,7 +229,9 @@ is_authorized_responder(CombinedResponderCert = #cert{otp = ResponderCert},
         %%      issue OCSP responses for that CA (id-kp-OCSPSigning)
         fun() ->
                 public_key:pkix_is_issuer(ResponderCert, IssuerCert) andalso
-                                 designated_for_ocsp_signing(ResponderCert)
+                    designated_for_ocsp_signing(ResponderCert) andalso
+                    public_key:pkix_verify(CombinedResponderCert#cert.der,
+                                           get_public_key_rec(IssuerCert))
         end,
     Case3 =
         %% a Trusted Responder whose public key is trusted by the requestor
diff --git a/lib/public_key/test/pubkey_ocsp_SUITE.erl b/lib/public_key/test/pubkey_ocsp_SUITE.erl
index 8082cc6ce67b..480d201c10dd 100644
--- a/lib/public_key/test/pubkey_ocsp_SUITE.erl
+++ b/lib/public_key/test/pubkey_ocsp_SUITE.erl
@@ -105,7 +105,7 @@
 %% Common Test interface functions -----------------------------------
 %%--------------------------------------------------------------------
 all() ->
-    [ocsp_test].
+    [ocsp_test, designated_responder].
 
 groups() ->
     [].
@@ -211,3 +211,148 @@ ocsp_test(Config) when is_list(Config) ->
                                     ?ISSUER_CERT,
                                     IsTrustedReponderFun),
     ok.
+
+%%--------------------------------------------------------------------
+designated_responder() ->
+    [{doc, "Test Case2 (designated responder) in is_authorized_responder/3. "
+      "Verifies that a legitimate designated responder cert signed by the CA "
+      "is accepted, and a forged self-signed cert with the same subject DN "
+      "is rejected (GHSA-gxrm-pf64-99xm)."}].
+designated_responder(Config) when is_list(Config) ->
+    %% EC keys are used for fast key generation (vs RSA-2048).
+    %% is_authorized_responder/3 Case 2 only checks DN match,
+    %% OCSPSigning EKU, and pkix_verify — no chain validation.
+    CAKey = public_key:generate_key({namedCurve, ?'secp256r1'}),
+    CAPubKey = ec_public_key(CAKey),
+    CASubject = cn_subject(<<"Test CA">>),
+    CACertDer = public_key:pkix_sign(ca_tbs(CASubject, CAPubKey), CAKey),
+    CACert = public_key:pkix_decode_cert(CACertDer, otp),
+
+    %% Legitimate designated responder cert (signed by CA)
+    ResponderKey = public_key:generate_key({namedCurve, ?'secp256r1'}),
+    {ResponderCertDer, ResponderCert} =
+        sign_responder_cert(2, CASubject, ec_public_key(ResponderKey), CAKey),
+
+    %% Forged designated responder (self-signed, same subject DN)
+    ForgedKey = public_key:generate_key({namedCurve, ?'secp256r1'}),
+    {ForgedCertDer, ForgedCert} =
+        sign_responder_cert(9999, CASubject, ec_public_key(ForgedKey), ForgedKey),
+
+    %% Build OCSP responses and verify
+    Nonce = crypto:strong_rand_bytes(8),
+    NonceExt = <<4, 8, Nonce/binary>>,
+    IsNotTrustedFun = fun(_) -> false end,
+
+    %% Positive: legitimate designated responder accepted
+    LegitResponse = build_ocsp_response(CASubject, CAKey, NonceExt, ResponderKey),
+    {ok, [_], _} =
+        pubkey_ocsp:verify_response(
+            LegitResponse,
+            [#cert{otp = ResponderCert, der = ResponderCertDer}],
+            NonceExt, CACert, IsNotTrustedFun),
+
+    %% Negative: forged responder (same DN, not signed by CA) rejected
+    ForgedResponse = build_ocsp_response(CASubject, CAKey, NonceExt, ForgedKey),
+    {error, ocsp_responder_cert_not_found} =
+        pubkey_ocsp:verify_response(
+            ForgedResponse,
+            [#cert{otp = ForgedCert, der = ForgedCertDer}],
+            NonceExt, CACert, IsNotTrustedFun),
+    ok.
+
+%%--------------------------------------------------------------------
+%% Helpers -----------------------------------------------------------
+%%--------------------------------------------------------------------
+
+ec_public_key(#'ECPrivateKey'{publicKey = PubKey}) ->
+    #'ECPoint'{point = PubKey}.
+
+cn_subject(CN) ->
+    {rdnSequence,
+     [[#'AttributeTypeAndValue'{
+          type = ?'id-at-commonName',
+          value = {utf8String, CN}}]]}.
+
+ec_subject_pubkey_info(PubKey) ->
+    #'OTPSubjectPublicKeyInfo'{
+       algorithm = #'PublicKeyAlgorithm'{
+                      algorithm = ?'id-ecPublicKey',
+                      parameters = {namedCurve, ?'secp256r1'}},
+       subjectPublicKey = PubKey}.
+
+ca_tbs(Subject, PubKey) ->
+    make_tbs(1, Subject, PubKey,
+             [#'Extension'{extnID = ?'id-ce-basicConstraints',
+                           critical = true,
+                           extnValue = #'BasicConstraints'{cA = true}},
+              #'Extension'{extnID = ?'id-ce-keyUsage',
+                           critical = false,
+                           extnValue = [keyCertSign, cRLSign]}]).
+
+ocsp_responder_tbs(Serial, Issuer, PubKey) ->
+    make_tbs(Serial, Issuer, PubKey,
+             [#'Extension'{extnID = ?'id-ce-basicConstraints',
+                           critical = false,
+                           extnValue = #'BasicConstraints'{cA = false}},
+              #'Extension'{extnID = ?'id-ce-keyUsage',
+                           critical = false,
+                           extnValue = [digitalSignature]},
+              #'Extension'{extnID = ?'id-ce-extKeyUsage',
+                           critical = false,
+                           extnValue = [?'id-kp-OCSPSigning']}]).
+
+sign_responder_cert(Serial, Issuer, PubKey, SigningKey) ->
+    Der = public_key:pkix_sign(ocsp_responder_tbs(Serial, Issuer, PubKey), SigningKey),
+    {Der, public_key:pkix_decode_cert(Der, otp)}.
+
+make_tbs(Serial, Subject, PubKey, Extensions) ->
+    #'OTPTBSCertificate'{
+       version = v3,
+       serialNumber = Serial,
+       signature = #'SignatureAlgorithm'{
+                      algorithm = ?'ecdsa-with-SHA256',
+                      parameters = asn1_NOVALUE},
+       issuer = Subject,
+       validity = #'Validity'{
+                     notBefore = {utcTime, "240101000000Z"},
+                     notAfter = {utcTime, "340101000000Z"}},
+       subject = Subject,
+       subjectPublicKeyInfo = ec_subject_pubkey_info(PubKey),
+       extensions = Extensions}.
+
+build_ocsp_response(IssuerName, IssuerKey, NonceExt, SignKey) ->
+    EncodedName = pubkey_cert_records:transform(IssuerName, encode),
+    IssuerNameHash = crypto:hash(sha, public_key:der_encode('Name', EncodedName)),
+    IssuerKeyHash = crypto:hash(sha, IssuerKey#'ECPrivateKey'.publicKey),
+    ResponseData = #'ResponseData'{
+        version = v1,
+        responderID = {byName, EncodedName},
+        producedAt = "20250101000000Z",
+        responses =
+            [#'SingleResponse'{
+                certID = #'CertID'{
+                    hashAlgorithm = #'AlgorithmIdentifier'{
+                        algorithm = ?'id-sha1',
+                        parameters = <<5,0>>},
+                    issuerNameHash = IssuerNameHash,
+                    issuerKeyHash = IssuerKeyHash,
+                    serialNumber = 100},
+                certStatus = {good, 'NULL'},
+                thisUpdate = "20250101000000Z",
+                nextUpdate = asn1_NOVALUE,
+                singleExtensions = asn1_NOVALUE}],
+        responseExtensions =
+            [#'Extension'{
+                extnID = ?'id-pkix-ocsp-nonce',
+                critical = false,
+                extnValue = NonceExt}]},
+    ResponseDataDer = public_key:der_encode('ResponseData', ResponseData),
+    Signature = public_key:sign(ResponseDataDer, sha256, SignKey),
+    #'BasicOCSPResponse'{
+        tbsResponseData = ResponseData,
+        signatureAlgorithm =
+            #'AlgorithmIdentifier'{
+                algorithm = ?'ecdsa-with-SHA256',
+                parameters = asn1_NOVALUE},
+        signature = Signature,
+        certs = asn1_NOVALUE}.
