用户证书自动续签,详解 CA 证书服务器的搭建!

227 阅读8分钟

本文正在参加「金石计划」

前一篇我介绍了证书的过期与吊销相关的知识和操作步骤,这一篇我们继续探索 CA 证书服务器(CA Server)的搭建。

我们知道,如果证书太多了,时间一长,我们很难知道哪些证书过期,为了避免服务的中断,一个可行的策略就是添加一个定时任务来监控证书的有效期,一旦发现证书过期,我们就自动续签

而要实现自动续签,我们需要 CA 以服务的形式随时待命,一旦 Web 服务发起续签请求,就能够自动签发证书,方便证书的自动更新。

当然,除了证书过期,CA 还需要自动或手动对证书进行吊销(Revoke),并及时更新证书吊销列表(CRL

证书重复

证书重复申请是我们首先需要解决的问题。当我们已经对一个 CSR 签发过证书后,如果再次对同一个 CSR 签发,openssl 会提示 “There is already a certificate for /CN=xxx",拒绝签发。

因此,我们是不需要担心重复申请的。

但是,如果一个证书已经过期了,我们需要再使用这个 CSR 申请证书,这时候,我们依然会被拒绝。因为 CA 并不会自动进行吊销,所以,这时候,客户端需要有某种机制通知 CA 去撤销该证书,否则我们无法更新证书了。

具体的方法是传递一个 force 参数来强制撤销 CA 签发列表中的某个证书。

那如何从 CA 的签发列表找到这个证书呢?

通过 index.txt 中的 "Subject Name" 中的 CommonName 来进行匹配。如果 CommonName 与 CSR 中的 "Subject Name" 中的 CommonName 字段相同,就说明是同一个用户证书。根据用户的要求来决定是否执行撤销操作。

比如,

  • 如果 index.txt 中记录的该证书尚未过期,那么就需要拒绝。
  • 如果已经过期,那么 CA 就先撤销原证书,然后再签发新证书。
  • 如果传递了 force 参数,就强制撤销证书,再签发新证书。

按照上述的逻辑,下面我们来具体写代码实现。

采用了 Python 的 Web 库 Flask 来实现会比较简单,使用 cryptography 库来解析证书字段。

如下:

from flask import Flask, request, jsonify
import subprocess
import os
from cryptography import x509
from cryptography.hazmat.backends import default_backend

app = Flask(__name__)

home_dir = os.path.expanduser("~")
INT_CA_DIR = os.path.join(home_dir, "Documents", "Dev", "allcerts", "allcerts", "int_ca")
INT_CA_PASSWORD_FILE = os.path.join(INT_CA_DIR, "CA", "private", "password.txt")
INT_CA_CONFIG = os.path.join(INT_CA_DIR, "ica.cnf")
INDEX_FILE = os.path.join(INT_CA_DIR, "index.txt")

def get_subject_from_csr(csr_path):
    """Extract the full subject name from the CSR using cryptography."""
    with open(csr_path, "rb") as csr_file:
        csr_data = csr_file.read()
    
    # Load the CSR using cryptography
    csr = x509.load_pem_x509_csr(csr_data, default_backend())
    
    # Return the full subject name
    return csr.subject


def get_cn_from_csr(csr_path):
    """Extract the CN from the CSR using the cryptography library."""
    with open(csr_path, "rb") as csr_file:
        csr_data = csr_file.read()
    
    csr = x509.load_pem_x509_csr(csr_data, default_backend())
    cn_attributes = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
    
    return cn_attributes[0].value if cn_attributes else None


def find_existing_cert_by_cn(cn):
    """Check the OpenSSL index.txt file to see if a valid certificate with this CN exists."""
    if not os.path.exists(INDEX_FILE):
        return None

    with open(INDEX_FILE, "r") as index_file:
        for line in index_file:
            # Strip any surrounding whitespace
            line = line.strip()
            
            # Locate the start of the Subject Name by finding "/C="
            subject_start_index = line.find("/C=")
            if subject_start_index == -1:
                continue  # Skip lines without a valid subject
            
            # Extract the Subject Name (from "/C=" to end of line)
            subject_name = line[subject_start_index:]
            
            # Extract the part before "/C=" for the other fields
            preceding_fields = line[:subject_start_index].strip()
            
            # Split the preceding fields into at most 4 parts: status, expiration_date, revocation_date, serial_number, and file_path
            parts = preceding_fields.split('\t', maxsplit=4)
            
            # Handle missing fields: ensure we have at least 3 fields (Status, Expiration Date, Serial Number)
            # status = parts[0] if len(parts) > 0 else None
            # expiration_date = parts[1] if len(parts) > 1 else None
            # revocation_date = parts[2] if len(parts) > 2 else None
            # serial_number = parts[3] if len(parts) > 3 else None

            status = parts[0] if len(parts) > 0 else None
            expiration_date = parts[1] if len(parts) > 1 else None
            revocation_date = parts[2] if len(parts) > 2 else None
            serial_number = parts[3] if len(parts) > 3 else None
            file_path = parts[4] if len(parts) > 4 else None
            
            # Only consider entries with status "V" (valid)
            if status != "V":
                continue
            
            # Check if the subject name contains the CN we are looking for
            if f"/CN={cn}" in subject_name:
                return {
                    "status": status,
                    "expiration_date": expiration_date,
                    "revocation_date": revocation_date or None,
                    "serial_number": serial_number,
                    "file_path": file_path,
                    "subject_name": subject_name
                }

    return None


def get_subject_from_index_entry(index_entry):
    """
    Extract the Common Name (CN) from the Subject Name in an index.txt entry
    using x509.Name().
    """
    # The subject name is typically the last field in the index.txt entry
    subject_name_str = index_entry["subject_name"]
    
    # Parse the subject name string into a list of x509.NameAttribute objects
    attributes = []
    for part in subject_name_str.split("/"):
        if not part:
            continue
        key, value = part.split("=", 1)
        oid = {
            "C": x509.NameOID.COUNTRY_NAME,
            "ST": x509.NameOID.STATE_OR_PROVINCE_NAME,
            "L": x509.NameOID.LOCALITY_NAME,
            "O": x509.NameOID.ORGANIZATION_NAME,
            "OU": x509.NameOID.ORGANIZATIONAL_UNIT_NAME,
            "CN": x509.NameOID.COMMON_NAME
        }.get(key)
        
        if oid:
            attributes.append(x509.NameAttribute(oid, value))

    # Create an x509.Name object from the list of NameAttribute objects
    subject = x509.Name(attributes)
    
    # Extract and return the Common Name (CN) from the subject, if present
    cn = subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
    return cn[0].value if cn else None


def is_cert_expired(index_entry):
    """Check if the certificate is expired based on the OpenSSL index entry."""
    status = index_entry["subject_name"]  # V = Valid, R = Revoked, E = Expired
    expiry_date = index_entry["expiration_date"]  # Expiry date in YYMMDDHHMMSSZ format
    return status == "E"

def revoke_certificate1(serial_number):
    """Revoke the certificate using OpenSSL by its serial number."""
    cert_path = os.path.join(INT_CA_DIR, "newcerts", f"{serial_number}.pem")
    openssl_cmd = [
        "openssl", "ca", "-config", INT_CA_CONFIG, "-revoke", cert_path,
        "-passin", f"file:{INT_CA_PASSWORD_FILE}"
    ]
    subprocess.run(openssl_cmd, check=True)

def revoke_certificate(serial_number):
    """Revoke the certificate using OpenSSL by its serial number and update the CRL."""
    cert_path = os.path.join(INT_CA_DIR, "newcerts", f"{serial_number}.pem")
    
    # Command to revoke the certificate
    revoke_cmd = [
        "openssl", "ca", "-config", INT_CA_CONFIG, "-revoke", cert_path,
        "-passin", f"file:{INT_CA_PASSWORD_FILE}"
    ]
    
    # Run the revocation command
    subprocess.run(revoke_cmd, check=True)
    
    # Command to generate a new CRL
    crl_path = os.path.join(INT_CA_DIR, "crl", "crl.pem")  # Adjust path as necessary
    gen_crl_cmd = [
        "openssl", "ca", "-config", INT_CA_CONFIG,
        "-passin", f"file:{INT_CA_PASSWORD_FILE}",
        "-gencrl", "-out", crl_path
    ]
    
    # Run the command to update the CRL
    subprocess.run(gen_crl_cmd, check=True)


@app.route('/sign_csr', methods=['POST'])
def sign_csr():
    # Get the CSR from the request
    csr_data = request.files['csr'].read()
    
    # Get the target directory and certificate name from the request
    target_dir = request.form.get('target_dir')
    cert_name = request.form.get('cert_name')
    
    # Check for "force" keyword to force certificate revocation
    force_revoke = request.form.get('force', 'false').lower() == 'true'
    
    # Validate target directory and certificate name
    if not target_dir or not os.path.exists(target_dir):
        return jsonify({"error": "Invalid target directory"}), 400
    
    if not cert_name or not cert_name.endswith(".crt"):
        return jsonify({"error": "Please specify the file extension to be .crt"}), 400
    
    # Save the CSR temporarily
    csr_path = os.path.join(INT_CA_DIR, "tmp_user.csr")
    with open(csr_path, "wb") as f:
        f.write(csr_data)
    
    # Extract full subject name from the CSR using cryptography
    try:
        csr_subject = get_subject_from_csr(csr_path)
    except Exception as e:
        return jsonify({"error": f"Failed to extract subject name from CSR: {str(e)}"}), 400

    # Check if there's an existing certificate with the same CN
    existing_cert_entry = find_existing_cert_by_cn(csr_subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value)
    
    if existing_cert_entry:
        serial_number = existing_cert_entry["serial_number"]  # Serial number from the index.txt
        
        if force_revoke:
            # If force is requested, revoke the certificate regardless of its status
            try:
                revoke_certificate(serial_number)
            except Exception as e:
                return jsonify({"error": f"Failed to force revoke certificate: {str(e)}"}), 500
        elif is_cert_expired(existing_cert_entry):
            # Revoke the expired certificate
            try:
                revoke_certificate(serial_number)
            except Exception as e:
                return jsonify({"error": f"Failed to revoke expired certificate: {str(e)}"}), 500
        else:
            # If certificate exists and is not expired, return a message
            return jsonify({"message": f"There is already a certificate for /CN={csr_subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value}"}), 200
    
    # Define the full certificate path in the target directory
    cert_path = os.path.join(target_dir, cert_name)
    
    # Run OpenSSL command to sign the CSR
    openssl_cmd = [
        "openssl", "ca", "-batch", "-notext",
        "-config", INT_CA_CONFIG,
        "-passin", f"file:{INT_CA_PASSWORD_FILE}",
        "-in", csr_path,
        "-out", cert_path
    ]
    
    try:
        result = subprocess.run(openssl_cmd, capture_output=True, text=True)
        if result.returncode != 0:
            return jsonify({"error": result.stderr}), 400
        
        # Read the generated certificate
        with open(cert_path, "r") as cert_file:
            cert_data = cert_file.read()
        
        # Return the certificate content to the client
        return jsonify({"certificate": cert_data}), 200
    
    finally:
        # Clean up temp CSR
        if os.path.exists(csr_path):
            os.remove(csr_path)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)
解释:

CA Server 通过检查 index.txt 来找到与 CSR 有相同 CommonName 的行,从而确定 serial 并定位系统中的证书(newcerts/ 目录中的 ${serial}.pem。如果是过期证书则忽略,如果是有效期内的证书。那么根据用户需求来决定是否强制撤销。

如果没有找到有相同 CommonName 的行,那就不存在重复证书,可以直接签发。

客户端请求证书:

客户端向 CA Server 发出证书申请,需要传递三个参数:

  • csr 如果没有申请过证书,我们需要先创建这个 csr 文件
  • target_dir 签发的证书想要存放的目标目录
  • cert_name 生成的目标证书名

请求命令如下:

curl -F "csr=@./rsa_router.csr" \
    -F "target_dir=/Users/tipman/allcerts/user_certs/router" \
    -F "cert_name=rsa_router.crt" \
    -F "force=true" \
    http://localhost:5001/sign_csr > /dev/null 2>&1

注:先不要添加 force 参数,如果请求被拒绝,说明有证书已签发过。检查后如果确定是需要强制重新签发,再添加 force 参数。

自动续签

想要自动续签,我们第一步自然是需要判断证书是否过期。这个可以由客户端来进行,比如实际使用用户证书的 Web 服务器。

接下来,编写一个 Python 脚本,然后把它添加到定时任务里面就行了。

脚本如下:

renew_user_certs

import os
import requests
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from datetime import datetime

def is_certificate_expired(cert_path):
    """Check if the certificate at cert_path is expired."""
    # Load the certificate
    with open(cert_path, "rb") as cert_file:
        cert_data = cert_file.read()
    cert = x509.load_pem_x509_certificate(cert_data, default_backend())
    
    # Get the expiration date from the certificate
    expiration_date = cert.not_valid_after
    
    # Compare with the current date
    return expiration_date < datetime.utcnow()

def renew_certificate_if_expired(cert_path, csr_path, target_dir, cert_name, server_url):
    """Check if the certificate is expired, and if so, request a new one using the requests library."""
    if is_certificate_expired(cert_path):
        print(f"The certificate at {cert_path} is expired. Requesting a new one...")

        # Prepare the data and files for the POST request
        files = {'csr': open(csr_path, 'rb')}
        data = {
            'target_dir': target_dir,
            'cert_name': cert_name,
            'force': 'true'
        }

        # Send the request to the CA server
        response = requests.post(server_url, files=files, data=data)

        # Check the response
        if response.status_code == 200:
            print(f"New certificate requested and will be saved at {target_dir}/{cert_name}.")
        else:
            print(f"Failed to request a new certificate: {response.status_code} - {response.text}")
    else:
        print("The certificate is still valid.")

# Usage
cert_path = "/Users/tipman/allcerts/user_certs/router/rsa_router.crt"
csr_path = "./rsa_router.csr"
target_dir = "/Users/tipman/allcerts/user_certs/router"
cert_name = "rsa_router.crt"
server_url = "http://localhost:5001/sign_csr"

renew_certificate_if_expired(cert_path, csr_path, target_dir, cert_name, server_url)

定时任务

0 3 * * * /opt/scripts/renew_user_certs.py >> /opt/scripts/renew_user_certs.log

全文完!

如果你喜欢我的文章,欢迎关注我的微信公众号 deliverit