Le chiffrement d'un PDF de 2 Go ressemble à un problème de streaming : ouvrez le fichier, poussez deux gigaoctets via AES-256, écrivez le résultat. Ce modèle mental est faux d'une manière qui décide de l'ensemble du budget de performances. L'ISO 32000-1 §7.6 définit la granularité du chiffrement PDF à l'objet individuel — chaque flux et chaque chaîne est chiffré séparément, chacun avec son propre vecteur d'initialisation (initialization vector) et son propre remplissage (padding). Une archive numérisée de 2 Go avec 500 000 objets correspond à 500 000 petites opérations CBC, et non à une longue passe, et à cette échelle, le coût fixe (fixed cost) autour de chaque opération compte plus que l'arithmétique AES à l'intérieur
Cet article traite de ce coût fixe : où va le temps lorsque le code Delphi applique AES-256 à de très grands documents, et comment le récupérer. Du côté de la configuration — mots de passe, drapeaux d'autorisation (permission flags), appel de compatibilité révision 5 par rapport à 6 — consultez l'article d'accompagnement sur la configuration du chiffrement AES-256 dans HotPDF ; rien de tout cela n'est répété ici
Un demi-million d'opérations CBC, pas une seule passe
Le squelette (skeleton) du fichier reste en texte brut. Tables de références croisées, numéros d'objets, clés de dictionnaire, arborescence des pages : rien de tout cela n'est chiffré, c'est ainsi qu'un lecteur peut localiser des objets avant d'avoir validé un mot de passe. Ce que la norme chiffre, c'est le contenu — les données de flux telles que les descriptions de page, les images, les polices et les pièces jointes, plus des chaînes telles que les valeurs de métadonnées et le texte d'annotation. Sous le filtre de chiffrement (crypt filter) AES-256, chacun est traité seul : un nouveau vecteur d'initialisation (IV) aléatoire de 16 octets, CBC sur les octets, un remplissage de bloc (block padding) sur une limite de 16 octets, et l'IV écrit en clair avant le texte chiffré (ciphertext)
Deux conséquences s'ensuivent. Premièrement, le texte chiffré (ciphertext) est toujours plus long que le texte en clair (plaintext) : l'IV ajoute 16 octets et le remplissage (padding) en ajoute 1 à 16 de plus, de sorte qu'une chaîne de 100 octets occupe 128 octets sur le disque et un flux vide en produit toujours 32. Un code qui dimensionne le tampon de sortie (output buffer) à la longueur d'entrée, ou qui ne réécrit (writes back) que le nombre d'octets qu'il a lu, produit des fichiers qui ne parviennent pas à être déchiffrés (decrypt) au dernier bloc de chaque objet. Deuxièmement, le coût suit le nombre d'objets, pas seulement le nombre d'octets. Une archive numérisée concentre ses octets dans quelques grands flux d'images, mais porte des centaines de milliers de flux courts et de petites chaînes où la surcharge (overhead) par opération, et non l'AES, est la facture (bill)
La seule miséricorde dans la conception AES-256 est la gestion des clés. Les gestionnaires de sécurité (Security handlers) jusqu'à la révision 4 dérivaient (derived) une clé distincte pour chaque objet en hachant la clé de fichier (file key) avec les numéros d'objet et de génération, forçant un nouveau calendrier de clés (key schedule) à chaque fois. Les schémas (schemes) /V 5 ont abandonné la dérivation par objet : une clé de fichier aléatoire de 256 bits chiffre chaque objet du document. Ce fait autorise (licenses) toutes les optimisations ci-dessous — l'état cryptographique coûteux peut être construit une fois par fichier, et non une fois par objet
Le dictionnaire /Encrypt R6 : une ouverture lente, des objets peu coûteux
Un document de la révision 6 déclare son schéma dans le dictionnaire /Encrypt de la bande-annonce, et les entrées qui comptent tiennent sur quelques lignes :
/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 sélectionne l'architecture de clé 256 bits et /R 6 le handshake (poignée de main) ISO 32000-2 renforcé. /CF définit le filtre de chiffrement (crypt filter) nommé — /AESV3 signifie AES-256 en mode CBC avec l'IV ajouté au début — et /StmF et /StrF affectent ce filtre aux flux et aux chaînes respectivement. /O, /U, /OE et /UE contiennent le matériel de vérification de mot de passe et d'enveloppement de clé (key-wrapping material), et /Perms porte une copie chiffrée en AES des bits d'autorisation afin qu'un éditeur hostile ne puisse pas basculer /P silencieusement
La structure de coût se cache dans /OE et /UE. Le déballage (Unwrapping) de la clé de fichier à partir d'eux exécute l'algorithme 2.B, une fonction de dérivation de clé (key-derivation function) itérée enchaînant les cycles SHA-256, SHA-384 et SHA-512 — au moins 64 d'entre eux, avec une règle d'arrêt dépendante des données — construite délibérément lentement pour que la devinette de mot de passe (password guessing) reste coûteuse. Ce prix est payé une fois lorsque le rédacteur (writer) produit le fichier et une fois lorsqu'un lecteur l'ouvre, de l'ordre de quelques millisecondes à un chiffre chacun. Sur un fichier d'un demi-million d'objets, le KDF est du bruit, et si une sauvegarde est lente, l'algorithme 2.B n'est pas le suspect ; c'est la boucle par objet
Réutiliser la poignée de clé (key handle), réutiliser le tampon de travail (scratch buffer)
L'implémentation naïve est une fonction utilitaire bien rangée : un assistant EncryptAes256Cbc qui ouvre le fournisseur Windows CNG (CNG provider), sélectionne CBC, génère l'objet clé, chiffre un tampon et démolit (tears down) le tout. Correct, testable unitairement et désastreux dans une boucle de 500 000 itérations. La documentation de Microsoft signale BCryptOpenAlgorithmProvider comme coûteux et recommande la mise en cache de la poignée (handle), et BCryptGenerateSymmetricKey exécute le calendrier complet des clés AES et alloue l'état du fournisseur (provider state) — un pur gaspillage lorsque la clé ne change jamais dans tout le document
La RTL de Delphi ne livre aucune unité d'importation bcrypt, alors déclarez les points d'entrée directement. La classe ci-dessous construit tout l'état cryptographique une fois, puis chiffre un nombre quelconque d'objets sans allocation à l'état stable (steady-state allocation) :
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;
Trois détails sont porteurs (load-bearing). La requête de taille (size query) — le premier appel BCryptEncrypt, avec un tampon de sortie nil — renvoie la longueur du texte chiffré rembourré (padded ciphertext), jamais égale à la longueur d'entrée ; le rembourrage est déterministe, vous pouvez donc calculer ((Len div 16) + 1) * 16 vous-même et diviser par deux le nombre d'appels, mais la requête est le contrat documenté. Deuxièmement, BCryptEncrypt fait avancer le tampon IV en place lors de l'enchaînement, de sorte qu'une copie de travail entre dans chaque appel et que l'IV immaculé (pristine IV) atterrit dans la sortie. Troisièmement, FScratch ne fait que croître, jusqu'au plus grand objet du fichier, après quoi la boucle n'alloue plus rien
Ce que vaut la réutilisation des poignées, mesuré
Le fichier qui a forcé cet exercice était une archive de prêt numérisée de 1,8 Go : 412 000 objets chiffrés portant 1 710 Mo de charge utile (payload) une fois la structure en texte clair soustraite. Même machine, même fichier, stockage NVMe, un seul thread :
- Configuration par appel (fournisseur ouvert et clé générée à l'intérieur de l'assistant) : phase de chiffrement 71,3 s — 1 710 Mo ÷ 71,3 s ≈ 24 Mo/s
- État hissé (State hoisted) (la classe ci-dessus) : 9,6 s — 1 710 Mo ÷ 9,6 s ≈ 178 Mo/s
La différence est de 61,7 s sur 412 000 appels, soit environ 150 µs par appel passés à ouvrir un fournisseur, à définir un mode de chaînage (chaining mode) et à reconstruire un calendrier de clés (key schedule) pour une clé qui n'a jamais changé. Rien de tout cela n'était de la cryptographie. Avec AES-NI, le chiffrement CBC de grands tampons s'exécute à près de 1,4 Go/s sur un cœur, de sorte que l'arithmétique AES elle-même représente environ 1,2 s sur les 9,6 ; l'essentiel du reste est constitué des deux transitions BCryptEncrypt en mode utilisateur (user-mode) par objet plus la génération d'IV par objet. Le traitement par lots des IV (Batching the IVs) — un appel BCryptGenRandom en remplissant 4 096 — a réduit l'exécution à 8,9 s. Au-delà de cela, vous êtes au plancher par objet de l'API, et le levier restant est le parallélisme : les objets /V 5 sont indépendants sous la clé de fichier partagée, de sorte que quatre threads de travail (worker threads) avec un objet de clé (key object) chacun ont amené la phase à 3,1 s avant que le rédacteur de sortie (output writer) ne devienne le point de sérialisation
Réécriture complète contre sauvegarde incrémentielle (incremental save)
La granularité décide également de ce que coûte une sauvegarde. L'ajout d'un chiffrement à un document en texte brut (plaintext document) existant réécrit chaque objet par définition : chaque flux et chaque chaîne modifie à la fois le contenu et la longueur, chaque décalage de références croisées (cross-reference offset) se déplace, et aucun chemin incrémentiel (incremental path) n'existe. Budgétisez-le comme une réécriture séquentielle complète, et écrivez dans un fichier temporaire qui est renommé sur la cible, car un plantage au milieu du chiffrement laisse autrement un fichier à moitié chiffré qu'aucun mot de passe n'ouvrira
Le sens inverse est celui qui est peu coûteux. Une fois qu'un fichier est chiffré, une mise à jour incrémentielle (incremental update) ajoute de nouveaux objets chiffrés avec la même clé de fichier et laisse chaque octet d'origine intact (untouched). L'apposition d'une annotation d'approbation sur une archive chiffrée de 2 Go coûte des kilo-octets de sortie ajoutée (appended output), et non une réécriture de 2 Go. Le corollaire du pipeline : chiffrez une fois, en tant que dernière étape du travail, et laissez les touches ultérieures chevaucher les sauvegardes incrémentielles. Une rotation de mot de passe qui fait également pivoter (rotates) la clé de fichier est à nouveau une réécriture complète — planifiez-la (schedule) comme telle
Mesurer le débit (throughput) sans se tromper
Les affirmations de débit (throughput) de chiffrement ont tendance à être erronées dans le numérateur, le dénominateur ou les deux. Le numérateur doit être des octets de charge utile (payload bytes) : la somme des longueurs de flux et de chaînes réellement poussées via AES, après compression, que le rédacteur (writer) peut totaliser au fur et à mesure. La taille du fichier le surestime — l'archive ci-dessus est de 1,8 Go sur le disque, mais seulement 1 710 Mo d'entre eux touchent le chiffrement. Le dénominateur doit être la phase de chiffrement seule, encadrée (bracketed) avec TStopwatch de System.Diagnostics, avec l'analyse (parsing), le dégonflement (deflate) et les E/S disque en dehors des parenthèses. Pliez-les (Fold those in) et le code de chiffrement identique mesurera plusieurs fois plus lentement sur un fichier qui se compresse simplement plus mal. Les chiffres ci-dessus sont comparables précisément parce que les deux côtés de la division sont uniquement du chiffrement (encryption-only)
Rien de tout cela ne doit être du code que vous possédez. HotPDF enveloppe (wraps) la même ingénierie (engineering) derrière les propriétés des composants — ActivateProtection, CryptKeyLength, UseAES256R6 — à la bonne altitude pour les applications VCL interactives, avec les pièges d'ordre d'affectation couverts dans l'article HotPDF AES-256. Pour les pipelines sans assistance (unattended pipelines), PDFlibPas applique l'AES-256 révision 6 aux fichiers existants dans un seul appel EncryptFile à la Force 4 (Strength 4) et vérifie ensuite ce qui a atterri sur le disque, un flux de travail parcouru (walked through) dans l'article d'audit de chiffrement PDFlibPas
Les chemins de chiffrement décrits ici sont livrés dans le Composant HotPDF pour Delphi et C++Builder et dans la bibliothèque PDFlibPas ; les deux pages de produits portent la référence complète de chiffrement