Article technique

Clés de chiffrement PDF : pourquoi /dev/urandom sous FPC

Générer du matériel de clé de chiffrement PDF avec la fonction Random de la bibliothèque d’exécution est un piège sur Free Pascal : à moins que quelque chose n’appelle Randomize, chaque processus produit la même séquence d’octets, si bien que les builds Linux et macOS écriraient des clés de chiffrement de fichier, des sels, des IV CBC et des préfixes de nonce AES-GCM identiques d’une exécution à l’autre. PDFiumPas lit /dev/urandom sur les cibles non Windows à la place et lève une exception quand il ne le peut pas

Le piège lui-même tient en une boucle de quatre lignes. La leçon la plus utile est de comprendre pourquoi une suite de tests de chiffrement, AESV3 et AESV4, avec et sans MAC PDF, peut rester verte juste au-dessus de lui. Un hasard constant par processus est invisible pour tout test qui s’exécute dans un seul processus, et c’est exactement ainsi que les suites de tests de chiffrement sont habituellement organisées

Où PDFiumPas a-t-il besoin d'octets aléatoires ?

Chaque octet aléatoire de la pile de chiffrement de PDFiumPas vient d'une seule procédure, AesGenerateRandomBytes dans l'unité FPdfAes, si bien qu'une seule mauvaise source contamine tout. Le gestionnaire de sécurité standard de l'ISO 32000-2 §7.6.4 et l'extension AESV4 de l'ISO/TS 32003 consomment ces octets aux endroits suivants :

  • La clé de chiffrement de fichier de 32 octets, générée fraîche par DeriveEncryptionKeys pour chaque document puis enveloppée dans /UE et /OE sous des clés dérivées du mot de passe
  • Deux sels de 16 octets, l'un stocké dans les 16 derniers octets de /U et l'autre dans les 16 derniers octets de /O, chacun scindé en un sel de validation de 8 octets et un sel de clé de 8 octets
  • Les octets 12 à 15 du texte clair derrière /Perms, que l'ISO 32000-2 remplit de données aléatoires avant que le bloc ne soit chiffré sous la clé de fichier
  • Un IV CBC de 16 octets préfixé à chaque chaîne et flux chiffrés d'un document AESV3
  • Un préfixe de nonce de 8 octets pour les documents AESV4, suivi d'un compteur par objet de 4 octets partant de zéro
  • Le /KDFSalt de 32 octets et la clé MAC quand EnableIntegrityProtection est positionné
Chaque octet aléatoire de la pile de chiffrement de PDFiumPas provient de AesGenerateRandomBytes dans FPdfAes et alimente six consommateurs : la clé de chiffrement de fichier de 32 octets enveloppée dans /UE et /OE, les sels /U et /O, les octets de remplissage /Perms, l’IV CBC AESV3, le préfixe de nonce GCM AESV4, et le sel KDF et la clé MAC
Un générateur partagé signifie qu’une seule mauvaise source contaminerait le matériel de clé partout à la fois, et une seule bonne source couvre chaque site d’appel

Pourquoi chaque processus produirait-il la même clé ?

Le générateur pseudo-aléatoire de la RTL part de RandSeed = 0 sauf si le programme appelle Randomize, si bien qu’une branche non Windows qui remplit le tampon de clé avec lui obtient la même séquence dans chaque processus. Un commentaire affirmant que le générateur est amorcé depuis GetTickCount64 n’aide pas quand aucune ligne de code ne le fait réellement ; le commentaire devient le seul endroit où l’amorçage existe :

// Le piège : un générateur RTL non amorcé comme source de clé
// (un commentaire promettant un amorçage par GetTickCount64 n’en applique aucun)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

La séquence recommence à chaque processus et avance à l'intérieur de celui-ci, si bien que le premier document que chiffre un processus partage sa clé de fichier avec le premier document de tout autre processus exécutant le même build, le deuxième avec le deuxième, et ainsi de suite. La clé de fichier en R5, R6 et R7 ne dépend pas du tout du mot de passe, puisque le mot de passe ne fait que l'envelopper, ce qui signifie que quiconque peut reproduire la séquence tient la clé sans connaître un mot de passe. AESV4 ajoute un deuxième échec : la même clé avec le même préfixe de 8 octets et un compteur repartant de zéro répète des nonces GCM, ce que NIST SP 800-38D §8 interdit carrément. Un nonce GCM répété sous une même clé révèle le XOR des deux textes clairs et expose la sous-clé d'authentification, si bien que les tags dont le chiffrement AESV4-GCM et le jeton MAC PDF dépendent cessent de vouloir dire quoi que ce soit. Confidentialité et intégrité partent en même temps

Générateur RTL non amorcé dans PDFiumPas sur builds FPC non Windows : avec RandSeed 0 chaque processus émet la même séquence, si bien que le document un du processus A porte la même clé de fichier que le document un du processus B, et AESV4 répète des nonces GCM parce que la même clé rencontre le même préfixe avec un compteur repartant de zéro
Comme la clé de fichier ne dépend jamais du mot de passe, quiconque peut reproduire la séquence tient la clé carrément, et des nonces GCM répétés détruisent confidentialité et intégrité ensemble

Le périmètre est plus étroit que ce paragraphe pourrait le laisser croire. Sur Windows, la question ne se pose pas, parce que la branche Windows appelle CryptGenRandom via advapi32 avec CRYPT_VERIFYCONTEXT et lève une exception quand cet appel échoue. Le piège est spécifique aux cibles non Windows, ce qui en pratique désigne les applications Lazarus et Free Pascal sur Linux et macOS, une entrée de plus pour la liste des pièges Delphi face à FPC dans les builds PDFium

Pourquoi Randomize n’est-il pas la réponse ?

Appeler Randomize cache le symptôme sans corriger la source, car RandSeed est une valeur de 32 bits et Randomize la dérive de l’horloge. Cela plafonne le nombre de flux de clés possibles à 2^32, et le fait de connaître approximativement la date d’écriture d’un fichier réduit la recherche bien en dessous, ce qui ne pèse rien face à une clé AES de 256 bits. Le matériel de clé doit venir du réservoir d’entropie du noyau, si bien que AesGenerateRandomBytes lit /dev/urandom, boucle sur les lectures courtes et lève une exception si le réservoir ne peut pas livrer chaque octet demandé :

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // échec ou fin de flux inattendue
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Refuser est délibéré, et cela colle à ce que fait la branche Windows quand CryptGenRandom est indisponible. Un enregistrement chiffré qui échoue est un incident que vous constatez le jour même ; un enregistrement réussi avec des clés prévisibles est un incident que vous apprenez par quelqu’un d’autre. Deux conséquences pratiques s’ensuivent. Un conteneur minimal ou un chroot sans /dev peuplé fait échouer le chiffrement au lieu de dégrader en silence, alors montez-le. Et parce que l’exception se propage hors de TPdf.SaveAsEncrypted après que le fichier cible a été ouvert avec fmCreate, un fichier de sortie vide reste sur le carreau, à supprimer par votre gestionnaire d’erreurs

Pourquoi les tests aller-retour ne les attrapent-ils pas ?

Un test aller-retour ne peut pas voir un hasard constant, parce que le déchiffrement récupère la clé de fichier que le chiffrement a choisie. Le test chiffre un document, le rouvre avec le mot de passe, déballe la clé depuis /UE et déchiffre chaque objet ; une clé prévisible déballait et déchiffrait exactement aussi bien qu'une clé aléatoire, et les tags GCM se vérifient parce qu'ils ont été calculés avec cette même clé. Même un test qui chiffre deux fois et affirme que les deux sorties diffèrent passe, puisque le deuxième appel dans le même processus tire les octets suivants de la séquence. La propriété qui compte, une clé différente dans chaque processus, n'est observable qu'en comparant les sorties entre processus. Chaque fois que le même code produit et consomme une valeur, les tests sont aveugles à des classes entières de défauts, et le hasard en est l'exemple le plus pur

Comment tester l'aléa des clés entre processus ?

Exécutez une petite sonde deux fois en processus séparés sur la plateforme cible et comparez les sorties. La sonde ci-dessous appelle DeriveEncryptionKeys et imprime le sel stocké dans les octets 32 à 47 de l'entrée /U. Cette valeur est écrite à découvert dans chaque fichier chiffré, donc l'imprimer dans les journaux de CI ne divulgue rien, et elle vient pourtant du même générateur que la clé de fichier :

Test d'aléa inter-processus pour PDFiumPas : le programme SaltProbe appelle DeriveEncryptionKeys et imprime l'hex des octets 32 à 47 de /U, le job l'exécute deux fois en processus séparés et échoue quand les lignes correspondent, et les PDF livrés sont comparés par les 16 derniers octets de leurs chaînes /U
Un hasard constant est invisible à l'intérieur d'un seul processus parce que le déchiffrement récupère la clé que le chiffrement a choisie, donc la propriété qui compte n'est observable qu'en comparant les sorties entre processus
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash de 32 octets + sel de 16 octets
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // doit différer à chaque exécution
end.

Branchez la sonde dans le build pour chaque cible non Windows : exécutez-la deux fois, faites échouer le job si les deux lignes coïncident. La même comparaison fonctionne sur des fichiers déjà en circulation. Prenez deux PDF chiffrés écrits par des exécutions différentes de la même application, lisez les chaînes /U de leurs dictionnaires Encrypt et comparez les 16 derniers octets ; des sels identiques signifient que la source de clé est prévisible, et ces documents doivent être chiffrés à nouveau depuis leur texte en clair pour que chacun reçoive une clé de fichier fraîche. L’habitude générale est d’exercer les chemins de code non Windows sur la plateforme elle-même plutôt que de se fier à l’exécution Windows, le même raisonnement qui sous-tend le backend d’horodatage libcurl pour les builds non Windows

PDFiumPas est un composant PDF Delphi et Lazarus construit sur le moteur PDFium, avec AES-256, AES-GCM et le jeton MAC PDF implémentés nativement en Pascal et le matériel de clés tiré du générateur du système d'exploitation sur chaque plateforme. Détails et téléchargements sur la page du composant PDFium pour Delphi