Artículo técnico

Cifrado de PDF AES-256 de alta velocidad para documentos masivos

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

Diagrama PDF de la granularidad del cifrado AES-256 de PDF donde una única clave de fichero de 256 bits compartida sella medio millón de content streams y cadenas por separado mientras el esqueleto estructural permanece en texto plano
El esqueleto sigue legible mientras que cada flujo y cadena queda sellado por su cuenta — cada elemento lleva un IV nuevo de 16 bytes, encadenamiento CBC y relleno de bloque bajo una única clave de archivo compartida de 256 bits

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:

Comparación PDF de un helper AES-256 ingenuo que reconstruye proveedor CNG de Windows, modo de encadenamiento y key schedule por cada objeto PDF frente a un constructor TPdfObjectEncryptor elevado cuyo bucle no asigna nada
Reabrir el proveedor CNG y reconstruir la planificación de claves cuesta unos 150 microsegundos por objeto; el constructor izado 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 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

PDF: Gráfico de barras de los tiempos de cifrado AES-256 sobre un PDF escaneado de 1,8 GB bajando de 71,3 segundos a 9,6 segundos con estado CNG elevado, 8,9 segundos con IVs por lotes y 3,1 segundos usando cuatro hilos de trabajo
Medido sobre un escaneo de 1,8 GB con 412.000 objetos: izar el estado CNG recupera unos 61,7 segundos de pura sobrecarga de API, los IV por lotes recortan más, y cuatro subprocesos worker alcanzan 3,1 s antes de que el escritor se serialice

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