Artículo técnico

Reintentar contraseñas de PDF cifrados en Delphi con PDFlibPas

PDFlibPas reintenta una contraseña errónea en un PDF cifrado descartando el TPDFDocument que acaba de fallar y creando uno completamente nuevo para el siguiente intento, gobernado por un callback OnPassword (TPDFlibPasswordEvent) que se ejecuta hasta dieciséis veces antes de rendirse. Eso es un apartamiento deliberado del instinto al que recurre primero la mayoría de los desarrolladores Delphi: conservar el objeto de documento ya alojado en memoria, alimentarlo con una contraseña corregida, y volver a cargarlo en el sitio en lugar de empezar de nuevo desde nada. El bucle de reintento de PDFlibPas, añadido en la v3.245.0, adopta la postura opuesta, por razones específicas de lo que deja atrás un intento de contraseña fallido. El escenario detrás de esto es lo bastante ordinario como para que la mayoría de las aplicaciones Delphi con mucho documento acaben topándose con él: una pantalla de admisión acepta un PDF, un trailer cifrado fuerza un diálogo de contraseña, el operador se equivoca al teclear la cadena, y el diálogo reaparece para un segundo intento. Nada de esa experiencia de usuario es inusual, así que el código detrás tiene que aceptar más de una contraseña candidata para el mismo archivo, y tiene que hacerlo con seguridad, sin filtrar estado del intento rechazado al que le sigue

¿Por qué no podéis simplemente reintentar sobre el mismo objeto de documento?

Reutilizar un TPDFDocument a través de intentos de contraseña no funciona, porque un intento fallido ya ha desmontado internamente ese objeto en lugar de dejarlo en algún estado pausado y reanudable. Abrir un PDF cifrado significa analizar la tabla de referencia cruzada, construir un lector sobre la fuente subyacente, y construir un gestor de cifrado a partir de cualquiera que sea la contraseña suministrada, todo ello antes de que PDFlibPas pueda siquiera comprobar si esa contraseña es correcta. Cuando resulta que la contraseña está mal, la rutina de carga interna del documento limpia el lector, la tabla de referencia cruzada, y el gestor de cifrado como parte de fallar hacia fuera, exactamente como debería, lo que significa que no hay ningún analizador a medio construir esperando ahí a una contraseña corregida en una segunda llamada. Conducid de todos modos ese mismo objeto a través de otro intento de carga y el modo de fallo es exactamente del tipo que es un suplicio depurar: aflora un error desde estado interno construido para un análisis distinto, ya fallido, sin nada en él que apunte obviamente de vuelta a la contraseña tres llamadas más arriba. PDFlibPas evita toda esa clase de problema no intentando jamás recuperar un objeto de documento en cuanto ha fallado al abrir; cada intento recibe un documento que nunca ha visto una contraseña errónea, lector y tabla de referencia cruzada incluidos

¿Cómo pide el callback OnPassword la siguiente contraseña?

TPDFlibPasswordEvent es el tipo de callback que invoca PDFlibPas a través de TPDFlib.LoadFromFile, LoadFromStream y LoadFromString cada vez que la contraseña recién probada resulta ser incorrecta, y le entrega al gestor tres cosas: qué intento está a punto de ejecutarse, un parámetro Password que sobrescribir con el siguiente candidato, y un indicador Retry que por defecto es false

TPDFlibPasswordEvent = procedure(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean) of object;

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

La contraseña pasada a la llamada original a LoadFromFile cuenta como intento uno, así que la primera vez que se dispara OnPassword en absoluto, AttemptNumber llega como 2. Dejad Retry sin establecer y la carga falla limpiamente con LastErrorCode 404; ponedlo a true y PDFlibPas vuelve a intentarlo con cualquiera que sea lo que el gestor acaba de escribir en Password

Dentro del bucle de reintento: un TPDFDocument nuevo para cada intento

Internamente, PDFlibPas responde a la pregunta del ciclo de vida del objeto de la misma manera para LoadFromFile, LoadFromStream y LoadFromString: cada intento, incluido el primero, construye un TPDFDocument nuevo, lo ejecuta a través de la secuencia completa de apertura con cualquiera que sea la contraseña que use ese intento, y solo conserva el objeto si la contraseña se verifica. El TPDFDocument de un intento rechazado se libera de inmediato, llevándose con él su lector, su tabla de referencia cruzada y su gestor de cifrado, y el siguiente intento empieza de nuevo con un objeto que no tiene historial alguno

// Simplified excerpt from inside LoadFromFile: every attempt gets a
// document that has never seen a previously rejected password. FileName,
// AttemptNumber and AttemptPassword come from the enclosing method.
Var
  Doc: TPDFDocument;
  LoadResult: TPLLoadResult;
  Success: Boolean;
Begin
  Success := False;
  Repeat
    Doc := TPDFDocument.Create;
    Doc.DecodeMode := FDefaultDecodeMode;
    Try
      LoadResult := Doc.LoadFromFile(FileName, AttemptPassword);
      Success := LoadResult = lrOkay;
      if Success then
      begin
        FDocs.Add(Doc);            // hand the verified document to the
        Doc := nil;                 // caller's collection; skip the Free below
      end;
    Finally
      Doc.Free;                     // a rejected attempt's reader, xref table
    End;                            // and crypt handler are torn down right here
    if Success or (LoadResult <> lrWrongPassword) then
      Break;                        // success, or a non-password failure: stop
    Inc(AttemptNumber);
  Until not RequestPasswordRetry(AttemptNumber, AttemptPassword);
End;

Esa línea Doc := nil justo antes del bloque Finally es todo el contrato de ciclo de vida del objeto en una sola instrucción. Un documento que falla se lleva su estado de analizador a medio construir a la tumba con él, por diseño, y un documento que tiene éxito es el único que se añade jamás a FDocs, la colección que mantiene TPDFlib para cada documento que quien llama tiene abierto. Nada de un intento rechazado es visible desde fuera del bucle de reintento: ni un lector a medio inicializar, ni un recuento de páginas obsoleto, ni un gestor de cifrado construido a partir de la clave equivocada

¿Cuántas veces reintentará PDFlibPas una contraseña equivocada?

PDFlibPas permite dieciséis intentos totales contra una única llamada a LoadFromFile, LoadFromStream o LoadFromString, contando la contraseña pasada a la propia llamada como el intento uno. OnPassword solo se dispara jamás para los intentos del dos al dieciséis, lo que limita el callback a quince invocaciones; pedid un decimoséptimo intento y PDFlibPas se niega sin siquiera invocar al gestor. Dejad Retry en su valor por defecto de false en cualquier momento, o agotad los dieciséis intentos sin una contraseña correcta, y LoadFromFile devuelve 0 con LastErrorCode puesto a 404, el código de PDFlibPas para una contraseña rechazada. El límite existe por razones más allá del orden: un bucle de reintento sin límite es una forma fácil de convertir una contraseña mal tecleada en una denegación de servicio accidental contra cualquiera que sea el hilo que ejecute la carga, sobre todo en cuanto un gestor se conecta a algo automatizado, como una lista de contraseñas vistas anteriormente, en lugar de a un humano haciendo clic a través de un diálogo. PDFlibPas también respeta Abort llamado sobre la instancia TPDFlib desde dentro del gestor, ya que Sender llega como ese mismo objeto, útil detrás de un botón Cancelar en un diálogo de contraseña, y detiene el bucle de reintento en la siguiente comprobación sin importar a qué se hubiera puesto Retry. Una carga que falla por una razón distinta de una contraseña equivocada, una tabla de referencia cruzada dañada, por ejemplo, nunca entra en absoluto en el bucle de reintento: PDFlibPas reporta LastErrorCode 401 y se detiene tras el primer intento, porque ningún número de intentos de contraseña arregla un archivo estructuralmente roto

¿Funciona el bucle de reintento igual para archivos, flujos y cadenas?

El callback OnPassword y el límite de dieciséis intentos se comportan de forma idéntica en LoadFromFile, LoadFromStream y LoadFromString, aunque los tres puntos de entrada retienen su fuente de forma distinta entre intentos. Una ruta de archivo es barata de revisitar, ya que cada intento simplemente vuelve a abrir el archivo nombrado, y una fuente de cadena ya reside en memoria como la propia copia de quien llama, así que ninguna de las dos necesita ninguna ayuda de quien llama entre intentos. Un flujo suministrado por quien llama es el único caso que merece la pena detenerse a considerar: LoadFromStream vuelve a situar ese flujo en la posición cero y lo copia internamente antes del primer intento de análisis, así que cada intento posterior, y el TPDFDocument recién construido detrás de él, reproduce desde esa copia interna en lugar de desde dondequiera que un análisis fallido dejara la posición del flujo. Entregadle a PDFlibPas un TFileStream o un TMemoryStream para un documento protegido por contraseña y no hace falta rebobinarlo entre reintentos; PDFlibPas ya tiene en cuenta una posición que un primer intento fallido pueda haber movido

Encajar el reintento de contraseña en una pantalla de admisión de documentos

Un flujo de trabajo de admisión de documentos es el hogar natural de este callback, porque es exactamente la forma de problema para el que se construyó OnPassword: un archivo llega desde fuera de la aplicación, su contraseña no se conoce con certeza de antemano, y la persona que suministra candidatos necesita más de un intento sin que el código circundante escriba su propio bucle de reintento alrededor de LoadFromFile

procedure TIntakeForm.SupplyPassword(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean);
var
  Typed: string;
begin
  // AttemptNumber counts from 2: the password already tried was attempt 1.
  Typed := '';
  Retry := InputQuery('Password required',
    Format('Attempt %d of 16 - enter the document password', [AttemptNumber]), Typed);
  if Retry then
    Password := Typed;
  // Retry is False when the operator cancels, which leaves
  // LastErrorCode at 404 for the caller to report.
end;
procedure TIntakeForm.LoadInboundDocument;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.OnPassword := SupplyPassword;
    if Lib.LoadFromFile('inbound-invoice.pdf', '') = 1 then
      RegisterIntakeDocument(Lib)        // only a verified document reaches here
    else
      LogRejectedIntake('inbound-invoice.pdf', Lib.LastErrorCode);
  finally
    Lib.Free;
  end;
end;

RegisterIntakeDocument solo recibe jamás Lib en cuanto LoadFromFile ha devuelto 1, lo que significa que alguna contraseña en ese intercambio realmente se verificó contra el gestor de cifrado del archivo; un intento rechazado nunca llega a esa línea, y tampoco lo hace un documento a medio abrir. Lo que viene después, en cuanto se confirma que un documento como este está abierto, merece una segunda mirada a sus ajustes de protección en lugar de una suposición de que la contraseña que funcionó es toda la historia de seguridad: auditar qué declara realmente el diccionario /Encrypt de un documento cubre la lectura del algoritmo, la revisión y los bits de permiso que expone PDFlibPas en cuanto se carga un archivo como este

El reintento de contraseña también es una instancia estrecha de una disciplina más amplia que aplica PDFlibPas a lo largo de toda su capa de análisis: un archivo que todavía no se ha demostrado a sí mismo no recibe ningún beneficio de la duda, ya sea que la pregunta sea qué contraseña lo desbloquea o si un campo de longitud dentro de él está mintiendo sobre el tamaño de búfer que necesita. Endurecer un analizador de PDF en Pascal contra archivos maliciosos cubre la otra mitad de esa disciplina, los decodificadores que tratan cada programa de fuente y cada flujo de imagen de un PDF entrante como entrada adversaria en lugar de como un documento bien formado que simplemente olvidó su contraseña

OnPassword y el bucle de reintento que hay detrás forman parte de la biblioteca PDF PDFlibPas para Delphi y C++Builder estándar, disponible en cualquier sitio donde ya estén LoadFromFile, LoadFromStream o LoadFromString, sin necesidad de ningún módulo aparte ni de ningún nivel de licencia adicional para un documento al que simplemente le hace falta un segundo intento con su contraseña