Crittografare un PDF da 2 GB suona come un problema di flusso (streaming): apri il file, fai passare due gigabyte attraverso AES-256, scrivi il risultato. Questo modello mentale è sbagliato in un modo che decide l'intero budget delle prestazioni. L'ISO 32000-1 §7.6 imposta la granularità della crittografia PDF al singolo oggetto — ogni flusso e ogni stringa vengono crittografati separatamente, ciascuno con il proprio vettore di inizializzazione e il proprio riempimento (padding). Un archivio scansionato da 2 GB con 500.000 oggetti corrisponde a 500.000 piccole operazioni CBC, non a un unico lungo passaggio, e a quella scala il costo fisso attorno a ogni operazione conta più dell'aritmetica AES al suo interno
Questo articolo tratta proprio di tale costo fisso: dove finisce il tempo quando il codice Delphi applica l'AES-256 a documenti molto grandi e come recuperarlo. Per quanto riguarda l'aspetto della configurazione — password, flag di autorizzazione (permission flags), la decisione sulla compatibilità tra revisione 5 e 6 — consulta l'articolo complementare sulla configurazione della crittografia AES-256 in HotPDF; niente di tutto ciò viene ripetuto in questa sede
Mezzo milione di operazioni CBC, non un unico passaggio
Lo scheletro del file rimane in chiaro (plaintext). Tabelle dei riferimenti incrociati, numeri degli oggetti, chiavi del dizionario, l'albero delle pagine: nulla di tutto questo è crittografato, ed è per questo che un lettore può individuare gli oggetti prima di aver convalidato una password. Ciò che lo standard crittografa è il contenuto — i dati dei flussi, quali le descrizioni delle pagine, le immagini, i font e gli allegati, oltre alle stringhe come i valori dei metadati e il testo delle annotazioni. Sotto il filtro crittografico (crypt filter) AES-256 ognuno viene elaborato singolarmente: un nuovo IV casuale da 16 byte, CBC sui byte, un block padding (riempimento) al limite dei 16 byte e l'IV scritto in chiaro prima del testo cifrato (ciphertext)
Ne conseguono due effetti. In primo luogo, il testo cifrato è sempre più lungo di quello in chiaro: l'IV aggiunge 16 byte e il padding ne aggiunge da 1 a 16, per cui una stringa da 100 byte occupa 128 byte su disco e un flusso vuoto ne produce comunque 32. Il codice che dimensiona il buffer di output in base alla lunghezza dell'input, o che riscrive solo il numero di byte letti, produce file che non riescono a essere decifrati all'ultimo blocco di ogni oggetto. In secondo luogo, il costo è proporzionale al conteggio degli oggetti, non solo al conteggio dei byte. Un archivio scansionato concentra i suoi byte in pochi flussi di immagini di grandi dimensioni, ma trasporta centinaia di migliaia di flussi brevi e stringhe piccole per i quali a pesare è il sovraccarico (overhead) per ogni singola operazione, e non l'AES in sé
L'unica grazia nel design di AES-256 è la gestione delle chiavi. Fino alla revisione 4, i gestori di sicurezza (security handlers) derivavano una chiave distinta per ogni oggetto eseguendo l'hashing della chiave del file insieme all'oggetto e ai numeri di generazione, costringendo ogni volta a un nuovo piano delle chiavi (key schedule). Gli schemi /V 5 hanno abbandonato la derivazione per singolo oggetto: un'unica chiave di file casuale a 256 bit crittografa ogni oggetto presente nel documento. Tale fatto autorizza tutte le ottimizzazioni illustrate di seguito — il dispendioso stato crittografico può essere costruito una sola volta per ciascun file, e non una volta per oggetto
Il dizionario /Encrypt R6: un'apertura lenta, oggetti economici
Un documento di revisione 6 dichiara il suo schema all'interno del dizionario /Encrypt del trailer, e le voci (entries) che contano si riassumono in poche righe:
/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 seleziona l'architettura a chiavi da 256 bit e /R 6 sceglie l'handshake rinforzato dell'ISO 32000-2. /CF definisce il filtro crittografico specificato — /AESV3 indica AES-256 in modalità CBC con l'IV preposto — mentre /StmF e /StrF assegnano quel filtro rispettivamente ai flussi e alle stringhe. /O, /U, /OE e /UE contengono la verifica della password e il materiale per l'incapsulamento della chiave (key-wrapping material), e /Perms trasporta una copia crittografata in AES dei bit di autorizzazione affinché un editor ostile non possa modificare silenziosamente /P
La struttura dei costi si nasconde in /OE e /UE. Lo spacchettamento (unwrapping) della chiave del file a partire da questi esegue l'Algorithm 2.B, una funzione iterativa di derivazione della chiave (KDF) che concatena round SHA-256, SHA-384 e SHA-512 — almeno 64, con una regola di arresto (stopping rule) dipendente dai dati — costruita per essere deliberatamente lenta, cosicché l'indovinamento delle password rimanga costoso. Quel prezzo viene pagato una volta quando lo scrittore (writer) produce il file e una volta quando un lettore lo apre, costando per ciascuno una manciata di millisecondi (a singola cifra). In un file da mezzo milione di oggetti, il KDF non è altro che rumore, e se il salvataggio risulta lento, l'Algorithm 2.B non è affatto l'indiziato; al contrario, il loop a livello di singolo oggetto lo è
Riutilizza l'handle della chiave, riutilizza il buffer temporaneo (scratch buffer)
L'implementazione ingenua (naive) è un'ordinata funzione di utilità: un helper EncryptAes256Cbc che apre il provider CNG di Windows, seleziona CBC, genera l'oggetto chiave (key object), crittografa un buffer e demolisce tutto alla fine. Corretta, testabile tramite unit-test, e disastrosa all'interno di un loop (ciclo) da 500.000 iterazioni. La documentazione Microsoft segnala che BCryptOpenAlgorithmProvider è dispendiosa e raccomanda di memorizzare l'handle nella cache; inoltre, BCryptGenerateSymmetricKey esegue l'intero piano delle chiavi AES (AES key schedule) e alloca lo stato del provider — uno spreco puro dal momento che la chiave non cambia mai in tutto il documento
La RTL di Delphi non è provvista di un'unità di importazione per bcrypt, pertanto i punti di ingresso (entry points) vanno dichiarati in maniera diretta. La classe sottostante costruisce l'intero stato crittografico una volta per tutte e quindi crittografa un qualsiasi numero di oggetti in assenza di allocazione allo stato stazionario (steady-state):
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; // spazio di lavoro (workspace) dell'oggetto chiave CNG, allocato una volta
FScratch: TBytes; // area temporanea (scratch) per il testo cifrato, cresce e poi resta immutata
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);
// Il key schedule AES viene costruito qui una volta e riutilizzato per ogni oggetto
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
// Nuovo IV casuale per ogni oggetto; viaggia in chiaro prima dei dati
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // nil per un input vuoto è valido: blocco con solo padding
// Query della dimensione: il padding CBC aggiunge sempre 1..16 byte, quindi Need > Length(Plain)
IVWork := IV; // BCryptEncrypt fa avanzare il buffer IV mentre esegue il chaining
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); // cresce solo qualche volta, per poi restare fissa
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// Layout AESV3: l'IV da 16 byte, seguito dal testo cifrato con padding
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
Tre dettagli sono da considerare come veri pilastri portanti. L'interrogazione (query) sulla dimensione — la prima chiamata a BCryptEncrypt, con un buffer di output impostato a nil — restituisce la lunghezza del testo cifrato sottoposto a padding, la quale non sarà mai uguale alla lunghezza dell'input; il padding è deterministico, per cui puoi calcolare ((Len div 16) + 1) * 16 da solo e dimezzare il conteggio delle chiamate, ma l'interrogazione resta comunque il contratto documentato. In secondo luogo, BCryptEncrypt fa avanzare il buffer IV in loco (in place) non appena effettua il concatenamento (chaining), quindi una copia di lavoro va in ciascuna chiamata e l'IV intatto arriva all'output. In terzo luogo, FScratch si limita a crescere, fino ad arrivare al più grande oggetto nel file, dopodiché il loop non alloca più niente
Quanto vale misurare il riutilizzo dell'handle
Il file che ha forzato (forced) quest'esercizio è stato un archivio di prestiti scansionato da 1,8 GB: 412.000 oggetti crittografati che trasportano 1.710 MB di payload, una volta sottratta la struttura in chiaro (plaintext structure). Stessa macchina, stesso file, archiviazione NVMe, un solo thread:
- Configurazione per chiamata (Per-call setup) (provider aperto e chiave generata all'interno dell'helper): fase di crittografia 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Stato issato (State hoisted) (la classe mostrata sopra): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
La differenza equivale a 61,7 s calcolata su 412.000 chiamate, oppure, per farla breve, circa 150 µs a chiamata spesi per aprire un provider, impostare una modalità di concatenamento (chaining mode) e ricostruire un piano (schedule) per una chiave che non è mai cambiata. Niente di tutto questo era crittografia. Con AES-NI, la crittografia CBC dei grandi buffer sfiora i 1,4 GB/s in un solo core, per cui l'aritmetica AES da sola rappresenta all'incirca 1,2 s dei 9,6; per il resto, si tratta perlopiù delle due transizioni in modalità utente (user-mode) di BCryptEncrypt per ogni oggetto, a cui si aggiunge la generazione dell'IV per singolo oggetto. L'elaborazione in blocco (batching) degli IV — una singola chiamata a BCryptGenRandom che provvede a riempirne ben 4.096 — ha ridotto l'esecuzione (run) a soli 8,9 s. Superato questo limite, ti trovi al livello base (floor) per ogni oggetto della API, per cui non ti resta che la leva del parallelismo (parallelism): gli oggetti /V 5 sono indipendenti sotto la chiave del file condivisa, perciò quattro worker thread provvisti ciascuno di un solo oggetto chiave (key object) hanno portato la fase a 3,1 s, ancor prima che il sistema di scrittura (writer) dell'output divenisse il punto di serializzazione
Riscrittura completa rispetto al salvataggio incrementale
La granularità stabilisce pure quale sia il costo di un salvataggio. L'aggiunta della crittografia a un documento in chiaro già esistente riscrive ogni oggetto per definizione: qualsiasi flusso (stream) o stringa subisce variazioni a livello di contenuto e lunghezza, così come ogni offset di riferimento incrociato finisce per spostarsi, il che significa che non è presente alcun percorso (path) incrementale. Mettilo in bilancio (budget) come una vera riscrittura sequenziale completa, e accertati di scrivere un file temporaneo affinché possa essere rinominato (renamed) e sovrapposto (over) al bersaglio (target), poiché in caso contrario un crash in piena crittografia ti lascerebbe con in mano un file decifrato a metà che non può essere aperto da nessuna password
La direzione inversa è indubbiamente quella più economica. Una volta che un file viene crittografato, un aggiornamento (update) di tipo incrementale provvede ad aggiungere (appends) i nuovi oggetti crittografati utilizzando la medesima chiave del file (file key) e al contempo lascia del tutto inalterato qualsivoglia byte originale. La timbratura di un'annotazione di approvazione su un archivio cifrato da 2 GB si traduce in appena dei kilobyte di output accodato (appended output), e non certo in una riscrittura da ben 2 GB. Da ciò si evince il corollario (corollary) associato alla pipeline: crittografa una singola volta (come ultimo passaggio del processo), dopodiché fai in modo che gli interventi (touches) successivi sfruttino i salvataggi incrementali. Una rotazione (rotation) della password in grado di far ruotare pure la chiave del file sfocia di nuovo in una riscrittura completa: per tale ragione ti converrebbe programmarla allo stesso modo
Misurazione del throughput (portata) senza illudersi
Le dichiarazioni relative al throughput di crittografia (portata crittografica) sono di norma errate per via del numeratore, del denominatore (denominator) oppure per via di entrambi. Il numeratore dovrebbe corrispondere ai byte di payload: la somma del flusso (stream) e della lunghezza della stringa spinti effettivamente lungo AES in seguito alla compressione, per consentire al sistema di scrittura (writer) di procedere con la totalizzazione di pari passo (as it goes). Le dimensioni del file tendono a sovrastimare il tutto: l'archivio sopracitato è da 1,8 GB su disco, quantunque al suo interno solo 1.710 MB tocchino effettivamente la cifra (cipher). Il denominatore invece dovrebbe corrispondere unicamente alla fase di crittografia, il tutto delimitato fra parentesi (bracketed) da TStopwatch prelevato da System.Diagnostics, e per finire parsing, decompressione (deflate) e I/O del disco vanno tenuti fuori da suddette parentesi. Piegali al loro interno (Fold those in) ed ecco che un codice di cifratura (encryption code) pressoché identico arriverà a misurare diverse volte più lentamente qualora si tratti di un file che subisce semplicemente un grado di compressione peggiore. Le cifre citate dianzi sono comparabili appieno proprio per il fatto che entrambi i lati afferenti alla divisione risultano esclusivamente crittografici
Niente di tutto ciò deve essere per forza del codice di cui detieni la proprietà. HotPDF è provvisto della medesima ingegnerizzazione racchiusa all'interno delle proprietà (properties) dei componenti — ActivateProtection, CryptKeyLength, UseAES256R6 — situate all'altitudine ideale per accogliere le applicazioni VCL interattive, il tutto assieme alle insidie che derivano dall'ordine di assegnazione (assignment-order pitfalls) contemplate nell'articolo AES-256 di HotPDF. Quanto alle pipeline automatizzate (unattended pipelines), PDFlibPas si occupa di applicare la revisione 6 (AES-256) ai file in circolazione servendosi di un'unica chiamata (call) EncryptFile presso lo Strength 4 (Forza 4) per poi verificare a posteriori quel che approda (landed) sul disco, un vero e proprio workflow (flusso di lavoro) passato al setaccio pezzo per pezzo all'interno dell'articolo dedicato all'audit crittografico (encryption audit) di PDFlibPas
I percorsi di crittografia (encryption paths) qui descritti vengono resi disponibili (ship) tanto all'interno del Componente HotPDF destinato a Delphi e C++Builder, quanto nella libreria PDFlibPas; le pagine di questi due prodotti mettono difatti a disposizione il riferimento completo afferente alla crittografia