Artículo técnico

Por qué los PDF firmados con AES-256 cargan lento en Delphi

HotPDF carga PDFs firmados y cifrados con AES-256 en Delphi mucho más rápido que antes, porque tres costos separados se quitaron de la ruta de carga: parseo de tokens sin buffer del flujo de origen, una segunda corrida de la verificación de contraseña del Algoritmo 2.A de ISO 32000-2 durante el descifrado, y un bucle interno lento de AES-128-CBC dentro del hash endurecido de R6. En los destinos Delphi, la corrección de R6 por sí sola llevó la carga de un documento de unos 66 ms a 16 ms

Ninguno de estos aparecía en los pequeños archivos de prueba que usan la mayoría de las suites. Una muestra sin firmar y sin cifrar de uno o dos kilobytes abre en unas centésimas de segundo haga lo que haga el parser. El problema empieza con los documentos que los clientes realmente envían: un contrato con una firma CMS incrustada, de unos 35 KB, protegido con una contraseña de revisión 6. Esa combinación enciende todas las rutas de código caras a la vez, y cada una tenía su propia razón para ser más lenta de lo necesario. Este artículo las recorre en el orden en que se encontraron, junto con los errores de cache-key que aparecieron cuando los resultados de verificación de firmas empezaron a memoizarse

¿Por qué un PDF firmado de 34 KB tarda 170 ms en cargar?

Un PDF firmado de 34 KB tardaba 170 ms en cargar porque el parser leía un THandleStream pelado directamente, y cada byte de token se convertía en su propia llamada al sistema ReadFile. Un tokenizer de PDF avanza un byte a la vez mientras busca delimitadores, así que un handle stream sin buffer convierte un escaneo barato en memoria en miles de transiciones al kernel

Los detalles explican por qué el bug sobrevivió. THotPDF.LoadFromFile lleva mucho tiempo envolviendo su fuente de archivo en un buffer de lectura interno, y la sonda AutoDecrypt ya se había corregido por la misma razón después de gastar 1.4 s re-consultando el tamaño de un archivo de 56 MB. Los documentos firmados y cifrados toman otra ruta, eso sí. Para asegurarse de que las lecturas diferidas de objetos y la inspección de firmas siempre vean la misma revisión inmutable, incluso después de que el llamador libera su propio flujo, THotPDF.LoadFromStream toma una instantánea de la fuente y la parsea una segunda vez. Ese segundo parseo, junto con la carga de reutilización usada para revisiones autenticadas, leía el handle de la instantánea sin buffer delante. Ambos ahora pasan por el mismo lector interno con buffer que usa la ruta de archivo

Pipeline de carga de PDF cifrado AES-256 firmado de HotPDF en Delphi: flujo de instantánea, parseo estructural con buffer, pre-escaneo del Algoritmo 2.A, un solo descifrado que reutiliza la clave recuperada, y un hash endurecido R6 que llama a AES-128-CBC nativo, bajando una carga de 66 ms a 16 ms
Las tres correcciones se apilan en una ruta: el buffering elimina miles de llamadas al sistema, la clave pre-escaneada se reutiliza bajo un flag de alcance, y el bucle interno de R6 llega al backend CBC nativo

Ese buffer es interno, así que usted nunca lo crea. Lo que usted controla es el flujo que entrega a LoadFromStream, y HotPDF lee ese flujo tal cual durante la primera pasada estructural. Si los datos ya están en memoria, pase un TMemoryStream. Si están en disco, llame a LoadFromFile, o en Delphi 10.1 Berlin y posteriores dele al loader un TBufferedFileStream en lugar de un TFileStream crudo

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // una lectura en bloque en lugar de lecturas por token
    Mem.Position := 0;
    // LoadFromStream devuelve el conteo de páginas, 0 cuando el documento no cargó
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

Liberar el stream de memoria justo después de la carga es seguro para entrada firmada y cifrada, porque el componente ya conserva su propia instantánea de los bytes. Conserve de todos modos el archivo o buffer original si piensa verificar firmas después, ya que la verificación aplica hash a los segmentos crudos /ByteRange del archivo en lugar del modelo de objetos descifrado

Medir carga y verificación como fases separadas

El tiempo de carga y el tiempo de verificación de firmas deben medirse por separado, porque fallan de formas distintas y se corrigen en lugares distintos. La fase de carga cubre parseo, verificación de contraseña y descifrado. La fase de verificación recalcula el hash de los rangos de bytes firmados y revisa la firma CMS. Si los funde en un solo número, una regresión en cualquiera se esconde dentro del otro

El TStopwatch de la RTL en System.Diagnostics basta para esto. El arnés de abajo carga con la sobrecarga con contraseña de THotPDF.LoadFromFile y luego verifica la primera firma a través de la sobrecarga TStream de VerifyLoadedSignature. La sobrecarga de stream es la elección correcta para un archivo cifrado, porque la ruta de carga con contraseña no conserva un nombre de archivo de origen para que las sobrecargas sin parámetros lo reabran

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Corralo varias veces e ignore la primera corrida. La primera llamada paga costos de configuración de una sola vez, incluida la construcción de las tablas AES y la corrida de su self-test, y el caché de disco también distorsiona la primera lectura. Lo que usted busca es el número de estado estable para un archivo real de un cliente, medido contra una copia sin firmar y sin cifrar del mismo documento

¿Por qué se derivaba dos veces la contraseña AES-256?

HotPDF derivaba la clave de archivo AES-256 dos veces porque dos etapas de la misma carga corrían cada una la verificación completa de contraseña por su cuenta. Antes del parseo estructural, RecoverReloadKeysFromStreamRanges pre-escanea los bytes crudos para que los contenedores de object streams cifrados puedan descifrarse mientras se decodifican los filtros. Ese pre-escaneo ya realiza las verificaciones del Algoritmo 2.A de ISO 32000-2 §7.6.4.3.3, validando la contraseña contra los salts de usuario y propietario y recuperando la clave de cifrado del archivo. DecryptLoadedDocument entonces ignoraba ese resultado y hacía toda la derivación de nuevo

Para la revisión 4 y anteriores, el duplicado costaba poco más que un bucle MD5. Para la revisión 6, el Algoritmo 2.A llama al hash endurecido del Algoritmo 2.B, así que el duplicado duplicaba la parte más cara de abrir el archivo. Reutilizar la clave recuperada es simple. Reutilizarla con seguridad es la parte más difícil, porque una clave en caché que sobrevive a su propia verificación de contraseña se convierte en un bypass de autenticación

La corrección limita la reutilización con un flag de alcance que es verdadero solo entre el pre-escaneo y la llamada de descifrado dentro de las sobrecargas con contraseña de LoadFromFile y LoadFromStream, y se limpia en un bloque finally. El flag se activa solo cuando el pre-escaneo realmente validó la contraseña. Una llamada directa a DecryptLoadedDocument tras una carga simple siempre corre la derivación completa, porque esa contraseña nunca fue pre-escaneada. Sin esa restricción, una clave de recarga obsoleta de una apertura exitosa anterior podría descifrar en silencio el documento para un llamador posterior que suministró la contraseña equivocada, y ese es un bug mucho peor que una carga lenta

¿Dónde gasta su tiempo el hash endurecido de R6?

El hash endurecido de R6 gasta casi todo su tiempo en cifrado AES-128-CBC. ISO 32000-2 §7.6.4.3.4 Algoritmo 2.B repite una ronda al menos 64 veces. Cada ronda construye un buffer de 64 copias de la contraseña, el hash actual y, del lado del propietario, la cadena /U, cifra ese buffer con AES-128-CBC usando los primeros 16 bytes del hash como clave y los siguientes 16 como IV, y elige SHA-256, SHA-384 o SHA-512 para el siguiente hash a partir de los primeros 16 bytes del ciphertext leídos como número módulo 3. Los hashes son cortos. Los buffers que se cifran son de varios kilobytes por ronda

Diagrama del bucle del hash endurecido del Algoritmo 2.B de R6 de HotPDF para Delphi: cada una de al menos 64 rondas construye un buffer de 64 copias de la contraseña, el hash y la cadena /U, lo cifra con AES-128-CBC usando el hash como clave y IV, y elige SHA-256, SHA-384 o SHA-512 a partir de los primeros bytes del ciphertext
El costo de R6 vive en el cifrado del buffer con AES-128-CBC, no en los digests, razón por la que una rutina CBC nativa dentro de PDF20HashDanceR6 baja una carga completa de documento de unos 66 ms a 16 ms

La implementación de ese bucle en HotPDF, PDF20HashDanceR6, cifraba antes el buffer un bloque a la vez a través de un AES de referencia orientado a bytes. Ahora primero prueba una rutina CBC nativa sin padding, que en los destinos Delphi llega al backend AES en C enlazado. Con ese cambio una carga completa de documento bajó de unos 66 ms a 16 ms. Si la rutina nativa no está disponible o devuelve un largo que no coincide, el código recae en el bucle de bloques histórico, y ese bucle a su vez puede usar un bloque AES-128 dirigido por tablas cuyas T-tables se generan a partir de la S-box existente. El cifrado es el mismo en cada caso, así que la clave derivada es byte-idéntica sin importar qué ruta la calculó

Este artículo va de velocidad. Ajustar la regla de terminación del Algoritmo 2.B es un problema de corrección aparte, y un off-by-one ahí rechaza contraseñas correctas solo algunas veces. El lado de configuración de R5 frente a R6 está cubierto en la guía de configuración de cifrado PDF AES-256 con HotPDF

Cuatro formas en que un bloque AES rápido puede mentirle

Un bloque AES dirigido por tablas puede producir ciphertext equivocado y aun así pasar su propio self-test. Cada una de las cuatro trampas de abajo apareció mientras se construía la ruta rápida, y dos de ellas pasaron el self-test tal como se diseñó al principio

  • Re-entrada del self-test. La rutina de preparación activa su flag de inicialización antes de correr el self-test, así que una llamada re-entrante hecha durante la prueba toma la ruta de referencia. La primera versión de la prueba comparaba por tanto la implementación de referencia consigo misma. La corrección es un bloque rápido crudo sin guarda que el self-test llama directamente, así que la ruta de tablas es el código que realmente se prueba
  • Layouts de tablas intercambiados. Intercambiar el orden de bytes de dos de las cuatro tablas de ronda (Te1 y Te3) produce ciphertext equivocado que el self-test re-entrante no tenía forma de notar. Lo que lo atrapó fue un programa de sondeo independiente que imprimía el ciphertext del Apéndice C.1 de FIPS-197 69c4e0d86a7b0430d8cdb78070b4c55a para la clave 000102…0f y el plaintext 00112233…ff. El self-test ahora exige tanto ese vector como igualdad byte a byte con el bloque de referencia antes de confiar en la ruta rápida
  • Verificaciones de rango y desbordamiento de FPC. Bajo Free Pascal con -Cr y -Co, conversiones explícitas Byte() al estilo Delphi y aritmética de desplazamientos de ancho byte lanzan excepciones de rango o desbordamiento. Los índices de tablas y el doblado GF(2^8) ahora se calculan a ancho entero nativo y se enmascaran con and $FF
  • Compilación errónea del inliner de FPC. Pequeños helpers de carga y almacenamiento big-endian que toman parámetros PByte se compilaban en access violations cuando se marcaban inline bajo FPC. Quitar inline los arregló, y el costo de la llamada es despreciable junto a las búsquedas en tablas

La misma experiencia está detrás del cableado final. Enrutar cada bloque AES-128 de la biblioteca por las tablas rápidas causó un access violation determinista de FPC durante la carga de un candidato a publicación, así que ese cambio se revirtió. El diseño que se envió es conservador: CBC nativo primero, una rama explícita de tablas rápidas dentro del bucle de R6, y el bloque de referencia en todo lo demás. La lección general es que el código criptográfico nuevo que debe compilar bajo varios compiladores necesita una sonda independiente de known-answer corrida en cada compilador objetivo. Una falla de prueba a nivel DLL no puede decirle si tiene una tabla equivocada o un crash. Para más del lado de Free Pascal, vea las notas sobre los backends Deflate y AES de HotPDF bajo Free Pascal

¿Cómo se cachea la verificación de firmas sin cachear la respuesta equivocada?

Un caché de verificación de firmas solo es seguro si su clave cubre cada entrada que puede cambiar el veredicto, incluidas las entradas que viven fuera de los rangos de bytes firmados. La capa de documentos headless de HotPDF memoiza los resultados de verificación CMS aplicando hash a los bytes del CMS, los bytes del diccionario de firma en el hueco /Contents, el contenido de los rangos cubiertos, una huella de política, los presupuestos de verificación, el tamaño total de la fuente y un intervalo de tiempo de un minuto. Tres de esas entradas se agregaron solo después de que un bug mostró que faltaban

Entradas de la clave del caché de verificación de firmas de HotPDF para Delphi: los bytes del CMS, el diccionario de firma en el hueco /Contents, el contenido de los rangos cubiertos, la huella de política, los presupuestos, el tamaño total de la fuente y un intervalo de un minuto se aplican juntos con hash antes de memoizar un veredicto CMS
Un veredicto solo puede reutilizarse cuando cada entrada que podría cambiarlo está en la clave - el tamaño de la fuente importa porque una revisión base y su actualización incremental pueden compartir rangos cubiertos idénticos

Los archivos de certificados de confianza se indexan por contenido, no por ruta. La capa de operaciones escribe el mismo material PEM en un nombre de archivo temporal fresco para cada comando, así que una clave basada en ruta nunca coincidía y el caché nunca acertaba. El tamaño total de la fuente tenía que ser parte de la clave porque el UnsignedTrailingBytes reportado depende de él: una revisión base y una actualización incremental posterior pueden compartir exactamente el mismo contenido cubierto teniendo largos finales distintos, y un caché indexado solo por los rangos entregaría el veredicto de la revisión base al archivo actualizado. El contexto de lo que significan esos bytes finales está en la guía de HotPDF para verificar firmas digitales PDF en Delphi

El tercer bug era una trampa del lenguaje Pascal, no un error de diseño. Las rutinas de hash toman un buffer const sin tipo, y pasar una variable de arreglo dinámico a un parámetro sin tipo pasa la dirección de la referencia del arreglo, no los datos del arreglo

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // Incorrecto: lee la referencia del arreglo y lo que le sigue en el stack.
  // Una lectura de 16 KB desde ahí se sale de la página del stack y lanza una
  // violation de acceso, y Delphi y FPC fallan en puntos distintos.
  Writeln(Checksum(CMS, Length(CMS)));

  // Correcto: pase el primer elemento, con guarda para el arreglo vacío
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

El lock del caché es SyncObjs.TCriticalSection en lugar de la API de Windows InitializeCriticalSection, porque la misma unidad también compila bajo FPC en Linux, donde esa API no existe

Qué se queda lento a propósito

La derivación de clave R6 misma nunca se cachea entre cargas, y eso es deliberado. Los bytes externos y una contraseña suministrada explícitamente siempre pasan por la ruta real de autenticación, así que cada carga con contraseña de un documento R6 paga al menos un hash del Algoritmo 2.B, y tanto un hash del lado de usuario como del lado de propietario cuando se prueba la contraseña del propietario. La reutilización con alcance descrita arriba elimina el duplicado dentro de una sola carga y nada más

  • La cifra de 66 ms a 16 ms aplica a destinos Delphi con el backend AES nativo enlazado. Las compilaciones FPC toman el bloque dirigido por tablas, y la generación de código de FPC en Windows corría de 2 a 4 veces más lenta que Delphi bajo las mismas aserciones
  • El caché de verificación se ata a un intervalo de tiempo de un minuto, así que las verificaciones repetidas de los mismos bytes dentro de una ventana corta son baratas, mientras que un veredicto en caché nunca sobrevive al minuto en que se calculó
  • Un caché de autoridades de revisión fusionada se implementó, se depuró y luego se eliminó. No cerró la brecha de FPC y hizo los callbacks de seguridad de Delphi mediblemente más lentos, lo cual es un buen recordatorio para medir antes de conservar un caché
  • La verificación de firmas necesita los bytes originales del archivo cifrado, así que mantenga la fuente disponible. Un descifrado más rápido no hace nada por eliminar ese requisito

FAQ

¿Reutilizar la clave pre-escaneada debilita la verificación de contraseñas?

No. HotPDF reutiliza la clave de archivo recuperada solo dentro de la misma llamada de sobrecarga con contraseña que validó la contraseña con las verificaciones completas del Algoritmo 2.A, y el flag de alcance se limpia en un bloque finally. Una llamada independiente a DecryptLoadedDocument siempre realiza la derivación completa, así que una clave sobrante de una apertura anterior no puede tomarse prestada con una contraseña equivocada

¿Por qué no cachear el resultado del hash R6 entre cargas?

Cachear el hash entre cargas significaría aceptar una contraseña sin verificarla contra el archivo que se abre. HotPDF trata cada fuente de bytes externa y cada contraseña explícita como entrada no confiable que tiene que autenticarse por sí misma, así que la aceleración medible vino de hacer el Algoritmo 2.B más rápido en lugar de saltárselo

¿Debería pasar un TFileStream a LoadFromStream?

Prefiera LoadFromFile para archivos en disco, porque ya hace buffering internamente. Cuando necesite la sobrecarga de stream, pase un TMemoryStream o un TBufferedFileStream, ya que HotPDF lee el flujo que usted suministra directamente durante la primera pasada estructural y un handle stream crudo cuesta una llamada al sistema por cada lectura pequeña

La carga protegida por contraseña, el cifrado AES-256 R6, la verificación de firmas CMS y el resto de la API de documentos cargados vienen juntos en una biblioteca VCL nativa para Delphi y C++Builder. Los detalles de funciones y las versiones de IDE soportadas están en la página de producto de HotPDF Component