Tekninen artikkeli

Nopea AES-256-salaus valtaville PDF-dokumenteille

2 gigatavun PDF-tiedoston salaaminen kuulostaa suoratoisto-ongelmalta: avaa tiedosto, työnnä kaksi gigatavua AES-256:n läpi, kirjoita tulos. Tuo ajatusmalli on väärä tavalla, joka ratkaisee koko suorituskykybudjetin. ISO 32000-1 §7.6 asettaa PDF-salauksen raekoon yksittäisen objektin tasolle — jokainen stream ja jokainen merkkijono salataan erikseen, kukin omalla alustusvektorillaan ja omalla täytteellään. 2 gigatavun skannattu arkisto, jossa on 500 000 objektia, on 500 000 pientä CBC-operaatiota eikä yksi pitkä ajo, ja tuossa mittakaavassa jokaisen operaation ympärillä oleva kiinteä kustannus painaa enemmän kuin sen sisällä tehtävä AES-aritmetiikka

Tämä artikkeli käsittelee tuota kiinteää kustannusta: mihin aika kuluu, kun Delphi-koodi soveltaa AES-256:ta hyvin suuriin dokumentteihin, ja miten sen saa takaisin. Asetuspuolesta — salasanat, oikeusliput, revisio 5:n ja 6:n välinen yhteensopivuusvalinta — kertoo rinnakkaisartikkeli AES-256-salauksen määrittämisestä HotPDF:ssä; mitään siitä ei toisteta tässä

Puoli miljoonaa CBC-operaatiota, ei yhtä ajoa

Tiedoston luuranko pysyy selkokielisenä. Ristiviitetaulukot, objektinumerot, sanakirjan avaimet, sivupuu: mitään näistä ei salata, ja juuri siksi lukija pystyy paikantamaan objektit ennen kuin se on vahvistanut salasanan. Standardi salaa sisällön — stream-datan kuten sivukuvaukset, kuvat, fontit ja liitteet, sekä merkkijonot kuten metatietoarvot ja merkintöjen tekstin. AES-256-cryptosuodattimen alla kukin niistä käsitellään omillaan: tuore satunnainen 16 tavun IV, CBC tavujen yli, lohkotäyte 16 tavun rajaan ja selkokielisenä salatekstin edelle kirjoitettu IV

PDF-kaavio AES-256-salauksen raekoosta, jossa yksi jaettu 256-bittinen tiedostoavain sinetöi puoli miljoonaa sisältöstreamia ja merkkijonoa erikseen samalla kun rakenteellinen luuranko pysyy selkokielisenä
Luuranko pysyy luettavana samalla kun jokainen stream ja merkkijono sinetöidään omillaan — jokainen alkio kantaa tuoretta 16 tavun IV:tä, CBC-ketjutusta ja lohkotäytettä yhden jaetun 256-bittisen tiedostoavaimen alla

Tästä seuraa kaksi asiaa. Ensinnäkin salateksti on aina selkotekstiä pidempi: IV lisää 16 tavua ja täyte 1–16 tavua lisää, joten 100 tavun merkkijono vie levyltä 128 tavua ja tyhjä stream tuottaa silti 32. Koodi, joka mitoittaa tulospuskurin syötteen pituuden mukaan tai kirjoittaa takaisin vain yhtä monta tavua kuin luki, tuottaa tiedostoja, joiden purku epäonnistuu jokaisen objektin viimeisessä lohkossa. Toiseksi kustannus seuraa objektien määrää, ei pelkkää tavumäärää. Skannattu arkisto keskittää tavunsa muutamaan suureen kuvastreamiin, mutta kantaa satojatuhansia lyhyitä streameja ja pieniä merkkijonoja, joissa laskun maksaa operaatiokohtainen yleiskustannus, ei AES

Ainoa armo AES-256-suunnittelussa on avainten käsittely. Revisioon 4 asti suojaushallitsijat johtivat jokaiselle objektille erillisen avaimen tiivistämällä tiedostoavaimen yhdessä objekti- ja generaationumeron kanssa, mikä pakotti rakentamaan avainaikataulun joka kerta uudestaan. /V 5 -skeemat luopuivat objektikohtaisesta johtamisesta: yksi satunnainen 256-bittinen tiedostoavain salaa dokumentin jokaisen objektin. Tuo tosiasia oikeuttaa kaikki alla olevat optimoinnit — kallis kryptografinen tila voidaan rakentaa kerran tiedostoa kohti, ei kerran objektia kohti

R6:n /Encrypt-sanakirja: yksi hidas avaus, halvat objektit

Revisio 6:n dokumentti ilmoittaa skeemansa trailerin /Encrypt-sanakirjassa, ja merkitsevät kentät mahtuvat muutamalle riville:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 valitsee 256-bittisen avainarkkitehtuurin ja /R 6 kovennetun ISO 32000-2 -kättelyn. /CF määrittelee nimetyn cryptosuodattimen — /AESV3 tarkoittaa AES-256:ta CBC-tilassa etuliitteenä olevalla IV:llä — ja /StmF sekä /StrF osoittavat tuon suodattimen streameille ja merkkijonoille. /O, /U, /OE ja /UE kantavat salasanan varmennus- ja avaimenkääreaineiston, ja /Perms sisältää AES-salatun kopion oikeusbiteistä, jotta vihamielinen editori ei voi hiljaisesti kääntää /P-arvoa

Kustannusrakenne piilee /OE- ja /UE-kentissä. Tiedostoavaimen purkaminen niistä ajaa algoritmin 2.B, iteroidun avaimenjohtofunktion, joka ketjuttaa SHA-256-, SHA-384- ja SHA-512-kierroksia — vähintään 64 niitä, datasta riippuvalla pysäytyssäännöllä — ja on tarkoituksella rakennettu hitaaksi, jotta salasanan arvaaminen pysyy kalliina. Tuo hinta maksetaan kerran, kun kirjoittaja tuottaa tiedoston, ja kerran, kun lukija avaa sen, kumpikin yksinumeroisia millisekunteja. Puolen miljoonan objektin tiedostossa KDF on kohinaa, ja jos tallennus on hidas, algoritmi 2.B ei ole epäilty; objektikohtainen silmukka on

Käytä avainkahva uudelleen, käytä työpuskuri uudelleen

Naiivi toteutus on siisti apufunktio: EncryptAes256Cbc-apuri, joka avaa Windowsin CNG-tarjoajan, valitsee CBC:n, luo avainobjektin, salaa yhden puskurin ja purkaa kaiken lopuksi. Oikein toimiva, yksikkötestattava ja tuhoisa 500 000 kierroksen silmukan sisällä. Microsoftin dokumentaatio merkitsee BCryptOpenAlgorithmProvider-kutsun kalliiksi ja suosittaa kahvan välimuistittamista, ja BCryptGenerateSymmetricKey ajaa täyden AES-avainaikataulun ja varaa tarjoajan tilan — puhdasta hukkaa, kun avain ei koskaan muutu dokumentin sisällä

Delphin RTL ei toimita bcrypt-tuontiyksikköä, joten sisääntulopisteet on esiteltävä suoraan. Alla oleva luokka rakentaa kaiken kryptografisen tilan kerran ja salaa sitten minkä tahansa määrän objekteja ilman varauksia vakiotilassa:

PDF-vertailu naiivista AES-256-apurista, joka rakentaa Windowsin CNG-tarjoajan, ketjutustilan ja avainaikataulun uudelleen jokaiselle PDF-objektille, ja nostetusta TPdfObjectEncryptor-konstruktorista, jonka silmukka ei varaa mitään
CNG-tarjoajan uudelleenavaaminen ja avainaikataulun uudelleenrakentaminen maksoivat noin 150 mikrosekuntia objektia kohti; nostettu konstruktori maksaa sen kerran eikä lämmin silmukka varaa mitään
uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG-avainobjektin työtila, varataan kerran
    FScratch: TBytes;    // salatekstin työtila, kasvaa ja jää sitten paikalleen
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // AES-avainaikataulu rakennetaan tässä kerran ja käytetään joka objektille
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Tuore satunnainen IV joka objektille; se kulkee selkokielisenä datan edellä
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil tyhjälle syötteelle on kelvollinen: pelkkä täytelohko

  // Kokokysely: CBC-täyte lisää aina 1..16 tavua, joten Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt siirtää IV-puskuria ketjuttaessaan
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // kasvaa kourallisen kertoja, sitten pysyy paikallaan

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3-asettelu: 16 tavun IV, sitten täytetty salateksti
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Kolme yksityiskohtaa kantaa kuormaa. Kokokysely — ensimmäinen BCryptEncrypt-kutsu nil-tulospuskurilla — palauttaa täytetyn salatekstin pituuden, joka ei koskaan ole sama kuin syötteen pituus; täyte on deterministinen, joten voit laskea ((Len div 16) + 1) * 16 itse ja puolittaa kutsujen määrän, mutta kysely on dokumentoitu sopimus. Toiseksi BCryptEncrypt siirtää IV-puskuria paikan päällä ketjuttaessaan, joten jokaiseen kutsuun menee työkopio ja koskematon IV päätyy tulokseen. Kolmanneksi FScratch vain kasvaa, tiedoston suurimpaan objektiin asti, minkä jälkeen silmukka ei varaa mitään

Mitä kahvan uudelleenkäyttö on arvoltaan, mitattuna

Tiedosto, joka pakotti tähän harjoitukseen, oli 1,8 gigatavun skannattu lainakanta-arkisto: 412 000 salattua objektia, jotka kantoivat 1 710 megatavua hyötykuormaa selkokielisen rakenteen vähentämisen jälkeen. Sama kone, sama tiedosto, NVMe-tallennus, yksi säie:

  • Kutsukohtainen alustus (tarjoaja avattu ja avain luotu apurin sisällä): salausvaihe 71,3 s — 1 710 MB ÷ 71,3 s ≈ 24 MB/s
  • Tila nostettu (yllä oleva luokka): 9,6 s — 1 710 MB ÷ 9,6 s ≈ 178 MB/s

Ero on 61,7 s 412 000 kutsun yli eli karkeasti 150 µs kutsua kohti, joka kului tarjoajan avaamiseen, ketjutustilan asettamiseen ja avainaikataulun uudelleenrakentamiseen avaimelle, joka ei koskaan muuttunut. Mikään siitä ei ollut kryptografiaa. AES-NI:n kanssa suurten puskureiden CBC-salaus kulkee lähellä 1,4 GB/s yhdellä ytimellä, joten AES-aritmetiikka itse vastaa noin 1,2 sekunnista tuosta 9,6:sta; suurin osa lopusta on kaksi käyttäjätilan BCryptEncrypt-siirtymää objektia kohti sekä objektikohtainen IV:n luonti. IV:iden niputtaminen — yksi BCryptGenRandom-kutsu, joka täyttää niitä 4 096 kerralla — leikkasi ajon 8,9 sekuntiin. Sen jälkeen ollaan rajapinnan objektikohtaisessa lattiassa, ja jäljellä oleva vipu on rinnakkaisuus: /V 5 -objektit ovat riippumattomia jaetun tiedostoavaimen alla, joten neljä työsäiettä kukin omalla avainobjektillaan vei vaiheen 3,1 sekuntiin ennen kuin tuloksen kirjoittajasta tuli sarjallistava pullonkaula

PDF: Pylväskaavio AES-256-salauksen ajoista 1,8 gigatavun skannatulla PDF-tiedostolla, jossa aika putoaa 71,3 sekunnista 9,6 sekuntiin nostetulla CNG-tilalla, 8,9 sekuntiin niputetuilla IV:illä ja 3,1 sekuntiin neljällä työsäikeellä
Mitattu 1,8 gigatavun skannauksella, jossa 412 000 objektia: CNG-tilan nostaminen palauttaa noin 61,7 sekuntia puhdasta rajapinnan yleiskustannusta, niputetut IV:t leikkaavat lisää ja neljä työsäiettä yltää 3,1 sekuntiin ennen kuin kirjoittaja sarjallistaa

Täysi uudelleenkirjoitus vastaan inkrementaalinen tallennus

Raekoko ratkaisee myös sen, mitä tallennus maksaa. Salauksen lisääminen olemassa olevaan selkokieliseen dokumenttiin kirjoittaa määritelmällisesti jokaisen objektin uudelleen: jokaisen streamin ja merkkijonon sekä sisältö että pituus muuttuvat, jokainen ristiviitesiirtymä liikkuu, eikä inkrementaalista polkua ole. Budjetoi se täytenä peräkkäisenä uudelleenkirjoituksena ja kirjoita väliaikaistiedostoon, joka nimetään kohteen päälle, koska muuten kesken salauksen sattuva kaatuminen jättää puoliksi salatun tiedoston, jota mikään salasana ei avaa

Vastakkainen suunta on halpa. Kun tiedosto on kerran salattu, inkrementaalinen päivitys lisää perään uusia objekteja, jotka on salattu samalla tiedostoavaimella, ja jättää jokaisen alkuperäisen tavun koskematta. Hyväksyntämerkinnän leimaaminen 2 gigatavun salattuun arkistoon maksaa kilotavuja lisättyä tulosta, ei 2 gigatavun uudelleenkirjoitusta. Liukuhihnan johtopäätös: salaa kerran, työn viimeisenä vaiheena, ja anna myöhempien kosketusten ratsastaa inkrementaalisilla tallennuksilla. Salasanan vaihto, joka vaihtaa myös tiedostoavaimen, on taas täysi uudelleenkirjoitus — aikatauluta se sen mukaisesti

Läpäisyn mittaaminen itseään huijaamatta

Salauksen läpäisyväitteet menevät yleensä pieleen osoittajassa, nimittäjässä tai molemmissa. Osoittajan pitäisi olla hyötykuormatavut: AES:n läpi todella työnnettyjen streamien ja merkkijonojen pituuksien summa pakkauksen jälkeen, jonka kirjoittaja voi laskea yhteen edetessään. Tiedostokoko liioittelee sitä — yllä oleva arkisto on levyllä 1,8 gigatavua, mutta siitä vain 1 710 megatavua koskettaa koskaan salausta. Nimittäjän pitäisi olla pelkkä salausvaihe, rajattuna TStopwatch-luokalla System.Diagnostics-yksiköstä, niin että jäsennys, deflate ja levy-I/O jäävät rajauksen ulkopuolelle. Sisällytä ne mukaan, ja identtinen salauskoodi mittautuu moninkertaisesti hitaammaksi tiedostolla, joka vain pakkautuu huonommin. Yllä olevat luvut ovat vertailukelpoisia juuri siksi, että jakolaskun molemmat puolet ovat pelkkää salausta

Minkään tästä ei tarvitse olla koodia, jonka omistat. HotPDF kääri saman insinöörityön komponenttiominaisuuksien taakse — ActivateProtection, CryptKeyLength, UseAES256R6 — oikealle korkeudelle interaktiivisia VCL-sovelluksia varten, ja sijoitusjärjestyksen sudenkuopat käsitellään artikkelissa HotPDF:n AES-256-artikkeli. Valvomattomia liukuhihnoja varten PDF Library for Delphi soveltaa AES-256-revisio 6:ta olemassa oleviin tiedostoihin yhdellä EncryptFile-kutsulla arvolla Strength 4 ja varmistaa jälkikäteen, mitä levylle päätyi — työnkulku käydään läpi artikkelissa PDF Library for Delphi -salausauditointi

Tässä kuvatut salauspolut toimitetaan tuotteissa HotPDF Delphi Component Delphille ja C++Builderille sekä PDF Library for Delphi; molemmat tuotesivut kantavat täydellisen salausviitteen