How to check OCSP client certificate revocation using Python Requests library?
Asked Answered
S

1

7

How do I make a simple request for certificate revocation status to an EJBCA OSCP Responder using the Python requests library?

Example:

# Determine if certificate has been revoked

    ocsp_url = req_cert.extensions[2].value[0].access_location.value
    ocsp_headers = {"whatGoes: here?"}
    ocsp_body = {"What goes here?"}
    ocsp_response = requests.get(ocsp_url, ocsp_headers, ocsp_body)

    if (ocsp_response == 'revoked'):
       return func.HttpResponse(
           "Certificate is not valid (Revoked)."
       )
Simoniac answered 19/10, 2020 at 23:18 Comment(0)
S
14

Basically it involves the following steps:

  • retrieve the corresponding cert for a hostname
  • if a corresponding entry is contained in the certificate, you can query the extensions via AuthorityInformationAccessOID.CA_ISSUERS, which will provide you with a link to the issuer certificate if successful
  • retrieve the issuer cert with this link
  • similarly you get via AuthorityInformationAccessOID.OCSP the corresponding OCSP server
  • with this information about the current cert, the issuer_cert and the ocsp server you can feed OCSPRequestBuilder to create an OCSP request
  • use requests.get to get the OCSP response
  • from the OCSP response retrieve the certificate_status

To retrieve a cert for a hostname and port, you can use this fine answer: https://stackoverflow.com/a/49132495. The OCSP handling in Python is documented here: https://cryptography.io/en/latest/x509/ocsp.html.

Code

If you convert the above points into a self-contained example, it looks something like this:

import base64
import ssl
import requests
from urllib.parse import urljoin

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.x509 import ocsp
from cryptography.x509.ocsp import OCSPResponseStatus
from cryptography.x509.oid import ExtensionOID, AuthorityInformationAccessOID


def get_cert_for_hostname(hostname, port):
    conn = ssl.create_connection((hostname, port))
    context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
    sock = context.wrap_socket(conn, server_hostname=hostname)
    certDER = sock.getpeercert(True)
    certPEM = ssl.DER_cert_to_PEM_cert(certDER)
    return x509.load_pem_x509_certificate(certPEM.encode('ascii'), default_backend())


def get_issuer(cert):
    aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS).value
    issuers = [ia for ia in aia if ia.access_method == AuthorityInformationAccessOID.CA_ISSUERS]
    if not issuers:
        raise Exception(f'no issuers entry in AIA')
    return issuers[0].access_location.value


def get_ocsp_server(cert):
    aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS).value
    ocsps = [ia for ia in aia if ia.access_method == AuthorityInformationAccessOID.OCSP]
    if not ocsps:
        raise Exception(f'no ocsp server entry in AIA')
    return ocsps[0].access_location.value


def get_issuer_cert(ca_issuer):
    issuer_response = requests.get(ca_issuer)
    if issuer_response.ok:
        issuerDER = issuer_response.content
        issuerPEM = ssl.DER_cert_to_PEM_cert(issuerDER)
        return x509.load_pem_x509_certificate(issuerPEM.encode('ascii'), default_backend())
    raise Exception(f'fetching issuer cert  failed with response status: {issuer_response.status_code}')


def get_oscp_request(ocsp_server, cert, issuer_cert):
    builder = ocsp.OCSPRequestBuilder()
    builder = builder.add_certificate(cert, issuer_cert, SHA256())
    req = builder.build()
    req_path = base64.b64encode(req.public_bytes(serialization.Encoding.DER))
    return urljoin(ocsp_server + '/', req_path.decode('ascii'))


def get_ocsp_cert_status(ocsp_server, cert, issuer_cert):
    ocsp_resp = requests.get(get_oscp_request(ocsp_server, cert, issuer_cert))
    if ocsp_resp.ok:
        ocsp_decoded = ocsp.load_der_ocsp_response(ocsp_resp.content)
        if ocsp_decoded.response_status == OCSPResponseStatus.SUCCESSFUL:
            return ocsp_decoded.certificate_status
        else:
            raise Exception(f'decoding ocsp response failed: {ocsp_decoded.response_status}')
    raise Exception(f'fetching ocsp cert status failed with response status: {ocsp_resp.status_code}')


def get_cert_status_for_host(hostname, port):
    print('   hostname:', hostname, "port:", port)
    cert = get_cert_for_hostname(hostname, port)
    ca_issuer = get_issuer(cert)
    print('   issuer ->', ca_issuer)
    issuer_cert = get_issuer_cert(ca_issuer)
    ocsp_server = get_ocsp_server(cert)
    print('   ocsp_server ->', ocsp_server)
    return get_ocsp_cert_status(ocsp_server, cert, issuer_cert)

Test 1: Good Certificate

A test call like the following with a good certificate

status = get_cert_status_for_host('software7.com', 443)
print('software7.com:', status, '\n')

results in the following output:

   hostname: software7.com port: 443
   issuer -> http://cacerts.digicert.com/EncryptionEverywhereDVTLSCA-G1.crt
   ocsp_server -> http://ocsp.digicert.com
software7.com: OCSPCertStatus.GOOD 

Test 2: Revoked Certificate

Of course you also have to do a counter test with a revoked cert. Here revoked.badssl.com is the first choice:

status = get_cert_status_for_host('revoked.badssl.com', 443)
print('revoked.badssl.com:', status, '\n')

This gives as output:

   hostname: revoked.badssl.com port: 443
   issuer -> http://cacerts.digicert.com/DigiCertSHA2SecureServerCA.crt
   ocsp_server -> http://ocsp.digicert.com
revoked.badssl.com: OCSPCertStatus.REVOKED 

AIA Retrieval of the Issuer Certificate

A typical scenario for a certificate relationship looks as follows:

Certificate Relationship

The server provides the server certificate and usually one or more intermediate certificates during the TLS handshake. The word 'usually' is used intentionally: some servers are configured not to deliver intermediate certificates. The browsers then use AIA fetching to build the certification chain.

Up to two entries can be present in the Certificate Authority Information Access extension: The entry for downloading the issuer certificate and the link to the OCSP server.

These entries may also be missing, but a short test script that checks the certs of the 100 most popular servers shows that these entries are usually included in certificates issued by public certification authorities.

The CA Issuers entry may also be missing, but while the information about an OCSP server is available, it can be tested e.g. with OpenSSL using a self-signed certificate:

Missing CA Issuers Entry

In this case you would have to determine the issuer certificate from the chain in the TLS handshake, it is the certificate that comes directly after the server certificate in the chain, see also the figure above.

Just for the sake of completeness: There is another case that can sometimes occur especially in conjunction with self-signed certificates: If no intermediate certificates are used, the corresponding root certificate (e.g. available in the local trust store) must be used as issuer certificate.

Soot answered 23/10, 2020 at 18:12 Comment(7)
Thank you very much @StephanSchlect. The solution looks great. I will study and incorporate it to my project. Take careSimoniac
Boom! Works great.Pneuma
Could you explain a bit about the def get_issuer(cert) function? When using the badssl.com example you provided, works no problems. When using a different cert (and corresponding hostname), it fails with MissingSchema: Invalid URL 'None': No schema supplied. Perhaps you meant http://None? pointing to the line: issuer_response = requests.get(ca_issuer). Here are the Cryptography docs. Should we pull issuer from subject instead of AIA?Simoniac
@Simoniac An explanation of the AIA retrieval of the issuer certificate was added to the answer. One possibility, if there is no CA issuer entry, would be to get the certificate chain from the TLS handshake and use the certificate as the issuer certificate that signed the host certificate, see also attached graphic.Soot
some CAs don't like SHA256() very much. using SHA1() in get_oscp_request() may help.Huneycutt
Have to note: you relay solely on .certificate_status (and doesn't handle UNKNOWN case which is mostly read as "use CRL, not me"). But one have to check more: (1.) .this_update is not ahead of time and within allowed delay (2.) .next_update is not passed by, if any given (3.) .signature is valid, if provided (4.) .serial_number is for the cert you have checked for!Williamswilliamsburg
You might want to consider using "create_default_context()" when creating the context rather than using "ssl.SSLContext()" directly as you then get a bunch of checks done for free, see docs.python.org/3/library/ssl.html#best-defaultsBiblioclast

© 2022 - 2024 — McMap. All rights reserved.