Artículo técnico

ML-DSA en Delphi: post-cuántico FIPS 204 en PDFlibPas

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

PDFlibPas enruta MLDSA44Sign, MLDSA65Sign y MLDSA87Sign a través de BuildMLDSAParams hacia un único registro TMLDSAParams cuyos campos derivados se calculan en lugar de transcribirse, así que un solo motor MLDSASignInternal compartido sirve a los tres juegos de parámetros de FIPS 204
Tres juegos de parámetros comparten un motor porque el número de juego solo selecciona un registro, y los anchos de bit derivados se calculan en vez de transcribirse de las tablas de FIPS 204

¿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

La rutina expand_a de ML-DSA en PDFlibPas siembra SHAKE128 con un búfer de 34 bytes que contiene rho, el índice de columna y el índice de fila, junto a un primer borrador que desplazaba ambos bytes de índice y una verificación cruzada que descartaba el último grupo de tres bytes de cada bloque
Dos defectos de desviación por uno en la misma rutina: bytes de índice escritos una posición tarde, y un bucle de rechazo que se detiene antes del grupo de tres bytes del desplazamiento 165

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

PDFlibPas ata dos parámetros de codificación de ML-DSA al nivel de seguridad: el hash de desafío c-tilde crece de 32 a 48 a 64 bytes, y el polinomio de máscara z se empaqueta con los bits de BitWidth de gamma1, con la longitud de la firma como prueba de regresión
Dos parámetros varían con el nivel de seguridad, y una firma que sale con 3293 bytes en lugar de 3309 anuncia el error antes de que corra cualquier 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