Artículo técnico

Por qué un PDF firmado AES-256 carga lento en Delphi

HotPDF carga PDFs firmados y cifrados con AES-256 en Delphi mucho más rápido que antes, porque se quitaron tres costes separados de la ruta de carga: el parseo de tokens sin búfer del stream de origen, una segunda pasada del chequeo de contraseña Algorithm 2.A de ISO 32000-2 durante el descifrado, y un bucle interno AES-128-CBC lento dentro del hash reforzado R6. En objetivos Delphi el arreglo R6 por sí solo bajó la carga de un documento de unos 66 ms a 16 ms

Ninguno de estos se manifestaba en los ficheros de prueba pequeños que usan la mayoría de suites. Una muestra sin firmar y sin cifrar de uno o dos kilobytes se abre en unas centésimas de segundo haga lo que haga el parser. El problema empieza con los documentos que los clientes envían de verdad: un contrato con una firma CMS embebida, unos 35 KB, protegido con una contraseña de revisión 6. Esa combinación activa a la vez todas las rutas de código caras, 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 PDF avanza un byte cada vez mientras busca delimitadores, así que un handle stream sin búfer 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 fichero en un búfer de lectura interno, y la sonda AutoDecrypt ya se había arreglado por el mismo motivo después de pasarse 1,4 s reconsultando el tamaño de un fichero de 56 MB. Los documentos firmados y cifrados toman una ruta distinta, eso sí. Para asegurar que las lecturas perezosas de objetos y la inspección de firmas ven siempre la misma revisión inmutable, incluso después de que el llamador libere su propio stream, 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 búfer delante. Ambos pasan ahora por el mismo lector con búfer interno que usa la ruta de fichero

Pipeline de carga de PDF cifrado AES-256 firmado en HotPDF para Delphi: stream de instantánea, parseo estructural con búfer, pre-escaneo Algorithm 2.A, un descifrado único que reutiliza la clave recuperada, y un hash reforzado R6 que llama a AES-128-CBC nativo, bajando una carga de 66 ms a 16 ms
Los tres arreglos se apilan en una ruta: el búfer elimina miles de llamadas al sistema, la clave pre-escaneada se reutiliza bajo un flag de ámbito, y el bucle interno R6 llega al backend CBC nativo

Ese búfer es interno, así que nunca lo creáis vosotros. Lo que controláis es el stream que entregáis a LoadFromStream, y HotPDF lee ese stream tal cual durante la primera pasada estructural. Si los datos ya están en memoria, pasad un TMemoryStream. Si están en disco, llamad a LoadFromFile, o en Delphi 10.1 Berlin y posteriores dad al loader un TBufferedFileStream en lugar de un TFileStream pelado

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 recuento 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 guarda su propia instantánea de los bytes. Conservad el fichero o búfer original de todos modos si pensáis verificar firmas después, ya que la verificación hace el hash de los segmentos /ByteRange en crudo del fichero y no del modelo de objetos descifrado

Medir carga y verificación como fases separadas

El tiempo de carga y el de verificación de firma deberían medirse por separado, porque fallan de formas distintas y se arreglan en sitios distintos. La fase de carga cubre parsing, chequeo de contraseña y descifrado. La fase de verificación recalcula el hash de los rangos de bytes firmados y comprueba la firma CMS. Si los fundís en un número, una regresión en cualquiera se esconde dentro del otro

El TStopwatch de la RTL de 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 mediante la sobrecarga TStream de VerifyLoadedSignature. La sobrecarga de stream es la elección correcta para un fichero cifrado, porque la ruta de carga con contraseña no conserva el nombre del fichero 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;

Ejecutadlo varias veces e ignorad la primera pasada. La primera llamada paga costes de configuración de una sola vez, incluida la construcción de tablas AES y su self-test, y la caché de disco también desvía la primera lectura. Lo que buscáis es el número de estado estable para un fichero real de 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 fichero AES-256 dos veces porque dos etapas de la misma carga ejecutaban cada una por su cuenta el chequeo completo de contraseña. Antes del parseo estructural, RecoverReloadKeysFromStreamRanges pre-escanea los bytes en crudo para que los contenedores de object streams cifrados puedan descifrarse mientras se decodifican los filtros. Ese pre-escaneo ya ejecuta las comprobaciones Algorithm 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 fichero. DecryptLoadedDocument ignoraba ese resultado y hacía toda la derivación otra vez

Para revisión 4 y anteriores, el duplicado costaba poco más que un bucle MD5. Para revisión 6, Algorithm 2.A llama al hash reforzado Algorithm 2.B, así que el duplicado doblaba la parte más cara de abrir el fichero. Reutilizar la clave recuperada es simple. Reutilizarla con seguridad es la parte difícil, porque una clave cacheada que sobrevive a su propio chequeo de contraseña se convierte en una elusión de autenticación

El arreglo limita la reutilización con un flag de ámbito que solo es verdadero 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 solo se activa cuando el pre-escaneo validó de verdad la contraseña. Una llamada directa a DecryptLoadedDocument después de una carga plana ejecuta siempre la derivación completa, porque esa contraseña nunca fue pre-escaneada. Sin esa restricción, una clave de reload caducada de una apertura anterior exitosa podría descifrar en silencio el documento para un llamador posterior que suministró la contraseña equivocada, y eso es un bug mucho peor que una carga lenta

Dónde gasta su tiempo el hash reforzado R6

El hash reforzado R6 gasta casi todo su tiempo en cifrado AES-128-CBC. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B repite una ronda al menos 64 veces. Cada ronda construye un búfer de 64 copias de la contraseña, el hash actual y, en el lado del propietario, el string /U, cifra ese búfer 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 búferes que se cifran son de varios kilobytes por ronda

Diagrama del bucle del hash reforzado R6 Algorithm 2.B en HotPDF para Delphi: cada una de al menos 64 rondas construye un búfer de 64 copias de la contraseña, el hash y el string /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 coste R6 vive en el cifrado del búfer AES-128-CBC, no en los digests, por eso 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 búfer un bloque cada vez a través de un AES de referencia orientado a bytes. Ahora prueba primero una rutina CBC nativa sin padding, que en objetivos 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 una longitud que no casa, el código vuelve al bucle histórico por bloques, y ese bucle puede a su vez usar un bloque AES-128 dirigido por tablas cuyas T-tables se generan desde la S-box existente. El cifrador es el mismo en todos los casos, así que la clave derivada es byte-idéntica independientemente de la ruta que la calculó

Este artículo va de velocidad. Ajustar bien la regla de terminación de Algorithm 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 mentirte

Un bloque AES dirigido por tablas puede producir ciphertext erróneo 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 ejecutar 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. El arreglo es un bloque rápido en crudo sin guarda al que el self-test llama directamente, así que la ruta de tablas es el código que de verdad se prueba
  • Disposiciones de tablas intercambiadas. Cambiar el orden de bytes de dos de las cuatro tablas de ronda (Te1 y Te3) produce ciphertext erróneo que el self-test re-entrante no tenía forma de notar. Lo que lo cazó fue un programa de sondeo independiente que imprimía el ciphertext FIPS-197 Apéndice C.1 69c4e0d86a7b0430d8cdb78070b4c55a para la clave 000102…0f y el plaintext 00112233…ff. El self-test exige ahora tanto ese vector como igualdad de bytes con el bloque de referencia antes de fiarse de la ruta rápida
  • Comprobaciones de rango y overflow en FPC. Bajo Free Pascal con -Cr y -Co, las conversiones explícitas estilo Delphi Byte() y la aritmética de desplazamientos a anchura de byte lanzan excepciones de rango u overflow. Los índices de tablas y el duplicado GF(2^8) se calculan ahora a anchura de entero nativo y se enmascaran con and $FF
  • Descompilación del inliner de FPC. Helpers pequeños de carga y guardado big-endian que toman parámetros PByte se compilaban en access violations al marcarlos inline bajo FPC. Quitar inline los arregló, y el coste de la llamada es despreciable junto a las consultas de tablas

La misma experiencia está detrás del cableado final. Enrutar cada bloque AES-128 de la biblioteca por las tablas rápidas causó una 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 envía es conservador: CBC nativo primero, una rama explícita de tablas rápidas dentro del bucle R6, y el bloque de referencia en el resto. La lección general es que el código criptográfico nuevo que tiene que compilar bajo varios compiladores necesita una sonda independiente de known-answer ejecutada en cada compilador objetivo. Un fallo de test a nivel de DLL no puede decir si tenéis una tabla equivocada o un crash. Para más sobre el lado Free Pascal, ved las notas sobre los backends Deflate y AES de HotPDF bajo Free Pascal

Cómo cachear la verificación de firmas sin cachear la respuesta equivocada

Una caché de verificación de firmas solo es segura 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 documento headless de HotPDF memoiza los resultados de verificación CMS haciendo hash de los bytes CMS, los bytes del diccionario de firma en el hueco de /Contents, el contenido de los rangos cubiertos, una fingerprint de política, los presupuestos de verificación, el tamaño total de la fuente y un cubo de tiempo de un minuto. Tres de esas entradas se añadieron solo después de que un bug mostró que faltaban

Entradas de la clave de caché de verificación de firmas en HotPDF para Delphi: bytes CMS, el diccionario de firma en el hueco /Contents, contenidos de rangos cubiertos, fingerprint de política, presupuestos, tamaño total de la fuente y un cubo de tiempo de un minuto se hashean juntos 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 ficheros de certificado de confianza se indexan por contenido, no por ruta. La capa de operaciones escribe el mismo material PEM en un nombre de fichero temporal fresco para cada comando, así que una clave por ruta nunca casaba y la caché nunca acertaba. El tamaño total de la fuente tenía que ser parte de la clave porque el UnsignedTrailingBytes informado depende de él: una revisión base y una actualización incremental posterior pueden compartir exactamente el mismo contenido cubierto teniendo longitudes finales distintas, y una caché indexada solo por los rangos entregaría el veredicto de la revisión base al fichero actualizado. El trasfondo de qué 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 búfer const sin tipo, y pasar una variable de array dinámico a un parámetro sin tipo pasa la dirección de la referencia del array, no los datos del array

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
  // Mal: lee la referencia del array y lo que siga en la pila.
  // Una lectura de 16 KB desde ahí se sale de la página de pila y lanza un
  // access violation, y Delphi y FPC fallan en puntos distintos.
  Writeln(Checksum(CMS, Length(CMS)));

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

El lock de la 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 en sí nunca se cachea entre cargas, y es deliberado. Los bytes externos y una contraseña suministrada explícitamente pasan siempre por la ruta real de autenticación, así que cada carga con contraseña de un documento R6 paga al menos un hash Algorithm 2.B, y tanto un hash del lado usuario como del lado propietario cuando se prueba la contraseña del propietario. La reutilización con ámbito descrita arriba elimina el duplicado dentro de una sola carga y nada más

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

FAQ

¿Reutilizar la clave pre-escaneada debilita el chequeo de contraseña?

No. HotPDF reutiliza la clave de fichero recuperada solo dentro de la misma llamada con sobrecarga de contraseña que validó la contraseña con las comprobaciones completas Algorithm 2.A, y el flag de ámbito se limpia en un bloque finally. Una llamada independiente a DecryptLoadedDocument ejecuta siempre la derivación completa, así que una clave sobrante de una apertura anterior no se puede tomar 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 comprobarla contra el fichero que se abre. HotPDF trata cada fuente de bytes externa y cada contraseña explícita como entrada no fiable que tiene que autenticarse por sí misma, así que la aceleración medible vino de hacer Algorithm 2.B más rápido y no de saltárselo

¿Debería pasar un TFileStream a LoadFromStream?

Preferid LoadFromFile para ficheros en disco, porque ya guarda en búfer internamente. Cuando necesitéis la sobrecarga de stream, pasad un TMemoryStream o un TBufferedFileStream, ya que HotPDF lee el stream que suministráis directamente durante la primera pasada estructural y un handle stream pelado 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 documento cargado 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 del HotPDF Component