Technický článek

Proč některá PDF s AES-256 R6 odmítají správné heslo

Pokud PDF s AES-256 revizí 6 odmítá heslo, o kterém víte, že je správné, obvyklým viníkem je počítadlo kol v otuženém hashi R6 (ISO 32000-2, algoritmus 2.B). PDFium Component for Delphi měl přesně tuhle chybu před v2.57.2: TPdf.SaveAsEncrypted počítal kola od špatného základu a zhruba 30 % souborů, které vyprodukoval, odmítl FPDF_LoadDocument s FPDF_ERR_PASSWORD

Škaredé není ta aritmetika. Škaredé je, že vada byla neviditelná pro každý test, který soubor zašifroval a pak ho zkontroloval stejným kódem, a že jediný úspěšný průchod tam a zpátky nedokázal nic. Tenhle příspěvek projde tím, co algoritmus 2.B doopravdy žádá, kde se uklouznutí o jedno kolo schovává, proč selhává jen občas a jak postavit regresní test, který chytne takovou chybu dřív než zákazník

Co pravidlo ukončení hashe R6 doopravdy říká?

Algoritmus 2.B končí až po nejméně 64 kolech a pak jen tehdy, když je poslední bajt nejnovějšího AES výstupu E menší nebo roven číslu kola minus 32, přičemž číslem kola se myslí počet již provedených kol počítaný od jedničky. Po N-tém kole porovnáváte s N. Ne s N − 1, ne s indexem dalšího kola. Tahle jedna definice je celý povrch chyby

Zbytek algoritmu je mechanika. Začněte s K jako SHA-256 z hesla, soli a (u vlastního klíče) 48bajtové hodnoty /U. Každé kolo postaví K1 jako password ‖ K ‖ UK opakované 64krát, zašifruje ho AES-128-CBC bez odsazení s prvními 16 bajty K jako klíčem a dalšími 16 jako IV, vezme prvních 16 bajtů E jako big-endian 128bitové celé číslo mod 3 a podle toho zvolí SHA-256, SHA-384 nebo SHA-512 k výrobě dalšího K. Konečný hash je prvních 32 bajtů K. Definuje ho ISO 32000-2 §7.6.4.3; délka smyčky závislá na datech je záměrná, protože práci na jeden pokus o heslo dělá pro GPU crackera nepředvídatelnou

Takto vypadá správný tvar smyčky, napsaný jako ilustrace, ne jako implementace k okamžitému vložení. BuildK1, AesCbcNoPad128, FirstBlockMod3 a hashovací pomocníci zastupují libovolné primitivy, které už vaše codebase má

// Ilustrace: kostra smyčky algoritmu 2.B (ISO 32000-2, revize 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // dosud provedená kola
repeat
  Inc(RoundNumber);               // toto je kolo 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) opakované 64krát
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // prvních 16 bajtů 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);

Čtěte řádek ukončení s počítadlem v hlavě. Přičtení sedí na vrcholu těla, takže v okamžiku, kdy běží test until, drží RoundNumber přesně počet dokončených kol, což norma míní číslem kola

Kde se off-by-one schovává v portu do Delphi

Chyba PDFium Component bylo počítadlo začínající na −1 s přičtením na vrcholu smyčky, takže hodnota testovaná v klauzuli until byla vždy provedený počet kol minus jedna. Rozbijí se dvě věci najednou: minimum 64 kol se stane 65 a bajtový práh je o jeden stupínek přísnější, než říká norma. Obojí posune výstupní bod přesně o jedno kolo v případech, kde to na tom záleží

Táž uklouznutí má několik stejně věrohodných převleků. Začít na 0 a přičítat na dně těla, když test už byl napsaný proti hodnotě před přičtením. Použít index smyčky for počítaný od nuly a znovu ho použít v srovnání. Přeložit C předlohu, jejíž smyčková proměnná se kontroluje před ++. Každá z těchto variant se v code review čte přirozeně a každá produkuje hash, který vypadá naprosto náhodně, protože taky je: je to prostě jiný, stejně platně vypadající SHA výstup, který žádná jiná čtečka nepočítá. Opravené CalculateR6Hash v FPdfEncrypt.pas teď nese komentář, který přibíjí sémantiku od jedničky, právě proto, že špatná verze nevypadá špatně

Proč PDF s AES-256 R6 selhává jen občas?

Selhání je pravděpodobnostní, protože nadbytečné kolo změní výsledek jen tehdy, když ukončovací bajt přistane v jedokolovém okně mezi správným prahem a posunutým. Ve všech ostatních případech vystoupí obě verze ve stejném kole a vyprodukují též K, takže soubor se otevře v pořádku. V měření PDFium Component vyprodukovalo 18 z 60 šifrování (asi 30 %) hodnoty /U, /O, /UE a /OE, které se neshodovaly se standardní implementací; zbylých 70 % bylo bajt po bajtu správné

Čerstvé náhodné soli se losují při každém uložení, takže tentýž dokument a totéž heslo uspěje při jednom běhu a selže při dalším. Z podpory to vypadá jako kolísavé úložiště, poškozené nahrání nebo uživatel, který překlepl heslo. Nikdo nepomyslí na obvinění smyčkového počítadla, když sedm z deseti souborů otevře. Vidí-li váš produkt přerušovaná hlášení „špatné heslo“ u PDF, která zašifroval váš vlastní kód, a jen u revize 6, tohle je první místo, kam se podívat

Revize 5 je imunní, protože používá jediný průchod SHA-256 bez iterací. Cesta ISO/TS 32003 AESV4 ne: hodnota erR7 typu TPdfEncryptionRevision znovu používá algoritmus 2.B pro hashe svých hesel, takže táž oprava pokrývá GCM soubory popsané v šifrování AES-256-GCM a integritě PDF MAC s ISO/TS 32003

Past samozákonosti: proč projdou testy tam a zpátky

Průchod přes vaše vlastní šifrování a vaše vlastní ověření vždycky uspěje, protože obě strany běží stejnou špatnou smyčku a shodnou se na stejném špatném hashi. Vada existuje jen vzhledem k implementaci někoho jiného, takže soudcem musí být nezávislý: engine PDFium v pdfium.dll, Acrobat, qpdf nebo jakákoli čtečka, kterou nenapsali vy

Tohle je nepříjemná obecná lekce pro každého, kdo píše kód dokumentů sousedící s kryptografií. Unit testy, které spočítají hash a srovnají ho s výstupem testované funkce, dokazují determinismus, ne správnost. Known-answer vektory z normy nebo referenčního nástroje jsou lepší a průchod napříč implementacemi je nejbližší věc ke ground truth, jakou dostanete. PDFium Component je tady v šťastné pozici: šifrování dělá v Pascalu EncryptDocumentBytes, zatímco dešifrování při načtení jde přes vlastní C++ security handler PDFium, takže znovuotevření uloženého souboru přes TPdf je skutečná nezávislá kontrola, ne zrcadlo

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, hash hesla algoritmu 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default startuje na revizi 5 se všemi oprávněními povolenými a metadaty šifrovanými, takže Revision nastavujte explicitně, když chcete R6. TPdf.SaveAsEncryptedToStream bere tentýž záznam voleb, když výstup míří do TStream místo souboru. Pozor, hesla jsou v záznamu voleb AnsiString, což hraje roli, když předáváte ne-ASCII přihlašovací údaje; normalizujte a zakódujte je, dřív než dorazí do záznamu

Jak regression-testovat pravděpodobnostní šifrovací chybu?

Opakujte průchod napříč implementacemi tolikrát, aby 30% míra selhání nemohla proklouznout. Jediný běh minul chybu PDFium Component asi v 70 % případů. Dvacet běhů, každý s čerstvou solí, srazí míru promíknutí na asi 0,08 % (0,7 na dvacátou), proto se dodávaný regresní test opakuje 20krát místo jednou

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);       // čerstvé soli při každém uložení
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium ověřuje /U nezávisle
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Spárujte to s negativním případem, špatným heslem, které musí selhat, aby rozbitý loader nemohl pozitivní test nechat projít prázdno. A stavíte-li s Lazarusem, znovu sestavte balíček knihovny před spuštěním testovacího projektu po úpravě Lib\*.pas; testovací build linkuje předkompilované unity .ppu balíčku a běh proti zastaralému objektovému kódu vám dá uklidňující zelenou u chyby, kterou jste ve skutečnosti neopravili. Seedování je příbuzná past, stojí za prověrku v témže průchodu: FPC Random produkující konstantní šifrovací klíče je další případ, kdy výstup vypadá náhodně a potichu je špatný

FAQ

Postihuje chyba počítadla kol R6 soubory zašifrované revizí 5?

Ne. Revize 5 hashuje heslo jediným průchodem SHA-256 a nemá žádný počet iterací, takže počítadlo kol, které by se dalo pokazit, neexistuje. Postihnuty jsou jen revize 6 a revize ISO/TS 32003 AESV4, které obě používají algoritmus 2.B

Dá se soubor napsaný vadnou verzí ještě otevřít?

Asi 70 % z nich se otevře normálně, protože jejich hash náhodou seděl. Zbytek nese hodnoty /U a /O, které žádná standardní čtečka nereprodukuje, takže kontrola hesla selže všude; praktická oprava je znovu zašifrovat z plaintextového zdroje s v2.57.2 nebo novější

Proč testovat proti PDFium místo vlastního dešifrovacího kódu?

Váš vlastní dešifrátor sdílí týž algoritmus 2.B implementaci jako váš šifrátor, takže se špatný počet kol vyruší a každý průchod projde. Nezávislá implementace, jakou je security handler PDFium, je jediná kontrola, která vám může oponovat

Šifrování, validace PDF MAC a zbytek ukládací pipeline dodává PDFium Component for Delphi and C++Builder, se zdrojem a regresními testy pro Delphi i Lazarus