Article technique

Pourquoi des PDF AES-256 R6 refusent le bon mot de passe

Quand un PDF AES-256 révision 6 refuse un mot de passe que vous savez correct, le coupable habituel est le compteur de rounds du hachage durci R6 (ISO 32000-2 Algorithme 2.B). Le PDFium Component pour Delphi avait exactement ce bug avant la v2.57.2 : TPdf.SaveAsEncrypted comptait les rounds depuis la mauvaise base, et environ 30 pour cent des fichiers qu'il produisait étaient refusés par FPDF_LoadDocument avec FPDF_ERR_PASSWORD

La partie pénible n'est pas l'arithmétique. La partie pénible, c'est que le défaut était invisible pour tout test qui chiffrait un fichier puis le vérifiait avec le même code, et qu'un unique aller-retour réussi ne prouvait rien. Ce billet parcourt ce que demande réellement l'Algorithme 2.B, où se cache un écart d'un round, pourquoi il n'échoue qu'une partie du temps, et comment construire un test de régression qui attrape un bug pareil avant un client

Que dit réellement la règle de terminaison du hachage R6 ?

L'Algorithme 2.B ne s'arrête qu'après au moins 64 rounds, et ensuite seulement quand le dernier octet de la sortie AES la plus récente E est inférieur ou égal au numéro de round moins 32, où le numéro de round est le compte à base 1 des rounds déjà exécutés. Après le N-ième round, on compare à N. Pas à N − 1, pas à l'indice du round suivant. Cette seule définition est toute la surface du bug

Le reste de l'algorithme est mécanique. Partez de K comme le SHA-256 du mot de passe, du sel et (pour la clé propriétaire) de la valeur /U de 48 octets. Chaque round construit K1 comme password ‖ K ‖ UK répété 64 fois, le chiffre en AES-128-CBC sans padding avec les 16 premiers octets de K comme clé et les 16 suivants comme IV, prend les 16 premiers octets de E comme entier 128 bits gros-boutien mod 3, et choisit SHA-256, SHA-384 ou SHA-512 en conséquence pour produire le prochain K. Le hachage final est les 32 premiers octets de K. ISO 32000-2 §7.6.4.3 le définit ; la longueur de boucle dépendante des données est délibérée, parce qu'elle rend le travail par devinette de mot de passe imprévisible pour un cracker GPU

Voici la forme correcte de la boucle, écrite comme illustration plutôt que comme implémentation à recopier telle quelle. BuildK1, AesCbcNoPad128, FirstBlockMod3 et les utilitaires de hachage remplacent les primitives que votre code possède déjà

// Illustration : squelette de boucle de l'Algorithme 2.B (ISO 32000-2, révision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // rounds exécutés jusque-là
repeat
  Inc(RoundNumber);               // voici le round 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) répété 64 fois
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // 16 premiers octets de E, gros-boutien, mod 3
    0: K := Sha256(E);
    1: K := Sha384(E);
    2: K := Sha512(E);
  end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);

Lisez la ligne de terminaison avec le compteur en tête. L'incrément siège en haut du corps, si bien qu'au moment où le test until tourne, RoundNumber porte exactement le nombre de rounds accomplis, ce que la norme entend par numéro de round

Où le off-by-one se cache dans un portage Delphi

Le bug du PDFium Component était un compteur qui démarrait à −1 avec l'incrément en haut de la boucle, si bien que la valeur testée dans la clause until était toujours le compte de rounds exécutés moins un. Deux choses tournent mal d'un coup : le minimum de 64 rounds devient 65, et le seuil d'octet est un cran plus serré que ce que dit la norme. Les deux décalent le point de sortie d'exactement un round dans les cas où cela compte

Le même écart a plusieurs déguisements également plausibles. Démarrer à 0 et incrémenter en bas du corps, après que le test a déjà été écrit contre la valeur avant incrément. Utiliser un indice de boucle for à base 0 et le réemployer dans la comparaison. Traduire une référence C dont la variable de boucle est vérifiée avant le ++. Chacun de ceux-là se lit naturellement en revue de code, et chacun produit un hachage qui a l'air parfaitement aléatoire, parce qu'il l'est : c'est juste une autre sortie SHA, différente et d'apparence également valide, qu'aucun autre lecteur ne calcule. Le CalculateR6Hash corrigé dans FPdfEncrypt.pas porte désormais un commentaire qui épingle la sémantique à base 1, précisément parce que la version fausse ne ressemble pas à une version fausse

Pourquoi un PDF AES-256 R6 ne s'échoue-t-il que de temps en temps ?

La panne est probabiliste parce que le round supplémentaire ne change le résultat que quand l'octet de terminaison atterrit dans la fenêtre d'un round entre le bon seuil et le seuil décalé. Dans tous les autres cas, les deux versions sortent au même round et produisent le même K, si bien que le fichier s'ouvre bien. Dans la mesure du PDFium Component, 18 des 60 chiffrements (environ 30 pour cent) ont produit des valeurs /U, /O, /UE et /OE en désaccord avec une implémentation standard ; les 70 pour cent restants étaient corrects octet pour octet

Des sels aléatoires neufs sont tirés à chaque enregistrement, si bien que le même document et le même mot de passe réussissent à un essai et échouent au suivant. Depuis un support technique, cela ressemble à du stockage capricieux, un envoi corrompu, ou un utilisateur qui tape mal son mot de passe. Personne ne pense à accuser un compteur de boucle quand sept fichiers sur dix s'ouvrent. Si votre produit voit des rapports intermittents de "mauvais mot de passe" sur des PDF que votre propre code a chiffrés, et seulement en révision 6, c'est le premier endroit où chercher

La révision 5 est immunisée, puisqu'elle utilise une seule passe SHA-256 sans itération. Le chemin AESV4 d'ISO/TS 32003 ne l'est pas : la valeur erR7 de TPdfEncryptionRevision réutilise l'Algorithme 2.B pour ses hachages de mot de passe, donc le même correctif couvre les fichiers GCM décrits dans le chiffrement AES-256-GCM et l'intégrité PDF MAC avec ISO/TS 32003

Le piège de l'auto-cohérence : pourquoi les tests aller-retour passent

Un aller-retour à travers votre propre chiffrement et votre propre vérification réussit toujours, parce que les deux côtés tournent la même boucle fausse et concordent sur le même hachage faux. Le défaut n'existe que relativement à l'implémentation de quelqu'un d'autre, donc le juge doit être indépendant : le moteur PDFium dans pdfium.dll, Acrobat, qpdf, ou tout lecteur que vous n'avez pas écrit

C'est la leçon générale inconfortable pour quiconque écrit du code documentaire voisin de la crypto. Des tests unitaires qui calculent un hachage et le comparent à la sortie de la fonction testée prouvent le déterminisme, pas la justesse. Des vecteurs à réponse connue venant de la norme ou d'un outil de référence valent mieux, et un aller-retour entre implémentations croisées est ce qui ressemble le plus à une vérité terrain. Le PDFium Component est dans une position chanceuse ici : le chiffrement est fait en Pascal par EncryptDocumentBytes, tandis que le déchiffrement au chargement passe par le gestionnaire de sécurité C++ de PDFium lui-même, donc rouvrir le fichier enregistré avec TPdf est une vérification réellement indépendante plutôt qu'un miroir

uses
  SysUtils, PDFium, FPdfEncrypt;

procedure SaveR6(const Source, Target: string);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Opts := TPdfEncryptOptions.Default;
    Opts.UserPassword := 'userpw';
    Opts.OwnerPassword := 'ownerpw';
    Opts.Revision := erR6;          // AESV3, hachage de mot de passe Algorithme 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default démarre en révision 5 avec toutes les permissions permises et les métadonnées chiffrées, donc posez Revision explicitement quand vous voulez du R6. TPdf.SaveAsEncryptedToStream prend le même enregistrement d'options quand la sortie va dans un TStream plutôt que dans un fichier. Notez que les mots de passe sont des AnsiString dans l'enregistrement d'options, ce qui compte si vous passez des identifiants non ASCII ; normalisez-les et encodez-les avant qu'ils n'atteignent l'enregistrement

Comment tester en régression un bug de chiffrement probabiliste ?

Répétez l'aller-retour inter-implémentations assez de fois pour qu'un taux d'échec de 30 pour cent ne puisse pas passer. Un essai unique manque le bug du PDFium Component environ 70 pour cent du temps. Vingt essais, chacun avec des sels neufs, ramènent le taux de manqué à environ 0.08 pour cent (0.7 à la puissance 20), voilà pourquoi le test de régression livré boucle 20 fois au lieu d'une

procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
  Pdf: TPdf;
  I: Integer;
  Target: string;
begin
  Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
  for I := 1 to 20 do
  begin
    SaveR6(SamplePdf, Target);       // sels neufs à chaque enregistrement
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium vérifie /U de façon indépendante
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Accouplez-le au cas négatif, un mauvais mot de passe qui doit échouer, pour qu'un chargeur cassé ne puisse pas faire passer le test positif de façon creuse. Et si vous construisez avec Lazarus, reconstruisez le paquet de bibliothèque avant de lancer le projet de test après avoir touché Lib\*.pas ; la build de test lie les unités .ppu précompilées du paquet, et tourner contre du code objet périmé vous donne un vert rassurant sur un bug que vous n'avez pas réellement corrigé. L'amorçage du générateur aléatoire est un piège voisin à relire dans la même passe : FPC Random produisant des clés de chiffrement constantes est un autre cas où la sortie a l'air aléatoire et est tranquillement fausse

FAQ

Le bug du compteur de rounds R6 touche-t-il les fichiers chiffrés en révision 5 ?

Non. La révision 5 hache le mot de passe en une seule passe SHA-256 et n'a pas de compte d'itérations, donc il n'y a pas de compteur de rounds à se tromper. Seules la révision 6 et la révision AESV4 d'ISO/TS 32003, qui utilisent toutes deux l'Algorithme 2.B, sont touchées

Un fichier écrit par la version buguée peut-il encore s'ouvrir ?

Environ 70 pour cent s'ouvrent normalement parce que leur hachage concordait par hasard. Les autres portent des valeurs /U et /O qu'aucun lecteur standard ne reproduit, si bien que la vérification du mot de passe échoue partout ; le correctif pratique est de re-chiffrer depuis la source en clair avec la v2.57.2 ou plus

Pourquoi tester contre PDFium plutôt que contre mon propre code de déchiffrement ?

Votre propre déchiffreur partage la même implémentation de l'Algorithme 2.B que votre chiffreur, donc un compte de rounds faux s'annule et chaque aller-retour passe. Une implémentation indépendante comme le gestionnaire de sécurité de PDFium est la seule vérification capable de vous contredire

Le chiffrement, la validation PDF MAC et le reste du pipeline d'enregistrement sont livrés dans le PDFium Component pour Delphi et C++Builder, avec sources et tests de régression pour Delphi comme Lazarus