Î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
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ă
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:
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