Techninis straipsnis

Didelio greičio AES-256 PDF šifravimas didžiuliams dokumentams

2 GB dydžio PDF šifravimas skamba kaip srautinio perdavimo problema: atidaryti failą, praleisti du gigabaitus per AES-256, įrašyti rezultatą. Toks psichologinis modelis yra klaidingas taip, kad tai nulemia visą našumo biudžetą. ISO 32000-1 §7.6 nustato PDF šifravimo detalumą atskiro objekto lygiu — kiekvienas srautas ir kiekviena eilutė šifruojami atskirai, kiekvienas turi savo inicijavimo vektorių ir savo užpildymą. 2 GB nuskaitytas archyvas su 500 000 objektų yra 500 000 mažų CBC operacijų, o ne vienas ilgas perėjimas, ir tokiu mastu fiksuotos išlaidos aplink kiekvieną operaciją yra svarbesnės nei AES aritmetika jos viduje

Šis straipsnis yra apie šias fiksuotas išlaidas: kur dingsta laikas, kai „Delphi“ kodas taiko AES-256 labai dideliems dokumentams, ir kaip jį atgauti. Apie sąrankos pusę — slaptažodžius, leidimų vėliavėles, 5 ir 6 redakcijų suderinamumo iškvietimą — žr. papildomą straipsnį apie AES-256 šifravimo konfigūravimą „HotPDF“; čia niekas iš to nekartojama

Pusė milijono CBC operacijų, o ne vienas perėjimas

Failo skeletas lieka atvirojo teksto formatu. Kryžminių nuorodų lentelės, objektų numeriai, žodynų raktai, puslapių medis: niekas iš to nėra šifruojama, todėl skaitytuvas gali rasti objektus dar prieš patvirtindamas slaptažodį. Tai, ką standartas šifruoja, yra turinys — srauto duomenys, tokie kaip puslapių aprašymai, vaizdai, šriftai ir priedai, plius eilutės, tokios kaip metaduomenų reikšmės ir anotacijų tekstas. Naudojant AES-256 kriptografinį filtrą, kiekvienas iš jų apdorojamas atskirai: naujas atsitiktinis 16 baitų IV, CBC per baitus, bloko užpildymas iki 16 baitų ribos ir IV, įrašytas atviru tekstu prieš pat šifruotą tekstą

Iš to kyla dvi pasekmės. Pirma, šifruotas tekstas visada yra ilgesnis nei atvirasis tekstas: IV prideda 16 baitų, o užpildymas prideda dar nuo 1 iki 16, todėl 100 baitų eilutė diske užima 128 baitus, o tuščias srautas vis tiek sukuria 32. Kodas, kuris išvesties buferio dydį pritaiko prie įvesties ilgio, arba įrašo atgal tik tiek baitų, kiek perskaitė, sukuria failus, kurių nepavyksta iššifruoti ties paskutiniu kiekvieno objekto bloku. Antra, išlaidos seka objektų skaičių, o ne tik baitų skaičių. Nuskaitytas archyvas koncentruoja savo baitus keliuose dideliuose vaizdų srautuose, tačiau turi šimtus tūkstančių trumpų srautų ir mažų eilučių, kur per vieną operaciją tenkančios papildomos išlaidos, o ne AES, yra pagrindinė kaina

Vienintelis palengvinimas AES-256 dizaine yra raktų tvarkymas. Saugumo apdorojimo programos iki 4 redakcijos kiekvienam objektui išvedė atskirą raktą, maišydamos failo raktą kartu su objekto ir generavimo numeriais, taip priversdamos kaskart sudaryti naują raktų tvarkaraštį. /V 5 schemos atsisakė išvedimo kiekvienam objektui atskirai: vienas atsitiktinis 256 bitų failo raktas šifruoja kiekvieną dokumento objektą. Šis faktas leidžia atlikti visus toliau nurodytus optimizavimus — brangi kriptografinė būsena gali būti sukurta vieną kartą failui, o ne kartą objektui

R6 /Encrypt žodynas: vienas lėtas atidarymas, pigūs objektai

6 redakcijos dokumentas deklaruoja savo schemą pabaigos bloko /Encrypt žodyne, ir svarbūs įrašai telpa keliose eilutėse:

/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 pasirenka 256 bitų raktų architektūrą, o /R 6 sustiprintą ISO 32000-2 pasisveikinimą. /CF apibrėžia pavadintą kriptografinį filtrą — /AESV3 reiškia AES-256 CBC režimu su priekyje pridėtu IV — o /StmF ir /StrF priskiria šį filtrą atitinkamai srautams ir eilutėms. /O, /U, /OE ir /UE laiko slaptažodžio patvirtinimo ir rakto pakavimo medžiagą, o /Perms neša AES šifruotą leidimų bitų kopiją, kad priešiškas redaktorius negalėtų tyliai apversti /P

Išlaidų struktūra slepiasi po /OE ir /UE. Failo rakto išpakavimas iš jų paleidžia 2.B algoritmą, iteracinę raktų išvedimo funkciją, susiejančią SHA-256, SHA-384 ir SHA-512 raundus — mažiausiai 64 iš jų, su nuo duomenų priklausančia sustabdymo taisykle — sukurtą sąmoningai lėtai, kad slaptažodžio spėjimas išliktų brangus. Ši kaina sumokama vieną kartą, kai rašytuvas sukuria failą, ir vieną kartą, kai skaitytuvas jį atidaro, atitinkamai po vienženklį milisekundžių skaičių. Pusės milijono objektų faile KDF yra triukšmas, ir jei išsaugojimas lėtas, 2.B algoritmas nėra įtariamasis; įtariamasis yra kiekvieno objekto ciklas

Pakartotinai naudoti rakto rodyklę, pakartotinai naudoti laikinąjį buferį

Naivus įgyvendinimas yra tvarkinga pagalbinė funkcija: „EncryptAes256Cbc“ pagalbininkas, kuris atidaro „Windows CNG“ teikėją, pasirenka CBC, sugeneruoja rakto objektą, užšifruoja vieną buferį ir viską nugriauna. Teisinga, testuojama moduliuose ir pražūtinga 500 000 iteracijų ciklo viduje. „Microsoft“ dokumentacijoje pažymima, kad BCryptOpenAlgorithmProvider yra brangus, ir rekomenduojama talpykloje išsaugoti rodyklę, o BCryptGenerateSymmetricKey paleidžia visą AES raktų tvarkaraštį ir paskirsto teikėjo būseną — grynas švaistymas, kai raktas dokumente niekada nesikeičia

„Delphi RTL“ netiekia „bcrypt“ importavimo modulio, todėl įvesties taškus deklaruokite tiesiogiai. Žemiau esanti klasė vieną kartą sukuria visą kriptografinę būseną ir tada šifruoja bet kokį objektų skaičių be stabilios būsenos paskirstymo:

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 key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  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);
  // The AES key schedule is built once here and reused for every object
  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
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it chains
  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);  // grows a handful of times, then stays put

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

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Trys detalės yra laikančiosios. Dydžio užklausa — pirmasis BCryptEncrypt iškvietimas su nuliniu išvesties buferiu — grąžina užpildyto šifruoto teksto ilgį, kuris niekada nelygus įvesties ilgiui; užpildymas yra deterministinis, todėl galite patys apskaičiuoti ((Len div 16) + 1) * 16 ir perpus sumažinti iškvietimų skaičių, tačiau užklausa yra dokumentuota sutartis. Antra, BCryptEncrypt slenka IV buferį vietoje, kol grandinėjama, todėl darbinė kopija patenka į kiekvieną iškvietimą, o nepaliestas IV atsiranda išvestyje. Trečia, FScratch tik auga, iki didžiausio failo objekto, po kurio ciklas nieko nebepaskirsto

Ką verta naudoti rodyklę pakartotinai, pamatuota

Failas, privertęs atlikti šį pratimą, buvo 1,8 GB nuskaitytas paskolų archyvas: 412 000 užšifruotų objektų, turinčių 1 710 MB naudingos apkrovos, atėmus atvirojo teksto struktūrą. Ta pati mašina, tas pats failas, NVMe saugykla, viena gija:

  • Sąranka kiekvienam iškvietimui (teikėjas atidarytas ir raktas sugeneruotas pagalbininko viduje): šifravimo fazė 71,3 s — 1 710 MB ÷ 71,3 s ≈ 24 MB/s
  • Būsena pakelta (aukščiau esanti klasė): 9,6 s — 1 710 MB ÷ 9,6 s ≈ 178 MB/s

Skirtumas yra 61,7 s per 412 000 iškvietimų, arba maždaug 150 µs vienam iškvietimui, praleistų atidarant teikėją, nustatant grandinėjimo režimą ir atstatant raktų tvarkaraštį raktui, kuris niekada nesikeitė. Niekas iš to nebuvo kriptografija. Naudojant AES-NI, didelių buferių CBC šifravimas viename branduolyje veikia beveik 1,4 GB/s greičiu, todėl pati AES aritmetika sudaro apie 1,2 s iš 9,6 s; didžiąją dalį likusio laiko užima du vartotojo režimo BCryptEncrypt perėjimai kiekvienam objektui plius kiekvienam objektui skirtos IV generavimas. IV grupavimas — vienas BCryptGenRandom iškvietimas, užpildantis 4 096 iš jų — sutrumpino vykdymą iki 8,9 s. Už to jūs pasiekiate API kiekvieno objekto dugną, ir likęs svertas yra lygiagretumas: /V 5 objektai yra nepriklausomi po bendru failo raktu, todėl keturios darbinės gijos, kurių kiekviena turi po vieną rakto objektą, sutrumpino fazę iki 3,1 s, kol išvesties rašytuvas tapo nuoseklumo tašku

Visiškas perrašymas prieš inkrementinį išsaugojimą

Detalumai taip pat nulemia išsaugojimo kainą. Pridėjus šifravimą į esamą atvirojo teksto dokumentą, pagal apibrėžimą perrašomas kiekvienas objektas: kiekvienas srautas ir eilutė pakeičia turinį ir ilgį, kiekvienas kryžminių nuorodų poslinkis pasislenka ir nėra jokio inkrementinio kelio. Biudžetuokite tai kaip visišką nuoseklų perrašymą ir rašykite į laikiną failą, kuris bus pervadintas virš taikinio, nes avarija vidury šifravimo kitu atveju paliks pusiau šifruotą failą, kurio neatidarys joks slaptažodis

Atvirkštinė kryptis yra pigi. Kai failas yra užšifruotas, inkrementinis atnaujinimas prideda naujus objektus, užšifruotus tuo pačiu failo raktu, ir palieka kiekvieną pradinį baitą nepaliestą. Patvirtinimo anotacijos uždėjimas ant 2 GB užšifruoto archyvo kainuoja kilobaitus pridėtos išvesties, o ne 2 GB perrašymą. Vamzdyno išvada: šifruokite vieną kartą, kaip paskutinį darbo žingsnį, ir leiskite vėlesniems prisilietimams naudotis inkrementiniais išsaugojimais. Slaptažodžio keitimas, kuris taip pat keičia failo raktą, vėlgi yra visiškas perrašymas — planuokite jį atitinkamai

Pralaidumo matavimas neapgaudinėjant savęs

Teiginiai apie šifravimo pralaidumą dažnai būna klaidingi skaitiklyje, vardiklyje arba abiejuose. Skaitiklis turėtų būti naudingos apkrovos baitai: srautų ir eilučių ilgių suma, faktiškai praleista per AES po suspaudimo, kurią rašytuvas gali susumuoti pakeliui. Failo dydis tai pervertina — anksčiau minėtas archyvas diske užima 1,8 GB, tačiau tik 1 710 MB jo kada nors paliečia šifrą. Vardiklis turėtų būti tik šifravimo fazė, apibrėžta TStopwatch iš „System.Diagnostics“, su analizavimu, „deflate“ išskleidimu ir disko I/O už skliaustų. Pridėkite juos ir tas pats šifravimo kodas bus matuojamas kelis kartus lėčiau faile, kuris tiesiog susispaudžia prasčiau. Aukščiau pateikti skaičiai yra palyginami būtent todėl, kad abi padalijimo pusės yra tik šifravimas

Niekas iš to neturi būti jūsų pačių kodas. „HotPDF“ apgaubia tą pačią inžineriją po komponento savybėmis — ActivateProtection, CryptKeyLength, UseAES256R6 — tinkamame aukštyje interaktyvioms VCL programoms, su priskyrimo tvarkos spąstais, aptartais AES-256 šifravimo konfigūravimo „HotPDF“ straipsnyje. Neprižiūrimiems vamzdynams „PDFlibPas“ taiko AES-256 6 redakciją esamiems failams viename EncryptFile iškvietime, kurio stiprumas yra 4, ir vėliau patikrina, kas atsirado diske; ši darbo eiga aprašyta „PDFlibPas“ šifravimo audito straipsnyje

Čia aprašyti šifravimo keliai tiekiami „HotPDF“ komponente, skirtame „Delphi“ ir „C++Builder“, bei „PDFlibPas“ bibliotekoje; abiejų produktų puslapiuose pateikiama išsami šifravimo nuoroda