Articol tehnic

Chei de criptare repetate pe FPC: reparare RNG PDFiumPas

Înainte de versiunea 3.114.8, PDFiumPas genera materialul de chei de criptare PDF pe țintele non-Windows cu funcția Random din biblioteca runtime, și pentru că nimic nu apela Randomize, fiecare proces producea aceeași secvență de byte. Build-urile Free Pascal pe Linux și macOS scriau prin urmare chei de criptare de fișier identice, salt-uri, IV-uri CBC și prefixe de nonce AES-GCM, de la o rulare la alta. Versiunea 3.114.8 citește în schimb /dev/urandom și ridică o excepție când nu poate

Defectul în sine este o buclă de patru linii. Lecția mai utilă este de ce o suită de teste care criptează și decriptează sute de documente, cu AESV3 și AESV4, cu și fără un PDF MAC, a rămas verde tot timpul. Aleatorietatea constantă per proces este invizibilă fiecărui test care rulează în interiorul unui singur proces, iar tocmai așa sunt scrise de obicei testele de criptare

Unde are nevoie PDFiumPas de byte aleatorii?

Fiecare byte aleatoriu din stiva de criptare PDFiumPas vine dintr-o singură procedură, AesGenerateRandomBytes în unit-ul FPdfAes, deci o sursă proastă contaminează tot. Handler-ul de securitate standard din ISO 32000-2 §7.6.4 și extensia AESV4 din ISO/TS 32003 consumă byte-urile aceia în locurile acestea:

  • Cheia de criptare de fișier de 32 de byte, generată proaspăt de DeriveEncryptionKeys pentru fiecare document și apoi înfășurată în /UE și /OE sub chei derivate din parolă
  • Două salt-uri de 16 byte, unul stocat în ultimii 16 byte ai lui /U și unul în ultimii 16 byte ai lui /O, fiecare împărțit într-un salt de validare de 8 byte și un salt de cheie de 8 byte
  • Byte-ii 12 până la 15 din textul clar din spatele lui /Perms, pe care ISO 32000-2 îi umple cu date aleatorii înainte ca blocul să fie criptat sub cheia de fișier
  • Un IV CBC de 16 byte prefixat fiecărui șir și flux criptat dintr-un document AESV3
  • Un prefix de nonce de 8 byte pentru documentele AESV4, urmat de un contor per obiect de 4 byte care pornește de la zero
  • Salt-ul /KDFSalt de 32 de byte și cheia MAC când EnableIntegrityProtection este setat
Fiecare byte aleatoriu din stiva de criptare PDFiumPas curge din AesGenerateRandomBytes din FPdfAes spre șase consumatori: cheia de criptare de fișier de 32 de byte înfășurată în /UE și /OE, salt-urile /U și /O, byte-ii de umplutură /Perms, IV-ul CBC AESV3, prefixul de nonce GCM AESV4, și salt-ul KDF și cheia MAC
Un generator partajat înseamnă că o sursă proastă contaminează materialul de chei peste tot dintr-o dată, motiv pentru care reparația a aterizat într-o singură procedură, nu la fiecare punct de apel

De ce producea fiecare proces aceeași cheie?

AesGenerateRandomBytes folosea generatorul sistemului de operare doar pe Windows; oriunde altundeva umplea buffer-ul din generatorul pseudo-aleatoriu RTL, iar acel generator pornește din RandSeed = 0 dacă programul nu apelează Randomize. Comentariul de deasupra buclei spunea că generatorul era inițializat din GetTickCount64. Nicio linie de cod nu a făcut vreodată asta, ceea ce a făcut comentariul singurul loc unde seed-ul exista:

// Ramura non-Windows a lui AesGenerateRandomBytes înainte de 3.114.8
// (comentariul de deasupra lui promitea un seed GetTickCount64 care nu a fost aplicat niciodată)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Secvența repornește cu fiecare proces și avansează în interiorul lui, deci primul document pe care îl criptează orice proces își împarte cheia de fișier cu primul document al fiecărui alt proces care rulează același build, al doilea cu al doilea, și tot așa. Cheia de fișier în R5, R6 și R7 nu depinde deloc de parolă, pentru că parola doar o înfășoară, ceea ce înseamnă că oricine poate reproduce secvența deține cheia fără să cunoască o parolă. AESV4 adaugă un al doilea eșec: aceeași cheie cu același prefix de 8 byte și un contor care repornește de la zero repetă nonce-uri GCM, ceea ce NIST SP 800-38D §8 interzice categoric. Un nonce GCM repetat sub aceeași cheie dezvăluie XOR-ul celor două texte clare și expune subcheia de autentificare, deci tag-urile de care se leagă criptarea AESV4-GCM și token-ul PDF MAC încetează să mai însemne ceva. Confidențialitatea și integritatea pleacă împreună

Generator RTL fără seed în PDFiumPas pe build-uri FPC non-Windows: cu RandSeed 0 fiecare proces emite aceeași secvență, deci documentul unu din procesul A poartă aceeași cheie de fișier ca documentul unu din procesul B, iar AESV4 repetă nonce-uri GCM pentru că aceeași cheie întâlnește același prefix cu contorul repornind de la zero
Pentru că cheia de fișier nu depinde niciodată de parolă, oricine poate reproduce secvența deține cheia din plin, iar nonce-urile GCM repetate distrug confidențialitatea și integritatea împreună

Amprenta este mai îngustă decât ar putea sugera paragraful acela. Build-urile Windows nu au fost niciodată afectate, pentru că ramura Windows a apelat întotdeauna CryptGenRandom prin advapi32 cu CRYPT_VERIFYCONTEXT și ridica când aceasta eșua. Ce a fost expus a fost ieșirea din build-urile non-Windows mai vechi de 3.114.8, ceea ce în practică înseamnă aplicații Lazarus și Free Pascal pe Linux și macOS, încă o intrare pentru lista de capcane Delphi versus FPC în build-urile PDFium

De ce nu a fost niciodată Randomize reparația corectă?

Apelarea lui Randomize ar fi ascuns simptomul fără să repare sursa, pentru că RandSeed este o valoare pe 32 de biți, iar Randomize o derivă din ceas. Asta plafonează numărul de fluxuri de chei posibile la 2^32, iar cunoașterea aproximativă a momentului când a fost scris un fișier taie căutarea mult sub atât, ceea ce nu înseamnă nimic lângă o cheie AES de 256 de biți. Materialul de chei trebuie să vină din bazinul de entropie al nucleului, deci AesGenerateRandomBytes în 3.114.8 citește /dev/urandom, buclează peste citiri scurte și ridică dacă bazinul nu poate livra fiecare byte cerut:

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;               // eșec sau sfârșit neașteptat al fluxului
    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');

Refuzul este deliberat și se potrivește cu ce a făcut întotdeauna ramura Windows când CryptGenRandom nu era disponibil. O salvare criptată eșuată este un incident pe care îl observați în aceeași zi; o salvare reușită cu chei previzibile este una despre care aflați de la altcineva. Două consecințe practice decurg. Un container minimal sau un chroot fără un /dev populat acum eșuează la criptare în loc să se degradeze în tăcere, deci montați-l. Și pentru că excepția se propagă afară din TPdf.SaveAsEncrypted după ce fișierul țintă a fost deschis cu fmCreate, un fișier de ieșire gol rămâne în urmă pentru ca handler-ul dvs. de erori să îl șteargă

De ce nu au prins-o niciodată testele dus-întors?

Un test dus-întors nu poate vedea aleatorietatea constantă, pentru că decriptarea recuperează orice cheie de fișier a ales criptarea. Testul criptează un document, îl redeschide cu parola, dezvăluie cheia din /UE și decriptează fiecare obiect; o cheie previzibilă se dezvăluie și decriptează la fel de bine ca una aleatorie, iar tag-urile GCM se verifică pentru că au fost calculate cu aceeași cheie. Chiar și un test care criptează de două ori și afirmă că cele două ieșiri diferă trece, pentru că al doilea apel în același proces trage byte-urile următoare ale secvenței. Proprietatea care contează, o cheie diferită în fiecare proces, este observabilă doar comparând ieșirea între procese. Ori de câte ori același cod și produce și consumă o valoare, testele sunt orbe față de clase întregi de defecte, iar aleatorietatea este cel mai pur exemplu

Cum testați aleatorietatea cheilor între procese?

Rulați o sondă mică de două ori ca procese separate pe platforma țintă și comparați ieșirea. Sonda de mai jos apelează DeriveEncryptionKeys și tipărește salt-ul stocat în byte-ii 32 până la 47 ai intrării /U. Valoarea aceea este scrisă vădit în fiecare fișier criptat, deci tipărirea ei în jurnalele CI nu dezvăluie nimic, însă vine de la același generator ca cheia de fișier:

Test de aleatorietate între procese pentru PDFiumPas: programul SaltProbe apelează DeriveEncryptionKeys și tipărește hex-ul byte-ilor 32 până la 47 ai lui /U, job-ul îl rulează de două ori ca procese separate și eșuează când liniile se potrivesc, iar PDF-urile livrate sunt comparate după ultimii 16 byte ai șirurilor lor /U
Aleatorietatea constantă este invizibilă în interiorul unui proces pentru că decriptarea recuperează orice cheie a ales criptarea, deci proprietatea care contează este observabilă doar comparând ieșirea între procese
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 de byte + salt de 16 byte
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // trebuie să difere la fiecare rulare
end.

Cuplați sonda în build pentru fiecare țintă non-Windows: rulați-o de două ori, eșuați job-ul dacă cele două linii se potrivesc. Aceeași comparație funcționează pe fișiere deja în circulație. Luați două PDF-uri criptate scrise de rulări diferite ale aceleiași aplicații, citiți șirurile /U din dicționarele lor Encrypt și comparați ultimii 16 byte; salt-urile identice identifică un build afectat, iar documentele ar trebui criptate din nou din textul lor clar cu 3.114.8 sau mai nou, astfel încât fiecare să primească o cheie de fișier proaspătă. Obiceiul general este să exersați căile de cod non-Windows pe platforma însăși în loc să aveți încredere în rularea Windows, același raționament din spatele backend-ului de timestamping libcurl pentru build-urile non-Windows

PDFiumPas este o componentă PDF pentru Delphi și Lazarus construită pe motorul PDFium, cu AES-256, AES-GCM și token-ul PDF MAC implementate nativ în Pascal și materialul de chei tras din generatorul sistemului de operare pe fiecare platformă. Detalii și descărcări pe pagina componentei PDFium pentru Delphi