Articol tehnic

Criptare PDF AES-256 de mare viteză pentru documente masive

Criptarea unui PDF de 2 GB sună ca o problemă de streaming: deschideți fișierul, împingeți doi gigabytes prin AES-256, scrieți rezultatul. Acest model mental este greșit într-un mod care decide întregul buget de performanță. ISO 32000-1 §7.6 setează granularitatea criptării PDF la nivelul obiectului individual — fiecare flux și fiecare șir este criptat separat, fiecare având propriul vector de inițializare și propria umplutură (padding). O arhivă scanată de 2 GB cu 500.000 de obiecte reprezintă 500.000 de operațiuni CBC mici, nu o singură trecere lungă, iar la această scară costul fix din jurul fiecărei operațiuni contează mai mult decât aritmetica AES din interiorul acesteia

Acest articol este despre acel cost fix: unde se duce timpul atunci când codul Delphi aplică AES-256 pe documente foarte mari, și cum să-l recuperezi. Pentru partea de configurare — parole, fanioane de permisiuni, decizia de compatibilitate revizuirea 5 versus 6 — vedeți piesa însoțitoare despre configurarea criptării AES-256 în HotPDF; nimic din toate acestea nu se repetă aici

Jumătate de milion de operațiuni CBC, nu o singură trecere

Scheletul fișierului rămâne în text clar (plaintext). Tabelele de referințe încrucișate, numerele obiectelor, cheile dicționarului, arborele paginii: nimic nu este criptat, acesta fiind modul în care un cititor poate localiza obiecte înainte de a valida o parolă. Ceea ce criptează standardul este conținutul — date de flux (stream), cum ar fi descrierile paginilor, imaginile, fonturile și atașamentele, plus șiruri (strings) precum valorile metadatelor și textul adnotărilor. Sub filtrul criptografic AES-256 fiecare este procesat pe cont propriu: un IV proaspăt aleatoriu de 16 octeți, CBC peste octeți, umplere a blocurilor la o graniță de 16 octeți, și IV-ul scris clar în fața textului cifrat

Urmează două consecințe. În primul rând, textul cifrat este întotdeauna mai lung decât textul clar: IV-ul adaugă 16 octeți și umplutura mai adaugă 1 până la 16, deci un șir de 100 de octeți ocupă 128 de octeți pe disc și un flux gol încă produce 32. Codul care dimensionează buffer-ul de ieșire la lungimea de intrare sau scrie înapoi doar câți octeți a citit, produce fișiere care eșuează la decriptare la ultimul bloc al fiecărui obiect. În al doilea rând, costul urmărește numărul de obiecte, nu doar numărul de octeți. O arhivă scanată își concentrează octeții în câteva fluxuri de imagini mari, dar poartă sute de mii de fluxuri scurte și șiruri mici unde costul operațional, nu AES, este cel care contează

Singura milă în designul AES-256 este gestionarea cheilor. Administratorii de securitate până la revizia 4 au derivat o cheie distinctă pentru fiecare obiect prin hashing-ul cheii fișierului împreună cu numerele de obiect și generație, forțând un nou program de chei de fiecare dată. Schemele /V 5 au renunțat la derivarea per-obiect: o singură cheie de fișier aleatoare pe 256 de biți criptează fiecare obiect din document. Acest fapt validează fiecare optimizare de mai jos — starea criptografică costisitoare poate fi construită o singură dată per fișier, nu o dată per obiect

Dicționarul /Encrypt R6: o deschidere lentă, obiecte ieftine

Un document din revizia 6 își declară schema în dicționarul /Encrypt al trailer-ului, și intrările care contează încap în câteva rânduri:

/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 selectează arhitectura cheilor pe 256 de biți și /R 6 modul întărit ISO 32000-2 de schimb de chei (handshake). /CF definește filtrul criptografic numit — /AESV3 înseamnă AES-256 în modul CBC cu IV atașat la început — iar /StmF și /StrF atribuie acel filtru fluxurilor și respectiv șirurilor. /O, /U, /OE, și /UE dețin materialul de verificare a parolei și ambalare a cheii (key-wrapping), iar /Perms conține o copie criptată AES a biților de permisiune, astfel încât un editor ostil să nu poată întoarce silențios /P

Structura de costuri se ascunde în /OE și /UE. Desfacerea cheii fișierului de la ele execută Algoritmul 2.B, o funcție iterativă de derivare a cheii înlănțuind runde SHA-256, SHA-384 și SHA-512 — cel puțin 64 dintre ele, cu o regulă de oprire dependentă de date — construită deliberat lent astfel încât ghicirea parolei să rămână costisitoare. Acest preț se plătește o dată când scriitorul produce fișierul și o dată când un cititor îl deschide, doar câteva milisecunde de fiecare. Pe un fișier de o jumătate de milion de obiecte, KDF este un zgomot de fond, iar dacă salvarea este lentă, Algoritmul 2.B nu este suspectul; bucla per-obiect este

Reutilizați mânerul cheii, reutilizați buffer-ul de lucru

Implementarea naivă este o funcție utilitară ordonată: un ajutor EncryptAes256Cbc care deschide furnizorul Windows CNG, selectează CBC, generează obiectul cheie, criptează un buffer, și dărâmă totul la loc. Corectă, ușor de testat, și dezastruoasă într-o buclă de 500.000 de iterații. Documentația Microsoft semnalează BCryptOpenAlgorithmProvider ca fiind scump și recomandă memorarea mânerului (handle), iar BCryptGenerateSymmetricKey rulează întregul orar de chei AES și alocă starea furnizorului — pierdere pură atunci când cheia nu se schimbă niciodată de-a lungul documentului

RTL-ul Delphi nu livrează nicio unitate de import bcrypt, deci declarați punctele de intrare direct. Clasa de mai jos construiește toată starea criptografică o singură dată și apoi criptează orice număr de obiecte fără alocare în starea de echilibru:

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;

Trei detalii sunt purtătoare de sarcină. Interogarea de dimensiune — primul apel BCryptEncrypt, cu un buffer de ieșire nul — returnează lungimea textului cifrat umplut (padded), niciodată egală cu lungimea de intrare; umplerea este deterministă, deci puteți calcula singuri ((Len div 16) + 1) * 16 și reduce la jumătate numărul de apeluri, dar interogarea este contractul documentat. În al doilea rând, BCryptEncrypt avansează buffer-ul IV pe loc în timp ce se înlănțuiește, astfel încât o copie de lucru intră în fiecare apel și IV-ul original aterizează la ieșire. În al treilea rând, FScratch crește doar până la cel mai mare obiect din fișier, după care bucla nu alocă nimic

Cât valorează reutilizarea mânerului, măsurat

Fișierul care a forțat acest exercițiu a fost o arhivă de împrumut scanată de 1,8 GB: 412.000 de obiecte criptate transportând o sarcină utilă de 1.710 MB odată ce structura text clar este scăzută. Aceeași mașină, același fișier, stocare NVMe, un fir de execuție:

  • Setare per-apel (furnizor deschis și cheie generată în interiorul funcției helper): faza de criptare 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
  • Stare ridicată (clasa de mai sus): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s

Diferența este de 61,7 s la 412.000 de apeluri, adică aproximativ 150 µs per apel petrecute deschizând un furnizor, setând un mod de înlănțuire și reconstruind un orar pentru o cheie care nu s-a schimbat niciodată. Nimic din toate acestea nu a fost criptografie. Cu AES-NI, criptarea CBC a bufferelor mari rulează aproape de 1,4 GB/s pe un singur nucleu, deci aritmetica AES însăși reprezintă aproximativ 1,2 s din cele 9,6; cea mai mare parte din rest reprezintă cele două tranziții în modul utilizator BCryptEncrypt per obiect plus generarea IV per obiect. Gruparea în loturi (batching) a IV-urilor — un singur apel BCryptGenRandom umplând 4.096 dintre ele — a redus rularea la 8,9 s. Dincolo de aceasta sunteți la limita per-obiect a API-ului, iar pârghia rămasă este paralelismul: obiectele /V 5 sunt independente sub cheia de fișier partajată, deci patru fire (threads) lucrătoare cu un obiect cheie fiecare au dus faza la 3,1 s înainte ca scriitorul de ieșire să devină punctul de serializare

Rescriere completă versus salvare incrementală

Granularitatea decide de asemenea cât costă o salvare. Adăugarea criptării unui document text clar existent rescrie fiecare obiect prin definiție: fiecare flux și șir își schimbă atât conținutul cât și lungimea, fiecare decalaj de referință încrucișată se mută, și nu există nicio cale incrementală. Bugetați ca o rescriere completă secvențială, și scrieți într-un fișier temporar care este redenumit peste țintă, deoarece un eșec (crash) la mijlocul criptării ar lăsa altfel un fișier pe jumătate cifrat pe care nicio parolă nu îl va deschide

Direcția inversă este cea ieftină. Odată ce un fișier este criptat, o actualizare incrementală adaugă obiecte noi criptate cu aceeași cheie a fișierului și lasă fiecare octet original neatins. Ștampilarea unei adnotări de aprobare pe o arhivă criptată de 2 GB costă kilobyți de ieșire adăugată, nu o rescriere de 2 GB. Corolarul fluxului de lucru: criptați o dată, ca ultim pas al lucrării, și lăsați atingerile ulterioare să se bazeze pe salvări incrementale. O rotație a parolei care de asemenea rotește cheia fișierului este din nou o rescriere completă — planificați-o ca atare

Măsurarea debitului fără a vă păcăli singur

Afirmațiile despre debitul criptării tind să fie greșite la numărător, numitor sau ambele. Numărătorul ar trebui să fie octeții sarcinii utile (payload): suma lungimilor fluxurilor și șirurilor efectiv trecute prin AES, după compresie, pe care scriitorul le poate totaliza pe parcurs. Dimensiunea fișierului exagerează acest lucru — arhiva de mai sus are 1,8 GB pe disc, dar doar 1.710 MB din ea ating cifrarea vreodată. Numitorul ar trebui să fie faza de criptare exclusiv, încadrată cu TStopwatch din System.Diagnostics, cu analiză (parsing), compresie deflate și I/O pe disc în afara parantezelor. Introduceți-le în calcul și același cod de criptare se va măsura de câteva ori mai lent pe un fișier care pur și simplu se comprimă mai rău. Cifrele de mai sus sunt comparabile tocmai pentru că ambele părți ale împărțirii reprezintă strict criptare

Nimic din toate acestea nu trebuie să fie cod deținut de dvs. HotPDF împachetează aceeași inginerie în spatele proprietăților componentei — ActivateProtection, CryptKeyLength, UseAES256R6 — la altitudinea potrivită pentru aplicații interactive VCL, cu capcanele ordinii de atribuire acoperite în articolul HotPDF AES-256. Pentru fluxurile de lucru nesupravegheate, PDFlibPas aplică criptarea AES-256 revizia 6 fișierelor existente într-un singur apel EncryptFile la Forța 4 și verifică apoi ce a ajuns pe disc, un flux de lucru parcurs detaliat în articolul PDFlibPas despre auditul criptării

Căile de criptare descrise aici sunt livrate în Componenta HotPDF pentru Delphi și C++Builder și în biblioteca PDFlibPas; ambele pagini ale produselor conțin referința completă de criptare