Generación en Python de certificados X.509 con "Subject Alternative Name" y una entidad de certificación privada (o autofirmados)

Hay infinidad de recetas OpenSSL en internet para generar certificados X.509 autofirmados o firmados por una autoridad de certificación privada, pero lo cierto es que, cuando entra el juego el Subject Alternative Name, la cosa se complica mucho.

Subject Alternative Name o SAN es una extensión de X.509 que permite que un mismo certificado X.509 cubra varios nombres diferentes. Esto es muy útil, por ejemplo, si tenemos un servidor web que atiende varios dominios distintos y queremos protegerlos con un único certificado X.509.

Utilizar OpenSSL, la opción habitual, para generar un certificado X.509 con SAN es un auténtico dolor de muelas, como puedes ver en mi artículo Generación de certificados X.509 autofirmados con "Subject Alternative Name". En fin, masoquismo. No obstante, el uso de SAN se ha hecho obligatorio desde 2016. Los navegadores (Firefox 48, Chrome 58) e incluso algunas bibliotecas Python nos obligan a ello.

Considerando eso, que tenía la necesidad de generar fácilmente un montón de certificados X.509 con SAN firmados por una Autoridad de certificación privada y que generarlos a mano con OpenSSL era un infierno... Pues eso, toca picar código:

z-generar_certificado_streaming-20180917.py (Código fuente)

#!/usr/bin/env python3

# (c) 2018 jcea@jcea.es - https://www.jcea.es/
# Código liberado como Dominio Público.
# Haz con él lo que quieras.

import os
import argparse
import datetime

# Ver https://cryptography.readthedocs.org/en/latest/x509/tutorial/
# Ver https://cryptography.io/en/latest/x509/reference/#cryptography.x509.CertificateSigningRequestBuilder

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import NameOID

parser = argparse.ArgumentParser(
            description='Genera un certificado con SubjectAlternativeName.')
parser.add_argument('--days', default=365, metavar='DÍAS', type=int,
                    help='Expiración en días (365 por defecto)')
parser.add_argument('--CA', default='webdav2.bt.jcea.es',
                    help='CA que se usará para firmar')
parser.add_argument('dominio', nargs='+', help='Dominio')
args = parser.parse_args()
dominios = args.dominio  # Esto es UNICODE

key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
        backend=default_backend()
    )

with open(args.CA + '.cert', 'rb') as f:
    CA_certificate = f.read()
CA_certificate = x509.load_pem_x509_certificate(CA_certificate,
                                                backend=default_backend())

try:
    is_CA = CA_certificate.extensions.get_extension_for_class(
                x509.BasicConstraints).value.ca
except x509.ExtensionNotFound:
    is_CA = False
if not is_CA:
    raise RuntimeError(f'El certificado "{args.CA}" '
                       'no parece ser una entidad de certificación')

with open(args.CA + '.key', 'rb') as f:
    CA_key = f.read()
CA_key = serialization.load_pem_private_key(CA_key, password=None,
                                            backend=default_backend())

subject = [i for i in CA_certificate.subject if i.oid != NameOID.COMMON_NAME]
subject.append(x509.NameAttribute(NameOID.COMMON_NAME, dominios[0]))
subject = x509.Name(subject)
SubjectAlternativeName = [x509.DNSName(dominio) for dominio in dominios]
SubjectAlternativeName = x509.SubjectAlternativeName(SubjectAlternativeName)

builder = x509.CertificateBuilder()
builder = builder.subject_name(subject)
builder = builder.issuer_name(CA_certificate.subject)
builder = builder.public_key(key.public_key())
builder = builder.serial_number(x509.random_serial_number())
builder = builder.not_valid_before(datetime.datetime.utcnow())
builder = (builder.not_valid_after(datetime.datetime.utcnow() +
           datetime.timedelta(days=args.days)))
builder = builder.add_extension(SubjectAlternativeName, critical=False)
builder = builder.add_extension(x509.BasicConstraints(ca=False,
                                                      path_length=None),
                                critical=True)

cert = builder.sign(CA_key, hashes.SHA256(), backend=default_backend())

with open(dominios[0] + '.cert', 'wb') as f:
    f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(dominios[0] + '.key', 'wb',
          opener=lambda x, y: os.open(x, y, mode=0o400)) as f:
    f.write(key.private_bytes(
              encoding=serialization.Encoding.PEM,
              format=serialization.PrivateFormat.TraditionalOpenSSL,
              encryption_algorithm=serialization.NoEncryption())
            )

Este programa Python utiliza la excelente biblioteca Cryptography para implementar todas las primitivas criptográficas necesarias. Gracias a ello, el código no tiene complicación alguna.

Las líneas 7-19 importan las bibliotecas y los objetos Python que vamos a necesitar. El programa proporciona diversos controles a través de la línea de comandos.

Generamos una clave de 2048 bits en las líneas 31-34. Las líneas 37-54 cargan el certificado de la autoridad de certificación privada. La línea 56 extrae los datos de identificación de autoridad de certificación y los utilizará para generar el certificado nuevo [1]. El programa pondrá el primer dominio que se le haya indicado en la línea de comandos en el campo de identidad (línea 57). Ese dominio y todos los demás que se hayan indicado en la línea de comandos se añadirán en el campo SAN (líneas 59-60).

[1] En mi caso tengo una autoridad de certificación privada y es normal que los certificados X.509 que firma compartan sus datos de contacto e identidad. En un sistema "serio" no sería así.

Seguidamente montamos el certificado poco a poco, en las líneas 62-74. Añadimos la identidad del certificado, la identidad de la autoridad de certificación que lo firma, la clave pública del certificado, un número de serie aleatorio [2], las fechas de validez del certificado (inicial y final) y diversos atributos adicionales.

[2] En un sistema serio habría que conservar de forma persistente los números de serie de los certificados X.509 que generamos para poder revocarlos con facilidad. En mi caso concreto, conservo copia de los certificados que genero, así que sigo teniendo acceso a sus números de serie.

La línea 75 firma el certificado. Se graba en las líneas 77-85. Obsérvese el uso de un opener en la línea 80 para crear el fichero de la clave privada con los permisos apropiados de forma atómica. Obsérvese también que la clave privada se guarda sin protección de cifrado.