Article technique

ML-DSA en Delphi : FIPS 204 post-quantique dans PDFlibPas

PDFlibPas implémente ML-DSA, le Module-Lattice-Based Digital Signature Algorithm standardisé dans FIPS 204, entièrement en Object Pascal. Les trois jeux de paramètres arrivent comme des fonctions ordinaires : MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, plus les entrées KeyGen et Verify correspondantes. Pas d’OpenSSL, pas de DLL de plateforme, pas de colle C. L’unique unité PDFlibMLDSA ne dépend de rien d’autre que l’éponge SHAKE de la bibliothèque, et sa sortie correspond aux vecteurs de test à réponses connues officiels de FIPS 204 octet pour octet

Cette dernière phrase est la seule partie qui a demandé un vrai travail. Écrire de l’arithmétique de treillis en Pascal est mécanique ; la faire correspondre au NIST ne l’est pas. Ce qui suit est le récit d’ingénierie du portage : comment les trois jeux de paramètres en sont venus à partager un même moteur, et les défauts précis qui séparaient « compile et s’exécute » de « correspond au KAT ». Si vous évaluez des options post-quantiques pour un pipeline documentaire Delphi ou C++Builder, les défauts sont la partie utile, car chacun d’eux produit une sortie d’apparence plausible qui échoue silencieusement à l’interopérabilité

Pourquoi écrire un signataire post-quantique en Object Pascal pur ?

Parce que l’alternative, c’est une dépendance native par cible, et une bibliothèque PDF Delphi en a déjà assez. PDFlibPas se construit sur Delphi, C++Builder et FPC/Lazarus pour les cibles Win32, Win64 et Unix ; lier une bibliothèque post-quantique C signifierait suivre une build de celle-ci pour chacun de ces emplacements, plus la surface de convention d’appel et de propriété mémoire entre eux. Une unité Pascal pure compile partout où le reste de la bibliothèque compile, et c’est tout l’argument

ML-DSA rend cela inhabituellement bon marché, car sa seule dépendance primitive est SHAKE. Pas de couche grands entiers, pas de courbe elliptique, pas de suite de hachage séparée. PDFlibPas a gagné un XOF en flux dans la version juste avant le portage : TPLShakeXOF dans PDFlibDigest, où PLShakeXOFInit sélectionne SHAKE128 (taux 168) ou SHAKE256 (taux 136), suivi de PLShakeXOFAbsorb, PLShakeXOFFinalize et une boucle PLShakeXOFSqueeze qui continue de permuter pour une longueur de sortie arbitraire. Chaque routine de rejet-échantillonnage de l’unité ML-DSA est écrite directement contre cette API à quatre appels

Un moteur, trois jeux de paramètres : TMLDSAParams

PDFlibPas décrit un jeu de paramètres ML-DSA entier avec un enregistrement et le sélectionne par numéro de jeu, si bien que ML-DSA-44, 65 et 87 passent par les mêmes chemins de code. La première implémentation fonctionnelle était une build fixe 4x4 câblée en dur pour ML-DSA-44 ; la généraliser signifiait hisser k et l, eta, tau, beta, gamma1 et gamma2, omega et la longueur du challenge dans TMLDSAParams, puis dériver tout le reste. Les points d’entrée publics sont devenus des wrappers de trois lignes

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;

// Les champs dérivés sont calculés, jamais recopiés d'une table
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;

Les cinq champs dérivés sont calculés plutôt que recopiés hors des tables FIPS 204 exprès. Les largeurs de bits transcrites à la main sont exactement la classe de constante qui semble correcte en revue et se trompe d’un en production, et deux des vrais défauts de ce portage étaient de cette forme. Les tailles déclarées restent des constantes nommées pour la validation : 1312 / 2560 / 2420 octets de clé publique, clé secrète et signature pour ML-DSA-44, 1952 / 4032 / 3309 pour ML-DSA-65, 2592 / 4896 / 4627 pour ML-DSA-87

PDFlibPas achemine MLDSA44Sign, MLDSA65Sign et MLDSA87Sign via BuildMLDSAParams vers un unique enregistrement TMLDSAParams dont les champs dérivés sont calculés plutôt que transcrits, si bien qu’un seul moteur partagé MLDSASignInternal sert les trois jeux de paramètres FIPS 204
Trois jeux de paramètres partagent un moteur car le numéro de jeu ne fait que sélectionner un enregistrement, et les largeurs de bits dérivées sont calculées au lieu d’être transcrites des tables FIPS 204

Où un portage ML-DSA écrit de zéro se trompe-t-il d’abord ?

Dans expand_a, algorithme 32 de FIPS 204, et le mode de défaillance est admirablement trompeur. La matrice A est échantillonnée en amorçant SHAKE128 avec rho suivi de deux octets d’index, si bien que le tampon de graine fait 34 octets : rho(32), puis j, puis i. Écrit en Pascal avec l’indexation AnsiString à base 1, ces deux octets sont Msg[33] et Msg[34]. La première ébauche de ce portage les écrivait à Msg[34] et Msg[35], décalés d’exactement un octet, et le résultat était une paire de clés dont le rho correspondait parfaitement au vecteur de test tandis que chaque coefficient de t était faux. Seule la matrice était polluée, et la matrice est la seule chose que la clé publique ne porte pas verbatim

Deux autres défauts vivaient dans la même routine. La longueur d’absorption doit être 34, pas 35 ; un octet parasite de plus change tout le flux étiré. Et la boucle de rejet interne doit consommer chaque groupe de trois octets que le bloc peut fournir, y compris celui qui commence au décalage 165 d’un bloc SHAKE128 de 168 octets, soit 56 groupes par bloc. Un script de contre-vérification qui s’arrêtait au décalage 162 laissait tomber la queue de chaque bloc et décalait le préfixe t1 échantillonné à partir d’environ le treizième octet

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // index de colonne d'abord
Msg[34]:= AnsiChar(I);          // puis l'index de ligne
PLShakeXOFInit(Ctx, True);      // SHAKE128, taux 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 garde le groupe au décalage 165 : 56 triplets par bloc
  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;

Avec ces trois corrections, les digests SHA-256 des clés publique et secrète ML-DSA-44 complètes correspondaient aux vecteurs à réponses connues de FIPS 204. Une leçon de débogage vaut aussi d’être nommée, car elle a coûté une session : quand vous construisez une contre-vérification Python pour une boucle de rejet pilotée par XOF, hashlib.shake_128().digest(n) renvoie le même préfixe à chaque appel au lieu de continuer le flux. Prenez la longueur entière une fois, puis tranchez-la en blocs de la taille du taux, sinon votre référence reconsommera joyeusement les valeurs mêmes que votre Pascal a correctement rejetées

La routine expand_a ML-DSA de PDFlibPas amorce SHAKE128 avec un tampon de 34 octets contenant rho, l’index de colonne et l’index de ligne, à côté d’une première ébauche qui décalait les deux octets d’index et d’une contre-vérification qui abandonnait le dernier groupe de trois octets de chaque bloc
Deux défauts de décalage d’un dans la même routine : des octets d’index écrits une position trop tard, et une boucle de rejet qui s’arrête avant le groupe de trois octets au décalage 165

Échantillonner eta : pourquoi ML-DSA-65 exige sa propre branche

PDFlibPas garde deux chemins séparés dans expand_s car l’algorithme 33 de FIPS 204 en définit vraiment deux. Pour eta = 2, chaque nibble est rejeté quand il atteint 15 et sinon réduit mod 5. Pour eta = 4, le nibble est rejeté à 9 ou au-dessus puis utilisé directement, sans aucune réduction modulaire. ML-DSA-65 est le seul jeu livré avec eta = 4, et réutiliser le chemin mod 5 pour lui désaligne s1 et s2 dès le premier coefficient, produisant une paire de clés internement cohérente, qui se vérifie contre elle-même et ne correspond à rien de ce que d’autres produisent

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // rejet, puis le nibble pris tel quel
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2 : rejet 15, puis réduction 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;

Les tailles sont le test : longueur c-tilde et largeur de bits gamma1

Deux paramètres d’encodage varient avec le niveau de sécurité de façons faciles à rater quand une build ML-DSA-44 fonctionnelle est juste là. Le hachage de challenge c-tilde fait 2 x lambda / 8 octets, soit 32 pour ML-DSA-44, 48 pour ML-DSA-65 et 64 pour ML-DSA-87. Le laisser fixé à 32 donne une signature ML-DSA-65 de 3293 octets au lieu des 3309 standard, et le préfixe du KAT diverge immédiatement. Le champ d’enregistrement CTildaBytes existe précisément pour que ce nombre ne puisse pas être oublié

Le second est la largeur de compression du polynôme masque z. PDFlibPas la calcule comme BitWidth(Gamma1), pas comme l’exposant : gamma1 = 2^19 pour ML-DSA-65 et 87 exige 20 bits par coefficient, pas 19, et ce bit unique décide si chaque polynôme z occupe 640 octets ou quelque chose qu’aucun vérificateur ne parse. Le vérificateur portait un défaut correspondant pendant le portage, où le tampon de désérialisation z était dimensionné à 192 octets au lieu de 576. La longueur de signature est le test de régression le moins cher que vous écrirez jamais : affirmez 2420, 3309 et 4627 contre Length(Signature) et la plupart des erreurs de paramétrage se dénoncent avant que vous n’arriviez à une seule assertion cryptographique

PDFlibPas lie deux paramètres d’encodage ML-DSA au niveau de sécurité : le hachage de challenge c-tilde passe de 32 à 48 puis 64 octets, et le polynôme masque z est comprimé sur BitWidth bits de gamma1, avec la longueur de signature comme test de régression
Deux paramètres varient avec le niveau de sécurité, et une signature qui sort à 3293 octets au lieu de 3309 dénonce l’erreur avant toute assertion cryptographique

Signer sans boucle non bornée

La signature ML-DSA est basée sur le rejet, donc elle réessaie avec un kappa incrémenté jusqu’à ce qu’une signature candidate passe ses contrôles de norme et de hint. PDFlibPas borne cela avec un budget externe explicite de 65535 tentatives ; à épuisement, MLDSASignInternal renvoie False et laisse la signature vide au lieu de tourner en rond dans un thread de production documentaire. En pratique, le vecteur officiel ML-DSA-44 réussit à kappa = 4 avec 55 hints contre le plafond omega de 80, si bien que le budget est un rail de sécurité plutôt qu’une limite de fonctionnement

Le bug qui a fait paraître ce rail nécessaire n’était pas numérique du tout. La signature semblait se bloquer, les soupçons sont tombés sur decompose et make_hint (algorithmes 36 et 39 de FIPS 204), et la vraie cause était une cible d’accumulation inversée : le vecteur alimentant le calcul de hint doit accumuler c*t0, tandis que le c*t0 original doit survivre intact pour le contrôle de norme. Visez les deux vers le même tampon et la boucle rejette pour toujours avec une arithmétique parfaitement correcte. Sur le chemin du succès comme sur celui de l’épuisement du budget, l’unité met à zéro les graines dérivées, les polynômes secrets, les masques, le challenge et les tampons d’encodage ; la graine, la clé secrète et le rnd fournis par l’appelant restent la responsabilité de l’appelant, ce qui est le bon partage pour une bibliothèque qui ne peut pas savoir d’où viennent ces chaînes

Où ML-DSA rencontre-t-il aujourd’hui la pile de signatures PDF ?

Soyez précis sur ce qui existe. PDFlibPas livre ML-DSA comme primitives de signature vérifiées plus une liaison de mécanisme PKCS #11, pas comme un remplacement immédiat de votre sortie PAdES actuelle. Le chemin token est TPDFlibPKCS11Client.SignMLDSA, et il est délibérément un point d’entrée séparé car CKM_ML_DSA consomme le message brut plutôt qu’un digest précalculé, si bien que les rappels SignHash et digest externe existants ne peuvent pas être réutilisés. La découverte sans certificat exige que CertificateOptional soit activé explicitement avec un label ou un ID de clé privée, et le client valide CKA_PARAMETER_SET contre la liste blanche CKP_ML_DSA_44 / 65 / 87 à la connexion, si bien que l’appariement de certificats RSA et ECDSA par défaut ne se desserre jamais par accident

L’intégration au niveau document reste gouvernée par le travail de normalisation plutôt que par le code de la bibliothèque. L’ISO 32000-2 §12.8 définit le dictionnaire de signature et sa charge CMS, et l’ISO/TS 32002 est le véhicule pour étendre cette prise en charge aux algorithmes de hachage et de signature plus récents ; tant que vos vérificateurs et vos contreparties ne suivent pas, la signature classique reste le chemin de production. La posture pratique est celle de voies parallèles : continuer à livrer des signatures PAdES B-B à B-LTA avec horodatage et données de validation long terme pour tout ce qu’un tiers doit valider aujourd’hui, tout en éprouvant la gestion des clés ML-DSA et l’intégration token à côté. Pour les expériences locales, le même flux de certificat auto-signé construit sur CryptoAPI vous donne une identité de signature sans impliquer une CA publique

Testez un changement de jeu de paramètres comme vous testeriez tout autre changement de signature. Les tailles d’abord, puis les vecteurs officiels, puis les cas négatifs : un octet de signature falsifié, une chaîne de contexte qui ne correspond pas, une clé tronquée. PDFlibPas couvre tout cela dans sa suite DUnitX, et la même discipline appartient à votre propre pipeline, idéalement à côté du banc de conformité et de signature qui traite la validation par lots sur un corpus documentaire pour qu’une régression n’atteigne jamais un client inaperçue

La préparation post-quantique pour le logiciel documentaire n’arrivera pas comme un simple interrupteur. Elle arrive comme des primitives que vous pouvez tester, un chemin token que vous pouvez câbler, et une piste de normalisation que vous suivez sans miser la version courante dessus. Pour voir comment l’unité ML-DSA se situe à côté du reste de l’outillage de signature, chiffrement et PDF/A dans un codebase Object Pascal natif, la page produit de la bibliothèque PDF PDFlibPas pour Delphi liste l’ensemble complet des composants et la matrice de compilateurs prise en charge