Avant la version 3.114.8, PDFiumPas générait le matériel de clés de chiffrement PDF sur les cibles non Windows avec la fonction Random de la bibliothèque runtime, et comme rien n'appelait Randomize, chaque processus produisait la même séquence d'octets. Les builds Free Pascal sous Linux et macOS écrivaient donc 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. La version 3.114.8 lit /dev/urandom à la place et lève une exception quand elle ne le peut pas
Le défaut lui-même est une boucle de quatre lignes. La leçon la plus utile est de comprendre pourquoi une suite de tests qui chiffre et déchiffre des centaines de documents, avec AESV3 et AESV4, avec et sans MAC PDF, est restée verte tout ce temps. Un hasard constant par processus est invisible pour chaque test qui s'exécute dans un seul processus, et c'est exactement ainsi que les tests de chiffrement sont généralement écrits
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é
Pourquoi chaque processus produisait-il la même clé ?
AesGenerateRandomBytes n'utilisait le générateur du système d'exploitation que sous Windows ; partout ailleurs, il remplissait le tampon depuis le générateur pseudo-aléatoire de la RTL, et ce générateur part de RandSeed = 0 tant que le programme n'appelle pas Randomize. Le commentaire au-dessus de la boucle disait que le générateur était amorcé depuis GetTickCount64. Aucune ligne de code ne l'a jamais fait, ce qui faisait du commentaire le seul endroit où la graine existait :
// Branche non Windows de AesGenerateRandomBytes avant 3.114.8
// (le commentaire au-dessus promettait une graine GetTickCount64 jamais appliquée)
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
Le périmètre est plus étroit que ce paragraphe pourrait le laisser croire. Les builds Windows n'ont jamais été affectés, parce que la branche Windows a toujours appelé CryptGenRandom via advapi32 avec CRYPT_VERIFYCONTEXT et levé une exception en cas d'échec. Ce qui était exposé, c'est la sortie des builds non Windows antérieurs à 3.114.8, ce qui en pratique signifie les applications Lazarus et Free Pascal sous Linux et macOS, une entrée de plus pour la liste des pièges Delphi contre FPC dans les builds PDFium
Pourquoi Randomize n'a-t-il jamais été le bon correctif ?
Appeler Randomize aurait caché le symptôme sans corriger la source, parce que RandSeed est une valeur 32 bits et que Randomize la dérive de l'horloge. Cela plafonne le nombre de flux de clés possibles à 2^32, et connaître approximativement le moment où un fichier a été écrit réduit la recherche bien en dessous, ce qui ne pèse rien à côté d'une clé AES de 256 bits. Le matériel de clés doit venir du réservoir d'entropie du noyau, donc AesGenerateRandomBytes dans 3.114.8 lit /dev/urandom, boucle sur les lectures courtes, et lève une exception si le réservoir ne peut pas dé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 la branche Windows a toujours fait quand CryptGenRandom est indisponible. Une sauvegarde chiffrée qui échoue est un incident que vous remarquez le jour même ; une sauvegarde réussie avec des clés prévisibles est celle dont vous apprenez l'existence par quelqu'un d'autre. Deux conséquences pratiques s'ensuivent. Un conteneur minimal ou un chroot sans /dev peuplé échoue maintenant au chiffrement au lieu de se dégrader en silence, alors montez-le. Et comme 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 place pour que votre gestionnaire d'erreurs le supprime
Pourquoi les tests aller-retour ne l'ont-ils jamais attrapé ?
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 :
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 correspondent. La même comparaison marche 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 identifient un build affecté, et les documents doivent être chiffrés à nouveau depuis leur texte clair avec 3.114.8 ou plus récent 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 que derrière 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