Artículo técnico

Cifrado PDF AES-256 de alta velocidad para archivos enormes

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

Diagrama PDF de la granularidad del cifrado AES-256 en PDF donde una única clave de archivo de 256 bits compartida sella por separado medio millón de flujos y cadenas de contenido mientras el esqueleto estructural permanece en texto claro
El esqueleto sigue siendo legible mientras cada flujo y cada cadena se sella por su cuenta — cada elemento lleva un IV fresco de 16 bytes, encadenamiento CBC y relleno de bloque bajo una única clave de archivo de 256 bits compartida

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:

Comparación PDF de un ayudante AES-256 ingenuo que reconstruye el proveedor CNG de Windows, el modo de encadenamiento y el programa de claves para cada objeto PDF frente a un constructor TPdfObjectEncryptor elevado cuyo bucle no asigna nada
Reabrir el proveedor CNG y reconstruir el programa de claves cuesta aproximadamente 150 microsegundos por objeto; el constructor elevado paga eso una vez y el bucle caliente no asigna nada
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

PDF: gráfico de barras de tiempos de cifrado AES-256 en un PDF escaneado de 1.8 GB que caen de 71.3 segundos a 9.6 segundos con estado CNG elevado, 8.9 segundos con IV agrupados y 3.1 segundos usando cuatro hilos de trabajo
Medido en un escaneo de 1.8 GB con 412,000 objetos: elevar el estado CNG recupera unos 61.7 segundos de pura sobrecarga de API, los IV agrupados recortan más, y cuatro hilos de trabajo llegan a 3.1 s antes de que el escritor serialice

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