PDFlibPas implementa ML-DSA, el algoritmo de firmas digitales basado en retículos modular estandarizado en FIPS 204, íntegramente en Object Pascal. Los tres juegos de parámetros se entregan como funciones ordinarias: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, más las entradas KeyGen y Verify correspondientes. Sin OpenSSL, sin DLL de plataforma, sin pegamento en C. La unidad única PDFlibMLDSA no depende de nada más que de la esponja SHAKE de la biblioteca, y su salida coincide byte a byte con los vectores de prueba oficiales de respuesta conocida de FIPS 204
Esa última oración es la única parte que exigió trabajo de verdad. Escribir aritmética de retículos en Pascal es mecánico; lograr que concuerde con NIST no lo es. Lo que sigue es el relato de ingeniería del porte: cómo los tres juegos de parámetros terminaron compartiendo un motor, y los defectos concretos que separaron compila y se ejecuta de coincide con el KAT. Si están evaluando opciones post-cuánticas para un pipeline de documentos en Delphi o C++Builder, los defectos son la parte útil, porque cada uno de ellos produce una salida de aspecto plausible que falla silenciosamente en interoperabilidad
¿Por qué escribir un firmador post-cuántico en Object Pascal puro?
Porque la alternativa es una dependencia nativa por cada objetivo, y una biblioteca PDF para Delphi ya tiene suficientes. PDFlibPas compila para Delphi, C++Builder y FPC/Lazarus en objetivos Win32, Win64 y Unix; enlazar una biblioteca post-cuántica en C significaría mantener una compilación de ella para cada uno de esos espacios, además de la superficie de convenciones de llamada y propiedad de memoria entre ambos. Una unidad Pascal pura compila donde compile el resto de la biblioteca, y ese es todo el argumento
ML-DSA hace esto inusualmente barato, porque su única dependencia primitiva es SHAKE. No hay capa de enteros grandes, ni curva elíptica, ni suite de hash separada. PDFlibPas incorporó un XOF de flujo en la versión inmediatamente anterior al porte: TPLShakeXOF en PDFlibDigest, donde PLShakeXOFInit selecciona SHAKE128 (rate 168) o SHAKE256 (rate 136), seguido de PLShakeXOFAbsorb, PLShakeXOFFinalize y un bucle PLShakeXOFSqueeze que sigue permutando para cualquier longitud de salida. Cada rutina de muestreo por rechazo en la unidad ML-DSA se escribe directamente contra esa API de cuatro llamadas
Un motor, tres juegos de parámetros: TMLDSAParams
PDFlibPas describe un juego completo de parámetros ML-DSA con un solo record y lo selecciona por número de juego, de modo que ML-DSA-44, 65 y 87 corren por los mismos caminos de código. La primera implementación funcional era una compilación fija 4x4 cableada en duro para ML-DSA-44; generalizarla significó elevar k y l, eta, tau, beta, gamma1 y gamma2, omega y la longitud del desafío a TMLDSAParams, y derivar todo lo demás. Los puntos de entrada públicos quedaron como envoltorios de tres líneas
Type
TMLDSAParams= Record
K, L, D, Eta, Tau, Beta, Gamma1, Gamma2, Omega: Integer;
Alpha, MW1: Cardinal;
W1BW, EtaBW, Gamma1BW, T1BW: Integer;
T0Rng: Cardinal;
CTildaBytes: Integer;
PublicKeyBytes, SecretKeyBytes, SignatureBytes: Integer;
End;
// Los campos derivados se calculan, nunca se transcriben de una tabla
Params.Alpha:= 2* Cardinal(Params.Gamma2);
Params.MW1:= (Q- 1)div Params.Alpha;
Params.W1BW:= BitWidth(Params.MW1- 1);
Params.EtaBW:= BitWidth(2* Cardinal(Params.Eta));
Params.Gamma1BW:= BitWidth(Cardinal(Params.Gamma1));
Function MLDSA65Sign(Const SecretKey, Message, Context, Rnd: AnsiString;
Out Signature: AnsiString): Boolean;
Var
Params: TMLDSAParams;
Begin
BuildMLDSAParams(65, Params);
Result:= MLDSASignInternal(Params, SecretKey, Message, Context, Rnd,
Signature);
End;
Los cinco campos derivados se calculan en lugar de copiarse de las tablas de FIPS 204 a propósito. Los anchos de bit transcritos a mano son exactamente la clase de constante que se ve bien en revisión y está desfasada en uno en producción, y dos de los defectos reales de este porte tuvieron esa forma. Los tamaños declarados quedan como constantes con nombre para validación: 1312 / 2560 / 2420 bytes de clave pública, clave secreta y firma para ML-DSA-44, 1952 / 4032 / 3309 para ML-DSA-65, 2592 / 4896 / 4627 para ML-DSA-87
¿Dónde falla primero un porte de ML-DSA desde cero?
En expand_a, el Algoritmo 32 de FIPS 204, y el modo de falla es hermosamente engañoso. La matriz A se muestrea sembrando SHAKE128 con rho seguido de dos bytes de índice, de modo que el búfer de semilla es de 34 bytes: rho(32), luego j, luego i. Escrito en Pascal con indexación AnsiString base 1, esos dos bytes son Msg[33] y Msg[34]. El primer borrador de este porte los escribió en Msg[34] y Msg[35], desplazados exactamente un byte, y el resultado fue un par de claves cuyo rho coincidía perfectamente con el vector de prueba mientras cada coeficiente de t estaba mal. Solo la matriz quedó contaminada, y la matriz es justo lo único que la clave pública no lleva tal cual
Dos defectos más vivían en la misma rutina. La longitud de absorción debe ser 34, no 35; un byte basura extra cambia por completo el stream de salida. Y el bucle interno de rechazo debe consumir cada grupo de tres bytes que el bloque pueda suministrar, incluido el que empieza en el offset 165 de un bloque SHAKE128 de 168 bytes, o sea 56 grupos por bloque. Un script de verificación cruzada que se detenía en el offset 162 descartaba la cola de cada bloque y desplazaba el prefijo t1 muestreado desde aproximadamente el decimotercer byte en adelante
SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J); // primero el índice de columna
Msg[34]:= AnsiChar(I); // luego el índice de fila
PLShakeXOFInit(Ctx, True); // SHAKE128, rate 168
PLShakeXOFAbsorb(Ctx, @Msg[1], 34);
PLShakeXOFFinalize(Ctx);
Cnt:= 0;
While Cnt< N Do
Begin
PLShakeXOFSqueeze(Ctx, @Buf[0], 168);
BOff:= 0;
// BOff+2 <= 167 mantiene el grupo en el offset 165: 56 triples por bloque
While (BOff+ 2<= High(Buf))And (Cnt< N) Do
Begin
T3:= ((Buf[BOff+ 2]and $7F)shl 16)xor (Buf[BOff+ 1]shl 8)xor Buf[BOff];
If T3< Q Then
Begin
Poly^[Cnt]:= T3;
Inc(Cnt);
End;
Inc(BOff, 3);
End;
End;
Con esas tres correcciones, los digests SHA-256 de las claves pública y secreta completas de ML-DSA-44 coincidieron con los vectores de respuesta conocida de FIPS 204. Una lección de depuración también vale la pena nombrar, porque costó una sesión: cuando construyen una verificación cruzada en Python para un bucle de rechazo impulsado por un XOF, hashlib.shake_128().digest(n) devuelve el mismo prefijo en cada llamada en lugar de continuar el stream. Tomen toda la longitud de una vez y luego córtenla en bloques del tamaño del rate, o su referencia consumirá felizmente otra vez los mismos valores que su Pascal rechazó correctamente
Muestrear eta: por qué ML-DSA-65 necesita su propia rama
PDFlibPas mantiene dos caminos separados en expand_s porque el Algoritmo 33 de FIPS 204 realmente define dos. Para eta = 2, cada nibble se rechaza al llegar a 15 y, en los demás casos, se reduce mod 5. Para eta = 4, el nibble se rechaza a partir de 9 y luego se usa directamente, sin reducción modular alguna. ML-DSA-65 es el único juego entregado con eta = 4, y reutilizar el camino mod 5 para él desalinea s1 y s2 desde el primer coeficiente, produciendo un par de claves internamente consistente, que verifica contra sí mismo y que no coincide con nada de lo que produce nadie más
Procedure StoreNibble(Nibble: Byte);
Var
M: Integer;
Centered: Cardinal;
Begin
If Cnt>= N Then
Exit;
If Eta= 4 Then
Begin
If Nibble>= 9 Then // rechazar y luego tomar el nibble tal cual
Exit;
M:= Nibble;
End
Else
Begin
If Nibble>= 15 Then // eta = 2: rechazar 15 y luego reducir mod 5
Exit;
M:= Nibble mod 5;
End;
If Eta>= M Then
Centered:= Eta- M
Else
Centered:= Q- (M- Eta);
Vec[I][Cnt]:= Centered;
Inc(Cnt);
End;
Los tamaños son la prueba: longitud de c-tilde y ancho de bit de gamma1
Dos parámetros de codificación varían con el nivel de seguridad de formas fáciles de pasar por alto cuando una compilación de ML-DSA-44 funcional está justo ahí. El hash de desafío c-tilde es de 2 x lambda / 8 bytes, es decir 32 para ML-DSA-44, 48 para ML-DSA-65 y 64 para ML-DSA-87. Dejarlo fijo en 32 produce una firma ML-DSA-65 de 3293 bytes en lugar de los 3309 estándar, y el prefijo del KAT diverge de inmediato. El campo del record CTildaBytes existe precisamente para que ese número no se pueda olvidar
El segundo es el ancho de empaquetado del polinomio de máscara z. PDFlibPas lo calcula como BitWidth(Gamma1), no como el exponente: gamma1 = 2^19 para ML-DSA-65 y 87 necesita 20 bits por coeficiente, no 19, y ese único bit decide si cada polinomio z ocupa 640 bytes o algo que ningún verificador interpretará. El verificador arrastró un defecto equivalente durante el porte: el búfer de deserialización de z tenía tamaño 192 en lugar de 576. La longitud de la firma es la prueba de regresión más barata que escribirán jamás: verifiquen 2420, 3309 y 4627 contra Length(Signature) y la mayoría de los errores de parametrización se delatan antes de llegar a una sola aserción criptográfica
Firmar sin un bucle sin límite
La firma ML-DSA se basa en rechazo, así que reintenta con un kappa incrementado hasta que una firma candidata pasa sus comprobaciones de norma y de hint. PDFlibPas lo acota con un presupuesto externo explícito de 65535 intentos; al agotarse, MLDSASignInternal devuelve False y deja la firma vacía en lugar de girar dentro de un hilo de producción de documentos. En la práctica, el vector oficial de ML-DSA-44 tiene éxito en kappa = 4 con 55 hints frente al techo omega de 80, así que el presupuesto es un riel de seguridad más que un límite operativo
El error que hizo sentir necesario ese riel no era numérico en absoluto. La firma parecía colgarse, la sospecha cayó sobre decompose y make_hint (Algoritmos 36 y 39 de FIPS 204), y la causa real era un objetivo de acumulación invertido: el vector que alimenta el cálculo del hint debe acumular c*t0, mientras que el c*t0 original tiene que sobrevivir intacto para la comprobación de norma. Apunten ambos al mismo búfer y el bucle rechazará para siempre con una aritmética perfectamente correcta. Tanto en el camino de éxito como en el de presupuesto agotado, la unidad pone a cero las semillas derivadas, los polinomios secretos, las máscaras, el desafío y los búferes de codificación; la semilla, la clave secreta y el rnd suministrados por el llamador siguen siendo responsabilidad del llamador, que es la división correcta para una biblioteca que no puede saber de dónde salieron esas cadenas
¿Dónde se encuentra ML-DSA con la pila de firmas PDF hoy?
Sean precisos con lo que existe. PDFlibPas entrega ML-DSA como primitivas de firma verificadas más un binding de mecanismo PKCS #11, no como un reemplazo directo de su salida PAdES actual. El camino del token es TPDFlibPKCS11Client.SignMLDSA, y es deliberadamente un punto de entrada separado porque CKM_ML_DSA consume el mensaje en bruto en lugar de un digest precalculado, así que los callbacks existentes de SignHash y de digest externo no se pueden reutilizar. El descubrimiento sin certificado requiere habilitar CertificateOptional explícitamente junto con una etiqueta o ID de clave privada, y el cliente valida CKA_PARAMETER_SET contra la lista blanca CKP_ML_DSA_44 / 65 / 87 al momento de conectar, de modo que el emparejamiento por defecto de certificados RSA y ECDSA nunca se afloja por accidente
La integración a nivel de documento es la parte que sigue gobernada por el trabajo de estándares y no por el código de la biblioteca. ISO 32000-2 §12.8 define el diccionario de firma y su carga CMS, e ISO/TS 32002 es el vehículo para extender ese soporte a algoritmos de hash y de firma más nuevos; hasta que sus validadores y contrapartes sigan, la firma clásica sigue siendo el camino de producción. La postura práctica son vías paralelas: sigan entregando firmas PAdES de B-B a B-LTA con sellado de tiempo y datos de validación a largo plazo para todo lo que un tercero deba validar hoy, mientras prueban el manejo de claves ML-DSA y la integración de tokens en paralelo. Para experimentos locales, el mismo flujo de certificado autofirmado construido sobre CryptoAPI les da una identidad de firma sin involucrar una CA pública
Prueben un cambio de juego de parámetros como probarían cualquier otro cambio de firma. Primero los tamaños, luego los vectores oficiales, después los casos negativos: un byte de firma alterado, una cadena de contexto que no coincide, una clave truncada. PDFlibPas cubre todo eso en su suite DUnitX, y la misma disciplina pertenece a su propio pipeline, idealmente junto al banco de trabajo de cumplimiento y firmas que lotea la validación sobre un corpus de documentos para que una regresión nunca llegue inadvertida a un cliente
La preparación post-cuántica para el software de documentos no llegará como un solo interruptor. Llega como primitivas que pueden probar, un camino de tokens que pueden cablear y una pista de estándares que siguen sin apostar la versión actual a ella. Para ver cómo la unidad ML-DSA se sitúa junto al resto del firmado, cifrado y herramientas PDF/A en una base de código Object Pascal nativa, la página de producto de la biblioteca PDF de PDFlibPas para Delphi lista el conjunto completo de componentes y la matriz de compiladores soportados