När en AES-256 revision 6-PDF avvisar ett lösenord du vet är korrekt är den vanliga boven rundräknaren i R6-härdat hash (ISO 32000-2 Algorithm 2.B). PDFium Component för Delphi hade exakt den här buggen före v2.57.2: TPdf.SaveAsEncrypted räknade rundor från fel bas, och ungefär 30 % av filerna den producerade avvisades av FPDF_LoadDocument med FPDF_ERR_PASSWORD
Det obehagliga är inte aritmetiken. Det obehagliga är att defekten var osynlig för varje test som krypterade en fil och sedan kontrollerade den med samma kod, och att en enda lyckad rundtur bevisade ingenting. Det här inlägget går igenom vad Algorithm 2.B faktiskt begär, var ett enrundors-skyndar gömmer sig, varför det bara fallerar ibland, och hur man bygger ett regressionstest som fångar en sådan bugg innan en kund gör det
Vad säger R6-hashens avslutningsregel egentligen?
Algorithm 2.B stannar först efter minst 64 rundor, och sedan bara när sista byten i den senaste AES-utdatan E är mindre än eller lika med rundnumret minus 32, där rundnumret är det 1-baserade antalet redan exekverade rundor. Efter den N:te rundan jämför du mot N. Inte N − 1, inte nästa ronds index. Den ena definitionen är hela buggytan
Resten av algoritmen är mekanisk. Börja med K som SHA-256 av lösenord, salt och (för ägarnyckeln) 48-bytes-värdet /U. Varje runda bygger K1 som lösenord ‖ K ‖ UK upprepat 64 gånger, krypterar det med AES-128-CBC utan utfyllnad med de första 16 bytes av K som nyckel och nästa 16 som IV, tar de första 16 bytes av E som ett big-endian 128-bitars heltal mod 3, och väljer SHA-256, SHA-384 eller SHA-512 därefter för att producera nästa K. Slutlig hash är de första 32 bytes av K. ISO 32000-2 §7.6.4.3 definierar den; den databeroende looplängden är medveten, eftersom den gör arbetet per lösenordsgissning oförutsägbart för en GPU-cracker
Här är rätt form på loopen, skriven som en illustration snarare än en drop-in-implementation. BuildK1, AesCbcNoPad128, FirstBlockMod3 och hash-hjälparna står in för de primitiver din kodbas redan har
// Illustration: Algorithm 2.B-loopskelett (ISO 32000-2, revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // rundor exekverade hittills
repeat
Inc(RoundNumber); // detta är runda 1, 2, 3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK) upprepat 64 gånger
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // första 16 bytes av E, big-endian, 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);
Läs avslutningsraden med räknaren i åtanke. Inkrementet sitter överst i kroppen, så i det ögonblick until-testet körs håller RoundNumber exakt antalet fullbordade rundor, vilket är vad standarden menar med rundnumret
Där off-by-one gömmer sig i en Delphi-port
PDFium Component-buggen var en räknare som startade på −1 med inkrementet överst i loopen, så värdet som testades i until-klausulen var alltid det exekverade rundaantalet minus ett. Två saker går fel samtidigt: minimumet på 64 rundor blir 65, och byteströskeln ligger ett snäpp hårdare än standarden säger. Båda skiftar utgångspunkten med exakt en runda i de fall där det spelar roll
Samma skyndar har flera lika troliga förklädnader. Börja på 0 och öka i botten av kroppen, efter att testet redan skrivits mot värdet före inkrementet. Använd ett for-loopindex som är 0-baserat och återanvänd det i jämförelsen. Översätt en C-referens vars loopvariabel kontrolleras före ++. Var och en av de här läser sig naturligt i kodgranskning, och var och en producerar en hash som ser helt slumpad ut, för den är det: det är bara en annan, lika giltigt seende SHA-utdata som ingen annan läsare beräknar. Den fixade CalculateR6Hash i FPdfEncrypt.pas bär nu en kommentar som fäster den 1-baserade semantiken, just eftersom den felaktiga versionen inte ser fel ut
Varför fallerar en AES-256 R6-PDF bara ibland?
Misslyckandet är probabilistiskt eftersom den extra rundan bara ändrar resultatet när avslutningsbyten landar i det enrundors-fönster mellan rätt tröskel och den förskjutna. I alla andra fall avslutar båda versionerna på samma runda och producerar samma K, så filen öppnas fint. I PDFium Component-mätningen producerade 18 av 60 krypteringar (ungefär 30 %) /U-, /O-, /UE- och /OE-värden som stred mot en standardimplementation; de övriga 70 % var byte-för-byte korrekta
Färska slumpmässiga salt dras för varje sparning, så samma dokument och samma lösenord lyckas i en körning och fallerar i nästa. Från en supportavdelning ser det ut som flackande lagring, en korrupt uppladdning eller en användare som stavar fel på ett lösenord. Ingen tänker på att skylla på en loopräknare när sju av tio filer öppnas. Ser din produkt intermittenta "fel lösenord"-rapporter på PDF:er din egen kod krypterat, och bara på revision 6, är det här första platsen att titta
Revision 5 är immun, eftersom den använder ett enda SHA-256-pass utan iteration. ISO/TS 32003 AESV4-vägen är det inte: TPdfEncryptionRevision-värdet erR7 återanvänder Algorithm 2.B för sina lösenordshashar, så samma fix täcker GCM-filerna som beskrivs i AES-256-GCM-kryptering och PDF MAC-integritet med ISO/TS 32003
Självkonsistensfällan: varför rundturstest går igenom
En rundtur genom din egen kryptering och din egen verifiering lyckas alltid, eftersom båda sidor kör samma felaktiga loop och kommer överens om samma felaktiga hash. Defekten finns bara relativt någons annans implementation, så domaren måste vara en oberoende: PDFium-motorn i pdfium.dll, Acrobat, qpdf, eller vilken läsare du inte skrev
Det är den obekväma allmänna läxan för alla som skriver krypto-nära dokumentkod. Unittest som beräknar en hash och jämför den med utdatan från funktionen under test bevisar determinism, inte korrekthet. Kända-svar-vektorer från standarden eller ett referensverktyg är bättre, och en kross-implementation-rundtur är det närmaste sanningsgrunden du får. PDFium Component är i en lycklig position här: kryptering sker i Pascal av EncryptDocumentBytes, medan dekryptering vid laddning går genom PDFium:s egen C++-säkerhetshanterare, så att återöppna den sparade filen med TPdf är en verklig oberoende kontroll snarare än en spegel
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, Algorithm 2.B-lösenordshash
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default börjar på revision 5 med alla behörigheter tillåtna och metadata krypterad, så sätt Revision explicit när du vill ha R6. TPdf.SaveAsEncryptedToStream tar samma options-post när utdata går till en TStream i stället för en fil. Notera att lösenorden är AnsiString i options-posten, vilket spelar roll om du skickar icke-ASCII-referenser; normalisera och koda dem innan de når posten
Hur regressionstestar du en probabilistisk krypteringsbugg?
Upprepa kross-implementation-rundturen tillräckligt många gånger för att en fallfrekvens på 30 % inte ska kunna smyga igenom. En enda körning missar PDFium Component-buggen ungefär 70 % av tiden. Tjugo körningar, var och en med färska salt, driver missfrekvensen till ungefär 0,08 % (0,7 upphöjt till tjugo), vilket är varför den utlämnade regressionstesten loopar 20 gånger i stället för en
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); // färska salt vid varje sparning
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium verifierar /U oberoende
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
Para ihop den med det negativa fallet, ett fel lösenord som måste misslyckas, så att en trasig laddare inte kan få det positiva testet att gå igenom vakant. Och bygger du med Lazarus, bygg om bibliotekspaketet innan du kör testprojektet efter att ha rört Lib\*.pas; testbygget länkar paketets förkompilerade .ppu-units, och att köra mot inaktuell objektkod ger dig en lugnande grön på en bugg du egentligen inte fixat. Seeding är en relaterad fälla värd att granska i samma pass: FPC Random som producerar konstanta krypteringsnycklar är ett annat fall där utdatan ser slumpad ut och tyst är fel
FAQ
Påverkar R6-rundräknarbuggen filer krypterade med revision 5?
Nej. Revision 5 hash:ar lösenordet med ett enda SHA-256-pass och har inget iterationsantal, så det finns ingen rundräknare att få fel. Endast revision 6 och ISO/TS 32003 AESV4-revisionen, som båda använder Algorithm 2.B, påverkas
Kan en fil skriven av den buggiga versionen fortfarande öppnas?
Ungefär 70 % av dem öppnas normalt eftersom deras hash råkade matcha. Resten bär /U- och /O-värden ingen standardläsare återskapar, så lösenordskontrollen misslyckas överallt; den praktiska fixen är att kryptera om från klartextkällan med v2.57.2 eller senare
Varför testa mot PDFium i stället för min egen dekrypteringskod?
Din egen dekryptor delar samma Algorithm 2.B-implementation som din krypterare, så ett felaktigt rundaantal klingar ut och varje rundtur går igenom. En oberoende implementation som PDFium:s säkerhetshanterare är den enda kontroll som kan säga emot dig
Kryptering, PDF MAC-validering och resten av sparningspipelinen medföljer PDFium Component for Delphi and C++Builder, med källkod och regressionstester för både Delphi och Lazarus