mirror of
https://github.com/certbot/certbot.git
synced 2026-07-27 08:09:10 +02:00
So here we are: after #6361 has been merged, time is to provide an environment to execute the automated testing on Windows. Here are the assertions used to build the CI on Windows: every test running on Linux should ultimately be runnable on Windows, in a cross-platform compatible manner (there is one or two exception, when a test does not have any meaning for Windows), currently some tests are not runnable on Windows: theses tests are ignored by default when the environment is Windows using a custom decorator: @broken_on_windows, test environment should have functionalities similar to Travis, in particular an execution test matrix against various versions of Python and Windows, so test execution is done through AppVeyor, as it supports the requirements: it add a CI step along Travis and Codecov for each PR, all of this ensuring that Certbot is entirely functional on both Linux and Windows, code in tests can be changed, but code in Certbot should be changed as little as possible, to avoid regression risks. So far in this PR, I focused on the tests on Certbot core and ACME library. Concerning the plugins, it will be done later, for plugins which have an interest on Windows. Test are executed against Python 3.4, 3.5, 3.6 and 3.7, for Windows Server 2012 R2 and Windows Server 2016. I succeeded at making 258/259 of acme tests to work, and 828/868 of certbot core tests to work. Most of the errors where not because of Certbot itself, but because of how the tests are written. After redesigning some test utilitaries, and things like file path handling, or CRLF/LF, a lot of the errors vanished. I needed also to ignore a lot of IO errors typically occurring when a tearDown test process tries to delete a file before it has been closed: this kind of behavior is acceptable for Linux, but not for Windows. As a consequence, and until the tearDown process is improved, a lot of temporary files are not cleared on Windows after a test campaign. Remaining broken tests requires a more subtile approach to solve the errors, I will correct them progressively in future PR. Last words about tox. I did not used the existing tox.ini for now. It is just to far from what is supported on Windows: lot of bash scripts that should be rewritten completely, and that contain test logic not ready/relevant for Windows (plugin tests, Docker compilation/test, GNU distribution versatility handling and so on). So I use an independent file tox-win.ini for now, with the goal to merge it ultimately with the existing logic. * Define a tox configuration for windows, to execute tests against Python 3.4, 3.5, 3.6 and 3.7 + code coverage on Codecov.io * Correct windows compatibility on certbot codebase * Correct windows compatibility on certbot display functionalities * Correct windows compatibility on certbot plugins * Correct test utils to run tests on windows. Add decorator to skip (permanently) or mark broken (temporarily) tests on windows * Correct tests on certbot core to run them both on windows and linux. Mark some of them as broken on windows for now. * Lock tests are completely skipped on windows. Planned to be replace in next PR. * Correct tests on certbot display to run them both on windows and linux. Mark some of them as broken on windows for now. * Correct test utils for acme on windows. Add decorator to skip (permanently) or mark broken (temporarily) tests on windows. * Correct acme tests to run them both on windows and linux. Allow a reduction of code coverage of 1% on acme code base. * Create AppVeyor CI for Certbot on Windows, to run the test matrix (py34,35,36,37+coverage) on Windows Server 2012 R2 and Windows Server 2016. * Update changelog with Windows compatibility of Certbot. * Corrections about tox, pyreadline and CI logic * Correct english * Some corrections for acme * Newlines corrections * Remove changelog * Use os.devnull instead of /dev/null to be used on Windows * Uid is a always a number now. * Correct linting * PR https://github.com/python/typeshed/pull/2136 has been merge to third-party upstream 6 months ago, so code patch can be removed. * And so acme coverage should be 100% again. * More compatible tests Windows+Linux * Use stable line separator * Remove unused import * Do not rely on pytest in certbot tests * Use json.dumps to another json embedding weird characters * Change comment * Add import * Test rolling builds #1 * Test rolling builds #2 * Correction on json serialization * It seems that rolling builds are not canceling jobs on PR. Revert back to fail fast code in the pipeline.
478 lines
15 KiB
Python
478 lines
15 KiB
Python
"""Certbot client crypto utility functions.
|
|
|
|
.. todo:: Make the transition to use PSS rather than PKCS1_v1_5 when the server
|
|
is capable of handling the signatures.
|
|
|
|
"""
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import warnings
|
|
|
|
import pyrfc3339
|
|
import six
|
|
import zope.component
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
|
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
# https://github.com/python/typeshed/tree/master/third_party/2/cryptography
|
|
from cryptography import x509 # type: ignore
|
|
from OpenSSL import crypto
|
|
from OpenSSL import SSL # type: ignore
|
|
|
|
from acme import crypto_util as acme_crypto_util
|
|
from acme.magic_typing import IO # pylint: disable=unused-import, no-name-in-module
|
|
from certbot import compat
|
|
from certbot import errors
|
|
from certbot import interfaces
|
|
from certbot import util
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# High level functions
|
|
def init_save_key(key_size, key_dir, keyname="key-certbot.pem"):
|
|
"""Initializes and saves a privkey.
|
|
|
|
Inits key and saves it in PEM format on the filesystem.
|
|
|
|
.. note:: keyname is the attempted filename, it may be different if a file
|
|
already exists at the path.
|
|
|
|
:param int key_size: RSA key size in bits
|
|
:param str key_dir: Key save directory.
|
|
:param str keyname: Filename of key
|
|
|
|
:returns: Key
|
|
:rtype: :class:`certbot.util.Key`
|
|
|
|
:raises ValueError: If unable to generate the key given key_size.
|
|
|
|
"""
|
|
try:
|
|
key_pem = make_key(key_size)
|
|
except ValueError as err:
|
|
logger.error("", exc_info=True)
|
|
raise err
|
|
|
|
config = zope.component.getUtility(interfaces.IConfig)
|
|
# Save file
|
|
util.make_or_verify_dir(key_dir, 0o700, compat.os_geteuid(),
|
|
config.strict_permissions)
|
|
key_f, key_path = util.unique_file(
|
|
os.path.join(key_dir, keyname), 0o600, "wb")
|
|
with key_f:
|
|
key_f.write(key_pem)
|
|
logger.debug("Generating key (%d bits): %s", key_size, key_path)
|
|
|
|
return util.Key(key_path, key_pem)
|
|
|
|
|
|
def init_save_csr(privkey, names, path):
|
|
"""Initialize a CSR with the given private key.
|
|
|
|
:param privkey: Key to include in the CSR
|
|
:type privkey: :class:`certbot.util.Key`
|
|
|
|
:param set names: `str` names to include in the CSR
|
|
|
|
:param str path: Certificate save directory.
|
|
|
|
:returns: CSR
|
|
:rtype: :class:`certbot.util.CSR`
|
|
|
|
"""
|
|
config = zope.component.getUtility(interfaces.IConfig)
|
|
|
|
csr_pem = acme_crypto_util.make_csr(
|
|
privkey.pem, names, must_staple=config.must_staple)
|
|
|
|
# Save CSR
|
|
util.make_or_verify_dir(path, 0o755, compat.os_geteuid(),
|
|
config.strict_permissions)
|
|
csr_f, csr_filename = util.unique_file(
|
|
os.path.join(path, "csr-certbot.pem"), 0o644, "wb")
|
|
with csr_f:
|
|
csr_f.write(csr_pem)
|
|
logger.debug("Creating CSR: %s", csr_filename)
|
|
|
|
return util.CSR(csr_filename, csr_pem, "pem")
|
|
|
|
|
|
# WARNING: the csr and private key file are possible attack vectors for TOCTOU
|
|
# We should either...
|
|
# A. Do more checks to verify that the CSR is trusted/valid
|
|
# B. Audit the parsing code for vulnerabilities
|
|
|
|
def valid_csr(csr):
|
|
"""Validate CSR.
|
|
|
|
Check if `csr` is a valid CSR for the given domains.
|
|
|
|
:param str csr: CSR in PEM.
|
|
|
|
:returns: Validity of CSR.
|
|
:rtype: bool
|
|
|
|
"""
|
|
try:
|
|
req = crypto.load_certificate_request(
|
|
crypto.FILETYPE_PEM, csr)
|
|
return req.verify(req.get_pubkey())
|
|
except crypto.Error:
|
|
logger.debug("", exc_info=True)
|
|
return False
|
|
|
|
|
|
def csr_matches_pubkey(csr, privkey):
|
|
"""Does private key correspond to the subject public key in the CSR?
|
|
|
|
:param str csr: CSR in PEM.
|
|
:param str privkey: Private key file contents (PEM)
|
|
|
|
:returns: Correspondence of private key to CSR subject public key.
|
|
:rtype: bool
|
|
|
|
"""
|
|
req = crypto.load_certificate_request(
|
|
crypto.FILETYPE_PEM, csr)
|
|
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, privkey)
|
|
try:
|
|
return req.verify(pkey)
|
|
except crypto.Error:
|
|
logger.debug("", exc_info=True)
|
|
return False
|
|
|
|
|
|
def import_csr_file(csrfile, data):
|
|
"""Import a CSR file, which can be either PEM or DER.
|
|
|
|
:param str csrfile: CSR filename
|
|
:param str data: contents of the CSR file
|
|
|
|
:returns: (`crypto.FILETYPE_PEM`,
|
|
util.CSR object representing the CSR,
|
|
list of domains requested in the CSR)
|
|
:rtype: tuple
|
|
|
|
"""
|
|
PEM = crypto.FILETYPE_PEM
|
|
load = crypto.load_certificate_request
|
|
try:
|
|
# Try to parse as DER first, then fall back to PEM.
|
|
csr = load(crypto.FILETYPE_ASN1, data)
|
|
except crypto.Error:
|
|
try:
|
|
csr = load(PEM, data)
|
|
except crypto.Error:
|
|
raise errors.Error("Failed to parse CSR file: {0}".format(csrfile))
|
|
|
|
domains = _get_names_from_loaded_cert_or_req(csr)
|
|
# Internally we always use PEM, so re-encode as PEM before returning.
|
|
data_pem = crypto.dump_certificate_request(PEM, csr)
|
|
return PEM, util.CSR(file=csrfile, data=data_pem, form="pem"), domains
|
|
|
|
|
|
def make_key(bits):
|
|
"""Generate PEM encoded RSA key.
|
|
|
|
:param int bits: Number of bits, at least 1024.
|
|
|
|
:returns: new RSA key in PEM form with specified number of bits
|
|
:rtype: str
|
|
|
|
"""
|
|
assert bits >= 1024 # XXX
|
|
key = crypto.PKey()
|
|
key.generate_key(crypto.TYPE_RSA, bits)
|
|
return crypto.dump_privatekey(crypto.FILETYPE_PEM, key)
|
|
|
|
|
|
def valid_privkey(privkey):
|
|
"""Is valid RSA private key?
|
|
|
|
:param str privkey: Private key file contents in PEM
|
|
|
|
:returns: Validity of private key.
|
|
:rtype: bool
|
|
|
|
"""
|
|
try:
|
|
return crypto.load_privatekey(
|
|
crypto.FILETYPE_PEM, privkey).check()
|
|
except (TypeError, crypto.Error):
|
|
return False
|
|
|
|
|
|
def verify_renewable_cert(renewable_cert):
|
|
"""For checking that your certs were not corrupted on disk.
|
|
|
|
Several things are checked:
|
|
1. Signature verification for the cert.
|
|
2. That fullchain matches cert and chain when concatenated.
|
|
3. Check that the private key matches the certificate.
|
|
|
|
:param `.storage.RenewableCert` renewable_cert: cert to verify
|
|
|
|
:raises errors.Error: If verification fails.
|
|
"""
|
|
verify_renewable_cert_sig(renewable_cert)
|
|
verify_fullchain(renewable_cert)
|
|
verify_cert_matches_priv_key(renewable_cert.cert, renewable_cert.privkey)
|
|
|
|
|
|
def verify_renewable_cert_sig(renewable_cert):
|
|
""" Verifies the signature of a `.storage.RenewableCert` object.
|
|
|
|
:param `.storage.RenewableCert` renewable_cert: cert to verify
|
|
|
|
:raises errors.Error: If signature verification fails.
|
|
"""
|
|
try:
|
|
with open(renewable_cert.chain, 'rb') as chain_file: # type: IO[bytes]
|
|
chain = x509.load_pem_x509_certificate(chain_file.read(), default_backend())
|
|
with open(renewable_cert.cert, 'rb') as cert_file: # type: IO[bytes]
|
|
cert = x509.load_pem_x509_certificate(cert_file.read(), default_backend())
|
|
pk = chain.public_key()
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
if isinstance(pk, RSAPublicKey):
|
|
# https://github.com/python/typeshed/blob/master/third_party/2/cryptography/hazmat/primitives/asymmetric/rsa.pyi
|
|
verifier = pk.verifier( # type: ignore
|
|
cert.signature, PKCS1v15(), cert.signature_hash_algorithm
|
|
)
|
|
verifier.update(cert.tbs_certificate_bytes)
|
|
verifier.verify()
|
|
elif isinstance(pk, EllipticCurvePublicKey):
|
|
verifier = pk.verifier(
|
|
cert.signature, ECDSA(cert.signature_hash_algorithm)
|
|
)
|
|
verifier.update(cert.tbs_certificate_bytes)
|
|
verifier.verify()
|
|
else:
|
|
raise errors.Error("Unsupported public key type")
|
|
except (IOError, ValueError, InvalidSignature) as e:
|
|
error_str = "verifying the signature of the cert located at {0} has failed. \
|
|
Details: {1}".format(renewable_cert.cert, e)
|
|
logger.exception(error_str)
|
|
raise errors.Error(error_str)
|
|
|
|
|
|
def verify_cert_matches_priv_key(cert_path, key_path):
|
|
""" Verifies that the private key and cert match.
|
|
|
|
:param str cert_path: path to a cert in PEM format
|
|
:param str key_path: path to a private key file
|
|
|
|
:raises errors.Error: If they don't match.
|
|
"""
|
|
try:
|
|
context = SSL.Context(SSL.SSLv23_METHOD)
|
|
context.use_certificate_file(cert_path)
|
|
context.use_privatekey_file(key_path)
|
|
context.check_privatekey()
|
|
except (IOError, SSL.Error) as e:
|
|
error_str = "verifying the cert located at {0} matches the \
|
|
private key located at {1} has failed. \
|
|
Details: {2}".format(cert_path,
|
|
key_path, e)
|
|
logger.exception(error_str)
|
|
raise errors.Error(error_str)
|
|
|
|
|
|
def verify_fullchain(renewable_cert):
|
|
""" Verifies that fullchain is indeed cert concatenated with chain.
|
|
|
|
:param `.storage.RenewableCert` renewable_cert: cert to verify
|
|
|
|
:raises errors.Error: If cert and chain do not combine to fullchain.
|
|
"""
|
|
try:
|
|
with open(renewable_cert.chain) as chain_file: # type: IO[str]
|
|
chain = chain_file.read()
|
|
with open(renewable_cert.cert) as cert_file: # type: IO[str]
|
|
cert = cert_file.read()
|
|
with open(renewable_cert.fullchain) as fullchain_file: # type: IO[str]
|
|
fullchain = fullchain_file.read()
|
|
if (cert + chain) != fullchain:
|
|
error_str = "fullchain does not match cert + chain for {0}!"
|
|
error_str = error_str.format(renewable_cert.lineagename)
|
|
raise errors.Error(error_str)
|
|
except IOError as e:
|
|
error_str = "reading one of cert, chain, or fullchain has failed: {0}".format(e)
|
|
logger.exception(error_str)
|
|
raise errors.Error(error_str)
|
|
except errors.Error as e:
|
|
raise e
|
|
|
|
|
|
def pyopenssl_load_certificate(data):
|
|
"""Load PEM/DER certificate.
|
|
|
|
:raises errors.Error:
|
|
|
|
"""
|
|
|
|
openssl_errors = []
|
|
|
|
for file_type in (crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1):
|
|
try:
|
|
return crypto.load_certificate(file_type, data), file_type
|
|
except crypto.Error as error: # TODO: other errors?
|
|
openssl_errors.append(error)
|
|
raise errors.Error("Unable to load: {0}".format(",".join(
|
|
str(error) for error in openssl_errors)))
|
|
|
|
|
|
def _load_cert_or_req(cert_or_req_str, load_func,
|
|
typ=crypto.FILETYPE_PEM):
|
|
try:
|
|
return load_func(typ, cert_or_req_str)
|
|
except crypto.Error:
|
|
logger.error("", exc_info=True)
|
|
raise
|
|
|
|
|
|
def _get_sans_from_cert_or_req(cert_or_req_str, load_func,
|
|
typ=crypto.FILETYPE_PEM):
|
|
# pylint: disable=protected-access
|
|
return acme_crypto_util._pyopenssl_cert_or_req_san(_load_cert_or_req(
|
|
cert_or_req_str, load_func, typ))
|
|
|
|
|
|
def get_sans_from_cert(cert, typ=crypto.FILETYPE_PEM):
|
|
"""Get a list of Subject Alternative Names from a certificate.
|
|
|
|
:param str cert: Certificate (encoded).
|
|
:param typ: `crypto.FILETYPE_PEM` or `crypto.FILETYPE_ASN1`
|
|
|
|
:returns: A list of Subject Alternative Names.
|
|
:rtype: list
|
|
|
|
"""
|
|
return _get_sans_from_cert_or_req(
|
|
cert, crypto.load_certificate, typ)
|
|
|
|
|
|
def _get_names_from_cert_or_req(cert_or_req, load_func, typ):
|
|
loaded_cert_or_req = _load_cert_or_req(cert_or_req, load_func, typ)
|
|
return _get_names_from_loaded_cert_or_req(loaded_cert_or_req)
|
|
|
|
|
|
def _get_names_from_loaded_cert_or_req(loaded_cert_or_req):
|
|
# pylint: disable=protected-access
|
|
return acme_crypto_util._pyopenssl_cert_or_req_all_names(loaded_cert_or_req)
|
|
|
|
|
|
def get_names_from_cert(csr, typ=crypto.FILETYPE_PEM):
|
|
"""Get a list of domains from a cert, including the CN if it is set.
|
|
|
|
:param str cert: Certificate (encoded).
|
|
:param typ: `crypto.FILETYPE_PEM` or `crypto.FILETYPE_ASN1`
|
|
|
|
:returns: A list of domain names.
|
|
:rtype: list
|
|
|
|
"""
|
|
return _get_names_from_cert_or_req(
|
|
csr, crypto.load_certificate, typ)
|
|
|
|
|
|
def dump_pyopenssl_chain(chain, filetype=crypto.FILETYPE_PEM):
|
|
"""Dump certificate chain into a bundle.
|
|
|
|
:param list chain: List of `crypto.X509` (or wrapped in
|
|
:class:`josepy.util.ComparableX509`).
|
|
|
|
"""
|
|
# XXX: returns empty string when no chain is available, which
|
|
# shuts up RenewableCert, but might not be the best solution...
|
|
return acme_crypto_util.dump_pyopenssl_chain(chain, filetype)
|
|
|
|
|
|
def notBefore(cert_path):
|
|
"""When does the cert at cert_path start being valid?
|
|
|
|
:param str cert_path: path to a cert in PEM format
|
|
|
|
:returns: the notBefore value from the cert at cert_path
|
|
:rtype: :class:`datetime.datetime`
|
|
|
|
"""
|
|
return _notAfterBefore(cert_path, crypto.X509.get_notBefore)
|
|
|
|
|
|
def notAfter(cert_path):
|
|
"""When does the cert at cert_path stop being valid?
|
|
|
|
:param str cert_path: path to a cert in PEM format
|
|
|
|
:returns: the notAfter value from the cert at cert_path
|
|
:rtype: :class:`datetime.datetime`
|
|
|
|
"""
|
|
return _notAfterBefore(cert_path, crypto.X509.get_notAfter)
|
|
|
|
|
|
def _notAfterBefore(cert_path, method):
|
|
"""Internal helper function for finding notbefore/notafter.
|
|
|
|
:param str cert_path: path to a cert in PEM format
|
|
:param function method: one of ``crypto.X509.get_notBefore``
|
|
or ``crypto.X509.get_notAfter``
|
|
|
|
:returns: the notBefore or notAfter value from the cert at cert_path
|
|
:rtype: :class:`datetime.datetime`
|
|
|
|
"""
|
|
# pylint: disable=redefined-outer-name
|
|
with open(cert_path) as f:
|
|
x509 = crypto.load_certificate(crypto.FILETYPE_PEM,
|
|
f.read())
|
|
# pyopenssl always returns bytes
|
|
timestamp = method(x509)
|
|
reformatted_timestamp = [timestamp[0:4], b"-", timestamp[4:6], b"-",
|
|
timestamp[6:8], b"T", timestamp[8:10], b":",
|
|
timestamp[10:12], b":", timestamp[12:]]
|
|
timestamp_str = b"".join(reformatted_timestamp)
|
|
# pyrfc3339 uses "native" strings. That is, bytes on Python 2 and unicode
|
|
# on Python 3
|
|
if six.PY3:
|
|
timestamp_str = timestamp_str.decode('ascii')
|
|
return pyrfc3339.parse(timestamp_str)
|
|
|
|
|
|
def sha256sum(filename):
|
|
"""Compute a sha256sum of a file.
|
|
|
|
NB: In given file, platform specific newlines characters will be converted
|
|
into their equivalent unicode counterparts before calculating the hash.
|
|
|
|
:param str filename: path to the file whose hash will be computed
|
|
|
|
:returns: sha256 digest of the file in hexadecimal
|
|
:rtype: str
|
|
"""
|
|
sha256 = hashlib.sha256()
|
|
with open(filename, 'rU') as file_d:
|
|
sha256.update(file_d.read().encode('UTF-8'))
|
|
return sha256.hexdigest()
|
|
|
|
def cert_and_chain_from_fullchain(fullchain_pem):
|
|
"""Split fullchain_pem into cert_pem and chain_pem
|
|
|
|
:param str fullchain_pem: concatenated cert + chain
|
|
|
|
:returns: tuple of string cert_pem and chain_pem
|
|
:rtype: tuple
|
|
|
|
"""
|
|
cert = crypto.dump_certificate(crypto.FILETYPE_PEM,
|
|
crypto.load_certificate(crypto.FILETYPE_PEM, fullchain_pem)).decode()
|
|
chain = fullchain_pem[len(cert):].lstrip()
|
|
return (cert, chain)
|