Artículo técnico

Certificados de prueba autofirmados en Delphi con CryptoAPI

La función PLCreateSelfSignedCertificate de PDFlibPas construye un certificado RSA/SHA-256 autofirmado y lo exporta, con clave privada incluida, directamente a un archivo PFX protegido por contraseña, usando nada más que la Win32 CryptoAPI ya instalada en cada máquina Windows. Sin herramienta externa, sin autoridad certificadora, sin paso manual de makecert u OpenSSL: una llamada de función, un certificado lo bastante bueno como para impulsar una prueba de firma

El escenario que hace que valga la pena tener esta función es casi siempre un pipeline de CI. Una prueba de humo de firma necesita un PFX real con una clave privada real detrás, y registrar uno en el repositorio es su propio problema de seguridad, ya que una clave privada comprometida es una clave privada filtrada desde el momento en que ese commit aterriza. Recurrir a makecert.exe o a una invocación de OpenSSL desde un script de compilación también funciona, pero entonces el pipeline depende de una herramienta que tiene que instalarse, encontrarse en el PATH, y mantenerse consistente en versión a través de cada agente de compilación. Generar el certificado dentro del mismo proceso que ejecuta la prueba, con las mismas llamadas Win32 CryptoAPI que Windows ya incluye, elimina esa dependencia por completo

¿Qué produce realmente PLCreateSelfSignedCertificate?

PLCreateSelfSignedCertificate produce un archivo PFX protegido por contraseña que contiene un certificado RSA autofirmado y su clave privada, firmado con sha256RSA, controlado por cinco parámetros: SubjectName, PFXFileName, PFXPassword, ValidDays, y KeyBits, y devuelve una simple bandera de éxito Boolean. SubjectName acepta una cadena X.500 completa como 'CN=Alice, O=Example', y un nombre simple sin ningún signo = en él se prefija automáticamente con CN=. ValidDays por debajo de 1 recurre a 365, y KeyBits fuera del rango de 1024 a 16384 recurre a 2048. PDFlibPas ha incluido esta función desde v3.224.0, accesible no solo desde la unidad Delphi sino también a través de las superficies DLL y ActiveX, y su propio comentario de documentación es directo sobre dónde deja de ser útil: cada visor convencional marca un certificado autofirmado como no confiable a menos que alguien lo instale explícitamente, así que trate lo que produce como un certificado para ejercitar una ruta de código, no una firma en la que se le deba pedir a nadie fuera de su equipo que confíe

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDFlibPas CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

¿Por qué codifica CryptGenKey la longitud de clave en el parámetro de banderas?

CryptGenKey empaqueta dos configuraciones no relacionadas en un único parámetro dwFlags. La palabra baja lleva banderas de comportamiento, CRYPT_EXPORTABLE entre ellas, mientras que la palabra alta, para una clave de intercambio de claves RSA, lleva la longitud de clave solicitada en bits. Pasar 2048 como si fuera solo otra bandera lo hace aterrizar en la palabra baja en su lugar, donde no coincide con ninguna bandera de comportamiento que defina CryptoAPI, así que la llamada genera una clave con cualquiera que sea la longitud predeterminada a la que recurra el proveedor en lugar de la longitud que quien llama creía haber pedido. Obtener una clave RSA de 2048 bits real significa desplazar el número a la palabra alta primero

// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

¿Qué pasa si se olvida CRYPT_EXPORTABLE?

Elimine CRYPT_EXPORTABLE de ese mismo valor de banderas y CryptGenKey sigue teniendo éxito, pero marca la clave privada generada como no exportable a nivel de CSP. Todo lo que sigue aguas abajo también sigue reportando éxito: CertCreateSelfSignCertificate devuelve un contexto de certificado válido, y PFXExportCertStoreEx, incluso llamado con EXPORT_PRIVATE_KEYS, igual tiene éxito y escribe un archivo PFX que abre, se analiza, y se ve completamente ordinario. Lo que no contiene es la clave privada, porque el CSP se negó a dejarla salir del contenedor de claves, y PFXExportCertStoreEx nunca trata esa negativa como una razón para fallar toda la exportación

El fallo solo aparece más tarde, y en un lugar completamente distinto: una llamada de firma abre ese PFX, encuentra un certificado sin ninguna clave privada adjunta, y reporta exactamente el error que se obtendría de un PFX corrupto o equivocado, no de una bandera faltante tres capas aguas arriba. Cualquiera que depure solo desde el lado de la firma puede quemar una tarde con el archivo equivocado antes de darse cuenta de que el bug real es un solo bit faltante en el momento de generación de clave, en una llamada de función completamente distinta, posiblemente en un script de compilación completamente distinto

¿Por qué debe coincidir ProvType entre CryptAcquireContextW y el certificado?

ProvType debe coincidir porque CertCreateSelfSignCertificate resuelve la clave privada del nuevo certificado mediante un registro CRYPT_KEY_PROV_INFO, y un campo de ese registro, ProvType, tiene que nombrar exactamente el mismo valor de tipo de CSP pasado a CryptAcquireContextW cuando se abrió el contenedor de claves, PROV_RSA_AES, numéricamente 24, en la implementación de PDFlibPas. Establezca ProvType en cero, o en cualquier constante de proveedor distinta de aquella a la que realmente pertenece el contenedor, y el certificado todavía se puede crear, pero su enlace registrado de vuelta a la clave privada ya no se resuelve al contenedor que la contiene, lo que sale a la superficie después como un fallo de firma o exportación que no tiene nada que ver con el contenido criptográfico real del certificado

// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES;   // same constant, both call sites

Uniendo todo: del contenedor GUID al PFX protegido por contraseña

La cadena de llamadas dentro de PLCreateSelfSignedCertificate sigue una línea directa: abre un contenedor de claves nuevo nombrado según un GUID recién generado para que las ejecuciones concurrentes de CI nunca choquen por nombres de contenedor, genera el par de claves RSA dentro de él con las dos banderas cubiertas arriba, codifica SubjectName en un blob de nombre X.500 mediante CertStrToNameW, y llama a CertCreateSelfSignCertificate con una ventana de validez calculada a partir de ValidDays y entregada como una simple estructura con forma de SYSTEMTIME. El contexto de certificado resultante entra en un almacén de certificados en memoria abierto con CertOpenStore y CERT_STORE_PROV_MEMORY, puramente para que PFXExportCertStoreEx tenga un almacén desde el cual exportar, ya que esa API trabaja contra un handle de almacén en lugar de un contexto de certificado simple

// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx en sí sigue la convención ordinaria de dos pasadas de Win32: se le llama una vez con un búfer de longitud cero para saber cuántos bytes necesita el PFX, se asigna esa cantidad, y luego se le vuelve a llamar para llenar el búfer. Una vez que los bytes están en disco, PDFlibPas elimina el contenedor de claves desechable con CRYPT_DELETEKEYSET en lugar de dejarlo atrás, porque el PFX ya lleva su propia copia de cada byte de material de clave que contenía el contenedor. Sáltese esa limpieza y cada llamada a PLCreateSelfSignedCertificate deja un contenedor de claves huérfano, nombrado por GUID, sentado en el perfil del usuario que llama, que es exactamente el tipo de fuga que un agente de CI que ejecuta esta función en cada compilación acumulará durante meses antes de que alguien lo note

¿Es seguro usar un certificado autofirmado para firma de producción?

No: un certificado autofirmado es seguro para ejercitar una ruta de código de firma e inseguro para una firma en la que se espere que confíe cualquiera fuera del equipo, porque nada lo encadena de vuelta a una raíz en la que ya confíe el software de una parte que confía. El siguiente paso natural para un PFX así es una llamada de firma real, cubierta en construir un banco de trabajo de conformidad y firma en Delphi con PDFlibPas, donde un PFX construido de esta manera impulsa la mitad de firma de un pipeline que también ejecuta preflight de PDF/A y auditorías de ByteRange. La firma es solo la mitad de lo que rodea a un certificado, sin embargo, y la otra mitad es exactamente donde se supone que falle una hoja autofirmada: firma y validación PAdES en Delphi con PDFlibPas cubre las comprobaciones de cadena de confianza que ejecuta un validador de conformidad, y un validador que recorre la cadena de vuelta a una raíz confiable no tiene razón para confiar en un certificado que esta función inventó hace cinco minutos de la nada

PLCreateSelfSignedCertificate es una función entre las API de certificado y firma en la biblioteca PDF PDFlibPas para Delphi y C++Builder, y existe exactamente para la brecha descrita aquí: una prueba de firma que necesita un par de claves real detrás de ella y nada externo para generar uno