Artículo técnico

Firma PDF post-cuántica y EdDSA con HotPDF en Delphi

Un PDF que lleva una firma ML-DSA-65 puede pasar el chequeo de la toolchain que lo produjo y aun así volver como algoritmo no soportado de un verificador que insiste en que el archivo declare qué extensión define su algoritmo. La clave pública tiene 1.952 bytes, exactamente el tamaño FIPS 204 para ese conjunto de parámetros. Los bytes de la firma están intactos y los rangos de bytes cubren todo el archivo. Lo que semejante verificador busca es una entrada en el Catalog del documento que nombre esa extensión, porque ISO 32000-2 no lista ML-DSA para nada

El lado de la firma tiene un muro distinto. Un token de hardware, un HSM en la nube o una tarjeta de eID nacional calculan una firma a pedido, pero ninguno le va a entregar la clave privada a su aplicación, así que cualquier código de firma que espere cargar un archivo PFX y firmar en el propio proceso simplemente no tiene nada que cargar. Ambos problemas tratan de dónde vive una decisión: la declaración de algoritmo vive en el archivo, y la clave vive en un lugar donde su código Delphi no puede llegar

¿Por qué una firma ML-DSA válida vuelve como no soportada?

HotPDF verifica firmas CMS ML-DSA-44, ML-DSA-65 y ML-DSA-87 solo cuando el documento cargado declara la extensión de desarrollador de HotPDF en su Catalog. Sin esa declaración el verificador devuelve svUnsupportedAlgorithm para una firma de retícula, por más sólida que sea la criptografía. El razonamiento es la reproducibilidad: un archivo que usa un algoritmo que la norma base no nombra, y no dice de qué extensión depende, produce un veredicto que otra implementación no tiene base para repetir

Del lado de producción, THotPDF.EnsureMLDSAExtensions escribe /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> en el Catalog y eleva el documento a PDF 2.0 si apuntaba a una versión anterior. Del lado de lectura, LoadedDocumentDeclaresMLDSAExtension devuelve True solo cuando ese diccionario está presente con /BaseVersion igual a 2.0 y /ExtensionLevel de al menos 1. VerifyLoadedSignatureWithOptions combina las dos cosas: toma el flag AllowMLDSA del llamador y le aplica un AND con la prueba de declaración antes de que corra cualquier verificación, así que activar el flag en un archivo no declarado no cambia nada

HotPDF somete la verificación PDF de ML-DSA a una declaración de extensión explícita del Catalog escrita por EnsureMLDSAExtensions antes de guardar el documento, y rechaza los algoritmos de retícula en archivos no declarados incluso cuando AllowMLDSA está activado
HotPDF admite ML-DSA solo cuando el Catalog todavía declara la extensión, lo que mantiene reproducible un veredicto post-cuántico, y llamar EnsureMLDSAExtensions antes de EndDoc coloca la declaración dentro del rango de bytes firmado y no después
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // el default; deja que la llamada eleve el archivo a PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // declare antes de que una firma cubra el Catalog
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Dos detalles de ese fragmento pesan. La llamada tiene que ocurrir antes de que el documento se guarde y se firme, porque el Catalog queda dentro del rango de bytes firmado; parchearlo después es o bien un cambio no firmado a un archivo firmado, o bien una nueva revisión incremental que cualquier validador listará como modificación. El segundo detalle es StrictVersionLock. Con el bloqueo activado y el documento fijado por debajo de PDF 2.0, EnsureMLDSAExtensions vuelve sin escribir nada, porque una extensión PDF 2.0 no se puede declarar en un archivo 1.7. Si su generador fija versiones por razones de archivo, revise Version = pdf20 antes de confiar en la declaración

Una sola llamada de verificación para EdDSA y ML-DSA

VerifyLoadedSignatureWithOptions toma un índice de firma, el flujo de origen, un record THPDFCMSVerifyOptions y un THPDFSignatureInfo de salida. Arranque el record desde THPDFCMSVerifyOptions.Default, que deja AllowMLDSA apagado, pone tope a un contenedor CMS en 256 firmantes y limita el anidamiento de contrafirmas a 8. Tres campos importan para los algoritmos más nuevos. SignatureProvider deja que su propio verificador responda primero. OpenSSLLibraryPath nombra el libcrypto de OpenSSL 3 que ejecuta la verificación Ed25519 y Ed448 en modo puro. AllowMLDSA activa los algoritmos de retícula, sujeto a la prueba del Catalog de arriba. El record también lleva campos de fijación de política de firma (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), que aplican a toda familia de algoritmos

El camino integrado se parte por algoritmo. Ed25519 y Ed448 pasan por OpenSSL 3; cuando no se da ninguna ruta, el proveedor de plataforma prueba libcrypto-3-x64.dll, libcrypto-3.dll y libcrypto.dll por el orden de búsqueda de DLL normal en Win64. ML-DSA pasa por el proveedor de algoritmo ML-DSA de Windows CNG, y en un build de Windows que no lo ofrece el resultado es no soportado. Fije OpenSSLLibraryPath a una copia que usted distribuya junto al ejecutable. Dejarlo vacío significa que el veredicto depende del libcrypto que otro instalador dejó caer en la ruta de búsqueda, y eso es difícil de explicar en una auditoría

Su propio SignatureProvider recibe la pregunta primero. Si devuelve spsUnsupported, el proveedor de plataforma integrado recibe la misma solicitud, así que un proveedor que solo conoce ML-DSA puede sentarse delante del manejo EdDSA por defecto sin reimplementarlo

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // pasar EdDSA al proveedor de plataforma
      // MyFips204Verify es su propio envoltorio alrededor de una librería ML-DSA;
      // Req.Input lleva los atributos firmados codificados en DER (modo puro)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // sigue en AND con la declaración del Catalog
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // el record de opciones no es su dueño
  end;
end;

Registre Info.SignatureAlgorithmOID en lugar del algoritmo que su configuración esperaba. Para ML-DSA-65 ese es 2.16.840.1.101.3.4.3.18, y una pista de auditoría que asienta el OID encontrado en el archivo responde después preguntas que un valor de configuración no puede. Si para nada necesita un proveedor a medida, la sobrecarga más corta VerifyLoadedSignature(SigIndex, Source, Info) construye opciones por defecto y le fija AllowMLDSA desde la declaración del Catalog

Reglas de codificación que hacen tropezar los CMS ML-DSA y EdDSA producidos afuera

Ambas familias de algoritmos se verifican en modo puro sobre los atributos firmados completos codificados en DER, y un SignerInfo sin atributos firmados se reporta como no soportado. Para EdDSA los parámetros del AlgorithmIdentifier deben estar ausentes. Un error común en CMS hechos a mano es hashear primero los atributos firmados con SHA-512 o SHAKE256 y firmar ese digest, lo que convierte silenciosamente PureEdDSA en una variante prehash que nada en el CMS declara. Para ML-DSA el verificador además revisa el emparejamiento entre conjunto de parámetros y digest del mensaje: ML-DSA-44 acepta SHA-256, SHA-384, SHA-512 o SHAKE256, ML-DSA-65 necesita SHA-384 o más fuerte, y ML-DSA-87 necesita SHA-512 o SHAKE256. Un emparejamiento equivocado se manifiesta en Info.DigestConsistencyIssue, que es el primer campo que hay que leer cuando falla un archivo de otra toolchain

EdDSA tiene su propia historia de registro. ISO/TS 32002 es la extensión que registra Ed25519 y Ed448 para firmas PDF 2.0, y EnsureISO32002Extensions agrega la entrada ISO_ correspondiente con ExtensionLevel 32002 al Catalog. La verificación acepta EdDSA sin esa entrada, pero un archivo que usted produzca debería llevarla igual, por la misma razón de reproducibilidad que la declaración ML-DSA

Qué se le pide hacer a un proveedor de firma

Un proveedor de firma recibe un THPDFSignatureProviderRequest y devuelve un THPDFSignatureProviderStatus más, en éxito, los bytes de la firma. La solicitud lleva el algoritmo y su OID, el OID del algoritmo de digest, el largo del salt PSS, si Input es un mensaje (sikMessage) o un digest precalculado (sikDigest), los bytes de entrada, el certificado del firmante, un identificador de clave y un identificador de operación único de la llamada. Ese es el vocabulario que el driver de un token o un servicio de firma ya habla, y nada de eso requiere que la clave cruce hacia su proceso

Cada proveedor además anuncia un conjunto de capacidades como spcSign, spcCancel, spcHardwareKey y spcRemoteKey. Varias implementaciones vienen con la librería:

  • THPDFCallbackSignatureProvider envuelve métodos anónimos para verificar, firmar y cancelar, que es la ruta más corta de una rutina de firma interna existente a una firma PDF
  • THPDFRemoteSignatureProvider envuelve una función de transporte con un límite de reintentos, un registro de cancelación y cotas sobre el tamaño de entrada y de firma, así que un servicio remoto que se porte mal no puede empujar una respuesta sobredimensionada al codificador CMS
  • THPDFPKCS11SignatureProvider toma prestada una sesión y un manejador de clave privada que su código ya abrió y ya autenticó. HotPDF nunca ve el PIN y no administra la sesión del token; serializa las llamadas C_Sign, antepone el DigestInfo para RSA PKCS#1 v1.5, pasa el largo de salt real a RSA-PSS mediante parámetros de mecanismo, y convierte un resultado crudo ECDSA r||s a la forma DER que CMS espera
  • THPDFWindowsKeyStorageSignatureProvider firma a través del almacenamiento de claves Windows CNG, que cubre tarjetas inteligentes y claves respaldadas por TPM, y puede exigir una clave respaldada por hardware

El proveedor PKCS#11 toma digest como entrada, así que maneja RSA PKCS#1 v1.5, RSA-PSS y ECDSA. Ed25519 y Ed448 firman el mensaje completo de atributos firmados, y esos van por el proveedor callback o remoto, donde su código le pasa Req.Input al token o servicio como mensaje

Los proveedores de firma conectables de HotPDF intercambian un record de solicitud simple y retornan un estado de seis valores, mostrado para las implementaciones callback, remota y PKCS#11, mientras la clave privada nunca entra al proceso de Delphi
Las tres implementaciones incluidas hablan el mismo registro de solicitud y responden con el mismo status de seis valores, y eso es lo que permite a los tokens de hardware, los servicios HSM remotos y las tarjetas eID nacionales seguir custodiando sus claves en lugar de exportarlas
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils; certificado DER de la clave remota
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService es su propio cliente HTTP: mapee timeouts y 5xx a
      // spsProviderError, una aprobación rechazada a spsCancelled, una clave desconocida a spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: hasta cuatro intentos en total
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf se guardó con un placeholder de AddSignedSignatureField
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

Seis estados, y qué debería disparar cada uno

THPDFSignatureProviderStatus tiene seis valores porque la reacción correcta difiere para cada uno. spsInvalid es un fallo criptográfico y pertenece a un log de seguridad. spsUnsupported significa que el proveedor no implementa el algoritmo, que es un problema de despliegue. spsMalformed significa que la solicitud o la respuesta tenían la forma equivocada. spsProviderError es un fallo de transporte o de dispositivo que puede tener éxito al reintentar. spsCancelled significa que una persona rechazó el aviso del token, y reintentarlo solo la molesta

El proveedor remoto trae esa política incorporada. Solo reintenta con spsProviderError, revisa el registro de cancelaciones antes de cada intento, rechaza entrada vacía o sobredimensionada como spsMalformed antes de llamar al transporte, y degrada una respuesta "válida" con firma vacía o sobredimensionada a spsMalformed. Del lado de la verificación, la respuesta de un proveedor se mapea al estado de verificación: spsInvalid se convierte en svSignatureInvalid, spsMalformed en svMalformed, spsProviderError y spsCancelled se convierten ambas en svCryptoProviderError, y spsUnsupported en svUnsupportedAlgorithm una vez que el proveedor de plataforma también rechazó la solicitud

Si usted escribe un proveedor, resista mapear toda condición inesperada a spsInvalid. Eso convierte una DLL faltante o un token de servicio vencido en un reporte de que la firma del cliente está falsificada, y alguien va a tener que explicárselo al cliente. Devuelva spsInvalid solo cuando la matemática de verdad falló. Para firmar, devuelva spsValid solo junto a una firma no vacía; ambos puntos de entrada lanzan EHPDFCMSError cuando un proveedor devuelve éxito sin bytes

Dónde aterriza realmente la firma en el archivo

Dos funciones conectan un proveedor con bytes PDF reales. HPDFCMSBuildSignedDataWithProvider construye CMS separado (detached) a partir de un digest del documento que usted calculó en otro lado; el largo del digest debe coincidir con Options.DigestAlgorithm, así que una configuración SHA-384 espera 48 bytes y rechaza un digest SHA-256 de 32 bytes con una excepción. HPDFCMSSignPDFStreamWithProvider firma el placeholder que AddSignedSignatureField maquetó, hashea ambas ventanas /ByteRange, le pide al proveedor la firma sobre los atributos firmados, y parchea el resultado hexadecimal en /Contents. Si el CMS crece más que el placeholder, la llamada lanza una excepción y restaura el placeholder sin firmar en el flujo de salida en lugar de truncar nada

La disposición es la misma que escribe la ruta de firma basada en PFX: dos rangos que saltan la ventana de firma hexadecimal. El verificador le exige a todo archivo esa forma, comprobando que el hueco entre los rangos contenga exactamente la cadena /Contents de esta firma y nada más, y reportando svInvalidByteRange en caso contrario. Un documento firmado a través de un token PKCS#11 se verifica entonces con el mismo código de verificación de firmas que uno firmado desde un archivo PFX. Las reglas de perfil que están por encima de la elección de algoritmo están cubiertas en las firmas baseline PAdES en Delphi, y las trampas de codificación ECDSA de las que se ocupa la conversión PKCS#11 están en la verificación CMS de ECDSA y los formatos de firma P1363

Dos puntos de entrada de firma con proveedor de HotPDF, HPDFCMSBuildSignedDataWithProvider para un digest calculado externamente y HPDFCMSSignPDFStreamWithProvider para un marcador de posición maquetado, convergen en una disposición CMS que preserva los rangos /ByteRange que los validadores verifican primero
Cualquiera de los dos puntos de entrada escribe la forma firmada idéntica de dos rangos cubiertos alrededor de la ventana hexadecimal, así que un documento firmado por proveedor llega a todo validador exactamente igual que uno firmado desde un archivo PFX

Desplegar firmas post-cuánticas sin dejar lectores varados

Un lector PDF que no implementa ML-DSA reporta semejante firma como no verificable, y la persona que mira el documento ve una advertencia en lugar de un visto bueno verde. Eso convierte la migración en un problema de calendario. Mantenga RSA o ECDSA como la firma que los validadores de hoy van a juzgar. Donde una política exija evidencia resistente a ataques cuánticos, declare la extensión con EnsureMLDSAExtensions y agregue una segunda firma producida por un firmante capaz de ML-DSA junto a la clásica. Esa firma viene de su propio firmante FIPS 204, ya que el constructor CMS de HotPDF no crea firmas ML-DSA; la parte de la librería es verificarla. Mueva la firma primaria solo cuando los sistemas que consumen sus documentos puedan verificarla

En ese plan HotPDF cubre ambos extremos que usted controla desde Delphi. Sus constructores CMS firman con RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 y Ed448 a través de cualquiera de los proveedores de arriba, y su verificador revisa ML-DSA, EdDSA y los algoritmos clásicos desde una sola llamada, con el OID realmente encontrado asentado en el resultado. HotPDF es un componente VCL PDF nativo para Delphi y C++Builder que no necesita ningún runtime PDF externo, así que el código de firma y verificación se compila dentro de su ejecutable. Ediciones, el conjunto de características de firma y una descarga de prueba están en la página del componente PDF de Delphi HotPDF