Teknisk artikel

Gentagne PDF-krypteringsnøgler på FPC: en PDFiumPas RNG-fix

Før version 3.114.8 genererede PDFiumPas PDF-krypteringsnøglemateriale på ikke-Windows-mål med runtime-bibliotekets Random-funktion, og fordi intet kaldte Randomize, producerede hver proces samme bytesekvens. Free Pascal-builds på Linux og macOS skrev derfor identiske filkrypteringsnøgler, salts, CBC-IV'er og AES-GCM nonce-præfikser, kørsel efter kørsel. Version 3.114.8 læser i stedet /dev/urandom og rejser en exception, når den ikke kan

Defekten selv er en fire-linjers løkke. Den mere nyttige læredom er, hvorfor en test-suite, der krypterer og dekrypterer hundredvis af dokumenter, med AESV3 og AESV4, med og uden en PDF MAC, forblev grøn hele vejen. Tilfældighed, der er konstant pr. proces, er usynlig for enhver test, der kører inde i én proces, og det er præcis, hvordan krypteringstester plejer at være skrevet

Hvor har PDFiumPas brug for tilfældige bytes?

Hver tilfældig byte i PDFiumPas krypteringsstak kommer fra én enkelt procedure, AesGenerateRandomBytes i uniten FPdfAes, så én dårlig kilde kontaminerer det hele. Standard security handler i ISO 32000-2 §7.6.4 og AESV4-udvidelsen i ISO/TS 32003 bruger de bytes disse steder:

  • Den 32-byte store filkrypteringsnøgle, genereret frisk af DeriveEncryptionKeys til hvert dokument og derefter wrappet ind i /UE og /OE under nøgler afledt af adgangskoden
  • To 16-byte salts, én gemt i de sidste 16 bytes af /U og én i de sidste 16 bytes af /O, hver delt i en 8-byte validation salt og en 8-byte key salt
  • Bytes 12 til 15 af klarteksten bag /Perms, som ISO 32000-2 fylder med tilfældige data, før blokken krypteres under filnøglen
  • En 16-byte CBC-IV foranstillet hver krypteret streng og stream i et AESV3-dokument
  • Et 8-byte nonce-præfiks til AESV4-dokumenter, efterfulgt af en 4-byte tæller pr. objekt, der starter på nul
  • Den 32-byte /KDFSalt og MAC-nøglen, når EnableIntegrityProtection er sat
Hver tilfældig byte i PDFiumPas krypteringsstak flyder fra AesGenerateRandomBytes i FPdfAes til seks forbrugere: den 32-byte filkrypteringsnøgle wrappet ind i /UE og /OE, /U- og /O-saltene, /Perms-padding-bytes, AESV3 CBC-IV'en, AESV4 GCM nonce-præfikset samt KDF-saltet og MAC-nøglen
Én delt generator betyder, at én dårlig kilde kontaminerer nøglematerialet alle steder på én gang, hvilket er grunden til, at fixet landede i én enkelt procedure frem for ved hvert kaldsted

Hvorfor producerede hver proces samme nøgle?

AesGenerateRandomBytes brugte kun operativsystemets generator på Windows; alle andre steder fyldte den bufferen fra RTL's pseudo-random-generator, og den generator starter fra RandSeed = 0, medmindre programmet kalder Randomize. Kommentaren over løkken hævdede, at generatoren blev seedet fra GetTickCount64. Ingen eneste linje kode gjorde det nogensinde, hvilket gjorde kommentaren til det eneste sted, seeden eksisterede:

// Ikke-Windows-gren af AesGenerateRandomBytes før 3.114.8
// (kommentaren over den lovede et GetTickCount64-seed, der aldrig blev anvendt)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sekvensen genstarter med hver proces og skrider frem inde i den, så det første dokument, en hvilken som helst proces krypterer, deler sin filnøgle med det første dokument i alle andre processer, der kører samme build, det andet med det andet og så videre. Filnøglen i R5, R6 og R7 afhænger slet ikke af adgangskoden, da adgangskoden kun wrapper den, hvilket betyder, at enhver, der kan reproducere sekvensen, holder nøglen uden at kende en adgangskode. AESV4 tilføjer en anden fejl: samme nøgle med samme 8-byte præfiks og en tæller, der genstarter på nul, gentager GCM-nonces, hvilket NIST SP 800-38D §8 forbyder kategorisk. En gentaget GCM-nonce under én nøgle afslører XOR af de to klartekster og blotlægger autentificerings-subnøglen, så de tags, som AESV4-GCM-kryptering og PDF MAC-token støtter sig til, holder op med at betyde noget. Fortrolighed og integritet forsvinder på samme tid

Usødet RTL-generator i PDFiumPas på ikke-Windows FPC-builds: med RandSeed 0 emitterer hver proces samme sekvens, så dokument ét i proces A bærer samme filnøgle som dokument ét i proces B, og AESV4 gentager GCM-nonces, fordi samme nøgle møder samme præfiks med tælleren genstartende på nul
Fordi filnøglen aldrig afhænger af adgangskoden, holder enhver, der kan reproducere sekvensen, nøglen direkte, og gentagne GCM-nonces ødelægger fortrolighed og integritet sammen

Omfanget er snævrere, end det afsnit kunne antyde. Windows-builds blev aldrig berørt, fordi Windows-grenen altid har kaldt CryptGenRandom gennem advapi32 med CRYPT_VERIFYCONTEXT og rejst en exception, når det fejlede. Det, der var eksponeret, var output fra ikke-Windows-builds ældre end 3.114.8, hvilket i praksis betyder Lazarus- og Free Pascal-applikationer på Linux og macOS, endnu et indslag på listen over Delphi versus FPC-fælder i PDFium-builds

Hvorfor var Randomize aldrig den rigtige fix?

Et kald af Randomize ville have skjult symptomet uden at rette kilden, fordi RandSeed er en 32-bit værdi, og Randomize afleder den fra uret. Det capper antallet af mulige nøglestrømme ved 2^32, og at kende omtrent tidspunktet for, hvornår en fil blev skrevet, skærer søgningen langt under det, hvilket er ingenting ved siden af en 256-bit AES-nøgle. Nøglemateriale skal komme fra kernen entropy-pool, så AesGenerateRandomBytes i 3.114.8 læser /dev/urandom, løkker over korte læsninger og rejser en exception, hvis poolen ikke kan levere hver anmodet byte:

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;               // fejl eller uventet stream-slut
    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');

Afvisningen er bevidst, og den matcher, hvad Windows-grenen altid har gjort, når CryptGenRandom er utilgængelig. En fejlet krypteret gemning er en hændelse, du opdager samme dag; en vellykket gemning med forudsigelige nøgler er en, du hører om fra andre. To praktiske konsekvenser følger. En minimal container eller et chroot uden en udfyldt /dev fejler nu kryptering i stedet for stille at degradere, så mount den. Og fordi exceptionen udbreder sig ud af TPdf.SaveAsEncrypted, efter målfilen blev åbnet med fmCreate, efterlades en tom outputfil til din fejlhandler at slette

Hvorfor fangede round-trip-teste det aldrig?

En round-trip-test kan ikke se konstant tilfældighed, fordi dekrypteringen genvinder den filnøgle, krypteringen valgte. Testen krypterer et dokument, åbner det igen med adgangskoden, unwrapper nøglen fra /UE og dekrypterer hvert objekt; en forudsigelig nøgle unwrapes og dekrypterer præcis lige så godt som en tilfældig, og GCM-tags verificerer, fordi de blev beregnet med samme nøgle. Selv en test, der krypterer to gange og assert'er, at de to outputs er forskellige, består, da det andet kald i samme proces trækker de næste bytes af sekvensen. Den egenskab, der tæller — en anden nøgle i hver proces — er kun observerbar ved at sammenligne output på tværs af processer. Når samme kode både producerer og forbruger en værdi, er testene blinde for hele klasser af defekter, og tilfældighed er det reneste eksempel

Hvordan tester man nøgletilfældighed på tværs af processer?

Kør en lille probe to gange som separate processer på målplatformen, og sammenlign outputtet. Proben nedenfor kalder DeriveEncryptionKeys og udskriver saltet gemt i bytes 32 til 47 af /U-entryen. Den værdi skrives fuldt synligt ind i hver krypteret fil, så udskrivning i CI-logge afslører intet, men den kommer fra samme generator som filnøglen:

Cross-process tilfældighedstest for PDFiumPas: SaltProbe-programmet kalder DeriveEncryptionKeys og udskriver hex af /U bytes 32 til 47, jobbet kører det to gange som separate processer og fejler, når linjerne matcher, og udsendte PDF'er sammenlignes ud fra de sidste 16 bytes af deres /U-strenge
Konstant tilfældighed er usynlig inde i én proces, fordi dekrypteringen genvinder den nøgle, krypteringen valgte, så den egenskab, der tæller, er kun observerbar ved at sammenligne output på tværs af processer
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 = 32-byte hash + 16-byte salt
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // skal være forskellig ved hver kørsel
end.

Kobl proben ind i buildet for hvert ikke-Windows-mål: kør den to gange, og fejlad jobbet, hvis de to linjer matcher. Samme sammenligning virker på filer, der allerede er i omløb. Tag to krypterede PDF'er skrevet af forskellige kørsler af samme applikation, læs /U-strengene fra deres Encrypt-dictionaries, og sammenlign de sidste 16 bytes; identiske salts afslører en berørt build, og dokumenterne bør krypteres igen fra deres klartekst med 3.114.8 eller senere, så hver enkelt får en frisk filnøgle. Den generelle vane er at øve ikke-Windows-kodestier på platformen selv frem for at stole på Windows-kørslen, samme reson som bag libcurl timestamp-backenden til ikke-Windows-builds

PDFiumPas er en Delphi- og Lazarus-PDF-komponent bygget på PDFium-motoren, med AES-256, AES-GCM og PDF MAC-token implementeret nativt i Pascal og nøglemateriale hentet fra operativsystemets generator på alle platforme. Detaljer og downloads findes på PDFium Delphi component-siden