Tekninen artikkeli

Toistuvat PDF-salausavaimet FPC:llä: PDFiumPasin RNG-korjaus

Ennen versiota 3.114.8 PDFiumPas generoi PDF-salauksen avaimateriaalia ei-Windows-kohdeympäristöissä ajonaikaisen kirjaston Random-funktiolla, ja koska mikään ei kutsunut Randomizea, jokainen prosessi tuotti saman tavusarjan. Linuxin ja macOS:n Free Pascal -käännökset kirjoittivat siksi identtiset tiedostonsalausavaimet, suolat, CBC-IV:t ja AES-GCM-nonce-etuliitteet ajo ajoalta. Versio 3.114.8 lukee /dev/urandomin ja nostaa poikkeuksen kun se ei onnistu

Vika itsessään on nelirivinen silmukka. Hyödyllisempi opetus on se miksi testipaketti joka salaa ja purkaa satoja asiakirjoja, AESV3:lla ja AESV4:llä, PDF MAC:n kanssa ja ilman, pysyi vihreänä koko ajan. Prosessia kohti vakio satunnaisuus on näkymätön jokaiselle yhdessä prosessissa ajetalle testille, ja juuri niin salaus-testit yleensä kirjoitetaan

Missä PDFiumPas tarvitsee satunnaisia tavuja?

Jokainen satunnainen tavu PDFiumPas:n salauspinossa tulee yhdestä proseduurista, AesGenerateRandomBytes FPdfAes-yksikössä, joten yksi huono lähde saastuttaa kaiken. ISO 32000-2:n §7.6.4:n standarditurvauskäsittelijä ja ISO/TS 32003:n AESV4-laajennus kuluttavat kyseiset tavut näissä paikoissa:

  • 32-tavuinen tiedostonsalausavain, jonka DeriveEncryptionKeys generoi tuoreena jokaiselle asiakirjalle ja joka sitten kääritään /UE:hen ja /OE:hen salasanasta johdettujen avainten alle
  • Kaksi 16-tavuista suolaa, toinen tallennettuna /U:n viimeisiin 16 tavuun ja toinen /O:n viimeisiin 16 tavuun, kumpikin jaettuna 8-tavuiseen validointisuolaan ja 8-tavuiseen avainsuolaan
  • /Permsin takana olevan selkotekstin tavut 12–15, jotka ISO 32000-2 täyttää satunnaisella datalla ennen kuin lohko salataan tiedostoavaimen alla
  • 16-tavuinen CBC-IV joka liitetään eteen jokaiseen salattuun merkkijonoon ja streamiin AESV3-asiakirjassa
  • 8-tavuinen nonce-etuliite AESV4-asiakirjoille, jota seuraa 4-tavuinen objektikohtainen laskuri joka alkaa nollasta
  • 32-tavuinen /KDFSalt ja MAC-avain kun EnableIntegrityProtection on asetettu
Jokainen satunnainen tavu PDFiumPas:n salauspinossa virtaa AesGenerateRandomBytesistä FPdfAes-yksikössä kuuteen kuluttajaan: 32-tavuinen tiedostonsalausavain joka kääritään /UE:hen ja /OE:hen, /U- ja /O-suolat, /Permsin täytetavut, AESV3:n CBC-IV, AESV4:n GCM-nonce-etuliite sekä KDF-suola ja MAC-avain
Yksi jaettu generaattori tarkoittaa että yksi huono lähde saastuttaa avaimateriaalin kaikkialla yhtä aikaa, mistä syystä korjaus laskeutui yhteen proseduuriin eikä kuhunkin kutsukohtaan

Miksi jokainen prosessi tuotti saman avaimen?

AesGenerateRandomBytes käytti käyttöjärjestelmän generaattoria vain Windowsissa; kaikkialla muualla se täytti puskurin RTL:n pseudo-satunnaisgeneraattorista, ja se generaattori alkaa arvosta RandSeed = 0 ellei ohjelma kutsu Randomizea. Silmukan yläpuolella oleva kommenti sanoi generaattorin kylvetyn GetTickCount64:stä. Mikään koodirivi ei koskaan tehnyt sitä, mistä tuli se että kommenti oli ainoa paikka missä siemen oli olemassa:

// AesGenerateRandomBytesin ei-Windows-haara ennen 3.114.8
// (sen yläpuolella oleva kommenti lupasi GetTickCount64-siemenen jota ei koskaan sovellettu)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sarja alkaa alusta jokaisessa prosessissa ja etenee sen sisällä, joten ensimmäinen asiakirja jonka mikä tahansa prosessi salaa jakaa tiedostoavaimensa jokaisen saman käännöksen ajavan muun prosessin ensimmäisen asiakirjan kanssa, toinen toisen kanssa ja niin edelleen. Tiedostoavain R5:ssä, R6:ssa ja R7:ssä ei riipu salasanasta lainkaan, koska salasana vain käärii sen, mikä tarkoittaa että kuka tahansa joka pystyy toistamaan sarjan pitää avainta tietämättä salasanaa. AESV4 lisää toisen vian: sama avain samalla 8-tavuisella etuliitteellä ja nollasta käynnistyvällä laskurilla toistaa GCM-nonceja, mikä on NIST SP 800-38D §8:n ehdottomasti kieltämä. Toistettu GCM-nonce yhden avaimen alla paljastaa kahden selkotekstin XORin ja paljastaa autentikoinnin aliavaimen, joten AESV4-GCM-salauksen ja PDF MAC -tokenin nojaamat tagit lakkaavat merkitsemästä mitään. Salassapito ja eheys lähtevät yhtä aikaa

Kylvämätön RTL-generaattori PDFiumPasissa ei-Windows FPC -käännöksissä: RandSeed 0:lla jokainen prosessi emittoi saman sarjan, joten asiakirja yksi prosessissa A kantaa samaa tiedostoavainta kuin asiakirja yksi prosessissa B, ja AESV4 toistaa GCM-nonceja koska sama avain kohtaa saman etuliitteen laskurin käynnistyessä uudelleen nollasta
Koska tiedostoavain ei koskaan riipu salasanasta, kuka tahansa joka pystyy toistamaan sarjan pitää avainta suoraan hallussaan, ja toistetut GCM-noncit tuhoavat salassapidon ja eheyden yhdessä

Laajuus on kapeampi kuin se kappale voi antaa ymmärtää. Windows-käännökset eivät koskaan kärsineet, koska Windows-haara on aina kutsunut CryptGenRandomia advapi32:n kautta CRYPT_VERIFYCONTEXTilla ja nostanut poikkeuksen jos se epäonnistui. Paljastunutta oli ei-Windows-käännösten tuotos ennen versiota 3.114.8, mikä käytännössä tarkoittaa Lazarus- ja Free Pascal -sovelluksia Linuxissa ja macOS:ssä, yksi merkintä lisää Delphin ja FPC:n sudenkuoppien listaan PDFium-käännöksissä

Miksi Randomize ei ollut koskaan oikea korjaus?

Randomizen kutsuminen olisi peittänyt oireen korjaamatta lähdettä, koska RandSeed on 32-bittinen arvo ja Randomize johtaa sen kellosta. Se kattoo mahdollisten avainvirtojen määrän arvoon 2^32, ja karkea tieto siitä milloin tiedosto kirjoitettiin leikkaa haun paljon kauemmas alle sen, mikä ei ole mitään 256-bittisen AES-avaimen rinnalla. Avaimateriaalin on tultava kernelin entropiapoolista, joten AesGenerateRandomBytes 3.114.8:ssa lukee /dev/urandomia, silmukoi lyhyiden lukujen yli ja nostaa poikkeuksen jos pooli ei pysty toimittamaan jokaista pyydettyä tavua:

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;               // vika tai odottamaton streamin loppu
    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');

Kieltäytyminen on tahallista, ja se vastaa sitä mitä Windows-haara on aina tehnyt kun CryptGenRandom ei ole saatavilla. Epäonnistunut salattu tallennus on tapaus jonka huomaat samana päivänä; onnistunut tallennus ennustettavilla avaimilla on sellainen josta kuulet joltakulta muulta. Kaksi käytännön seurausta seuraa. Minimaalinen kontti tai chroot jolla ei ole täytettyä /dev:tä epäonnistuu nyt salauksessa hiljaisen heikkenemisen sijaan, joten mounttaa se. Ja koska poikkeus leviää ulos TPdf.SaveAsEncryptedista sen jälkeen kun kohdetiedosto avattiin fmCreatella, tyhjä tulostiedosto jää jäljelle virheenkäsittelijäsi poistettavaksi

Miksi round trip -testit eivät koskaan löytäneet sitä?

Round trip -testi ei näe vakiota satunnaisuutta, koska purku palauttaa sen tiedostoavaimen minkä salaus valitsi. Testi salaa asiakirjan, avaa sen uudelleen salasanalla, käärii avaimen auki /UE:stä ja purkaa jokaisen objektin; ennustettava avain kääriytyy ja purkautuu yhtä hyvin kuin satunnainenkin, ja GCM-tagit verifioituvat koska ne laskettiin samalla avaimella. Jopa testi joka salaa kahdesti ja vaatii kahden tuloksen eroavan läpäisee, koska toinen kutsu samassa prosessissa nostaa sarjan seuraavat tavut. Ominaisuus jolla on väliä, eri avain joka prosessissa, on havaittavissa vain vertaamalla tuotosta prosessien välillä. Aina kun sama koodi sekä tuottaa että kuluttaa arvon, testit ovat sokeita kokonaisille vikaluokille, ja satunnaisuus on puhtain esimerkki

Miten voit testata avainten satunnaisuutta prosessien välillä?

Aja pieni anturi kahdesti erillisinä prosesseina kohdealustalla ja vertaa tuotosta. Alla oleva anturi kutsuu DeriveEncryptionKeysia ja tulostaa /U-merkinnän tavuihin 32–47 tallennetun suolan. Se arvo kirjoitetaan näkyvästi jokaiseen salattuun tiedostoon, joten sen tulostaminen CI-lokeihin ei paljasta mitään, ja silti se tulee samasta generaattorista kuin tiedostoavain:

Prosessien välinen satunnaisuustesti PDFiumPasille: SaltProbe-ohjelma kutsuu DeriveEncryptionKeysia ja tulostaa /U:n tavujen 32–47 heksat, työ aja sen kahdesti erillisinä prosesseina ja epäonnistuu kun rivit täsmäävät, ja toimitetut PDF:t vertaillaan /U-merkkijonojen viimeisten 16 tavun perusteella
Vakio satunnaisuus on näkymätön yhden prosessin sisällä koska purku palauttaa sen avaimen minkä salaus valitsi, joten ominaisuus jolla on väliä on havaittavissa vain vertaamalla tuotosta prosessien välillä
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-tavuinen tiiviste + 16-tavuinen suola
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // täytyy erota jokaisella ajolla
end.

Kytkä anturi käännökseen jokaiselle ei-Windows-kohteelle: aja se kahdesti ja hylkää työ jos rivit täsmäävät. Sama vertailu toimii jo kiertoon laskettuihin tiedostoihin. Ota kaksi salattua PDFää jotka eri ajot samasta sovelluksesta kirjoittivat, lue /U-merkkijonot niiden Encrypt-sanakirjoista ja vertaa viimeisiä 16 tavua; identtiset suolat tunnistavat vaikutuksen alaisen käännöksen, ja asiakirjat kannattaa salata uudelleen niiden selkotekstistä versiolla 3.114.8 tai uudemmalla jotta jokainen saa tuoreen tiedostoavaimen. Yleinen tapa on harjoitella ei-Windows-koodipolkuja itse alustalla sen sijaan että luottaisi Windows-ajoon, sama päättely jonka takana on libcurl-aikaleintabackend ei-Windows-käännöksille

PDFiumPas on Delphi- ja Lazarus-PDF-komponentti joka rakentuu PDFium-moottorin päälle, AES-256, AES-GCM ja PDF MAC -token toteutettuina natiivisti Pascalissa ja avaimateriaali haettu käyttöjärjestelmän generaattorista joka alustalla. Yksityiskohdat ja lataukset ovat PDFium Delphi -komponentin sivulla