Un PDF que lleva una firma ML-DSA-65 puede pasar el check de la toolchain que lo produjo y aun así volver como algoritmo no soportado de parte de un verificador que exige que el archivo declare qué extensión define su algoritmo. La clave pública son 1.952 bytes, exactamente el tamaño de FIPS 204 para ese conjunto de parámetros. Los bytes de la firma están intactos y los byte ranges cubren el archivo entero. 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 en absoluto
El lado de firma tiene un muro distinto. Un token de hardware, un HSM en la nube o un eID nacional calcularán una firma a petición, pero ninguno le entregará la clave privada a tu 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 van de dónde vive una decisión: la declaración del algoritmo vive en el archivo, y la clave vive en un sitio donde tu 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 HotPDF en su Catalog. Sin esa declaración el verificador devuelve svUnsupportedAlgorithm para una firma de retícula, por sana que esté la criptografía. El razonamiento es la reproducibilidad: un archivo que usa un algoritmo que el estándar base no nombra, y no dice de qué extensión depende, produce un veredicto que otra implementación no tiene base para repetir
En el lado productor, THotPDF.EnsureMLDSAExtensions escribe /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> en el Catalog y sube el documento a PDF 2.0 si apuntaba a una versión anterior. En el lado lector, LoadedDocumentDeclaresMLDSAExtension devuelve True solo cuando ese diccionario está presente con /BaseVersion igual a 2.0 y /ExtensionLevel de al menos 1. VerifyLoadedSignatureWithOptions combina ambos: toma el flag AllowMLDSA del llamador y lo ANDa con el test de declaración antes de que corra cualquier verificación, así que activar el flag en un archivo no declarado no cambia nada
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'contract-pq.pdf';
Pdf.StrictVersionLock := False; // el default; deja que la llamada suba 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; // declara antes de que cualquier 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 firme, porque el Catalog está dentro del rango de bytes firmado; parchearlo después es o bien un cambio sin firmar sobre 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 activo y el documento clavado por debajo de PDF 2.0, EnsureMLDSAExtensions regresa sin escribir nada, porque una extensión PDF 2.0 no puede declararse en un archivo 1.7. Si tu generador clava versiones por razones de archivo, comprueba Version = pdf20 antes de fiarte de la declaración
Una sola llamada de verificación para EdDSA y ML-DSA
VerifyLoadedSignatureWithOptions toma un índice de firma, el stream fuente, un record THPDFCMSVerifyOptions y un out THPDFSignatureInfo. Arranca el record desde THPDFCMSVerifyOptions.Default, que deja AllowMLDSA apagado, limita un contenedor CMS a 256 firmantes y acota el anidado de countersignatures a 8. Tres campos importan para los algoritmos nuevos. SignatureProvider deja que tu propio verificador responda primero. OpenSSLLibraryPath nombra el libcrypto de OpenSSL 3 que ejecuta la verificación en modo puro de Ed25519 y Ed448. AllowMLDSA activa los algoritmos de retícula, sujeto al test de Catalog de arriba. El record lleva además campos de fijación de política de firma (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), que aplican a todas las familias de algoritmos
El camino integrado se reparte por algoritmo. Ed25519 y Ed448 pasan por OpenSSL 3; cuando no se da ruta, el provider 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 provider de algoritmo CNG ML-DSA de Windows, y en un build de Windows que no lo ofrezca el resultado es no soportado. Pon OpenSSLLibraryPath a una copia que envíes junto al ejecutable. Dejarlo vacío significa que el veredicto depende del libcrypto que otro instalador haya dejado caer en el path de búsqueda, y eso es algo difícil de explicar en una auditoría
Tu propio SignatureProvider recibe la pregunta primero. Si devuelve spsUnsupported, el provider de plataforma integrado recibe la misma petición, así que un provider 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); // pasa EdDSA al provider de plataforma
// MyFips204Verify es tu 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 ANDándose 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;
Registra Info.SignatureAlgorithmOID en lugar del algoritmo que tu configuración esperaba. Para ML-DSA-65 es 2.16.840.1.101.3.4.3.18, y un audit trail que apunta el OID encontrado en el archivo responde luego preguntas que un valor de configuración no puede. Si no necesitas para nada un provider a medida, la sobrecarga más corta VerifyLoadedSignature(SigIndex, Source, Info) construye las opciones por defecto y te activa AllowMLDSA a partir de la declaración del Catalog
Reglas de codificación que despistan al CMS ML-DSA y EdDSA producido externamente
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 comprueba además 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, y ML-DSA-87 necesita SHA-512 o SHAKE256. Un emparejamiento equivocado se ve en Info.DigestConsistencyIssue, que es el primer campo que leer cuando un archivo de otra toolchain falla
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 añade la entrada ISO_ correspondiente con ExtensionLevel 32002 al Catalog. La verificación acepta EdDSA sin esa entrada, pero un archivo que produzcas debería llevarla igualmente, por la misma razón de reproducibilidad que la declaración ML-DSA
Qué se le pide a un provider de firma
Un provider de firma recibe un THPDFSignatureProviderRequest y devuelve un THPDFSignatureProviderStatus más, en caso de éxito, los bytes de la firma. La petición lleva el algoritmo y su OID, el OID del algoritmo de digest, la longitud de 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 un driver de token o un servicio de firma ya habla, y nada de ello requiere que la clave cruce hacia tu proceso
Cada provider anuncia además un conjunto de capacidades como spcSign, spcCancel, spcHardwareKey y spcRemoteKey. Varias implementaciones viajan con la librería:
THPDFCallbackSignatureProviderenvuelve anonymous methods para verificar, firmar y cancelar, que es la ruta más corta desde una rutina de firma interna existente hasta una firma PDFTHPDFRemoteSignatureProviderenvuelve una función de transporte con un límite de reintentos, un registro de cancelaciones 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 encoder CMSTHPDFPKCS11SignatureProvidertoma prestadas una sesión y un handle de clave privada que tu código ya abrió y en el que ya hizo login. HotPDF nunca ve el PIN y no gestiona la sesión del token; serializa las llamadasC_Sign, antepone el DigestInfo para RSA PKCS#1 v1.5, pasa la longitud de salt real a RSA-PSS mediante parámetros de mecanismo, y convierte un resultado crudo ECDSAr||sa la forma DER que CMS esperaTHPDFWindowsKeyStorageSignatureProviderfirma a través del key storage CNG de Windows, que cubre tarjetas inteligentes y claves respaldadas por TPM, y puede exigir una clave respaldada por hardware
El provider PKCS#11 toma entrada de digest, 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 provider callback o remoto, donde tu código pasa Req.Input al token o servicio como mensaje
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 tu propio cliente HTTP: mapea 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 en cada uno. spsInvalid es un fallo criptográfico y pertenece a un log de seguridad. spsUnsupported significa que el provider no implementa el algoritmo, lo que es un problema de despliegue. spsMalformed significa que la petición o la respuesta tenían la forma equivocada. spsProviderError es un fallo de transporte o de dispositivo que puede tener éxito en el reintento. spsCancelled significa que una persona rechazó el prompt del token, y repetirlo solo la molesta
El provider remoto trae esa política de fábrica. Solo reintenta con spsProviderError, consulta 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. En el lado de verificación, la respuesta de un provider 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 provider de plataforma también ha declinado la petición
Si escribes un provider, resiste el mapear cada condición inesperada a spsInvalid. Eso convierte una DLL ausente o un token de servicio caducado en un informe que dice que la firma del cliente está falsificada, y alguien tendrá que explicárselo al cliente. Devuelve spsInvalid solo cuando las mates fallaron de verdad. Para firmar, devuelve spsValid solo junto a una firma no vacía; ambos puntos de entrada lanzan EHPDFCMSError cuando un provider devuelve éxito sin bytes
Dónde aterriza la firma del provider en el archivo
Dos funciones conectan un provider con bytes PDF reales. HPDFCMSBuildSignedDataWithProvider construye CMS detached desde un digest del documento que calculaste en otro sitio; la longitud 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 dispuso, hashea ambas ventanas /ByteRange, pide al provider la firma sobre los atributos firmados, y parchea el resultado hexadecimal en /Contents. Si el CMS desborda el placeholder, la llamada lanza una excepción y restaura el placeholder sin firmar en el stream de salida en lugar de truncar nada
El layout es el mismo que escribe el camino de firma basado en PFX: dos rangos que se saltan la ventana hexadecimal de la firma. El verificador exige esa forma a todos los archivos, comprobando que el hueco entre rangos contiene 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 verifica por tanto con el mismo código de verificación de firmas que uno firmado desde un archivo PFX. Las reglas de perfil que se sientan por encima de la elección de algoritmo están cubiertas en firmas baseline PAdES en Delphi, y las trampas de codificación ECDSA con las que lidia la conversión PKCS#11 están en verificación CMS ECDSA y formatos de firma P1363
Desplegar firmas poscuánticas sin dejar lectores tirados
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 check verde. Eso convierte la migración en un problema de calendario. Mantén RSA o ECDSA como la firma que los validadores de hoy juzgarán. Donde una política exija evidencia resistente a la computación cuántica, declara la extensión con EnsureMLDSAExtensions y añade una segunda firma producida por un firmante capaz de ML-DSA junto a la clásica. Esa firma sale de tu propio firmante FIPS 204, porque el constructor CMS de HotPDF no crea firmas ML-DSA; la parte de la librería es verificarla. Mueve la firma primaria solo cuando los sistemas que consumen tus documentos puedan verificarla
En ese plan HotPDF cubre ambos extremos que controlas 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 providers de arriba, y su verificador comprueba ML-DSA, EdDSA y los algoritmos clásicos desde una llamada, con el OID realmente encontrado apuntado en el resultado. HotPDF es un componente PDF VCL nativo para Delphi y C++Builder que no necesita ningún runtime PDF externo, así que el código de firma y verificación compila dentro de tu ejecutable. Ediciones, el conjunto de características de firma y una descarga de prueba están en la página del componente PDF HotPDF para Delphi