Repository: certbot/certbot

Issue:
ocsp: support key hash ResponderIDs, not just names.
This was first reported [on the forums](https://community.letsencrypt.org/t/invalid-ocsp-response-for-no-matching-responder-certificate-could-be-found/122497).

Certbot produces a warning when checking the revocation status of any certificate issued by [this Sectigo CA](https://censys.io/certificates?q=parsed.extensions.authority_key_id:%2017d9d6252767f931c24943d93036448c6ca94feb). 

>Invalid OCSP response for sectigo-leaf.pem: no matching responder certificate could be found.

I think the warning is produced because Certbot's (`cryptography`) OCSP client is assuming that the Basic Response's `ResponderID` is always going to be a name. 

https://github.com/certbot/certbot/blob/71e3d82e47696daa24c5f07bd2cb8c553b40babd/certbot/certbot/ocsp.py#L265-L272

[However](https://tools.ietf.org/html/rfc6960#section-4.2.2.3), it may be a hash of the public key:

>   o  either the name of the responder or a hash of the responder's
>      public key as the ResponderID;

This is exposed in `cryptography` as [`OCSPResponse.responder_key_hash`](https://cryptography.io/en/latest/x509/ocsp/#cryptography.x509.ocsp.OCSPResponse.responder_key_hash).

It seems that an OCSP client should implement both ways of identifying the OCSP issuer.


Discussion:

Resolving patch:
diff --git a/certbot/certbot/ocsp.py b/certbot/certbot/ocsp.py
--- a/certbot/certbot/ocsp.py
+++ b/certbot/certbot/ocsp.py
@@ -256,7 +256,11 @@ def _check_ocsp_response(response_ocsp, request_ocsp, issuer_cert, cert_path):
 
 def _check_ocsp_response_signature(response_ocsp, issuer_cert, cert_path):
     """Verify an OCSP response signature against certificate issuer or responder"""
-    if response_ocsp.responder_name == issuer_cert.subject:
+    def _key_hash(cert):
+        return x509.SubjectKeyIdentifier.from_public_key(cert.public_key()).digest
+
+    if response_ocsp.responder_name == issuer_cert.subject or \
+       response_ocsp.responder_key_hash == _key_hash(issuer_cert):
         # Case where the OCSP responder is also the certificate issuer
         logger.debug('OCSP response for certificate %s is signed by the certificate\'s issuer.',
                      cert_path)
@@ -267,7 +271,8 @@ def _check_ocsp_response_signature(response_ocsp, issuer_cert, cert_path):
                      cert_path)
 
         responder_certs = [cert for cert in response_ocsp.certificates
-                           if cert.subject == response_ocsp.responder_name]
+                           if response_ocsp.responder_name == cert.subject or \
+                              response_ocsp.responder_key_hash == _key_hash(cert)]
         if not responder_certs:
             raise AssertionError('no matching responder certificate could be found')
 


Test patch:
diff --git a/certbot/tests/ocsp_test.py b/certbot/tests/ocsp_test.py
--- a/certbot/tests/ocsp_test.py
+++ b/certbot/tests/ocsp_test.py
@@ -182,13 +182,23 @@ def test_responder_is_issuer(self):
 
         with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED,
                         ocsp_lib.OCSPResponseStatus.SUCCESSFUL) as mocks:
+            # OCSP response with ResponseID as Name
             mocks['mock_response'].return_value.responder_name = issuer.subject
+            mocks['mock_response'].return_value.responder_key_hash = None
             self.checker.ocsp_revoked(self.cert_obj)
+            # OCSP response with ResponseID as KeyHash
+            key_hash = x509.SubjectKeyIdentifier.from_public_key(issuer.public_key()).digest
+            mocks['mock_response'].return_value.responder_name = None
+            mocks['mock_response'].return_value.responder_key_hash = key_hash
+            self.checker.ocsp_revoked(self.cert_obj)
+
         # Here responder and issuer are the same. So only the signature of the OCSP
         # response is checked (using the issuer/responder public key).
-        self.assertEqual(mocks['mock_check'].call_count, 1)
-        self.assertEqual(mocks['mock_check'].call_args[0][0].public_numbers(),
-                         issuer.public_key().public_numbers())
+        self.assertEqual(mocks['mock_check'].call_count, 2)
+        self.assertEqual(mocks['mock_check'].call_args_list[0][0][0].public_numbers(),
+            issuer.public_key().public_numbers())
+        self.assertEqual(mocks['mock_check'].call_args_list[1][0][0].public_numbers(),
+            issuer.public_key().public_numbers())
 
     def test_responder_is_authorized_delegate(self):
         issuer = x509.load_pem_x509_certificate(
@@ -198,15 +208,28 @@ def test_responder_is_authorized_delegate(self):
 
         with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED,
                         ocsp_lib.OCSPResponseStatus.SUCCESSFUL) as mocks:
+            # OCSP response with ResponseID as Name
+            mocks['mock_response'].return_value.responder_name = responder.subject
+            mocks['mock_response'].return_value.responder_key_hash = None
             self.checker.ocsp_revoked(self.cert_obj)
+            # OCSP response with ResponseID as KeyHash
+            key_hash = x509.SubjectKeyIdentifier.from_public_key(responder.public_key()).digest
+            mocks['mock_response'].return_value.responder_name = None
+            mocks['mock_response'].return_value.responder_key_hash = key_hash
+            self.checker.ocsp_revoked(self.cert_obj)
+
         # Here responder and issuer are not the same. Two signatures will be checked then,
         # first to verify the responder cert (using the issuer public key), second to
         # to verify the OCSP response itself (using the responder public key).
-        self.assertEqual(mocks['mock_check'].call_count, 2)
+        self.assertEqual(mocks['mock_check'].call_count, 4)
         self.assertEqual(mocks['mock_check'].call_args_list[0][0][0].public_numbers(),
                          issuer.public_key().public_numbers())
         self.assertEqual(mocks['mock_check'].call_args_list[1][0][0].public_numbers(),
                          responder.public_key().public_numbers())
+        self.assertEqual(mocks['mock_check'].call_args_list[2][0][0].public_numbers(),
+                         issuer.public_key().public_numbers())
+        self.assertEqual(mocks['mock_check'].call_args_list[3][0][0].public_numbers(),
+                         responder.public_key().public_numbers())
 
     def test_revoke_resiliency(self):
         # Server return an invalid HTTP response