Cifrar un PDF de 2 GB suena a un problema de streaming: abrir el archivo, empujar dos gigabytes a través de AES-256, escribir el resultado. Ese modelo mental está equivocado 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 cifra 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 operaciones CBC pequeñas, no una sola pasada larga, y a esa escala el costo fijo alrededor de cada operación importa más que la aritmética AES en su interior
Este artículo trata de ese costo fijo: a dónde se 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 llamada de compatibilidad entre revisión 5 y 6 — consulta el artículo complementario sobre configurar el cifrado AES-256 en HotPDF; nada de eso se repite aquí
Medio millón de operaciones CBC, no una sola pasada
El esqueleto del archivo permanece en texto claro. Tablas de referencias cruzadas, números de objeto, claves de diccionario, el árbol de páginas: nada de eso se cifra, y así es como un lector puede localizar objetos antes de haber validado una contraseña. Lo que el estándar 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 su cuenta: un IV aleatorio fresco de 16 bytes, CBC sobre los bytes, relleno de bloque hasta un límite de 16 bytes, y el IV escrito en claro por delante del texto cifrado
Se siguen dos consecuencias. Primero, el texto cifrado siempre es más largo que el texto claro: el IV agrega 16 bytes y el relleno agrega de 1 a 16 más, así que una cadena de 100 bytes ocupa 128 bytes en disco y un flujo vacío aún produce 32. El código que dimensiona el búfer de salida según la longitud de 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 costo sigue al conteo de objetos, no solo al conteo de bytes. Un archivo escaneado concentra sus bytes en unos pocos flujos de imagen grandes, pero carga cientos de miles de flujos cortos y cadenas pequeñas donde la factura es la sobrecarga por operación, no AES
La única clemencia en el diseño de AES-256 es el manejo de claves. Los manejadores de seguridad hasta la revisión 4 derivaban una clave distinta para cada objeto haciendo hash de la clave de archivo junto con los números de objeto y generación, lo que forzaba un programa de claves fresco cada vez. Los esquemas /V 5 abandonaron 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 de abajo — el estado criptográfico costoso puede construirse 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 trailer, y las entradas que importan caben en unas 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 handshake endurecido 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ñas y de envoltura de claves, y /Perms lleva una copia cifrada con AES de los bits de permisos para que un editor hostil no pueda voltear /P en silencio
La estructura de costos se esconde en /OE y /UE. Desenvolver la clave de archivo a partir de ellos 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 dependiente de los datos — construida deliberadamente lenta para que adivinar contraseñas siga siendo costoso. Ese precio se paga una vez cuando el escritor produce el archivo y una vez cuando un lector lo abre, unos pocos milisegundos 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 lo es
Reutiliza el manejador de clave, reutiliza el búfer de trabajo
La implementación ingenua es una función utilitaria ordenada: 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 marca BCryptOpenAlgorithmProvider como costosa y recomienda almacenar el manejador en caché, y BCryptGenerateSymmetricKey ejecuta el programa de claves AES completo y asigna estado del proveedor — puro desperdicio cuando la clave nunca cambia a lo largo del documento
La RTL de Delphi no incluye una unidad de importación de bcrypt, así que declara los puntos de entrada directamente. La clase de abajo construye todo el estado criptográfico una vez y luego cifra cualquier cantidad de objetos sin asignaciones 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 de trabajo del objeto de clave CNG, asignado una vez
FScratch: TBytes; // búfer de trabajo del texto cifrado, crece y luego se queda
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 AES se construye una vez aquí 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 fresco por objeto; viaja en claro por 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 de solo relleno
// Consulta de tamaño: el relleno CBC siempre agrega 1..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 luego se queda quieto
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// Disposición AESV3: el IV de 16 bytes, luego 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 nil — devuelve la longitud del texto cifrado con relleno, nunca igual a la longitud de entrada; el relleno es determinista, así que puedes calcular ((Len div 16) + 1) * 16 tú mismo y reducir a la mitad el conteo de llamadas, pero la consulta es el contrato documentado. Segundo, BCryptEncrypt avanza el búfer del IV en el lugar mientras encadena, así que una copia de trabajo entra en cada llamada y el IV prístino aterriza en la salida. Tercero, FScratch solo crece, hasta el objeto más grande del archivo, tras lo cual el bucle no asigna nada
Lo que vale reutilizar el manejador, medido
El archivo que forzó este ejercicio fue un archivo histórico de préstamos escaneado de 1.8 GB: 412,000 objetos cifrados que cargan 1,710 MB de carga útil una vez restada la estructura en texto claro. Misma máquina, mismo archivo, almacenamiento NVMe, un 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 61.7 s a lo largo de 412,000 llamadas, o aproximadamente 150 µs por llamada gastados en 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 aritmética AES en sí representa alrededor de 1.2 s de los 9.6; la mayor parte del resto son las dos transiciones de modo usuario de BCryptEncrypt por objeto más la generación de IV por objeto. Agrupar los IV — una sola llamada a BCryptGenRandom que llena 4,096 de ellos — recortó la ejecución a 8.9 s. Más allá de eso estás en el piso por objeto de la API, y la palanca restante 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. Agregar cifrado a un documento existente en texto claro reescribe cada objeto 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 ruta incremental. Presupuéstalo como una reescritura secuencial completa, y escribe en un archivo temporal que 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 anexa objetos nuevos cifrados con la misma clave de archivo y deja intacto cada byte original. Estampar una anotación de aprobación sobre un archivo histórico cifrado de 2 GB cuesta kilobytes de salida anexada, no una reescritura de 2 GB. El corolario para la canalización: cifra una vez, como último paso del trabajo, y deja 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ámala como tal
Medir el rendimiento sin engañarte
Las afirmaciones sobre rendimiento de cifrado tienden a estar equivocadas en el numerador, en el denominador o en ambos. El numerador deberían ser los bytes de carga útil: la suma de las longitudes de flujos y cadenas realmente empujadas a través de AES, después de la compresión, que el escritor puede totalizar sobre la marcha. El tamaño del archivo lo sobreestima — el archivo histórico de arriba ocupa 1.8 GB en disco, pero solo 1,710 MB de él tocan el cifrador. El denominador debería ser la fase de cifrado sola, delimitada con TStopwatch de System.Diagnostics, con el análisis, el deflate y la E/S de disco fuera de los límites. Inclúyelos 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 solo cifrado
Nada de esto tiene que ser código tuyo. HotPDF envuelve la misma ingeniería detrás de propiedades del 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 sobre AES-256 en HotPDF. Para canalizaciones desatendidas, PDF Library for Delphi aplica AES-256 revisión 6 a archivos existentes en una sola llamada a EncryptFile con Strength 4 y verifica después lo que quedó en disco, un flujo de trabajo recorrido en el artículo sobre auditoría de cifrado de PDF Library for Delphi
Las rutas de cifrado descritas aquí se incluyen en el componente HotPDF para Delphi para Delphi y C++Builder y en la biblioteca PDF Library for Delphi; ambas páginas de producto llevan la referencia completa de cifrado