Teknisk artikel

Upprepade PDF-krypteringsnycklar på FPC: PDFiumPas RNG-fix

Före version 3.114.8 genererade PDFiumPas nyckelmaterial för PDF-kryptering på icke-Windows-mål med runtime-bibliotekets Random-funktion, och eftersom ingenting anropade Randomize producerade varje process samma bytesekvens. Free Pascal-byggen på Linux och macOS skrev därför identiska filkrypteringsnycklar, salter, CBC-IV:er och AES-GCM-nonceprefix körning efter körning. Version 3.114.8 läser i stället /dev/urandom och kastar ett undantag när den inte kan

Defekten själv är en loop på fyra rader. Den mer användbara lärdomen är varför en testsvit som krypterar och dekrypterar hundratals dokument, med AESV3 och AESV4, med och utan PDF-MAC, höll sig grön hela tiden. Slump som är konstant per process är osynlig för varje test som körs inuti en process, och det är exakt hur krypteringstester vanligen skrivs

Var behöver PDFiumPas slumpmässiga byte?

Varje slumpmässig byte i PDFiumPas krypteringsstack kommer från en enda procedur, AesGenerateRandomBytes i enheten FPdfAes, så en dålig källa kontaminerar allt. Den standardiserade säkerhetshanteraren i ISO 32000-2 §7.6.4 och AESV4-utvidgningen i ISO/TS 32003 konsumerar dessa byte på följande ställen:

  • Den 32 byte stora filkrypteringsnyckeln, genererad färsk av DeriveEncryptionKeys för varje dokument och därefter insvept i /UE och /OE under lösenordshärledda nycklar
  • Två salter på 16 byte, ett lagrat i de sista 16 bytena av /U och ett i de sista 16 bytena av /O, vart och ett delat i ett valideringssalt på 8 byte och ett nyckelsalt på 8 byte
  • Byte 12 till 15 i klartexten bakom /Perms, som ISO 32000-2 fyller med slumpdata innan blocket krypteras under filnyckeln
  • En CBC-IV på 16 byte som föregår varje krypterad sträng och ström i ett AESV3-dokument
  • Ett nonceprefix på 8 byte för AESV4-dokument, följt av en per objekt räknare på 4 byte som börjar på noll
  • /KDFSalt på 32 byte och MAC-nyckeln när EnableIntegrityProtection är satt
Varje slumpmässig byte i PDFiumPas krypteringsstack flyter från AesGenerateRandomBytes i FPdfAes till sex konsumenter: den 32 byte stora filkrypteringsnyckeln insvept i /UE och /OE, salterna i /U och /O, utfyllnadsbytena i /Perms, AESV3 CBC-IV, AESV4 GCM-nonceprefix samt KDF-saltet och MAC-nyckeln
En gemensam generator betyder att en dålig källa kontaminerar nyckelmaterial överallt på en gång, vilket är varför fixen landade i en enda procedur snarare än vid varje anropsplats

Varför producerade varje process samma nyckel?

AesGenerateRandomBytes använde operativsystemets generator bara på Windows; överallt annars fyllde den bufferten från RTL:s pseudoslumpgenerator, och den generatorn startar från RandSeed = 0 om inte programmet anropar Randomize. Kommentaren ovanför loopen hävdade att generatorn seedades från GetTickCount64. Ingen rad kod gjorde någonsin det, vilket gjorde kommentaren till den enda plats där själva seedet fanns:

// Icke-Windows-grenen av AesGenerateRandomBytes före 3.114.8
// (kommentaren ovanför lovade ett GetTickCount64-seed som aldrig tillämpades)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sekvensen börjar om med varje process och går framåt inom den, så det första dokument som en process krypterar delar sin filnyckel med det första dokumentet i varje annan process som kör samma bygge, det andra med det andra, och så vidare. Filnyckeln i R5, R6 och R7 beror inte alls på lösenordet, eftersom lösenordet bara sveper in den, vilket betyder att vem som helst som kan reproducera sekvensen håller nyckeln utan att känna ett lösenord. AESV4 lägger till ett andra fel: samma nyckel med samma 8-byte-prefix och en räknare som börjar om på noll upprepar GCM-nonces, vilket NIST SP 800-38D §8 förbjuder rakt ut. En upprepad GCM-nonce under en nyckel avslöjar XOR av de två klartexterna och exponerar autentiseringsundernyckeln, så de taggar som AESV4-GCM-kryptering och PDF-MAC-token litar på slutar betyda någonting. Konfidentialitet och integritet går på samma gång

Oseedad RTL-generator i PDFiumPas på icke-Windows FPC-byggen: med RandSeed 0 emitterar varje process samma sekvens, så dokument ett i process A bär samma filnyckel som dokument ett i process B, och AESV4 upprepar GCM-nonces eftersom samma nyckel möter samma prefix med räknaren börjande om på noll
Eftersom filnyckeln aldrig beror på lösenordet håller den som kan reproducera sekvensen nyckeln helt utantill, och upprepade GCM-nonces förstör konfidentialitet och integritet tillsammans

Omfattningen är smalare än det stycket kan låta. Windows-byggen drabbades aldrig, för Windows-grenen har alltid anropat CryptGenRandom genom advapi32 med CRYPT_VERIFYCONTEXT och kastat när det misslyckades. Det som exponerades var utdata från icke-Windows-byggen äldre än 3.114.8, vilket i praktiken betyder Lazarus- och Free Pascal-applikationer på Linux och macOS, ytterligare en post i listan över Delphi kontra FPC-fällor i PDFium-byggen

Varför var Randomize aldrig den rätta fixen?

Ett anrop av Randomize skulle ha gömt symptomet utan att laga källan, för RandSeed är ett 32-bitarsvärde och Randomize härleder det från klockan. Det takar antalet möjliga nyckelströmmar vid 2^32, och att veta ungefär när en fil skrevs skär sökningen långt under det, vilket inte är någonting bredvid en AES-nyckel på 256 bitar. Nyckelmaterial måste komma från kärnans entropipool, så AesGenerateRandomBytes i 3.114.8 läser /dev/urandom, loopar över korta läsningar och kastar om poolen inte kan leverera varje begärt 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;               // fel eller oväntat slut 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');

Att vägra är medvetet, och det stämmer med vad Windows-grenen alltid gjort när CryptGenRandom inte finns. En misslyckad krypterad sparning är en incident du märker samma dag; en lyckad sparning med förutsägbara nycklar är en du får höra om av någon annan. Två praktiska konsekvenser följer. En minimal container eller chroot utan en befolkad /dev misslyckas nu med kryptering i stället för att tysta degradera, så montera den. Och eftersom undantaget fortplantar sig ut ur TPdf.SaveAsEncrypted efter att målfilen öppnats med fmCreate lämnas en tom utdatafil kvar för din felhanterare att radera

Varför fångade rundturstester aldrig felet?

Ett rundturstest kan inte se konstant slump, för dekrypteringen återvinner vilken filnyckel krypteringen än valde. Testet krypterar ett dokument, öppnar det igen med lösenordet, vecker upp nyckeln ur /UE och dekrypterar varje objekt; en förutsägbar nyckel veckas upp och dekrypterar precis lika bra som en slumpmässig, och GCM-taggarna verifieras för de räknades fram med samma nyckel. Även ett test som krypterar två gånger och assertar att de två utdatorna skiljer sig går igenom, eftersom det andra anropet i samma process drar nästa byte av sekvensen. Egenskapen som spelar roll, en annan nyckel i varje process, är bara observerbar genom att jämföra utdata över processer. Närhelst samma kod både producerar och konsumerar ett värde är testerna blinda för hela klasser av defekter, och slump är det renaste exemplet

Hur kan du testa nyckelslumpen över processer?

Kör en liten sond två gånger som separata processer på målplattformen och jämför utdatan. Sonden nedan anropar DeriveEncryptionKeys och skriver ut saltet som lagras i byte 32 till 47 av /U-posten. Det värdet skrivs öppet in i varje krypterad fil, så att skriva ut det i CI-loggar avslöjar ingenting, men det kommer från samma generator som filnyckeln:

Över processer test av slumpmässighet för PDFiumPas: programmet SaltProbe anropar DeriveEncryptionKeys och skriver ut hexen av /U byte 32 till 47, jobbet kör det två gånger som separata processer och misslyckas när raderna matchar, och utskickade PDF:er jämförs med de sista 16 bytena av sina /U-strängar
Konstant slump är osynlig inuti en process eftersom dekrypteringen återvinner vilken nyckel krypteringen än valde, så egenskapen som spelar roll är bara observerbar genom att jämföra utdata över 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);                 // måste skilja sig vid varje körning
end.

Koppla in sonden i bygget för varje icke-Windows-mål: kör den två gånger och låt jobbet misslyckas om de två raderna matchar. Samma jämförelse fungerar på filer som redan är i omlopp. Ta två krypterade PDF:er skrivna av olika körningar av samma applikation, läs /U-strängarna ur deras Encrypt-ordböcker och jämför de sista 16 bytena; identiska salter identifierar ett drabbat bygge, och dokumenten bör krypteras om från sin klartext med 3.114.8 eller senare så att var och en får en färsk filnyckel. Den allmänna vanan är att öva upp icke-Windows-kodvägar på plattformen själv i stället för att lita på Windows-körningen, samma resonemang som ligger bakom libcurl tidsstämpel-backend för icke-Windows-byggen

PDFiumPas är en PDF-komponent för Delphi och Lazarus byggd på PDFium-motorn, med AES-256, AES-GCM och PDF-MAC-token implementerade nativt i Pascal och nyckelmaterial hämtat från operativsystemets generator på alla plattformar. Detaljer och nedladdningar finns på sidan för PDFium Delphi-komponenten