Technisch artikel

Versleutelde PDF-wachtwoorden opnieuw proberen in Delphi met PDFlibPas

PDFlibPas probeert een verkeerd wachtwoord op een versleutelde PDF opnieuw door het TPDFDocument dat net faalde weg te gooien en een compleet nieuw exemplaar aan te maken voor de volgende poging, aangedreven door een OnPassword-callback (TPDFlibPasswordEvent) die tot zestien pogingen lang draait voordat het opgeeft. Dat is een doelbewuste afwijking van het instinct waar de meeste Delphi-ontwikkelaars als eerste naar grijpen: het documentobject dat al in het geheugen zit behouden, er een gecorrigeerd wachtwoord aan voeren, en opnieuw ter plekke laden in plaats van vanaf niets opnieuw te beginnen. De retry-lus van PDFlibPas, toegevoegd in v3.245.0, neemt het tegenovergestelde standpunt in, om redenen specifiek voor wat een mislukte wachtwoordpoging achterlaat. Het scenario erachter is gewoon genoeg dat de meeste document-zware Delphi-toepassingen het uiteindelijk tegenkomen: een intakescherm accepteert een PDF, een versleutelde trailer forceert een wachtwoorddialoog, de operator typt de string verkeerd, en de dialoog verschijnt opnieuw voor een tweede poging. Niets aan die gebruikerservaring is ongewoon, dus de code erachter moet meer dan één kandidaatwachtwoord voor hetzelfde bestand accepteren, en dat moet veilig gebeuren, zonder dat toestand van de afgewezen poging lekt naar de poging die erop volgt

Waarom kunt u niet gewoon opnieuw proberen op hetzelfde documentobject?

Een TPDFDocument hergebruiken over wachtwoordpogingen heen werkt niet, omdat een mislukte poging dat object intern al heeft afgebroken in plaats van het in een gepauzeerde, hervatbare toestand achter te laten. Een versleutelde PDF openen betekent de cross-reference-tabel parsen, een lezer bouwen over de onderliggende bron, en een crypt-handler construeren uit welk wachtwoord ook is aangeleverd, allemaal voordat PDFlibPas zelfs maar kan testen of dat wachtwoord correct is. Wanneer het wachtwoord verkeerd blijkt, ruimt de interne laadroutine van het document de lezer, de cross-reference-tabel, en de crypt-handler op als onderdeel van het falen, precies zoals het hoort, wat betekent dat er geen halfgebouwde parser klaarstaat om te wachten op een gecorrigeerd wachtwoord bij een tweede aanroep. Drijf datzelfde object toch door een volgende laadpoging en de faalmodus is precies het soort dat ellendig is om te debuggen: een fout komt naar boven vanuit interne toestand gebouwd voor een andere, al mislukte parse, zonder dat er duidelijk iets naar het wachtwoord drie aanroepen stroomopwaarts wijst. PDFlibPas vermijdt de hele klasse probleem door nooit te proberen een documentobject te herstellen zodra het niet is geopend; elke poging krijgt een document dat nog nooit een verkeerd wachtwoord heeft gezien, lezer en cross-reference-tabel inbegrepen

Hoe vraagt de OnPassword-callback om het volgende wachtwoord?

TPDFlibPasswordEvent is het callbacktype dat PDFlibPas aanroept via TPDFlib.LoadFromFile, LoadFromStream, en LoadFromString telkens wanneer het net geprobeerde wachtwoord verkeerd blijkt, en het geeft de handler drie dingen: welke poging op het punt staat te draaien, een Password-parameter om te overschrijven met de volgende kandidaat, en een Retry-vlag die standaard false is

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

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

Het wachtwoord dat in de oorspronkelijke LoadFromFile-aanroep is doorgegeven, telt als poging één, dus de eerste keer dat OnPassword überhaupt afgaat, komt AttemptNumber binnen als 2. Laat Retry ongewijzigd en het laden mislukt netjes met LastErrorCode 404; zet het op true en PDFlibPas probeert het opnieuw met wat de handler net in Password heeft geschreven

Binnen in de retry-lus: een nieuw TPDFDocument voor elke poging

Intern beantwoordt PDFlibPas de vraag over de objectlevenscyclus op dezelfde manier voor LoadFromFile, LoadFromStream, en LoadFromString: elke poging, inclusief de eerste, construeert een vers TPDFDocument, laat het door de volledige openingssequentie lopen met welk wachtwoord die poging ook gebruikt, en behoudt het object alleen als het wachtwoord verifieert. Het TPDFDocument van een afgewezen poging wordt onmiddellijk vrijgegeven, en neemt zijn lezer, cross-reference-tabel, en crypt-handler daarbij mee naar beneden, en de volgende poging begint opnieuw met een object zonder enige geschiedenis

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

Die regel Doc := nil vlak voor het Finally-blok is het hele objectlevenscycluscontract in één statement. Een document dat faalt, draagt zijn halfgebouwde parsertoestand met zich mee het graf in, doelbewust, en een document dat slaagt, is het enige dat ooit aan FDocs wordt toegevoegd, de verzameling die TPDFlib bijhoudt voor elk document dat de aanroeper open heeft. Niets van een afgewezen poging is zichtbaar van buiten de retry-lus: geen half-geïnitialiseerde lezer, geen verouderde paginatelling, geen crypt-handler gebouwd uit de verkeerde sleutel

Hoe vaak zal PDFlibPas een verkeerd wachtwoord opnieuw proberen?

PDFlibPas staat zestien totale pogingen toe tegen één enkele aanroep van LoadFromFile, LoadFromStream, of LoadFromString, waarbij het wachtwoord dat in de aanroep zelf is doorgegeven als poging één telt. OnPassword gaat alleen af voor pogingen twee tot en met zestien, wat de callback beperkt tot vijftien aanroepen; vraag om een zeventiende poging en PDFlibPas weigert zonder zelfs maar de handler aan te roepen. Laat Retry op enig moment op zijn standaard false staan, of put alle zestien pogingen uit zonder correct wachtwoord, en LoadFromFile geeft 0 terug met LastErrorCode ingesteld op 404, PDFlibPas's code voor een afgewezen wachtwoord. Het plafond bestaat om redenen die verder gaan dan netheid: een onbegrensde retry-lus is een gemakkelijke manier om één verkeerd getypt wachtwoord te veranderen in een onbedoelde denial-of-service tegen welke thread ook het laden draait, vooral zodra een handler is aangesloten op iets geautomatiseerds, zoals een lijst met eerder geziene wachtwoorden, in plaats van een mens die door een dialoog klikt. PDFlibPas eerbiedigt ook Abort aangeroepen op de TPDFlib-instantie van binnen de handler, aangezien Sender als datzelfde object binnenkomt, nuttig achter een Cancel-knop op een wachtwoorddialoog, en stopt de retry-lus bij de volgende controle, ongeacht wat Retry was ingesteld. Een laadbewerking die om een andere reden dan een verkeerd wachtwoord mislukt, bijvoorbeeld een beschadigde cross-reference-tabel, komt de retry-lus helemaal nooit binnen: PDFlibPas rapporteert LastErrorCode 401 en stopt na de eerste poging, omdat geen enkel aantal wachtwoordgissingen een structureel kapot bestand repareert

Werkt de retry-lus hetzelfde voor bestanden, streams, en strings?

De OnPassword-callback en het plafond van zestien pogingen gedragen zich identiek over LoadFromFile, LoadFromStream, en LoadFromString, hoewel de drie toegangspunten hun bron tussen pogingen door verschillend vasthouden. Een bestandspad is goedkoop om opnieuw te bezoeken, aangezien elke poging simpelweg het genoemde bestand opnieuw opent, en een string-bron zit al in het geheugen als de eigen kopie van de aanroeper, dus geen van beide heeft hulp van de aanroeper tussen pogingen nodig. Een door de aanroeper aangeleverde stream is het ene geval dat het waard is om bij stil te staan: LoadFromStream zoekt die stream terug naar positie nul en kopieert deze intern vóór de eerste parse-poging, dus elke volgende poging, en het vers geconstrueerde TPDFDocument erachter, speelt af vanuit die interne kopie in plaats van vanaf waar een mislukte parse de positie van de stream ook achterliet. Geef PDFlibPas een TFileStream of TMemoryStream voor een met wachtwoord beveiligd document en er is geen noodzaak om deze tussen pogingen door terug te spoelen; PDFlibPas houdt al rekening met een positie die een eerste, mislukte poging mogelijk heeft verplaatst

Wachtwoord-retry inpassen in een documentintakescherm

Een documentintake-workflow is het natuurlijke thuis voor deze callback, omdat het precies de vorm probleem is waarvoor OnPassword is gebouwd om op te lossen: een bestand komt van buiten de toepassing binnen, het wachtwoord ervan is vooraf niet met zekerheid bekend, en degene die kandidaten aanlevert, heeft meer dan één gok nodig zonder dat de omringende code zijn eigen retry-lus rond LoadFromFile schrijft

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 ontvangt Lib pas zodra LoadFromFile 1 heeft teruggegeven, wat betekent dat een of ander wachtwoord in die uitwisseling daadwerkelijk verifieerde tegen de crypt-handler van het bestand; een afgewezen poging bereikt die regel nooit, en evenmin een half-open document. Wat er hierna komt, zodra een document zoals dit bevestigd is geopend, is een tweede blik op zijn beschermingsinstellingen waard, in plaats van de aanname dat het wachtwoord dat werkte het hele beveiligingsverhaal is: het auditen van wat de /Encrypt-dictionary van een document daadwerkelijk verklaart behandelt het lezen van het algoritme, de revisie, en de toestemmingsbits die PDFlibPas blootstelt zodra een bestand zoals dit laadt

Wachtwoord-retry is ook een smalle instantie van een bredere discipline die PDFlibPas overal in zijn parselaag toepast: een bestand dat zichzelf nog niet heeft bewezen, krijgt geen voordeel van de twijfel, of de vraag nu is welk wachtwoord het ontgrendelt of of een lengteveld erin liegt over de grootte van de buffer die het nodig heeft. Het verharden van een Pascal-PDF-parser tegen kwaadaardige bestanden behandelt de andere helft van die discipline, de decoders die elk lettertypeprogramma en elke beeldstream in een binnenkomende PDF als vijandige invoer behandelen in plaats van een welgevormd document dat gewoon zijn wachtwoord vergat

OnPassword en de retry-lus erachter maken deel uit van de standaard PDFlibPas PDF-bibliotheek voor Delphi en C++Builder, beschikbaar overal waar LoadFromFile, LoadFromStream, of LoadFromString al beschikbaar zijn, zonder aparte module of licentieniveau vereist voor een document dat gewoon een tweede gok op zijn wachtwoord nodig heeft