Articolo tecnico

Ritentare le Password dei PDF Crittografati in Delphi con PDFlibPas

PDFlibPas ritenta una password sbagliata su un PDF crittografato scartando il TPDFDocument appena fallito e creandone uno interamente nuovo per il tentativo successivo, guidato da un callback OnPassword (TPDFlibPasswordEvent) che gira fino a sedici tentativi prima di rinunciare. Questa è una deviazione deliberata dall'istinto a cui la maggior parte degli sviluppatori Delphi ricorre per primo: mantenere l'oggetto documento già seduto in memoria, alimentarlo con una password corretta, e caricare di nuovo sul posto invece di ripartire da zero. Il ciclo di retry di PDFlibPas, aggiunto nella v3.245.0, assume la posizione opposta, per ragioni specifiche a cosa un tentativo di password fallito lascia dietro di sé. Lo scenario dietro di esso è abbastanza ordinario che la maggior parte delle applicazioni Delphi ricche di documenti lo incontra prima o poi: una schermata di intake accetta un PDF, un trailer crittografato forza una finestra di dialogo password, l'operatore sbaglia la stringa, e la finestra ricompare per un secondo tentativo. Nulla di quell'esperienza utente è insolito, quindi il codice dietro di essa deve accettare più di una password candidata per lo stesso file, e deve farlo in sicurezza, senza far trapelare stato dal tentativo respinto in quello che segue

Perché non puoi semplicemente ritentare sullo stesso oggetto documento?

Riutilizzare un TPDFDocument attraverso i tentativi di password non funziona, perché un tentativo fallito ha già smantellato internamente quell'oggetto invece di lasciarlo in qualche stato in pausa e ripristinabile. Aprire un PDF crittografato significa analizzare la tabella cross-reference, costruire un lettore sopra la sorgente sottostante, e costruire un crypt handler da qualunque password sia stata fornita, tutto ciò prima ancora che PDFlibPas possa testare se quella password sia corretta. Quando la password risulta sbagliata, la routine di caricamento interna del documento pulisce il lettore, la tabella cross-reference, e il crypt handler come parte del fallimento, esattamente come dovrebbe, il che significa che non c'è alcun parser mezzo costruito seduto lì in attesa di una password corretta a una seconda chiamata. Pilota comunque quello stesso oggetto attraverso un altro tentativo di caricamento e la modalità di fallimento è esattamente del tipo miserabile da fare debug: un errore emerge da stato interno costruito per un'analisi diversa, già fallita, senza nulla che ovviamente punti indietro alla password tre chiamate a monte. PDFlibPas evita l'intera classe di problema non tentando mai di recuperare un oggetto documento una volta che ha fallito ad aprirsi; ogni tentativo ottiene un documento che non ha mai visto una password sbagliata, lettore e tabella cross-reference inclusi

Come chiede il callback OnPassword la password successiva?

TPDFlibPasswordEvent è il tipo di callback che PDFlibPas invoca tramite TPDFlib.LoadFromFile, LoadFromStream, e LoadFromString ogni volta che la password appena provata risulta sbagliata, e passa al gestore tre cose: quale tentativo sta per girare, un parametro Password da sovrascrivere con il candidato successivo, e un flag Retry che ha come default false

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

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

La password passata nella chiamata originale LoadFromFile conta come tentativo uno, quindi la prima volta che OnPassword si genera affatto, AttemptNumber arriva come 2. Lascia Retry non impostato e il caricamento fallisce pulitamente con LastErrorCode 404; impostalo a true e PDFlibPas riprova con qualunque cosa il gestore abbia appena scritto in Password

Dentro il ciclo di retry: un nuovo TPDFDocument per ogni tentativo

Internamente, PDFlibPas risponde alla domanda sul ciclo di vita dell'oggetto allo stesso modo per LoadFromFile, LoadFromStream, e LoadFromString: ogni tentativo, incluso il primo, costruisce un TPDFDocument fresco, lo fa passare attraverso l'intera sequenza di apertura con qualunque password quel tentativo stia usando, e mantiene l'oggetto solo se la password si verifica. Il TPDFDocument di un tentativo respinto viene liberato immediatamente, portando con sé il proprio lettore, tabella cross-reference, e crypt handler, e il tentativo successivo riparte con un oggetto che non ha alcuna storia

// 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;

Quella riga Doc := nil appena prima del blocco Finally è l'intero contratto di ciclo di vita dell'oggetto in una sola istruzione. Un documento che fallisce porta con sé nella tomba il proprio stato di parser mezzo costruito, per progetto, e un documento che riesce è l'unico mai aggiunto a FDocs, la collezione che TPDFlib mantiene per ogni documento che il chiamante ha aperto. Nulla di un tentativo respinto è visibile dall'esterno del ciclo di retry: non un lettore mezzo inizializzato, non un conteggio pagine obsoleto, non un crypt handler costruito dalla chiave sbagliata

Quante volte ritenterà PDFlibPas una password sbagliata?

PDFlibPas permette sedici tentativi totali contro una singola chiamata LoadFromFile, LoadFromStream, o LoadFromString, contando la password passata nella chiamata stessa come tentativo uno. OnPassword si genera solo per i tentativi da due a sedici, il che limita il callback a quindici invocazioni; chiedi un diciassettesimo tentativo e PDFlibPas rifiuta senza nemmeno invocare il gestore. Lascia Retry al proprio default di false in qualsiasi momento, oppure esaurisci tutti e sedici i tentativi senza una password corretta, e LoadFromFile restituisce 0 con LastErrorCode impostato a 404, il codice di PDFlibPas per una password respinta. Il tetto esiste per ragioni oltre l'ordine: un ciclo di retry illimitato è un modo facile per trasformare una password digitata male in un accidentale denial-of-service contro qualunque thread stia eseguendo il caricamento, specialmente una volta che un gestore è collegato a qualcosa di automatizzato, come un elenco di password viste in precedenza, piuttosto che un essere umano che clicca attraverso una finestra di dialogo. PDFlibPas rispetta anche Abort chiamato sull'istanza TPDFlib dall'interno del gestore, poiché Sender arriva come quello stesso oggetto, utile dietro un pulsante Annulla su una finestra di dialogo password, e ferma il ciclo di retry al controllo successivo indipendentemente da cosa fosse impostato Retry. Un caricamento che fallisce per un motivo diverso da una password sbagliata, una tabella cross-reference danneggiata ad esempio, non entra mai affatto nel ciclo di retry: PDFlibPas segnala LastErrorCode 401 e si ferma dopo il primo tentativo, perché nessun numero di tentativi di password risolve un file strutturalmente rotto

Il ciclo di retry funziona allo stesso modo per file, stream e stringhe?

Il callback OnPassword e il tetto di sedici tentativi si comportano in modo identico su LoadFromFile, LoadFromStream, e LoadFromString, sebbene i tre punti di ingresso mantengano la propria sorgente diversamente tra un tentativo e l'altro. Un percorso file è economico da rivisitare, poiché ogni tentativo semplicemente riapre il file nominato, e una sorgente stringa risiede già in memoria come copia propria del chiamante, quindi nessuna delle due ha bisogno di alcun aiuto dal chiamante tra un tentativo e l'altro. Uno stream fornito dal chiamante è l'unico caso su cui vale la pena soffermarsi: LoadFromStream riporta quello stream alla posizione zero e lo copia internamente prima del primo tentativo di analisi, quindi ogni tentativo successivo, e il TPDFDocument appena costruito dietro di esso, riproduce da quella copia interna invece che da qualunque punto un'analisi fallita abbia lasciato la posizione dello stream. Passa a PDFlibPas un TFileStream o TMemoryStream per un documento protetto da password e non c'è bisogno di riavvolgerlo tra un retry e l'altro; PDFlibPas tiene già conto di una posizione che un primo tentativo fallito potrebbe aver spostato

Inserire il retry della password in una schermata di intake documenti

Un flusso di lavoro di intake documenti è la sede naturale per questo callback, perché è esattamente la forma di problema per cui OnPassword è stato costruito per risolvere: un file arriva dall'esterno dell'applicazione, la sua password non è nota con certezza in anticipo, e la persona che fornisce i candidati ha bisogno di più di un tentativo senza che il codice circostante scriva il proprio ciclo di retry attorno a 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 riceve Lib solo una volta che LoadFromFile ha restituito 1, il che significa che qualche password in quello scambio si è effettivamente verificata contro il crypt handler del file; un tentativo respinto non raggiunge mai quella riga, e nemmeno lo fa un documento mezzo aperto. Ciò che viene dopo, una volta che un documento come questo è confermato aperto, merita una seconda occhiata alle proprie impostazioni di protezione piuttosto che il presupposto che la password che ha funzionato sia tutta la storia della sicurezza: l'audit di ciò che il dizionario /Encrypt di un documento dichiara realmente tratta la lettura dell'algoritmo, revisione, e bit di permesso che PDFlibPas espone una volta che un file come questo si carica

Il retry della password è anche un'istanza ristretta di una disciplina più ampia che PDFlibPas applica in tutto il proprio livello di analisi: un file che non ha ancora dimostrato se stesso non riceve alcun beneficio del dubbio, che la domanda sia quale password lo sblocchi o se un campo lunghezza al suo interno stia mentendo sulla dimensione del buffer di cui ha bisogno. Irrobustire un parser PDF Pascal contro file malevoli tratta l'altra metà di quella disciplina, i decoder che trattano ogni programma font e stream immagine in un PDF in ingresso come input avversario piuttosto che un documento ben formato che ha semplicemente dimenticato la propria password

OnPassword e il ciclo di retry dietro di esso fanno parte della libreria PDF PDFlibPas per Delphi e C++Builder standard, disponibile ovunque LoadFromFile, LoadFromStream, o LoadFromString siano già disponibili, senza alcun modulo separato o livello di licenza richiesto per un documento che ha semplicemente bisogno di un secondo tentativo sulla propria password