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
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:
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
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