diff --git a/README.md b/README.md index 6b332e4..ac3a5bb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ By default the directory (work_dir) containing the working data (csr,certificate | account_key_algorithm | d,**g** | Key-algorithm for newly generated account keys (RSA, EC, ED25519, ED448) | RSA | | account_key_length | d,**g** | Key-length for newly generated RSA account keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on account_key_algorithm | | ttl_days | d,**g** | Renew certificate if it has less than this value validity left | 30 | +| validate_ocsp | d,**g** | Renew certificate if it's OCSP status is REVOKED. Allowed values for this key are: false, sha1, sha224, sha256, sha384, sha512 | sha1 (as mandated by RFC5019) | | cert_dir | d,**g** | Directory containing all certificate related data (crt,key,csr) | {work_dir} | | key_algorithm | d,**g** | Key-algorithm for newly generated private keys (RSA, EC, ED25519, ED448) | RSA | | key_length | d,**g** | Key-length for newly generated RSA private keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on key_algorithm | diff --git a/acertmgr/__init__.py b/acertmgr/__init__.py index 88c201a..327a7dc 100755 --- a/acertmgr/__init__.py +++ b/acertmgr/__init__.py @@ -129,7 +129,8 @@ def cert_revoke(cert, configs, fallback_authority, reason=None): if not acmeconfig: acmeconfig = fallback_authority log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert), - tools.get_cert_domains(cert)), warning=True) + tools.get_cert_domains( + cert)), warning=True) acme = authority(acmeconfig) acme.register_account() acme.revoke_crt(cert, reason) @@ -157,9 +158,21 @@ def main(): cert = None if os.path.isfile(config['cert_file']): cert = tools.read_pem_file(config['cert_file']) - if not cert or not tools.is_cert_valid(cert, config['ttl_days']) or ( - 'force_renew' in runtimeconfig and - all(d in config['domainlist'] for d in runtimeconfig['force_renew'])): + validate_ocsp = str(config.get('validate_ocsp')).lower() != 'false' + if validate_ocsp and cert and os.path.isfile(config['ca_file']): + try: + issuer = tools.read_pem_file(config['ca_file']) + except Exception as e1: + log("Failed to retrieve issuer from ca file: {}. Trying to download...".format(e1)) + try: + issuer = tools.download_issuer_ca(cert) + except Exception as e2: + log("Failed to download issuer for cert file: {}. Cannot validate OCSP.".format(e2)) + validate_ocsp = False + if not cert or ('force_renew' in runtimeconfig and all( + d in config['domainlist'] for d in runtimeconfig['force_renew'])) \ + or not tools.is_cert_valid(cert, config['ttl_days']) \ + or (validate_ocsp and not tools.is_ocsp_valid(cert, issuer, config['validate_ocsp'])): cert_get(config) if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert: superseded.add(cert) diff --git a/acertmgr/configuration.py b/acertmgr/configuration.py index 9e6185d..6a0ac0c 100644 --- a/acertmgr/configuration.py +++ b/acertmgr/configuration.py @@ -19,6 +19,7 @@ from acertmgr.tools import idna_convert DEFAULT_CONF_DIR = "/etc/acertmgr" DEFAULT_CONF_FILENAME = "acertmgr.conf" DEFAULT_TTL = 30 # days +DEFAULT_VALIDATE_OCSP = "sha1" # mandated by RFC5019 DEFAULT_API = "v2" DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org" @@ -106,6 +107,9 @@ def parse_config_entry(entry, globalconfig, runtimeconfig): update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL) config['ttl_days'] = int(config['ttl_days']) + # Validate OCSP on certificate verification + update_config_value(config, 'validate_ocsp', localconfig, globalconfig, DEFAULT_VALIDATE_OCSP) + # Revoke old certificate with reason superseded after renewal update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false") diff --git a/acertmgr/tools.py b/acertmgr/tools.py index 3f0730a..de4a3ce 100644 --- a/acertmgr/tools.py +++ b/acertmgr/tools.py @@ -22,6 +22,11 @@ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from cryptography.utils import int_to_bytes from cryptography.x509.oid import NameOID, ExtensionOID +try: + from cryptography.x509 import ocsp +except ImportError: + pass + try: from cryptography.hazmat.primitives.asymmetric import ed25519, ed448 except ImportError: @@ -388,3 +393,48 @@ def idna_convert(domainlist): if any(ord(c) >= 128 for c in ''.join(domainlist)) and 'idna' not in sys.modules: log("Unicode domain(s) found but IDNA names could not be translated due to missing idna module", error=True) return [(x, x) for x in domainlist] + + +# @brief validate the OCSP status for a given certificate by the given issuer +def is_ocsp_valid(cert, issuer, hash_algo): + if hash_algo == 'sha1': + algorithm = hashes.SHA1 + elif hash_algo == 'sha224': + algorithm = hashes.SHA224 + elif hash_algo == 'sha256': + algorithm = hashes.SHA256 + elif hash_algo == 'sha385': + algorithm = hashes.SHA384 + elif hash_algo == 'sha512': + algorithm = hashes.SHA512 + else: + log("Invalid hash algorithm '{}' used for OCSP validation. Validation ignored.".format(hash_algo), warning=True) + return True + + try: + ocsp_urls = [] + aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS) + for data in aia.value: + if data.access_method == x509.OID_OCSP: + ocsp_urls.append(data.access_location.value) + + # This is a bit of a hack due to validation problems within cryptography (TODO: Check if this is still true) + # Correct replacement: ocsprequest = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, algorithm).build() + ocsprequest = ocsp.OCSPRequestBuilder((cert, issuer, algorithm)).build() + ocsprequestdata = ocsprequest.public_bytes(serialization.Encoding.DER) + for ocsp_url in ocsp_urls: + response = get_url(ocsp_url, + ocsprequestdata, + { + 'Accept': 'application/ocsp-response', + 'Content-Type': 'application/ocsp-request', + }) + ocspresponsedata = response.read() + ocspresponse = ocsp.load_der_ocsp_response(ocspresponsedata) + if ocspresponse.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL \ + and ocspresponse.certificate_status == ocsp.OCSPCertStatus.REVOKED: + return False + except Exception as e: + log("An exception occurred during OCSP validation (Validation will be ignored): {}".format(e), error=True) + + return True diff --git a/setup.py b/setup.py index c19bafa..16180af 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ extra_requirements = { "yaml": ["PyYAML"], "idna": ["idna"], "ocsp-must-staple": ["cryptography>=2.1"], + "ocsp-validation": ["cryptography>=2.4"], "ed25519": ["cryptography>=2.6"], }