Artículo técnico

Firma de PDF con el almacén de certificados en HotPDF: orden de bytes CNG vs. CAPI

HotPDF firma un PDF contra un certificado que ya está en el almacén de certificados de Windows entregando el digest al propio Windows, y Windows completa esa solicitud a través de uno de dos backends de clave privada: CNG, que devuelve la firma RSA en formato big-endian, o el CSP de CryptoAPI heredado, que la devuelve en little-endian. Si se confunden los dos, la firma CMS que HotPDF incrusta queda invertida en bytes para el backend que realmente respondió, de modo que un validador conforme reporta la firma como inválida aunque los bytes del documento nunca se hayan tocado

Detrás de esa sola frase se esconden dos problemas sin relación entre sí, y el firmador de certificado del sistema de HotPDF tiene que resolver ambos antes de firmar cualquier cosa. El desajuste de orden de bytes es silencioso: la llamada de firma sigue devolviendo True, el PDF sigue abriendo, y la falla 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 incluye no las exporta. Ninguno de los dos problemas existe si uno solo firma con un archivo PFX, por eso suele atrapar a los desarrolladores que pasan de la firma de una sola llamada basada en PFX a un certificado que el departamento de TI ya instaló en el perfil del usuario

Seleccionar un certificado del almacén

HotPDF expone esta ruta como HPDFSignPDFStreamWithSystemCertificate y HPDFSignPDFFileWithSystemCertificate, ambas gobernadas por un registro THPDFCertificateStoreSelector: Location (cslCurrentUser o cslLocalMachine), StoreName ('MY', el almacén personal, por defecto), un Thumbprint SHA-1, y una bandera AllowUI. El thumbprint se normaliza internamente, así que los guiones o espacios copiados directamente de la interfaz del Administrador de certificados se eliminan antes de que se ejecute 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, porque se mapea directamente a CRYPT_ACQUIRE_SILENT_FLAG, y Windows lo respeta al pie de la letra: si la clave privada del certificado encontrado vive 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. Esa falla es ruidosa, un EHPDFCMSError que se ve de inmediato, pero es fácil malinterpretarla como "certificado no encontrado" cuando la causa real es un token esperando un PIN que nadie va a escribir

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

Qué backend responde no es una suposición: CryptAcquireCertificatePrivateKey lo informa directamente mediante un parámetro de salida KeySpec, y ese único valor es sobre el que bifurca el firmador de HotPDF. Una clave de un proveedor de almacenamiento de claves CNG vuelve con KeySpec establecido en el centinela CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); cualquier otro valor es 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 un shim de CSP heredado por compatibilidad, por eso HotPDF solicita CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG junto con CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG antes de mirar qué valor volvió

Los dos backends no solo llaman a funciones distintas, NCryptSignHash contra una clave CNG, CryptSignHashA contra una clave CSP; también devuelven la firma RSA cruda 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 bajo 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 se omite la inversión en la ruta 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é pasa con un callback de firmador personalizado?

Cualquiera que se salte el firmador de almacén de certificados integrado de HotPDF hereda la misma regla de orden de bytes. HPDFCMSSignPDFStreamWithExternalSigner recibe un THPDFCMSSignDigestCallback, un closure 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 entregar un handle de clave. Sea cual sea el backend detrás de ese callback, los bytes que devuelva tienen que 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);

Vale la pena dejar clara una frontera aquí: las dos rutas de firma integradas de HotPDF, CNG mediante NCryptSignHash con padding PKCS#1, y CAPI mediante CryptSignHashA, apuntan ambas a claves RSA que firman un digest SHA-256 de 32 bytes. Ninguna negocia un formato de firma ECDSA. Un certificado cuya clave privada es de tipo EC necesita un firmador que usted mismo escriba contra HPDFCMSSignPDFStreamWithExternalSigner, codificando la firma ECDSA de la forma que CMS espera en lugar de asumir una cadena de bytes RSA de longitud fija, así que no espere que el firmador de almacén de certificados integrado haga lo correcto para un token provisto con un certificado EC

¿Por qué C++Builder no logra enlazar CertOpenStore?

Porque la biblioteca de importación predeterminada de C++Builder en RAD Studio, import32.lib, no exporta CertOpenStore, ni cinco de sus 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 arregla: ilink32 solo enlaza las bibliotecas de importación realmente nombradas en su línea de comandos, import32.lib cp32mt.lib por defecto, y agregar una ruta de búsqueda no hace que traiga nada extra de esa ruta. Ejecutar tdump contra import32.lib confirma la brecha directamente: cero coincidencias para CertOpenStore, contra seis coincidencias limpias en el crypt32.lib del SDK

HotPDF resuelve esto de la misma manera en que ya maneja la enumeración de certificados en otras partes de la biblioteca: en lugar de pedirle 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 lo profundo 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 vez por llamada en lugar de perezosamente dentro de cada helper, porque el closure que elige entre CNG y CAPI captura la tabla de funciones cargada por valor y tiene que mantenerse vivo durante todo el flujo de firma, incluido el callback hacia 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 toca 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 recompilación para quienes ya la usan, no un cambio de código

Qué no cubre esto

Corregir el orden de bytes y el enlace de C++Builder produce un SignerInfo de 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 detrás de ella, ya que la construcción de la cadena, la comprobación de revocación y la política de sellado de tiempo son asuntos separados que se agregan encima mediante las opciones de CMS, no algo que la corrección del orden de bytes otorgue gratis. Dos detalles de mantenimiento importan tanto como la criptografía: el PCCERT_CONTEXT devuelto por la búsqueda de certificado debe liberarse con CertFreeCertificateContext antes de que se cierre el almacén, y un handle de clave CNG o CSP adquirido, cuando la API informa que la propiedad es del llamador, debe liberarse a través de la llamada propia del backend correspondiente, nunca la del otro. Si el resultado svValid que obtiene al final resulta más limitado de lo que esperaba, el artículo sobre verificación de firmas digitales de PDF explica exactamente qué promete y qué no promete esa bandera. Como el certificado permanece bajo custodia de Windows todo el tiempo aquí, la firma con almacén de certificados evita toda una superficie de ataque: no hay archivo PKCS#12 que analizar ni ASN.1 que recorrer uno mismo, que es el problema que el endurecimiento de PKCS#12 y ASN.1 de HotPDF aborda en cambio para la ruta de firma con archivo PFX

La firma con almacén de certificados, la firma con PFX y los callbacks de firmador externo son tres puertas hacia el mismo pipeline CMS/PKCS#7 dentro del componente PDF HotPDF para Delphi y C++Builder, y elegir la correcta se reduce sobre todo a quién tiene permitido conservar la clave privada: su proceso, un archivo PFX, o el propio Windows