Artículo técnico

Firma de PDF con el Almacén de Certificados en HotPDF: orden de bytes CNG frente a CAPI

HotPDF firma un PDF contra un certificado que ya reside en el Almacén de Certificados de Windows entregando el resumen (digest) al propio Windows, y Windows completa esa petición a través de uno de dos backends de clave privada: CNG, que devuelve la firma RSA en big-endian, o el CSP de CryptoAPI heredado, que la devuelve en little-endian. Confundid los dos y la firma CMS que HotPDF incrusta queda con los bytes invertidos para el backend que realmente haya respondido, de modo que un validador conforme reporta la firma como inválida aunque los bytes del documento no se hayan tocado en ningún momento

Detrás de esa única frase se esconden dos problemas sin relación entre sí, y el firmante de certificado del sistema de HotPDF tiene que resolver ambos antes de firmar nada. El desajuste de orden de bytes es silencioso: la llamada de firma sigue devolviendo True, el PDF sigue abriéndose, y el fallo solo aparece cuando un visor recorre la estructura CMS y la rechaza. El segundo problema es ruidoso y específico de C++Builder: media docena de funciones de crypt32 se niegan a enlazar, porque la biblioteca de importación que RAD Studio distribuye no las exporta. Ninguno de los dos problemas existe si solo firmáis con un archivo PFX, razón por la cual suele atrapar a quienes pasan de la firma en una llamada basada en PFX a un certificado que el departamento de TI ya ha instalado en el perfil del usuario

Selección de un certificado del almacén

HotPDF expone esta vía como HPDFSignPDFStreamWithSystemCertificate y HPDFSignPDFFileWithSystemCertificate, ambas gobernadas por un registro THPDFCertificateStoreSelector: Location (cslCurrentUser o cslLocalMachine), StoreName ('MY', el almacén personal, por defecto), una Thumbprint SHA-1 y un indicador AllowUI. La huella digital se normaliza internamente, así que los guiones o espacios copiados directamente desde la interfaz del Administrador de certificados se eliminan antes de ejecutar la comparación

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False importa más de lo que parece a simple vista, porque se traduce directamente en CRYPT_ACQUIRE_SILENT_FLAG, y Windows lo respeta al pie de la letra: si la clave privada del certificado encontrado reside en una tarjeta inteligente o token que necesita un PIN que Windows aún no tiene en caché, CryptAcquireCertificatePrivateKey falla en lugar de mostrar un diálogo desde lo que podría ser un proceso de servicio. Ese fallo es ruidoso, un EHPDFCMSError que veréis de inmediato, pero es fácil interpretarlo erróneamente como «certificado no encontrado» cuando la causa real es un token esperando un PIN que nadie va a teclear

¿Por qué CNG y CAPI discrepan en el orden de bytes?

Qué backend responde no es una conjetura: CryptAcquireCertificatePrivateKey lo informa directamente mediante un parámetro de salida KeySpec, y es ese único valor sobre el que se ramifica el firmante de HotPDF. Una clave de un proveedor de almacenamiento de claves CNG vuelve con KeySpec fijado al centinela CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); cualquier otro valor corresponde a una clave CSP de CryptoAPI tradicional. La mayoría de los certificados personales emitidos o importados en una instalación de Windows actual resuelven a CNG, aunque todavía existe una capa de compatibilidad CSP heredada, razón por la cual HotPDF solicita CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG junto con CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG antes de mirar qué valor ha vuelto

Los dos backends no solo llaman a funciones distintas, NCryptSignHash contra una clave CNG, CryptSignHashA contra una clave CSP; devuelven la firma RSA en bruto en orden de bytes opuesto. La salida de CNG ya coincide con lo que espera PKCS#1: una cadena de octetos big-endian, con el byte más significativo primero, exactamente lo que produce la conversión I2OSP de RFC 8017 y lo que necesita un SignerInfo de CMS (RFC 5652) en su campo de firma según ISO 32000-1 §12.8.3. CryptSignHash de CryptoAPI, en cambio, devuelve la firma en little-endian, una peculiaridad documentada que se remonta a cómo los CSP clásicos representaban internamente los números grandes. Si os saltáis la inversión en la vía CAPI, cada byte de la firma queda en el lugar equivocado; la aritmética RSA sigue siendo correcta, pero la cadena de octetos que lee un verificador no es la que define PKCS#1

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

¿Qué ocurre con un callback de firma personalizado?

Cualquiera que se salte el firmante integrado de HotPDF para el almacén de certificados hereda la misma regla de orden de bytes. HPDFCMSSignPDFStreamWithExternalSigner recibe un THPDFCMSSignDigestCallback, una clausura de tipo reference to function(const SignedAttributesSHA256: TBytes): TBytes, para firmar a través de un HSM, una pila de middleware de tarjeta inteligente o cualquier otra cosa que no sea un certificado para el que el almacén de Windows pueda entregaros un handle de clave. Sea cual sea el backend que haya detrás de ese callback, los bytes que devuelva deben quedar en orden big-endian antes de que HotPDF los incorpore a la estructura CMS

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

Merece la pena dejar clara una frontera aquí: las dos vías de firma integradas de HotPDF, CNG mediante NCryptSignHash con relleno PKCS#1, y CAPI mediante CryptSignHashA, apuntan ambas a claves RSA que firman un resumen SHA-256 de 32 bytes. Ninguna de las dos negocia un formato de firma ECDSA. Un certificado cuya clave privada sea de tipo EC necesita un firmante que escribáis vosotros mismos contra HPDFCMSSignPDFStreamWithExternalSigner, codificando la firma ECDSA tal como espera CMS en lugar de asumir una cadena de bytes RSA de longitud fija, así que no esperéis que el firmante integrado del almacén de certificados haga lo correcto para un token aprovisionado con un certificado EC

¿Por qué falla C++Builder al enlazar CertOpenStore?

Porque la biblioteca de importación por defecto de C++Builder en RAD Studio, import32.lib, no exporta CertOpenStore, ni cinco funciones vecinas: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore y CryptAcquireCertificatePrivateKey. Las compilaciones de Delphi nunca ven este problema, porque dcc32/dcc64 resuelven una importación estática external 'crypt32.dll' directamente en la tabla de importación del PE. C++Builder es distinto: el compilador de Delphi emite un .obj OMF para la compilación del paquete, ilink32 lo enlaza, y en ese punto la misma declaración external es solo un símbolo sin resolver esperando una biblioteca de importación en la línea de comandos. Apuntar el enlazador al directorio psdk del Windows SDK, donde el crypt32.lib completo sí exporta los seis símbolos, tampoco lo soluciona: ilink32 solo enlaza las bibliotecas de importación efectivamente nombradas en su línea de comandos, import32.lib cp32mt.lib por defecto, y añadir una ruta de búsqueda no hace que incorpore nada extra de esa ruta. Ejecutar tdump sobre import32.lib confirma directamente la carencia, cero coincidencias para CertOpenStore, frente a seis coincidencias limpias en el crypt32.lib del SDK

HotPDF resuelve esto de la misma manera que ya gestiona la enumeración de certificados en otras partes de la biblioteca: en lugar de pedir estos símbolos al enlazador, los carga en tiempo de ejecución. Un registro interno THPDFCryptoProcs lleva un handle de crypt32.dll, un handle de advapi32.dll y once campos de puntero a función; LoadCryptoProcs carga ambas DLL y resuelve cada punto de entrada con GetProcAddress exactamente una vez, al inicio de HPDFSignPDFStreamWithSystemCertificate, lanzando EHPDFCMSError de inmediato si falta algo en lugar de fallar más tarde con una violación de acceso en las profundidades del flujo de firma

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

La carga ocurre una sola vez por llamada en lugar de perezosamente dentro de cada función auxiliar, porque la clausura que elige entre CNG y CAPI captura por valor la tabla de funciones cargada y tiene que permanecer viva durante todo el flujo de firma, incluida la llamada de retorno a HPDFCMSSignPDFStreamWithExternalSigner; ambos handles de DLL se liberan en el bloque finally más externo una vez que la firma termina o lanza una excepción. Nada de esto afecta a la superficie pública: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate y THPDFCertificateStoreSelector conservan exactamente las mismas firmas que tenían antes, así que adoptar la corrección es una simple recompilación para el código existente, no un cambio de código

Qué queda fuera de este alcance

Resolver correctamente el orden de bytes y el enlazado en C++Builder produce un SignerInfo CMS que un validador puede analizar y una firma que puede comprobar aritméticamente; no dice nada sobre si ese validador debería confiar en el certificado que hay detrás, ya que la construcción de la cadena, la comprobación de revocación y la política de sellado de tiempo son asuntos independientes que se añaden por encima mediante las opciones de CMS, y que la corrección del orden de bytes no os regala. Dos detalles de mantenimiento importan tanto como la criptografía: el PCCERT_CONTEXT devuelto por la búsqueda del certificado debe liberarse con CertFreeCertificateContext antes de cerrar el almacén, y un handle de clave CNG o CSP adquirido, cuando la API indica que la propiedad recae en quien la llama, debe liberarse mediante la llamada propia del backend correspondiente, nunca la del otro. Si el resultado svValid que obtenéis tras todo esto resulta ser más limitado de lo que esperabais, el artículo sobre la verificación de firmas digitales en PDF expone exactamente qué garantiza ese indicador y qué no. Como el certificado permanece todo el tiempo bajo la custodia de Windows, la firma desde el almacén de certificados esquiva toda una superficie de ataque: no hay ningún archivo PKCS#12 que analizar ni ASN.1 que recorrer vosotros mismos, que es precisamente el problema que el endurecimiento de PKCS#12 y ASN.1 de HotPDF resuelve para la vía de firma con archivo PFX

La firma desde el almacén de certificados, la firma con PFX y los callbacks de firmante externo son tres puertas hacia el mismo pipeline CMS/PKCS#7 dentro del componente PDF HotPDF para Delphi y C++Builder, y elegir la adecuada depende sobre todo de quién tiene permiso para custodiar la clave privada: vuestro proceso, un archivo PFX o el propio Windows