"""Tests for ocsp.py"""
# pylint: disable=protected-access
import contextlib
from datetime import datetime
from datetime import timedelta
import sys
import unittest
from unittest import mock
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.x509 import ocsp as ocsp_lib
import pytest
import pytz
from certbot import errors
from certbot.tests import util as test_util
out = """Missing = in header key=value
ocsp: Use -help for summary.
"""
class OCSPTestOpenSSL(unittest.TestCase):
"""
OCSP revocation tests using OpenSSL binary.
"""
def setUp(self):
from certbot import ocsp
with mock.patch('certbot.ocsp.subprocess.run') as mock_run:
with mock.patch('certbot.util.exe_exists') as mock_exists:
mock_run.stderr = out
mock_exists.return_value = True
self.checker = ocsp.RevocationChecker(enforce_openssl_binary_usage=True)
@mock.patch('certbot.ocsp.logger.info')
@mock.patch('certbot.ocsp.subprocess.run')
@mock.patch('certbot.util.exe_exists')
def test_init(self, mock_exists, mock_run, mock_log):
mock_run.return_value.stderr = out
mock_exists.return_value = True
from certbot import ocsp
checker = ocsp.RevocationChecker(enforce_openssl_binary_usage=True)
assert mock_run.call_count == 1
assert checker.host_args("x") == ["Host=x"]
mock_run.return_value.stderr = out.partition("\n")[2]
checker = ocsp.RevocationChecker(enforce_openssl_binary_usage=True)
assert checker.host_args("x") == ["Host", "x"]
assert checker.broken is False
mock_exists.return_value = False
mock_run.call_count = 0
checker = ocsp.RevocationChecker(enforce_openssl_binary_usage=True)
assert mock_run.call_count == 0
assert mock_log.call_count == 1
assert checker.broken is True
@mock.patch('certbot.ocsp._determine_ocsp_server')
@mock.patch('certbot.ocsp.crypto_util.notAfter')
@mock.patch('certbot.util.run_script')
def test_ocsp_revoked(self, mock_run, mock_na, mock_determine):
now = datetime.now(pytz.UTC)
cert_obj = mock.MagicMock()
cert_obj.cert_path = "x"
cert_obj.chain_path = "y"
mock_na.return_value = now + timedelta(hours=2)
self.checker.broken = True
mock_determine.return_value = ("", "")
assert self.checker.ocsp_revoked(cert_obj) is False
self.checker.broken = False
mock_run.return_value = tuple(openssl_happy[1:])
assert self.checker.ocsp_revoked(cert_obj) is False
assert mock_run.call_count == 0
mock_determine.return_value = ("http://x.co", "x.co")
assert self.checker.ocsp_revoked(cert_obj) is False
mock_run.side_effect = errors.SubprocessError("Unable to load certificate launcher")
assert self.checker.ocsp_revoked(cert_obj) is False
assert mock_run.call_count == 2
# cert expired
mock_na.return_value = now
mock_determine.return_value = ("", "")
count_before = mock_determine.call_count
assert self.checker.ocsp_revoked(cert_obj) is False
assert mock_determine.call_count == count_before
def test_determine_ocsp_server(self):
cert_path = test_util.vector_path('ocsp_certificate.pem')
from certbot import ocsp
result = ocsp._determine_ocsp_server(cert_path)
assert ('http://ocsp.test4.buypass.com', 'ocsp.test4.buypass.com') == result
@mock.patch('certbot.ocsp.logger')
@mock.patch('certbot.util.run_script')
def test_translate_ocsp(self, mock_run, mock_log):
# pylint: disable=protected-access
mock_run.return_value = openssl_confused
from certbot import ocsp
assert ocsp._translate_ocsp_query(*openssl_happy) is False
assert ocsp._translate_ocsp_query(*openssl_confused) is False
assert mock_log.debug.call_count == 1
assert mock_log.warning.call_count == 0
mock_log.debug.call_count = 0
assert ocsp._translate_ocsp_query(*openssl_unknown) is False
assert mock_log.debug.call_count == 1
assert mock_log.warning.call_count == 0
assert ocsp._translate_ocsp_query(*openssl_expired_ocsp) is False
assert mock_log.debug.call_count == 2
assert ocsp._translate_ocsp_query(*openssl_broken) is False
assert mock_log.warning.call_count == 1
mock_log.info.call_count = 0
assert ocsp._translate_ocsp_query(*openssl_revoked) is True
assert mock_log.info.call_count == 0
assert ocsp._translate_ocsp_query(*openssl_expired_ocsp_revoked) is True
assert mock_log.info.call_count == 1
class OSCPTestCryptography(unittest.TestCase):
"""
OCSP revokation tests using Cryptography >= 2.4.0
"""
def setUp(self):
from certbot import ocsp
self.checker = ocsp.RevocationChecker()
self.cert_path = test_util.vector_path('ocsp_certificate.pem')
self.chain_path = test_util.vector_path('ocsp_issuer_certificate.pem')
self.cert_obj = mock.MagicMock()
self.cert_obj.cert_path = self.cert_path
self.cert_obj.chain_path = self.chain_path
now = datetime.now(pytz.UTC)
self.mock_notAfter = mock.patch('certbot.ocsp.crypto_util.notAfter',
return_value=now + timedelta(hours=2))
self.mock_notAfter.start()
# Ensure the mock.patch is stopped even if test raises an exception
self.addCleanup(self.mock_notAfter.stop)
@mock.patch('certbot.ocsp._determine_ocsp_server')
@mock.patch('certbot.ocsp._check_ocsp_cryptography')
def test_ensure_cryptography_toggled(self, mock_check, mock_determine):
mock_determine.return_value = ('http://example.com', 'example.com')
self.checker.ocsp_revoked(self.cert_obj)
mock_check.assert_called_once_with(self.cert_path, self.chain_path, 'http://example.com', 10)
def test_revoke(self):
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked
def test_responder_is_issuer(self):
issuer = x509.load_pem_x509_certificate(
test_util.load_vector('ocsp_issuer_certificate.pem'), default_backend())
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).
assert mocks['mock_check'].call_count == 2
assert mocks['mock_check'].call_args_list[0][0][0].public_numbers() == \
issuer.public_key().public_numbers()
assert 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(
test_util.load_vector('ocsp_issuer_certificate.pem'), default_backend())
responder = x509.load_pem_x509_certificate(
test_util.load_vector('ocsp_responder_certificate.pem'), default_backend())
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).
assert mocks['mock_check'].call_count == 4
assert mocks['mock_check'].call_args_list[0][0][0].public_numbers() == \
issuer.public_key().public_numbers()
assert mocks['mock_check'].call_args_list[1][0][0].public_numbers() == \
responder.public_key().public_numbers()
assert mocks['mock_check'].call_args_list[2][0][0].public_numbers() == \
issuer.public_key().public_numbers()
assert 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
with _ocsp_mock(ocsp_lib.OCSPCertStatus.UNKNOWN, ocsp_lib.OCSPResponseStatus.SUCCESSFUL,
http_status_code=400):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# OCSP response in invalid
with _ocsp_mock(ocsp_lib.OCSPCertStatus.UNKNOWN, ocsp_lib.OCSPResponseStatus.UNAUTHORIZED):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# OCSP response is valid, but certificate status is unknown
with _ocsp_mock(ocsp_lib.OCSPCertStatus.UNKNOWN, ocsp_lib.OCSPResponseStatus.SUCCESSFUL):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# The OCSP response says that the certificate is revoked, but certificate
# does not contain the OCSP extension.
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL):
with mock.patch('cryptography.x509.Extensions.get_extension_for_class',
side_effect=x509.ExtensionNotFound(
'Not found', x509.AuthorityInformationAccessOID.OCSP)):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# OCSP response uses an unsupported signature.
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL,
check_signature_side_effect=UnsupportedAlgorithm('foo')):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# OSCP signature response is invalid.
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL,
check_signature_side_effect=InvalidSignature('foo')):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# Assertion error on OCSP response validity
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL,
check_signature_side_effect=AssertionError('foo')):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# No responder cert in OCSP response
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED,
ocsp_lib.OCSPResponseStatus.SUCCESSFUL) as mocks:
mocks['mock_response'].return_value.certificates = []
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
# Responder cert is not signed by certificate issuer
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED,
ocsp_lib.OCSPResponseStatus.SUCCESSFUL) as mocks:
cert = mocks['mock_response'].return_value.certificates[0]
mocks['mock_response'].return_value.certificates[0] = mock.Mock(
issuer='fake', subject=cert.subject)
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
with _ocsp_mock(ocsp_lib.OCSPCertStatus.REVOKED, ocsp_lib.OCSPResponseStatus.SUCCESSFUL):
# This mock is necessary to avoid the first call contained in _determine_ocsp_server
# of the method cryptography.x509.Extensions.get_extension_for_class.
with mock.patch('certbot.ocsp._determine_ocsp_server') as mock_server:
mock_server.return_value = ('https://example.com', 'example.com')
with mock.patch('cryptography.x509.Extensions.get_extension_for_class',
side_effect=x509.ExtensionNotFound(
'Not found', x509.AuthorityInformationAccessOID.OCSP)):
revoked = self.checker.ocsp_revoked(self.cert_obj)
assert revoked is False
@contextlib.contextmanager
def _ocsp_mock(certificate_status, response_status,
http_status_code=200, check_signature_side_effect=None):
with mock.patch('certbot.ocsp.ocsp.load_der_ocsp_response') as mock_response:
mock_response.return_value = _construct_mock_ocsp_response(
certificate_status, response_status)
with mock.patch('certbot.ocsp.requests.post') as mock_post:
mock_post.return_value = mock.Mock(status_code=http_status_code)
with mock.patch('certbot.ocsp.crypto_util.verify_signed_payload') \
as mock_check:
if check_signature_side_effect:
mock_check.side_effect = check_signature_side_effect
yield {
'mock_response': mock_response,
'mock_post': mock_post,
'mock_check': mock_check,
}
def _construct_mock_ocsp_response(certificate_status, response_status):
cert = x509.load_pem_x509_certificate(
test_util.load_vector('ocsp_certificate.pem'), default_backend())
issuer = x509.load_pem_x509_certificate(
test_util.load_vector('ocsp_issuer_certificate.pem'), default_backend())
responder = x509.load_pem_x509_certificate(
test_util.load_vector('ocsp_responder_certificate.pem'), default_backend())
builder = ocsp_lib.OCSPRequestBuilder()
builder = builder.add_certificate(cert, issuer, hashes.SHA1())
request = builder.build()
return mock.Mock(
response_status=response_status,
certificate_status=certificate_status,
serial_number=request.serial_number,
issuer_key_hash=request.issuer_key_hash,
issuer_name_hash=request.issuer_name_hash,
responder_name=responder.subject,
certificates=[responder],
hash_algorithm=hashes.SHA1(),
next_update_utc=datetime.now(pytz.UTC) + timedelta(days=1),
this_update_utc=datetime.now(pytz.UTC) - timedelta(days=1),
signature_algorithm_oid=x509.oid.SignatureAlgorithmOID.RSA_WITH_SHA1,
)
# pylint: disable=line-too-long
openssl_confused = ("", """
/etc/letsencrypt/live/example.org/cert.pem: good
This Update: Dec 17 00:00:00 2016 GMT
Next Update: Dec 24 00:00:00 2016 GMT
""",
"""
Response Verify Failure
139903674214048:error:27069065:OCSP routines:OCSP_basic_verify:certificate verify error:ocsp_vfy.c:138:Verify error:unable to get local issuer certificate
""")
openssl_happy = ("blah.pem", """
blah.pem: good
This Update: Dec 20 18:00:00 2016 GMT
Next Update: Dec 27 18:00:00 2016 GMT
""",
"Response verify OK")
openssl_revoked = ("blah.pem", """
blah.pem: revoked
This Update: Dec 20 01:00:00 2016 GMT
Next Update: Dec 27 01:00:00 2016 GMT
Revocation Time: Dec 20 01:46:34 2016 GMT
""",
"""Response verify OK""")
openssl_unknown = ("blah.pem", """
blah.pem: unknown
This Update: Dec 20 18:00:00 2016 GMT
Next Update: Dec 27 18:00:00 2016 GMT
""",
"Response verify OK")
openssl_broken = ("", "tentacles", "Response verify OK")
openssl_expired_ocsp = ("blah.pem", """
blah.pem: WARNING: Status times invalid.
140659132298912:error:2707307D:OCSP routines:OCSP_check_validity:status expired:ocsp_cl.c:372:
good
This Update: Apr 6 00:00:00 2016 GMT
Next Update: Apr 13 00:00:00 2016 GMT
""",
"""Response verify OK""")
openssl_expired_ocsp_revoked = ("blah.pem", """
blah.pem: WARNING: Status times invalid.
140659132298912:error:2707307D:OCSP routines:OCSP_check_validity:status expired:ocsp_cl.c:372:
revoked
This Update: Apr 6 00:00:00 2016 GMT
Next Update: Apr 13 00:00:00 2016 GMT
""",
"""Response verify OK""")
if __name__ == '__main__':
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
|