Generación en Python de certificados X.509 con "Subject Alternative Name" y direcciones IP en el SAN
En Generación en Python de certificados X.509 con "Subject Alternative Name" y una entidad de certificación privada (o autofirmados) muestro un programa en Python para generar certificados X.509 con Subject Alternative Name sin depender de OpenSSL y su sintaxis y configuración oscuras. Prefiero hacerlo con un programa propio.
Eso fue en 2018. En los años transcurrido he ido haciendo cambios:
- Permitir que el campo SAN incluya direcciones IP, no solo nombres DNS. Esto es útil con infraestructura interna.
- La autoridad de certificación X.509 se especifica de forma explícita. Si utilizas constantemente una autoridad de certificación privada, puedes usar un wrapper Shell.
z-generar_certificado-20250408.py (Código fuente)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
#!/usr/bin/env python3 # (c) 2018-2023 jcea@jcea.es - https://www.jcea.es/ # Código liberado como Dominio Público. # Haz con él lo que quieras. import os import sys import argparse import datetime import ipaddress # 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('--CA', required=True, nargs=1, help='CA que se usará para firmar') parser.add_argument('--days', default=365, metavar='DÍAS', type=int, help='Expiración en días (365 por defecto)') parser.add_argument('NAME', help='Nombre del certificado y el CN') parser.add_argument('SAN', nargs='+', help='Nombres de dominio e IPs') args = parser.parse_args() name = args.NAME # Esto es UNICODE san = args.SAN # Esto es UNICODE CA = args.CA[0] print(f'Generamos un certificado que expira en {args.days} días', file=sys.stderr) key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend() ) with open(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 "{CA}" ' 'no parece ser una entidad de certificación') with open(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, name)) subject = x509.Name(subject) SubjectAlternativeName = [] for element in san: try: ip = ipaddress.ip_address(element) element = x509.IPAddress(ip) except ValueError: element = x509.DNSName(element) SubjectAlternativeName.append(element) 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) builder = builder.add_extension(x509.ExtendedKeyUsage([ x509.oid.ExtendedKeyUsageOID.SERVER_AUTH, x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH ]), critical=True) cert = builder.sign(CA_key, hashes.SHA256(), backend=default_backend()) with open(name + '.cert', 'wb') as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) with open(name + '.key', 'wb', opener=lambda x, y: os.open(x, y, mode=0o600)) as f: f.write(key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption()) ) |
Sólo voy a comentar los cambios más importantes respecto a la versión publicada en Generación en Python de certificados X.509 con "Subject Alternative Name" y una entidad de certificación privada (o autofirmados).
En las líneas 25-26 vemos que es necesario especificar la autoridad de certificación de forma explícita y que se trata de un parámetro obligatorio.
La línea 37 nos indica la duración del certificado que creamos. Se pueden cambiar con el parámetro --days, como ya ocurría en la versión anterior.
En las líneas 69-77 vemos que la construcción del Subject Alternative Name se complica bastante. Ahora hay que repasar dato a dato y comprobar si es un nombre DNS o una dirección IP, creando objetos distintos para cada caso. El resto de gestión de SAN es básicamente idéntica a la versión de 2018.
En las líneas 91-95 tenemos una novedad: restringimos los certificados X.509 a autenticación. No serán válidos, por ejemplo, para firmar otros certificados X.509.
Por último, un cambio aparentemente trivial en la línea 102: en vez de almacenar la clave privada del certificado con permisos 400, la grabamos con permisos 600. Es decir, que en vez de que el propietario solo pueda leer la clave privada, ahora pueda leer y escribir.
| [1] | 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. |
Advertencia
Obsérvese que la clave privada se guarda sin protección de cifrado. Dado los permisos del sistema de ficheros, sólo puede acceder a esa clave privada el propio usuario que la ha creado.
Esto es común, pero hay que tenerlo en cuenta.
Acompañando a este código Python, tengo el siguiente código Shell, que te puede interesar como ejemplo:
#!/bin/bash # El "$@" es para pasar parámetros sin que se rompa con los # posibles espacios que contenga. # El primer parámetro es el nombre del certificado, que se incluirá también # en el SAN. ./z-generar_certificado.py --days 3650 --CA p2ppriv "$1" "$@"
Este pequeño script genera un certificado que dura diez años, firmado por una autoridad de certificación privada específica. Lo más interesante son los dos últimos parámetros: El primero toma el primer parámetro de la línea de comando y lo utilizará como nombre del certificado. El segundo parámetro toma todos parámetros en la línea de comandos y los envía al programa Python, incluyendo el primero.
Obsérvese que el primer parámetro aparecerá repetido en la invocación programa Python. Eso es normal y correcto. El software moderno ignora el nombre del certificado y utilizara exclusivamente el campo SAN, así que si el nombre del certificado es significativo, debe aparecer también en el SAN.
Esto es importante y un error frecuente al pasar de software antiguo a moderno.