Teknisk artikel

Genforsøg af krypterede PDF-adgangskoder i Delphi med PDFlibPas

PDFlibPas genforsøger en forkert adgangskode på en krypteret PDF ved at kassere den TPDFDocument, der lige fejlede, og oprette en helt ny til det næste forsøg, drevet af et OnPassword-callback (TPDFlibPasswordEvent), der kører op til seksten forsøg, før den giver op. Det er en bevidst afvigelse fra det instinkt, de fleste Delphi-udviklere griber til først: behold dokumentobjektet allerede siddende i hukommelsen, fodr det en rettet adgangskode, og indlæs igen på plads frem for at starte forfra fra intet. PDFlibPas' genforsøgs-løkke, tilføjet i v3.245.0, tager det modsatte standpunkt, af grunde specifikke for, hvad et mislykket adgangskode-forsøg efterlader. Scenariet bag det er almindeligt nok til, at de fleste dokument-tunge Delphi-applikationer rammer det til sidst: en intake-skærm accepterer en PDF, en krypteret trailer tvinger en adgangskode-dialog, operatøren fejltaster strengen, og dialogen dukker op igen til et andet forsøg. Intet ved den brugeroplevelse er usædvanligt, så koden bag den skal acceptere mere end én kandidat-adgangskode til den samme fil, og den skal gøre det sikkert, uden at lække tilstand fra det afviste forsøg ind i det, der følger

Hvorfor kan man ikke bare genforsøge på det samme dokumentobjekt?

At genbruge en TPDFDocument på tværs af adgangskode-forsøg fungerer ikke, fordi et mislykket forsøg allerede har revet det objekt ned internt frem for at efterlade det i en pauseret, genoptagelig tilstand. At åbne en krypteret PDF betyder at parse kryds-reference-tabellen, bygge en læser over den underliggende kilde, og konstruere en crypt-handler ud fra hvilken som helst adgangskode der blev leveret, alt sammen før PDFlibPas overhovedet kan teste, om den adgangskode er korrekt. Når adgangskoden viser sig at være forkert, rydder dokumentets interne indlæsningsrutine læseren, kryds-reference-tabellen, og crypt-handleren op som del af at fejle ud, nøjagtig som den skal, hvilket betyder, der ikke er nogen halvbygget parser siddende der og venter på en rettet adgangskode ved et andet kald. At drive det samme objekt gennem endnu et indlæsningsforsøg alligevel giver den fejltilstand, der er præcis den slags elendig at fejlfinde: en fejl viser sig fra intern tilstand bygget til en anden, allerede mislykket parsing, uden at noget ved den åbenlyst peger tilbage på adgangskoden tre kald opstrøms. PDFlibPas undgår hele klassen af problem ved aldrig at forsøge at genoprette et dokumentobjekt, når det først er fejlet med at åbne; hvert forsøg får et dokument, der aldrig har set en forkert adgangskode, læser og kryds-reference-tabel inkluderet

Hvordan beder OnPassword-callback'et om den næste adgangskode?

TPDFlibPasswordEvent er den callback-type PDFlibPas påkalder gennem TPDFlib.LoadFromFile, LoadFromStream og LoadFromString, hver gang adgangskoden lige forsøgt viser sig forkert, og den overdrager handleren tre ting: hvilket forsøg der er ved at køre, en Password-parameter at overskrive med den næste kandidat, og et Retry-flag der som standard er false

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

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

Adgangskoden sendt ind i det oprindelige LoadFromFile-kald tæller som forsøg ét, så første gang OnPassword overhovedet udløses, ankommer AttemptNumber som 2. Lad Retry stå utildelt på noget tidspunkt, og indlæsningen fejler rent med LastErrorCode 404; sæt den til true, og PDFlibPas forsøger igen med, hvad end handleren lige skrev ind i Password

Inde i genforsøgs-løkken: en ny TPDFDocument for hvert forsøg

Internt besvarer PDFlibPas objekt-livscyklus-spørgsmålet på samme måde for LoadFromFile, LoadFromStream og LoadFromString: hvert forsøg, inklusive det første, konstruerer en frisk TPDFDocument, kører den gennem den komplette åbne-sekvens med hvilken som helst adgangskode det forsøg bruger, og beholder kun objektet, hvis adgangskoden verificerer. Et afvist forsøgs TPDFDocument frigives med det samme og tager sin læser, kryds-reference-tabel, og crypt-handler ned med sig, og det næste forsøg starter forfra med et objekt, der ingen historie har overhovedet

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

Den Doc := nil-linje lige før Finally-blokken er hele objekt-livscyklus-kontrakten i én sætning. Et dokument der fejler, bærer sin halvbyggede parser-tilstand med sig i graven, med vilje, og et dokument der lykkes, er det eneste, der nogensinde tilføjes til FDocs, samlingen TPDFlib holder for hvert dokument, kalderen har åbent. Intet ved et afvist forsøg er synligt uden for genforsøgs-løkken: ikke en halv-initialiseret læser, ikke en forældet sideoptælling, ikke en crypt-handler bygget fra den forkerte nøgle

Hvor mange gange vil PDFlibPas genforsøge en forkert adgangskode?

PDFlibPas tillader seksten samlede forsøg mod et enkelt LoadFromFile-, LoadFromStream- eller LoadFromString-kald, med adgangskoden sendt ind i selve kaldet talt som forsøg ét. OnPassword udløses kun nogensinde til forsøg to gennem seksten, hvilket loftfæster callback'et til femten påkaldelser; bed om et syttende forsøg, og PDFlibPas afslår uden overhovedet at påkalde handleren. Lad Retry stå på sin standard false på noget tidspunkt, eller opbrug alle seksten forsøg uden en korrekt adgangskode, og LoadFromFile returnerer 0 med LastErrorCode sat til 404, PDFlibPas' kode for en afvist adgangskode. Loftet findes af grunde ud over nydelighed: en ubegrænset genforsøgs-løkke er en let måde at gøre én forkert-tastet adgangskode til et utilsigtet denial-of-service mod hvilken som helst tråd der kører indlæsningen, især når en handler er koblet til noget automatiseret, som en liste af tidligere sete adgangskoder, frem for et menneske der klikker gennem en dialog. PDFlibPas respekterer også Abort kaldt på TPDFlib-instansen fra inde i handleren, da Sender ankommer som det samme objekt, nyttigt bag en Annuller-knap på en adgangskode-dialog, og stopper genforsøgs-løkken ved det næste tjek uanset, hvad Retry var sat til. En indlæsning der fejler af en anden grund end en forkert adgangskode, en beskadiget kryds-reference-tabel for eksempel, går aldrig ind i genforsøgs-løkken overhovedet: PDFlibPas rapporterer LastErrorCode 401 og stopper efter det første forsøg, fordi intet antal adgangskode-gæt fikser en strukturelt ødelagt fil

Fungerer genforsøgs-løkken ens for filer, streams og strenge?

OnPassword-callback'et og seksten-forsøgs-loftet opfører sig identisk på tværs af LoadFromFile, LoadFromStream og LoadFromString, selvom de tre indgangspunkter holder på deres kilde forskelligt mellem forsøg. En filsti er billig at genbesøge, da hvert forsøg simpelthen genåbner den navngivne fil, og en streng-kilde sidder allerede i hukommelsen som kalderens egen kopi, så ingen af dem har brug for nogen hjælp fra kalderen mellem forsøg. En kalder-leveret stream er det ene tilfælde værd at dvæle ved: LoadFromStream søger den stream tilbage til position nul og kopierer den internt, før det første parsings-forsøg, så hvert efterfølgende forsøg, og den friskt konstruerede TPDFDocument bag det, genafspiller fra den interne kopi frem for fra hvor end en mislykket parsing efterlod streamens position. Giv PDFlibPas en TFileStream eller TMemoryStream til et adgangskode-beskyttet dokument, og der er intet behov for at spole den tilbage mellem genforsøg; PDFlibPas tager allerede højde for en position, et første, mislykket forsøg måske har flyttet

At tilpasse adgangskode-genforsøg til en dokument-intake-skærm

Et dokument-intake-workflow er det naturlige hjem for dette callback, fordi det er præcis den form problem, OnPassword blev bygget til at løse: en fil ankommer udefra applikationen, dens adgangskode kendes ikke med sikkerhed på forhånd, og personen der leverer kandidater, har brug for mere end ét gæt uden at den omgivende kode skriver sin egen genforsøgs-løkke omkring 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 modtager kun nogensinde Lib, når LoadFromFile har returneret 1, hvilket betyder en eller anden adgangskode i den udveksling rent faktisk verificerede mod filens crypt-handler; et afvist forsøg når aldrig den linje, og det gør et halvt-åbent dokument heller ikke. Hvad der kommer næste, når et dokument som dette er bekræftet åbent, er et andet kig på dets beskyttelses-indstillinger værd frem for en antagelse om, at adgangskoden der virkede, er hele sikkerheds-historien: at auditere, hvad et dokuments /Encrypt-ordbog rent faktisk erklærer dækker at læse algoritmen, revisionen, og tilladelses-bittene PDFlibPas eksponerer, når en fil som denne indlæses

Adgangskode-genforsøg er også en snæver instans af en bredere disciplin, PDFlibPas anvender gennem hele sit parsings-lag: en fil der endnu ikke har bevist sig selv, får ingen tvivlens fordel, uanset om spørgsmålet er, hvilken adgangskode der låser den op, eller om et længde-felt inde i den lyver om størrelsen af buffer, den har brug for. At hærde en Pascal-PDF-parser mod ondsindede filer dækker den anden halvdel af den disciplin, dekoderne der behandler hvert skrifttype-program og billed-stream i en indkommende PDF som fjendtligt input frem for et velformet dokument, der blot glemte sin adgangskode

OnPassword og genforsøgs-løkken bag det er en del af standard-PDFlibPas-PDF-biblioteket til Delphi og C++Builder, tilgængeligt hvor som helst LoadFromFile, LoadFromStream eller LoadFromString allerede er, uden noget separat modul eller licens-niveau krævet til et dokument, der bare har brug for et andet gæt på sin adgangskode