Cifrar un PDF de 2 GB suena como un problema de streaming: abra el archivo, empuje dos gigabytes a través de AES-256, escriba el resultado. Ese modelo mental es erróneo de una manera que decide todo el presupuesto de rendimiento. ISO 32000-1 §7.6 fija la granularidad del cifrado de PDF en el objeto individual — cada flujo y cada cadena se cifran por separado, cada uno con su propio vector de inicialización y su propio relleno. Un archivo escaneado de 2 GB con 500 000 objetos son 500 000 pequeñas operaciones CBC, no un único paso largo, y a esa escala el coste fijo que rodea cada operación pesa más que la aritmética de AES en su interior
Este artículo trata de ese coste fijo: adónde va el tiempo cuando el código Delphi aplica AES-256 a documentos muy grandes, y cómo recuperarlo. Para el lado de la configuración — contraseñas, indicadores de permisos, la decisión de compatibilidad entre la revisión 5 y la 6 — consulte el artículo complementario sobre la configuración del cifrado AES-256 en HotPDF; nada de eso se repite aquí
Medio millón de operaciones CBC, no un solo paso
El esqueleto del archivo permanece en texto plano. Las tablas de referencias cruzadas, los números de objeto, las claves de diccionario, el árbol de páginas: nada de eso está cifrado, lo cual permite a un lector localizar objetos antes de haber validado una contraseña. Lo que la norma cifra es el contenido — datos de flujo como descripciones de página, imágenes, fuentes y adjuntos, más cadenas como valores de metadatos y texto de anotaciones. Bajo el filtro de cifrado AES-256, cada uno se procesa por separado: un IV aleatorio de 16 bytes recién generado, CBC sobre los bytes, relleno de bloque hasta un límite de 16 bytes, y el IV escrito en claro delante del texto cifrado
De ahí se derivan dos consecuencias. Primero, el texto cifrado siempre es más largo que el texto plano: el IV añade 16 bytes y el relleno añade de 1 a 16 más, así que una cadena de 100 bytes ocupa 128 bytes en disco y un flujo vacío igualmente produce 32. El código que dimensiona el búfer de salida según la longitud de la entrada, o que escribe de vuelta solo tantos bytes como leyó, produce archivos que fallan al descifrar en el último bloque de cada objeto. Segundo, el coste sigue al recuento de objetos, no solo al recuento de bytes. Un archivo escaneado concentra sus bytes en unos pocos flujos de imagen grandes, pero acarrea cientos de miles de flujos cortos y cadenas pequeñas donde la factura la pone la sobrecarga por operación, no AES
La única gracia en el diseño de AES-256 es la gestión de claves. Los manejadores de seguridad hasta la revisión 4 derivaban una clave distinta para cada objeto aplicando un hash a la clave del archivo junto con los números de objeto y generación, obligando a reconstruir el programa de claves cada vez. Los esquemas /V 5 eliminaron la derivación por objeto: una única clave de archivo aleatoria de 256 bits cifra todos los objetos del documento. Ese hecho autoriza todas las optimizaciones que siguen — el costoso estado criptográfico se puede construir una vez por archivo, no una vez por objeto
El diccionario /Encrypt de R6: una apertura lenta, objetos baratos
Un documento de revisión 6 declara su esquema en el diccionario /Encrypt del tráiler, y las entradas que importan caben en pocas líneas:
/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 selecciona la arquitectura de clave de 256 bits y /R 6 el protocolo reforzado de ISO 32000-2. /CF define el filtro de cifrado con nombre — /AESV3 significa AES-256 en modo CBC con el IV antepuesto — y /StmF y /StrF asignan ese filtro a flujos y cadenas respectivamente. /O, /U, /OE y /UE contienen el material de verificación de contraseña y de envoltura de clave, y /Perms lleva una copia cifrada con AES de los bits de permisos para que un editor hostil no pueda alterar /P en silencio
La estructura de coste se esconde en /OE y /UE. Desenvolver la clave de archivo a partir de ellas ejecuta el Algoritmo 2.B, una función iterada de derivación de claves que encadena rondas de SHA-256, SHA-384 y SHA-512 — al menos 64 de ellas, con una regla de parada que depende de los datos — construida deliberadamente lenta para que adivinar contraseñas siga siendo costoso. Ese precio se paga una vez cuando quien escribe produce el archivo y una vez cuando un lector lo abre, milisegundos de un solo dígito cada vez. En un archivo de medio millón de objetos, la KDF es ruido, y si un guardado es lento, el Algoritmo 2.B no es el sospechoso; el bucle por objeto sí lo es
Reutilice el manejador de clave, reutilice el búfer temporal
La implementación ingenua es una función utilitaria pulcra: un ayudante EncryptAes256Cbc que abre el proveedor CNG de Windows, selecciona CBC, genera el objeto de clave, cifra un búfer y desmonta todo. Correcta, comprobable con pruebas unitarias, y desastrosa dentro de un bucle de 500 000 iteraciones. La documentación de Microsoft señala BCryptOpenAlgorithmProvider como costosa y recomienda almacenar en caché el manejador, y BCryptGenerateSymmetricKey ejecuta el programa de claves de AES completo y asigna estado del proveedor — puro derroche cuando la clave nunca cambia a lo largo del documento
El RTL de Delphi no incluye ninguna unidad de importación de bcrypt, así que declare los puntos de entrada directamente. La clase de abajo construye todo el estado criptográfico una vez y después cifra cualquier número de objetos sin asignación en estado estable:
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; // espacio del objeto de clave CNG, asignado una vez
FScratch: TBytes; // búfer temporal de texto cifrado, crece y luego se mantiene
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);
// El programa de claves de AES se construye aquí una vez y se reutiliza para cada objeto
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
// IV aleatorio nuevo por objeto; viaja en claro delante de los datos
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // nil para una entrada vacía es válido: bloque solo de relleno
// Consulta de tamaño: el relleno CBC siempre añade de 1 a 16 bytes, así que Need > Length(Plain)
IVWork := IV; // BCryptEncrypt avanza el búfer del IV mientras encadena
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); // crece un puñado de veces y después se mantiene fijo
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// Diseño AESV3: el IV de 16 bytes, después el texto cifrado con relleno
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
Tres detalles son estructurales. La consulta de tamaño — la primera llamada a BCryptEncrypt, con un búfer de salida nulo — devuelve la longitud del texto cifrado con relleno, nunca igual a la longitud de la entrada; el relleno es determinista, así que puede calcular ((Len div 16) + 1) * 16 usted mismo y reducir a la mitad el número de llamadas, pero la consulta es el contrato documentado. Segundo, BCryptEncrypt avanza el búfer del IV in situ mientras encadena, así que a cada llamada entra una copia de trabajo y el IV original queda en la salida. Tercero, FScratch solo crece, hasta el objeto más grande del archivo, tras lo cual el bucle no asigna nada más
Lo que vale la reutilización del manejador, medido
El archivo que forzó este ejercicio fue un archivo de préstamos escaneado de 1.8 GB: 412,000 objetos cifrados que transportan 1,710 MB de carga útil una vez descontada la estructura en texto plano. Misma máquina, mismo archivo, almacenamiento NVMe, un solo hilo:
- Configuración por llamada (proveedor abierto y clave generada dentro del ayudante): fase de cifrado 71.3 s — 1,710 MB ÷ 71.3 s ≈ 24 MB/s
- Estado elevado (la clase de arriba): 9.6 s — 1,710 MB ÷ 9.6 s ≈ 178 MB/s
La diferencia es de 61.7 s a lo largo de 412,000 llamadas, o aproximadamente 150 µs por llamada dedicados a abrir un proveedor, fijar un modo de encadenamiento y reconstruir un programa de claves para una clave que nunca cambió. Nada de eso era criptografía. Con AES-NI, el cifrado CBC de búferes grandes corre cerca de 1.4 GB/s en un núcleo, así que la propia aritmética de AES supone alrededor de 1.2 s de los 9.6; la mayor parte del resto son las dos transiciones en modo usuario de BCryptEncrypt por objeto más la generación de IV por objeto. Agrupar los IV — una única llamada a BCryptGenRandom que rellena 4,096 de ellos — recortó la ejecución a 8.9 s. Más allá de eso está el suelo por objeto de la API, y la palanca que queda es el paralelismo: los objetos /V 5 son independientes bajo la clave de archivo compartida, así que cuatro hilos de trabajo con un objeto de clave cada uno llevaron la fase a 3.1 s, antes de que el escritor de salida se convirtiera en el punto de serialización
Reescritura completa frente a guardado incremental
La granularidad también decide lo que cuesta un guardado. Añadir cifrado a un documento existente en texto plano reescribe todos los objetos por definición: cada flujo y cada cadena cambian tanto de contenido como de longitud, cada desplazamiento de referencia cruzada se mueve, y no existe ninguna vía incremental. Presupuéstelo como una reescritura secuencial completa, y escriba en un archivo temporal que después se renombra sobre el destino, porque de lo contrario un fallo a mitad del cifrado deja un archivo medio cifrado que ninguna contraseña abrirá
La dirección inversa es la barata. Una vez que un archivo está cifrado, una actualización incremental añade objetos nuevos cifrados con la misma clave de archivo y deja intacto cada byte original. Estampar una anotación de aprobación en un archivo cifrado de 2 GB cuesta kilobytes de salida añadida, no una reescritura de 2 GB. El corolario para la canalización: cifre una vez, como último paso del trabajo, y deje que los toques posteriores viajen en guardados incrementales. Una rotación de contraseña que también rota la clave de archivo vuelve a ser una reescritura completa — prográmela como tal
Medir el rendimiento sin engañarse
Las afirmaciones sobre el rendimiento del cifrado suelen ser erróneas en el numerador, en el denominador, o en ambos. El numerador debería ser los bytes de carga útil: la suma de las longitudes de flujos y cadenas que realmente pasan por AES, después de la compresión, que quien escribe puede totalizar sobre la marcha. El tamaño del archivo lo exagera — el archivo de arriba ocupa 1.8 GB en disco, pero solo 1,710 MB de él llegan a tocar el cifrador. El denominador debería ser solo la fase de cifrado, delimitada con TStopwatch de System.Diagnostics, dejando el análisis, el deflate y la E/S de disco fuera de los corchetes. Incluya esos y el mismo código de cifrado medirá varias veces más lento en un archivo que simplemente comprime peor. Las cifras de arriba son comparables precisamente porque ambos lados de la división son exclusivamente de cifrado
Nada de esto tiene que ser código que usted mismo mantenga. HotPDF envuelve la misma ingeniería detrás de propiedades de componente — ActivateProtection, CryptKeyLength, UseAES256R6 — a la altura adecuada para aplicaciones VCL interactivas, con las trampas del orden de asignación cubiertas en el artículo de HotPDF sobre AES-256. Para canalizaciones desatendidas, PDF Library for Delphi aplica AES-256 revisión 6 a archivos existentes en una única llamada a EncryptFile con Strength 4, y después verifica lo que quedó en disco, un flujo de trabajo recorrido en el artículo de auditoría de cifrado de PDF Library for Delphi
Las rutas de cifrado descritas aquí se incluyen en el HotPDF Delphi Component para Delphi y C++Builder y en la biblioteca PDF Library for Delphi; ambas páginas de producto llevan la referencia completa de cifrado