PDFlibPas implementa ML-DSA, el algoritmo de firma digital basado en retículos de módulos estandarizado en FIPS 204, por completo en Object Pascal. Los tres juegos de parámetros se entregan como funciones ordinarias: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, además de las entradas KeyGen y Verify correspondientes. Sin OpenSSL, sin DLL de plataforma, sin pegamento en C. La única unidad PDFlibMLDSA no depende de nada salvo de la esponja SHAKE de la biblioteca, y su salida coincide byte a byte con los vectores de prueba de respuesta conocida oficiales de FIPS 204
Esa última frase es la única parte que exigió trabajo de verdad. Escribir aritmética de retículos en Pascal es mecánico; conseguir que concuerde con NIST no lo es. Lo que sigue es el relato de ingeniería del port: cómo los tres juegos de parámetros acabaron compartiendo un único motor, y los defectos concretos que separaban compila y se ejecuta de coincide con el KAT. Si estás evaluando opciones post-cuánticas para una canalización 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 en silencio la interoperabilidad
¿Por qué escribir un firmador post-cuántico en Object Pascal puro?
Porque la alternativa es una dependencia nativa por destino, y una biblioteca PDF para Delphi ya tiene bastantes. PDFlibPas compila en Delphi, C++Builder y FPC/Lazarus sobre destinos 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 huecos, más la superficie de convención de llamada y de propiedad de memoria entre ambos. Una unidad Pascal pura compila dondequiera que compila 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 aparte. PDFlibPas estrenó un XOF de flujo en la versión inmediatamente anterior al port: 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 longitud de salida arbitraria. Cada rutina de muestreo por rechazo de la unidad ML-DSA está escrita directamente contra esa API de cuatro llamadas
Un motor, tres juegos de parámetros: TMLDSAParams
PDFlibPas describe un juego de parámetros ML-DSA completo con un único registro y lo selecciona por número de juego, de modo que ML-DSA-44, 65 y 87 recorren las mismas rutas de código. La primera implementación funcional era una compilación fija 4x4 cableada a mano para ML-DSA-44; generalizarla significaba elevar k y l, eta, tau, beta, gamma1 y gamma2, omega y la longitud del desafío a TMLDSAParams, y derivar de ahí 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 parece correcta en la revisión y está desviada en uno en producción, y dos de los defectos reales de este port tuvieron esa forma. Los tamaños declarados se 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 port de ML-DSA desde cero?
En expand_a, el algoritmo 32 de FIPS 204, y el modo de fallo es bellamente engañoso. La matriz A se muestrea sembrando SHAKE128 con rho seguido de dos bytes de índice, así que el búfer de semilla es de 34 bytes: rho(32), luego j, luego i. Escrito en Pascal con indexación base 1 de AnsiString esos dos bytes son Msg[33] y Msg[34]. El primer borrador de este port 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 era erróneo. Solo la matriz quedó contaminada, y la matriz es justamente lo que la clave pública no lleva tal cual
Dos defectos más vivían en la misma rutina. La longitud de absorb tiene que ser 34, no 35; un byte basura extra cambia todo el flujo comprimido. Y el bucle interno de rechazo debe consumir cada grupo de tres bytes que el bloque pueda suministrar, incluido el que empieza en el desplazamiento 165 de un bloque SHAKE128 de 168 bytes, que son 56 grupos por bloque. Un script de verificación cruzada que se detenía en el desplazamiento 162 descartaba la cola de cada bloque y desplazaba el prefijo t1 muestreado a partir de aproximadamente el decimotercer byte
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 conserva el grupo en el desplazamiento 165: 56 tríos 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;
Corregidos esos tres, los resúmenes SHA-256 de las claves pública y secreta ML-DSA-44 completas coincidieron con los vectores de respuesta conocida de FIPS 204. Una lección de depuración merece nombrarse también, porque costó una sesión: cuando construyes una verificación cruzada en Python para un bucle de rechazo guiado por XOF, hashlib.shake_128().digest(n) devuelve el mismo prefijo en cada llamada en lugar de continuar el flujo. Toma la longitud completa una vez y córtala después en bloques del tamaño del rate, o tu referencia re-consumirá encantada los mismos valores que tu Pascal rechazó correctamente
Muestrear eta: por qué ML-DSA-65 necesita su propia rama
PDFlibPas mantiene dos rutas separadas en expand_s porque el algoritmo 33 de FIPS 204 define genuinamente dos. Para eta = 2 cada nibble se rechaza al llegar a 15 y en caso contrario se reduce módulo 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 para él la ruta del módulo 5 desalinea s1 y s2 desde el primer coeficiente, produciendo un par de claves internamente consistente que verifica contra sí mismo y no coincide con nada de lo que produzca 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, luego tomar el nibble tal cual
Exit;
M:= Nibble;
End
Else
Begin
If Nibble>= 15 Then // eta = 2: rechazar 15, luego reducir módulo 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 maneras fáciles de pasar por alto cuando tienes delante una compilación ML-DSA-44 que funciona. El hash de desafío c-tilde mide 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 registro CTildaBytes existe precisamente para que ese número no pueda olvidarse
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 analizará. El verificador arrastró un defecto correspondiente durante el port, con el búfer de deserialización de z dimensionado en 192 bytes en lugar de 576. La longitud de la firma es la prueba de regresión más barata que escribirás nunca: afirma 2420, 3309 y 4627 contra Length(Signature) y la mayoría de los errores de parametrización se delatan antes de que llegues 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 limita 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 con kappa = 4 y 55 hints frente al techo de omega de 80, así que el presupuesto es un raíl de seguridad y no un límite de trabajo
El error que hizo que ese raíl pareciera necesario no era numérico en absoluto. Firmar parecía colgarse, la sospecha recayó en decompose y make_hint (algoritmos 36 y 39 de FIPS 204), y la causa real era un destino 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. Apunta ambos al mismo búfer y el bucle rechaza para siempre con una aritmética perfectamente correcta. Tanto en la ruta de éxito como en la 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 que aporta quien llama siguen siendo responsabilidad de quien llama, que es el reparto correcto para una biblioteca que no puede saber de dónde salieron esas cadenas
¿Dónde se encuentra hoy ML-DSA con la pila de firmas PDF?
Sé preciso con lo que existe. PDFlibPas entrega ML-DSA como primitivas de firma verificadas más un enlace de mecanismo PKCS #11, no como un reemplazo directo de tu salida PAdES actual. La ruta de 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 resumen precalculado, así que los callbacks existentes SignHash y de resumen externo no pueden reutilizarse. El descubrimiento sin certificado exige 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 en el momento de la conexión, de modo que el emparejamiento de certificados RSA y ECDSA por defecto nunca se afloja por accidente
La integración a nivel de documento es la parte que sigue gobernada por el trabajo de normalización 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 tus validadores y contrapartes sigan, la firma clásica sigue siendo la ruta de producción. La postura práctica son vías paralelas: sigue enviando 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 demuestras a la par el manejo de claves ML-DSA y la integración de tokens. Para experimentos locales, el mismo flujo de certificado autofirmado construido sobre CryptoAPI te da una identidad de firma sin involucrar a una CA pública
Prueba un cambio de juego de parámetros como probarías cualquier otro cambio de firma. Primero los tamaños, luego los vectores oficiales, después los casos negativos: un byte de firma manipulado, una cadena de contexto que no coincide, una clave truncada. PDFlibPas cubre todo eso en su suite DUnitX, y la misma disciplina pertenece a tu propia canalización, idealmente junto al banco de trabajo de cumplimiento y firma que lotea la validación sobre un corpus de documentos para que una regresión nunca llegue a un cliente sin que se note
La preparación post-cuántica para el software de documentos no llegará como un único interruptor. Llega como primitivas que puedes probar, una ruta de token que puedes cablear y una vía de normalización que sigues sin apostar la versión actual a ella. Para ver cómo la unidad ML-DSA se sitúa junto al resto del firmado, el cifrado y las herramientas PDF/A en una base de código Object Pascal nativa, la página de producto de PDFlibPas Delphi PDF library lista el conjunto completo de componentes y la matriz de compiladores soportados