Files

350 lines
12 KiB
Python

#!/usr/bin/env python3
"""
SSL Certificate Lifecycle Management Tool
Implements certificate generation, parsing, monitoring, chain validation,
and OCSP checking for managing TLS certificate lifecycles.
Requirements:
pip install cryptography requests
Usage:
python process.py generate-csr --domain example.com --output ./certs
python process.py check-expiry --host example.com
python process.py parse-cert --cert ./server.crt
python process.py monitor --domains domains.txt --threshold 30
python process.py verify-chain --cert ./server.crt --ca-bundle ./ca-bundle.crt
"""
import os
import ssl
import sys
import json
import socket
import argparse
import logging
import datetime
from pathlib import Path
from typing import Dict, List, Optional
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.backends import default_backend
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
EXPIRY_WARNING_DAYS = 30
EXPIRY_CRITICAL_DAYS = 15
def generate_csr(
domain: str,
output_dir: str,
key_type: str = "ecdsa",
san_domains: Optional[List[str]] = None,
organization: Optional[str] = None,
) -> Dict:
"""Generate a private key and CSR for a domain."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
if key_type == "ecdsa":
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
else:
private_key = rsa.generate_private_key(
public_exponent=65537, key_size=4096, backend=default_backend()
)
subject_attrs = [
x509.NameAttribute(NameOID.COMMON_NAME, domain),
]
if organization:
subject_attrs.insert(0, x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization))
subject = x509.Name(subject_attrs)
san_list = [x509.DNSName(domain)]
if san_domains:
for d in san_domains:
san_list.append(x509.DNSName(d))
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(subject)
.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
.sign(private_key, hashes.SHA256(), default_backend())
)
key_path = output_path / f"{domain}.key"
csr_path = output_path / f"{domain}.csr"
key_path.write_bytes(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
csr_path.write_bytes(csr.public_bytes(serialization.Encoding.PEM))
logger.info(f"Generated CSR for {domain}")
return {
"domain": domain,
"key_type": key_type,
"key_path": str(key_path),
"csr_path": str(csr_path),
"san_domains": [d.value for d in san_list],
}
def parse_certificate(cert_path: str) -> Dict:
"""Parse an X.509 certificate and extract key information."""
cert_data = Path(cert_path).read_bytes()
if b"-----BEGIN CERTIFICATE-----" in cert_data:
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
else:
cert = x509.load_der_x509_certificate(cert_data, default_backend())
subject_attrs = {}
for attr in cert.subject:
subject_attrs[attr.oid._name] = attr.value
issuer_attrs = {}
for attr in cert.issuer:
issuer_attrs[attr.oid._name] = attr.value
san_names = []
try:
san_ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
san_names = [name.value for name in san_ext.value.get_values_for_type(x509.DNSName)]
except x509.ExtensionNotFound:
pass
now = datetime.datetime.utcnow()
not_after = cert.not_valid_after_utc.replace(tzinfo=None)
days_remaining = (not_after - now).days
pub_key = cert.public_key()
if isinstance(pub_key, rsa.RSAPublicKey):
key_info = {"type": "RSA", "size": pub_key.key_size}
elif isinstance(pub_key, ec.EllipticCurvePublicKey):
key_info = {"type": "ECDSA", "curve": pub_key.curve.name, "size": pub_key.key_size}
else:
key_info = {"type": "Unknown"}
return {
"subject": subject_attrs,
"issuer": issuer_attrs,
"serial_number": hex(cert.serial_number),
"not_valid_before": cert.not_valid_before_utc.isoformat(),
"not_valid_after": cert.not_valid_after_utc.isoformat(),
"days_remaining": days_remaining,
"san_domains": san_names,
"signature_algorithm": cert.signature_algorithm_oid._name,
"public_key": key_info,
"version": cert.version.value,
"is_expired": days_remaining < 0,
"fingerprint_sha256": cert.fingerprint(hashes.SHA256()).hex(),
}
def check_remote_certificate(host: str, port: int = 443, timeout: int = 10) -> Dict:
"""Check the TLS certificate of a remote host."""
result = {
"host": host,
"port": port,
"status": "unknown",
"days_remaining": None,
"certificate": None,
"errors": [],
}
try:
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert_der = ssock.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(cert_der, default_backend())
now = datetime.datetime.utcnow()
not_after = cert.not_valid_after_utc.replace(tzinfo=None)
days_remaining = (not_after - now).days
result["days_remaining"] = days_remaining
result["not_after"] = not_after.isoformat()
result["protocol"] = ssock.version()
result["cipher"] = ssock.cipher()[0]
subject_cn = None
for attr in cert.subject:
if attr.oid == NameOID.COMMON_NAME:
subject_cn = attr.value
break
result["common_name"] = subject_cn
result["fingerprint_sha256"] = cert.fingerprint(hashes.SHA256()).hex()
if days_remaining < 0:
result["status"] = "EXPIRED"
elif days_remaining < EXPIRY_CRITICAL_DAYS:
result["status"] = "CRITICAL"
elif days_remaining < EXPIRY_WARNING_DAYS:
result["status"] = "WARNING"
else:
result["status"] = "OK"
except ssl.SSLCertVerificationError as e:
result["status"] = "INVALID"
result["errors"].append(f"Certificate verification failed: {e}")
except socket.timeout:
result["status"] = "TIMEOUT"
result["errors"].append("Connection timed out")
except Exception as e:
result["status"] = "ERROR"
result["errors"].append(str(e))
return result
def monitor_domains(domains: List[str], threshold_days: int = 30) -> Dict:
"""Monitor certificate expiration for multiple domains."""
results = {
"scan_time": datetime.datetime.utcnow().isoformat() + "Z",
"threshold_days": threshold_days,
"total_domains": len(domains),
"ok": 0,
"warning": 0,
"critical": 0,
"expired": 0,
"errors": 0,
"domains": [],
}
for domain in domains:
domain = domain.strip()
if not domain or domain.startswith("#"):
continue
host = domain.split(":")[0]
port = int(domain.split(":")[1]) if ":" in domain else 443
logger.info(f"Checking {host}:{port}...")
check = check_remote_certificate(host, port)
results["domains"].append(check)
status = check["status"]
if status == "OK":
results["ok"] += 1
elif status == "WARNING":
results["warning"] += 1
elif status == "CRITICAL":
results["critical"] += 1
elif status == "EXPIRED":
results["expired"] += 1
else:
results["errors"] += 1
return results
def verify_certificate_chain(cert_path: str, ca_bundle_path: str) -> Dict:
"""Verify a certificate chain against a CA bundle."""
cert_data = Path(cert_path).read_bytes()
ca_data = Path(ca_bundle_path).read_bytes()
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
ca_certs = []
pem_blocks = ca_data.split(b"-----END CERTIFICATE-----")
for block in pem_blocks:
block = block.strip()
if block and b"-----BEGIN CERTIFICATE-----" in block:
pem = block + b"\n-----END CERTIFICATE-----\n"
ca_certs.append(x509.load_pem_x509_certificate(pem, default_backend()))
chain = []
current = cert
chain.append({
"subject": current.subject.rfc4514_string(),
"issuer": current.issuer.rfc4514_string(),
})
for ca in ca_certs:
if current.issuer == ca.subject:
chain.append({
"subject": ca.subject.rfc4514_string(),
"issuer": ca.issuer.rfc4514_string(),
})
current = ca
if ca.issuer == ca.subject:
break
is_self_signed = cert.issuer == cert.subject
chain_complete = len(chain) > 1 or is_self_signed
return {
"certificate": cert.subject.rfc4514_string(),
"chain_length": len(chain),
"chain": chain,
"chain_complete": chain_complete,
"is_self_signed": is_self_signed,
}
def main():
parser = argparse.ArgumentParser(description="SSL Certificate Lifecycle Tool")
subparsers = parser.add_subparsers(dest="command")
csr = subparsers.add_parser("generate-csr", help="Generate CSR")
csr.add_argument("--domain", required=True, help="Primary domain")
csr.add_argument("--output", default="./certs", help="Output directory")
csr.add_argument("--key-type", choices=["ecdsa", "rsa"], default="ecdsa")
csr.add_argument("--san", nargs="*", help="Additional SAN domains")
csr.add_argument("--org", help="Organization name")
parse = subparsers.add_parser("parse-cert", help="Parse certificate")
parse.add_argument("--cert", required=True, help="Certificate file path")
check = subparsers.add_parser("check-expiry", help="Check remote cert expiry")
check.add_argument("--host", required=True, help="Hostname")
check.add_argument("--port", type=int, default=443, help="Port")
mon = subparsers.add_parser("monitor", help="Monitor multiple domains")
mon.add_argument("--domains", required=True, help="File with domain list")
mon.add_argument("--threshold", type=int, default=30, help="Warning threshold (days)")
chain = subparsers.add_parser("verify-chain", help="Verify certificate chain")
chain.add_argument("--cert", required=True, help="Certificate file")
chain.add_argument("--ca-bundle", required=True, help="CA bundle file")
args = parser.parse_args()
if args.command == "generate-csr":
result = generate_csr(args.domain, args.output, args.key_type, args.san, args.org)
print(json.dumps(result, indent=2))
elif args.command == "parse-cert":
result = parse_certificate(args.cert)
print(json.dumps(result, indent=2, default=str))
elif args.command == "check-expiry":
result = check_remote_certificate(args.host, args.port)
print(json.dumps(result, indent=2, default=str))
elif args.command == "monitor":
domains = Path(args.domains).read_text().strip().split("\n")
result = monitor_domains(domains, args.threshold)
print(json.dumps(result, indent=2, default=str))
elif args.command == "verify-chain":
result = verify_certificate_chain(args.cert, args.ca_bundle)
print(json.dumps(result, indent=2))
else:
parser.print_help()
if __name__ == "__main__":
main()