Files

456 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Certificate Authority Builder using Python cryptography library.
Builds a complete two-tier CA hierarchy (Root CA + Intermediate CA)
and issues server/client certificates programmatically.
Requirements:
pip install cryptography
Usage:
python process.py build-ca --output ./pki --org "My Organization"
python process.py issue-cert --ca-dir ./pki --domain server.example.com --type server
python process.py revoke --ca-dir ./pki --serial 1001
python process.py generate-crl --ca-dir ./pki
"""
import os
import sys
import json
import argparse
import logging
import datetime
from pathlib import Path
from typing import Dict, Optional, List
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.backends import default_backend
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def generate_key(key_type: str = "rsa", key_size: int = 4096):
"""Generate a private key."""
if key_type == "ecdsa":
return ec.generate_private_key(ec.SECP384R1(), default_backend())
return rsa.generate_private_key(
public_exponent=65537, key_size=key_size, backend=default_backend()
)
def save_key(key, path: Path, passphrase: Optional[str] = None):
"""Save a private key to PEM file."""
if passphrase:
enc = serialization.BestAvailableEncryption(passphrase.encode())
else:
enc = serialization.NoEncryption()
path.write_bytes(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=enc,
))
def save_cert(cert, path: Path):
"""Save a certificate to PEM file."""
path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
def build_root_ca(
output_dir: Path,
organization: str,
country: str = "US",
validity_years: int = 20,
) -> Dict:
"""Build a Root CA with self-signed certificate."""
ca_dir = output_dir / "root-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
(ca_dir / "certs").mkdir(exist_ok=True)
(ca_dir / "private").mkdir(exist_ok=True)
key = generate_key("rsa", 4096)
save_key(key, ca_dir / "private" / "root-ca.key")
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, f"{organization} Root CA"),
])
now = datetime.datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_years * 365))
.add_extension(
x509.BasicConstraints(ca=True, path_length=1), critical=True
)
.add_extension(
x509.KeyUsage(
digital_signature=True, key_cert_sign=True, crl_sign=True,
content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False,
encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
critical=False,
)
.sign(key, hashes.SHA384(), default_backend())
)
save_cert(cert, ca_dir / "certs" / "root-ca.crt")
# Initialize serial number tracker
(ca_dir / "serial.json").write_text(json.dumps({"next_serial": 1001}))
(ca_dir / "index.json").write_text(json.dumps({"certificates": []}))
logger.info(f"Root CA created: {ca_dir}")
return {
"type": "root-ca",
"subject": subject.rfc4514_string(),
"key_path": str(ca_dir / "private" / "root-ca.key"),
"cert_path": str(ca_dir / "certs" / "root-ca.crt"),
"serial_number": hex(cert.serial_number),
"valid_until": cert.not_valid_after_utc.isoformat(),
}
def build_intermediate_ca(
output_dir: Path,
organization: str,
country: str = "US",
validity_years: int = 10,
) -> Dict:
"""Build an Intermediate CA signed by the Root CA."""
root_dir = output_dir / "root-ca"
int_dir = output_dir / "intermediate-ca"
int_dir.mkdir(parents=True, exist_ok=True)
(int_dir / "certs").mkdir(exist_ok=True)
(int_dir / "private").mkdir(exist_ok=True)
root_key_data = (root_dir / "private" / "root-ca.key").read_bytes()
root_key = serialization.load_pem_private_key(root_key_data, password=None, backend=default_backend())
root_cert_data = (root_dir / "certs" / "root-ca.crt").read_bytes()
root_cert = x509.load_pem_x509_certificate(root_cert_data, default_backend())
int_key = generate_key("rsa", 4096)
save_key(int_key, int_dir / "private" / "intermediate-ca.key")
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COMMON_NAME, f"{organization} Intermediate CA"),
])
now = datetime.datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(root_cert.subject)
.public_key(int_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_years * 365))
.add_extension(
x509.BasicConstraints(ca=True, path_length=0), critical=True
)
.add_extension(
x509.KeyUsage(
digital_signature=True, key_cert_sign=True, crl_sign=True,
content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False,
encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(int_key.public_key()),
critical=False,
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(root_key.public_key()),
critical=False,
)
.sign(root_key, hashes.SHA384(), default_backend())
)
save_cert(cert, int_dir / "certs" / "intermediate-ca.crt")
# Create chain file
chain = cert.public_bytes(serialization.Encoding.PEM) + root_cert.public_bytes(serialization.Encoding.PEM)
(int_dir / "certs" / "ca-chain.crt").write_bytes(chain)
# Initialize serial and index
(int_dir / "serial.json").write_text(json.dumps({"next_serial": 2001}))
(int_dir / "index.json").write_text(json.dumps({"certificates": [], "revoked": []}))
logger.info(f"Intermediate CA created: {int_dir}")
return {
"type": "intermediate-ca",
"subject": subject.rfc4514_string(),
"key_path": str(int_dir / "private" / "intermediate-ca.key"),
"cert_path": str(int_dir / "certs" / "intermediate-ca.crt"),
"chain_path": str(int_dir / "certs" / "ca-chain.crt"),
"serial_number": hex(cert.serial_number),
"valid_until": cert.not_valid_after_utc.isoformat(),
}
def issue_certificate(
ca_dir: Path,
domain: str,
cert_type: str = "server",
validity_days: int = 365,
san_domains: Optional[List[str]] = None,
) -> Dict:
"""Issue a certificate from the Intermediate CA."""
int_dir = ca_dir / "intermediate-ca"
int_key_data = (int_dir / "private" / "intermediate-ca.key").read_bytes()
int_key = serialization.load_pem_private_key(int_key_data, password=None, backend=default_backend())
int_cert_data = (int_dir / "certs" / "intermediate-ca.crt").read_bytes()
int_cert = x509.load_pem_x509_certificate(int_cert_data, default_backend())
# Read and update serial
serial_data = json.loads((int_dir / "serial.json").read_text())
serial_num = serial_data["next_serial"]
serial_data["next_serial"] = serial_num + 1
(int_dir / "serial.json").write_text(json.dumps(serial_data))
# Generate end-entity key
ee_key = generate_key("ecdsa")
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, domain),
])
san_list = [x509.DNSName(domain)]
if san_domains:
for d in san_domains:
san_list.append(x509.DNSName(d))
if cert_type == "server":
eku = x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH])
elif cert_type == "client":
eku = x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH])
else:
eku = x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
])
now = datetime.datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(int_cert.subject)
.public_key(ee_key.public_key())
.serial_number(serial_num)
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_days))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True, key_encipherment=True,
key_cert_sign=False, crl_sign=False,
content_commitment=False, data_encipherment=False,
key_agreement=False, encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(eku, critical=False)
.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(int_key.public_key()),
critical=False,
)
.sign(int_key, hashes.SHA256(), default_backend())
)
certs_dir = int_dir / "certs" / "issued"
certs_dir.mkdir(exist_ok=True)
save_key(ee_key, certs_dir / f"{domain}.key")
save_cert(cert, certs_dir / f"{domain}.crt")
# Update index
index_data = json.loads((int_dir / "index.json").read_text())
index_data["certificates"].append({
"serial": serial_num,
"domain": domain,
"type": cert_type,
"issued": now.isoformat(),
"expires": (now + datetime.timedelta(days=validity_days)).isoformat(),
"status": "valid",
})
(int_dir / "index.json").write_text(json.dumps(index_data, indent=2))
logger.info(f"Issued {cert_type} certificate for {domain} (serial: {serial_num})")
return {
"domain": domain,
"serial": serial_num,
"type": cert_type,
"key_path": str(certs_dir / f"{domain}.key"),
"cert_path": str(certs_dir / f"{domain}.crt"),
"chain_path": str(int_dir / "certs" / "ca-chain.crt"),
"valid_until": cert.not_valid_after_utc.isoformat(),
}
def revoke_certificate(ca_dir: Path, serial: int, reason: str = "unspecified") -> Dict:
"""Revoke a certificate by serial number."""
int_dir = ca_dir / "intermediate-ca"
index_data = json.loads((int_dir / "index.json").read_text())
reason_map = {
"unspecified": x509.ReasonFlags.unspecified,
"key_compromise": x509.ReasonFlags.key_compromise,
"ca_compromise": x509.ReasonFlags.ca_compromise,
"affiliation_changed": x509.ReasonFlags.affiliation_changed,
"superseded": x509.ReasonFlags.superseded,
"cessation_of_operation": x509.ReasonFlags.cessation_of_operation,
}
found = False
for cert_entry in index_data["certificates"]:
if cert_entry["serial"] == serial:
cert_entry["status"] = "revoked"
cert_entry["revoked_at"] = datetime.datetime.utcnow().isoformat()
cert_entry["revocation_reason"] = reason
found = True
break
if not found:
raise ValueError(f"Certificate with serial {serial} not found")
if "revoked" not in index_data:
index_data["revoked"] = []
index_data["revoked"].append({
"serial": serial,
"revoked_at": datetime.datetime.utcnow().isoformat(),
"reason": reason,
})
(int_dir / "index.json").write_text(json.dumps(index_data, indent=2))
logger.info(f"Revoked certificate serial {serial} (reason: {reason})")
return {"serial": serial, "status": "revoked", "reason": reason}
def generate_crl(ca_dir: Path, validity_days: int = 30) -> Dict:
"""Generate a Certificate Revocation List."""
int_dir = ca_dir / "intermediate-ca"
int_key_data = (int_dir / "private" / "intermediate-ca.key").read_bytes()
int_key = serialization.load_pem_private_key(int_key_data, password=None, backend=default_backend())
int_cert_data = (int_dir / "certs" / "intermediate-ca.crt").read_bytes()
int_cert = x509.load_pem_x509_certificate(int_cert_data, default_backend())
index_data = json.loads((int_dir / "index.json").read_text())
now = datetime.datetime.utcnow()
builder = x509.CertificateRevocationListBuilder()
builder = builder.issuer_name(int_cert.subject)
builder = builder.last_update(now)
builder = builder.next_update(now + datetime.timedelta(days=validity_days))
reason_map = {
"unspecified": x509.ReasonFlags.unspecified,
"key_compromise": x509.ReasonFlags.key_compromise,
"ca_compromise": x509.ReasonFlags.ca_compromise,
"superseded": x509.ReasonFlags.superseded,
"cessation_of_operation": x509.ReasonFlags.cessation_of_operation,
}
for entry in index_data.get("revoked", []):
revoked_cert = (
x509.RevokedCertificateBuilder()
.serial_number(entry["serial"])
.revocation_date(datetime.datetime.fromisoformat(entry["revoked_at"]))
.add_extension(
x509.CRLReason(reason_map.get(entry.get("reason", "unspecified"), x509.ReasonFlags.unspecified)),
critical=False,
)
.build()
)
builder = builder.add_revoked_certificate(revoked_cert)
crl = builder.sign(int_key, hashes.SHA256(), default_backend())
crl_path = int_dir / "crl" / "intermediate.crl"
crl_path.parent.mkdir(exist_ok=True)
crl_path.write_bytes(crl.public_bytes(serialization.Encoding.PEM))
logger.info(f"Generated CRL with {len(index_data.get('revoked', []))} revoked certificates")
return {
"crl_path": str(crl_path),
"revoked_count": len(index_data.get("revoked", [])),
"next_update": (now + datetime.timedelta(days=validity_days)).isoformat(),
}
def main():
parser = argparse.ArgumentParser(description="Certificate Authority Builder")
subparsers = parser.add_subparsers(dest="command")
build = subparsers.add_parser("build-ca", help="Build complete CA hierarchy")
build.add_argument("--output", "-o", default="./pki", help="Output directory")
build.add_argument("--org", required=True, help="Organization name")
build.add_argument("--country", default="US", help="Country code")
issue = subparsers.add_parser("issue-cert", help="Issue a certificate")
issue.add_argument("--ca-dir", required=True, help="CA directory")
issue.add_argument("--domain", required=True, help="Domain name")
issue.add_argument("--type", choices=["server", "client", "both"], default="server")
issue.add_argument("--days", type=int, default=365, help="Validity days")
issue.add_argument("--san", nargs="*", help="Additional SAN domains")
rev = subparsers.add_parser("revoke", help="Revoke a certificate")
rev.add_argument("--ca-dir", required=True, help="CA directory")
rev.add_argument("--serial", type=int, required=True, help="Serial number")
rev.add_argument("--reason", default="unspecified", help="Revocation reason")
crl = subparsers.add_parser("generate-crl", help="Generate CRL")
crl.add_argument("--ca-dir", required=True, help="CA directory")
crl.add_argument("--days", type=int, default=30, help="CRL validity days")
args = parser.parse_args()
if args.command == "build-ca":
output = Path(args.output)
root = build_root_ca(output, args.org, args.country)
intermediate = build_intermediate_ca(output, args.org, args.country)
print(json.dumps({"root_ca": root, "intermediate_ca": intermediate}, indent=2))
elif args.command == "issue-cert":
result = issue_certificate(
Path(args.ca_dir), args.domain, args.type, args.days, args.san
)
print(json.dumps(result, indent=2))
elif args.command == "revoke":
result = revoke_certificate(Path(args.ca_dir), args.serial, args.reason)
print(json.dumps(result, indent=2))
elif args.command == "generate-crl":
result = generate_crl(Path(args.ca_dir), args.days)
print(json.dumps(result, indent=2))
else:
parser.print_help()
if __name__ == "__main__":
main()