Teknisk artikel

Hvorfor AES-256 R6-filer afviser den rigtige adgangskode

Når en AES-256 revision 6-PDF afviser en adgangskode, man ved er korrekt, er den sædvanlige synderen round-tælleren i R6-hærdet hash (ISO 32000-2 Algorithm 2.B). PDFium Component til Delphi havde præcis denne bug før v2.57.2: TPdf.SaveAsEncrypted talte rounds fra den forkerte base, og omtrent 30% af filerne, den frembragte, blev nægtet af FPDF_LoadDocument med FPDF_ERR_PASSWORD

Det ubehagelige er ikke aritmetikken. Det ubehagelige er, at defekten var usynlig for enhver test, der krypterede en fil og derefter tjekkede den med samme kode, og at en enkelt vellykket round trip beviste intet. Dette opslag gennemgår, hvad Algorithm 2.B rent faktisk kræver, hvor et ét-round-forskydning gemmer sig, hvorfor det kun fejler nogle gange, og hvordan man bygger en regressionstest, der fanger en sådan bug, før en kunde gør

Hvad siger R6-hashens termineringsregel rent faktisk?

Algorithm 2.B stopper først efter mindst 64 rounds, og kun når den sidste byte af det seneste AES-output E er mindre end eller lig round-nummeret minus 32, hvor round-nummeret er den 1-baserede optælling af allerede udførte rounds. Efter den N'te round sammenligner man med N. Ikke N − 1, ikke næste rounds indeks. Den ene definition er hele bug-overfladen

Resten af algoritmen er mekanisk. Start med K som SHA-256 af adgangskode, salt og (for owner key) 48-byte /U-værdien. Hver round bygger K1 som adgangskode ‖ K ‖ UK gentaget 64 gange, krypterer den med AES-128-CBC uden padding med de første 16 bytes af K som nøgle og de næste 16 som IV, tager de første 16 bytes af E som et big-endian 128-bit heltal mod 3 og vælger SHA-256, SHA-384 eller SHA-512 derefter for at frembringe næste K. Den endelige hash er de første 32 bytes af K. ISO 32000-2 §7.6.4.3 definerer den; den dataafhængige loop-længde er bevidst, for den gør arbejdet pr. adgangskodegæt uforudsigeligt for en GPU-cracker

Her er loopens korrekte form, skrevet som en illustration snarere end en drop-in-implementering. BuildK1, AesCbcNoPad128, FirstBlockMod3 og hash-helpers erstatter de primitiver, ens kodebase allerede har

// Illustration: Algorithm 2.B loop-skelet (ISO 32000-2, revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // rounds udført indtil nu
repeat
  Inc(RoundNumber);               // det er round 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) gentaget 64 gange
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // første 16 bytes af 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 termineringslinjen med tælleren i mente. Inkrementet sidder øverst i kroppen, så når until-testen kører, holder RoundNumber præcis antallet af fuldførte rounds, hvilket er, hvad standarden mener med round-nummeret

Hvor off-by-one gemmer sig i en Delphi-port

PDFium Component-bug'en var en tæller, der startede på −1 med inkrementet øverst i loop'en, så værdien testet i until-klausulen altid var det udførte round-antal minus én. To ting går galt på én gang: 64-round-minimum bliver 65, og byte-tærsklen er et hak strammere, end standarden siger. Begge forskubber exit-punktet med præcis én round i de tilfælde, hvor det betyder noget

Samme glip har flere lige plausible forklædninger. Start på 0 og inkrementér i bunden af kroppen, efter testen allerede er skrevet mod pre-inkrement-værdien. Brug et for-loop-indeks, der er 0-baseret, og genbrug det i sammenligningen. Oversæt en C-reference, hvis loop-variabel tjekkes før ++. Hver af dem læser naturligt i code review, og hver frembringer en hash, der ligner noget perfekt tilfældigt, for det er den: det er blot et andet, lige så gyldigt-lignende SHA-output, som ingen anden reader beregner. Den fikserede CalculateR6Hash i FPdfEncrypt.pas bærer nu en kommentar, der fastlåser 1-baseret semantik, præcis fordi den forkerte version ikke ligner noget forkert

Hvorfor fejler en AES-256 R6-PDF kun nogle gange?

Fejlen er probabilistisk, for ekstra-round kun ændrer resultatet, når terminerings-byten lander i ét-round-vinduet mellem den korrekte tærskel og den forskubbet. I alle andre tilfælde exiter begge versioner på samme round og frembringer samme K, så filen åbner fint. I PDFium Component-målingen frembragte 18 af 60 krypteringer (omtrent 30%) /U-, /O-, /UE- og /OE-værdier, der tog fejl af en standardimplementering; de andre 70% var byte for byte korrekte

Friske tilfældige salts trækkes ved hver gem, så samme dokument og samme adgangskode lykkes ved én kørsel og fejler ved den næste. Fra en support-pult ligner det ustabil lagring, en korrupt upload eller en bruger, der taster adgangskoden forkert. Ingen tænker på at bebrejde en loop-tæller, når syv ud af ti filer åbner. Ser ens produkt intermitterende "forkert adgangskode"-rapporter på PDF'er, ens egen kode krypterede, og kun på revision 6, er dette det første sted at kigge

Revision 5 er immun, da den bruger ét enkelt SHA-256-gennemløb uden iteration. ISO/TS 32003 AESV4-vejen er ikke: TPdfEncryptionRevision-værdien erR7 genbruger Algorithm 2.B til sine adgangskode-hash, så samme fix dækker de GCM-filer, der er beskrevet i AES-256-GCM-kryptering og PDF MAC-integritet med ISO/TS 32003

Self-konsistens-fælden: hvorfor round trip-tests består

En round trip gennem ens egen kryptering og ens egen verifikation lykkes altid, for begge sider kører samme forkerte loop og er enige om samme forkerte hash. Defekten findes kun relativt til en andens implementering, så dommeren skal være en uafhængig: PDFium-motoren i pdfium.dll, Acrobat, qpdf eller enhver reader, man ikke skrev selv

Det er den ubehagelige generelle lektie for enhver, der skriver crypto-nær dokumentkode. Unit tests, der beregner en hash og sammenligner den med outputtet af funktionen under test, beviser determinisme, ikke korrekthed. Known-answer-vektorer fra standarden eller et referenceværktøj er bedre, og en cross-implementation round trip er det nærmeste, man kommer ground truth. PDFium Component er i en heldig position her: kryptering udføres i Pascal af EncryptDocumentBytes, mens dekryptering ved indlæsning går gennem PDFiums egen C++ security handler, så genåbning af den gemte fil med TPdf er et ægte uafhængigt tjek snarere end et spejl

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

TPdfEncryptOptions.Default starter på revision 5 med alle tilladelser tilladt og metadata krypteret, så sæt Revision eksplicit, når man vil have R6. TPdf.SaveAsEncryptedToStream tager samme options-record, når output går til en TStream i stedet for en fil. Bemærk, at adgangskoderne er AnsiString i options-recorden, hvilket betyder noget, hvis man giver ikke-ASCII-legitimation videre; normaliser og encod dem, før de når recorden

Hvordan regressionstester man en probabilistisk krypteringsbug?

Gentag cross-implementation round trip'en nok gange til, at en fejlrate på 30% ikke kan snyde igennem. En enkelt kørsel misser PDFium Component-bug'en omtrent 70% af tiden. Tyve kørsler, hver med friske salts, driver miss-raten til omkring 0,08% (0,7 i 20. potens), hvilket er derfor, den leverede regressionstest looper 20 gange i stedet for én

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);       // friske salts ved hver gem
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium verificerer /U uafhængigt
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Par det med det negative tilfælde, en forkert adgangskode, der skal fejle, så en ødelagt loader ikke kan få den positive test til at bestå vakuøst. Og bygger man med Lazarus, så rebuild bibliotekspakken, før man kører testprojektet efter at have rørt Lib\*.pas; test-buildet linker pakkens prækompilerede .ppu-units, og at køre mod forældet objektkode giver en beroligende grøn på en bug, man faktisk ikke har fikset. Seeding er en relateret fælde, værd at gennemgå i samme gennemløb: FPC Random, der frembringer konstante krypteringsnøgler er et andet tilfælde, hvor outputtet ligner noget tilfældigt og stille er forkert

FAQ

Påvirker R6 round-tæller-bug'en filer krypteret med revision 5?

Nej. Revision 5 hasher adgangskoden med ét enkelt SHA-256-gennemløb og har intet iterationsantal, så der er ingen round-tæller at tage fejl af. Kun revision 6 og ISO/TS 32003 AESV4-revisionen, som begge bruger Algorithm 2.B, er påvirket

Kan en fil skrevet af den buggy-version stadig åbnes?

Omkring 70% af dem åbner normalt, fordi deres hash tilfældigt matchede. Resten bærer /U- og /O-værdier, som ingen standardreader gengiver, så adgangskodetjekket fejler overalt; den praktiske fix er at re-kryptere fra plaintext-kilden med v2.57.2 eller senere

Hvorfor teste mod PDFium i stedet for min egen dekrypteringskode?

Ens egen dekryptor deler samme Algorithm 2.B-implementering som ens kryptor, så et forkert round-antal udligner sig, og hver round trip består. En uafhængig implementering som PDFiums security handler er det eneste tjek, der kan tage fejl af en

Kryptering, PDF MAC-validering og resten af gem-pipelinen følger med i PDFium Component til Delphi og C++Builder med source og regressionstests til både Delphi og Lazarus