Teknisk artikkel

Gjentatte PDF-krypteringsnøkler på FPC: PDFiumPas RNG-fiks

Før versjon 3.114.8 genererte PDFiumPas PDF-krypteringsnøkkelmateriale på ikke-Windows-mål med Random-funksjonen i runtime-biblioteket, og fordi ingen kalte Randomize, produserte hver prosess den samme bytesekvensen. Free Pascal-bygg på Linux og macOS skrev derfor identiske filkrypteringsnøkler, salter, CBC-IV-er og AES-GCM-nonce-prefikser, kjøring etter kjøring. Versjon 3.114.8 leser i stedet /dev/urandom og kaster et unntak når den ikke kan

Defekten i seg selv er en løkke på fire linjer. Den mer nyttige lærdommen er hvorfor en testpakke som krypterer og dekrypterer hundrevis av dokumenter, med AESV3 og AESV4, med og uten en PDF MAC, holdt seg grønn hele veien. Tilfeldighet som er konstant per prosess, er usynlig for hver test som kjører inne i én prosess, og det er akkurat slik krypteringstester vanligvis er skrevet

Hvor trenger PDFiumPas tilfeldige byte?

Hver tilfeldig byte i PDFiumPas krypteringsstakk kommer fra én enkelt prosedyre, AesGenerateRandomBytes i uniten FPdfAes, så én dårlig kilde forurenser alt sammen. Standard sikkerhetshåndterer i ISO 32000-2 §7.6.4 og AESV4-utvidelsen i ISO/TS 32003 bruker de bytene på disse stedene:

  • Den 32-byte filkrypteringsnøkkelen, generert fersk av DeriveEncryptionKeys for hvert dokument og deretter pakket inn i /UE og /OE under passordavledede nøkler
  • To 16-byte salter, én lagret i de siste 16 bytene av /U og én i de siste 16 bytene av /O, hver delt i et 8-byte valideringssalt og et 8-byte nøkkelsalt
  • Byte 12 til 15 av klarteksten bak /Perms, som ISO 32000-2 fyller med tilfeldige data før blokken krypteres under filnøkkelen
  • En 16-byte CBC-IV foran hver kryptert streng og strøm i et AESV3-dokument
  • Et 8-byte nonce-prefiks for AESV4-dokumenter, etterfulgt av en 4-byte per-objekt-teller som starter på null
  • Det 32-byte /KDFSalt-et og MAC-nøkkelen når EnableIntegrityProtection er satt
Hver tilfeldig byte i PDFiumPas krypteringsstakk flyter fra AesGenerateRandomBytes i FPdfAes inn i seks forbrukere: den 32-byte filkrypteringsnøkkelen pakket inn i /UE og /OE, /U- og /O-saltene, /Perms-utfyllingsbytene, AESV3 CBC-IV-en, AESV4 GCM-nonce-prefikset, og KDF-saltet og MAC-nøkkelen
Én delt generator betyr at én dårlig kilde forurenser nøkkelmateriale over alt på én gang, og det er derfor fiksen landet i én enkelt prosedyre i stedet for på hvert kallsted

Hvorfor produserte hver prosess den samme nøkkelen?

AesGenerateRandomBytes brukte operativsystemets generator bare på Windows; alle andre steder fylte den bufferen fra RTLs pseudo-tilfeldige generator, og den generatoren starter fra RandSeed = 0 med mindre programmet kaller Randomize. Kommentaren over løkken sa at generatoren ble seedet fra GetTickCount64. Ingen linje kode gjorde noensinne det, noe som gjorde kommentaren til det eneste stedet frøet fantes:

// Ikke-Windows-grenen av AesGenerateRandomBytes før 3.114.8
// (kommentaren over lov et GetTickCount64-frø som aldri ble anvendt)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sekvensen starter på nytt med hver prosess og avanserer innenfor den, så det første dokumentet enhver prosess krypterer, deler filnøkkelen sin med det første dokumentet til alle andre prosesser som kjører samme bygg, det andre med det andre, og så videre. Filnøkkelen i R5, R6 og R7 avhenger ikke av passordet i det hele tatt, siden passordet bare pakker den inn, noe som betyr at enhver som kan reprodusere sekvensen, holder nøkkelen uten å kjenne et passord. AESV4 legger til et nytt feiltrinn: samme nøkkel med samme 8-byte prefiks og en teller som starter på null, gjentar GCM-nonce-er, noe NIST SP 800-38D §8 forbyr kategorisk. En gjentatt GCM-nonce under én nøkkel avslører XOR-en av de to klartekstene og blottlegger autentiseringsundernøkkelen, så taggene som AESV4-GCM-kryptering og PDF MAC-tokenet støtter seg på, slutter å bety noe. Konfidensialitet og integritet ryker samtidig

Ikke-seedet RTL-generator i PDFiumPas på ikke-Windows FPC-bygg: med RandSeed 0 sender hver prosess den samme sekvensen, så dokument én i prosess A bærer samme filnøkkel som dokument én i prosess B, og AESV4 gjentar GCM-nonce-er fordi samme nøkkel møter samme prefiks med telleren som starter på null
Fordi filnøkkelen aldri avhenger av passordet, holder enhver som kan reprodusere sekvensen, nøkkelen fullt ut, og gjentatte GCM-nonce-er ødelegger konfidensialitet og integritet sammen

Omfanget er snevrere enn det avsnittet kan antyde. Windows-bygg ble aldri berørt, fordi Windows-grenen alltid har kalt CryptGenRandom gjennom advapi32 med CRYPT_VERIFYCONTEXT og kastet unntak når det feilet. Det som var eksponert, var output fra ikke-Windows-bygg tidligere enn 3.114.8, som i praksis betyr Lazarus- og Free Pascal-applikasjoner på Linux og macOS, nok én oppføring på listen over Delphi versus FPC-feller i PDFium-bygg

Hvorfor var Randomize aldri den riktige fiksen?

Å kalle Randomize ville ha skjult symptomet uten å fikse kilden, fordi RandSeed er en 32-bit verdi og Randomize avleder den fra klokken. Det takserer antallet mulige nøkkelstrømmer til 2^32, og å vite omtrent når en fil ble skrevet, kutter søket langt under det, som er ingenting ved siden av en 256-bit AES-nøkkel. Nøkkelmateriale må komme fra kjernens entropibasseng, så AesGenerateRandomBytes i 3.114.8 leser /dev/urandom, håndterer korte lesinger i en løkke og kaster unntak hvis bassenget ikke kan levere hver forespurt 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;               // feil eller uventet slutt på strømmen
    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');

Å nekte er bevisst, og det matcher det Windows-grenen alltid har gjort når CryptGenRandom ikke er tilgjengelig. En mislykket kryptert lagring er en hendelse du legger merke til samme dag; en vellykket lagring med forutsigbare nøkler er en du får høre om fra noen andre. To praktiske konsekvenser følger. En minimal kontainer eller chroot uten et fylt /dev feiler nå kryptering i stedet for å degraderes stille, så mount den. Og fordi unntaket forplanter seg ut av TPdf.SaveAsEncrypted etter at målfilen ble åpnet med fmCreate, blir det etterlatt en tom output-fil som feilbehandleren din kan slette

Hvorfor fanget tur-retur-tester det aldri?

En tur-retur-test kan ikke se konstant tilfeldighet, fordi dekryptering gjenoppretter hvilken filnøkkel krypteringen enn valgte. Testen krypterer et dokument, åpner det igjen med passordet, pakker nøkkelen ut av /UE og dekrypterer hvert objekt; en forutsigbar nøkkel pakkes ut og dekrypterer nøyaktig like godt som en tilfeldig, og GCM-tagger verifiserer fordi de ble beregnet med den samme nøkkelen. Selv en test som krypterer to ganger og hevder at de to output-ene er forskjellige, består, siden det andre kallet i samme prosess trekker de neste bytene i sekvensen. Egenskapen som teller, en annen nøkkel i hver prosess, er bare observerbar ved å sammenligne output på tvers av prosesser. Når den samme koden både produserer og forbruker en verdi, er testene blinde for hele klasser av defekter, og tilfeldighet er det reneste eksempelet

Hvordan kan du teste nøkkeltilfeldighet på tvers av prosesser?

Kjør en liten probe to ganger som separate prosesser på målplattformen og sammenlign output-en. Proben under kaller DeriveEncryptionKeys og skriver ut saltet lagret i byte 32 til 47 av /U-oppføringen. Den verdien skrives fullt synlig inn i hver kryptert fil, så å skrive den ut i CI-logger avslører ingenting, men den kommer fra den samme generatoren som filnøkkelen:

Tverrprosesstest av tilfeldighet for PDFiumPas: programmet SaltProbe kaller DeriveEncryptionKeys og skriver ut heks-verdiene av /U-byte 32 til 47, jobben kjører den to ganger som separate prosesser og feiler når linjene matcher, og utleverte PDF-er sammenlignes med de siste 16 bytene av /U-strengene sine
Konstant tilfeldighet er usynlig inne i én prosess fordi dekryptering gjenoppretter hvilken nøkkel krypteringen enn valgte, så egenskapen som teller, er bare observerbar ved å sammenligne output på tvers av prosesser
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);                 // må være forskjellig ved hver kjøring
end.

Koble proben inn i bygget for hvert ikke-Windows-mål: kjør den to ganger, feil jobben hvis de to linjene matcher. Samme sammenligning fungerer på filer allerede i omløp. Ta to krypterte PDF-er skrevet av ulike kjøringer av samme applikasjon, les /U-strengene fra Encrypt-ordlistene deres, og sammenlign de siste 16 bytene; identiske salter identifiserer et berørt bygg, og dokumentene bør krypteres på nytt fra klarteksten sin med 3.114.8 eller nyere, slik at hvert av dem får en fersk filnøkkel. Den generelle vanen er å trene ikke-Windows-kodebaner på selve plattformen i stedet for å stole på Windows-kjøringen, samme resonnementet som ligger bak libcurl-tidsstempel-backend for ikke-Windows-bygg

PDFiumPas er en Delphi- og Lazarus-PDF-komponent bygget på PDFium-motoren, med AES-256, AES-GCM og PDF MAC-tokenet implementert nativt i Pascal og nøkkelmateriale hentet fra operativsystemets generator på hver plattform. Detaljer og nedlastinger finnes på siden for PDFium Delphi-komponenten