Teknisk artikkel

Bygge en arbeidsbenk for samsvar og signering i Delphi med PDF Library for Delphi

En arbeidsbenk som kjeder samsvarsvalidering sammen med digital signering må koordinere fire trinn, i denne rekkefølgen, og holde dem knyttet til ett og samme sett med byte hele veien. Den kjører en PDF/A- eller PDF/UA-preflight. Den bruker de rettelsene funnene krever, og lagrer en korrigert revisjon. Den signerer nøyaktig den revisjonen. Deretter leser den den signerte filen tilbake og bekrefter at signaturen virkelig dekker den. Rekkefølgen er ikke kosmetisk. Hopper du over tilbakelesingen, stoler du på din egen skrivevei; lar du preflighten kjøre mot feil revisjon, beskriver samsvarsrapporten en fil du aldri leverte

Den delen de fleste hjemmesnekrede pipelinene bommer på, er skjøten mellom validering og signering. Kjører du dem som to separate verktøy med en utbedringsrunde imellom, oppstår minst tre ulike revisjoner av filen, hver med sine egne byte. Preflight-rapporten du gir en revisor, beskriver den ene. Signaturen fryser en annen. Ingenting i filen sier at de er samme revisjon, og ofte er de det ikke. PDF Library for Delphi, losLabs PDF Developer Library for Delphi og C++Builder, samler preflight og PAdES-signering bak én fasadeklasse, slik at hele sekvensen kan leve i én prosess som aldri mister oversikten over hvilke byte den snakker om. Hvert kall nedenfor finnes i biblioteket i dag, og det samme gjør hver felle som nevnes ved siden av

Diagram over en Delphi-arbeidsbenk for samsvar og signering der preflight, utbedring, PAdES-signering og ByteRange-revisjon hver registrerer en SHA-256 over nøyaktig den revisjonen de berører
Hasher registrert ved hver lagring knytter preflight-rapporten, PAdES-signaturen og gjennomgangen til én identisk revisjon

Tre revisjoner av ett dokument, og hvordan gapet oppstår

Tell lagringene. Originalen kommer fra et oppstrømssystem. Utbedringsrunden laster den, slår på en samsvarsmodus og skriver en korrigert revisjon. Signeringsrunden legger til en signatur som en inkrementell oppdatering, og det er en tredje skriving. Tre lagringer, tre byteoppsett, og en preflight-rapport betyr ingenting med mindre den navngir hvilken av de tre den dekker. En SHA-256 av filen, notert ved siden av hver preflight-kjøring og hver signatur, er det billige ankeret som lar deg bevise at revisjonen du validerte, er revisjonen du signerte

Én oppførsel i biblioteket strammer den disiplinen ytterligere. Samsvarsrettelser som bes om gjennom SetPDFAMode eller SetPDFUAMode, trer ikke i kraft når du kaller dem. De blir anvendt under lagringen. Automatiske reparasjoner som å tvinge fram utskriftsflagg på annotasjoner eller tildele en PDF/UA-tabulatorrekkefølge havner i utdatafilen og ingen andre steder, så en kontroll kjørt mot dokumentet du nettopp «fikset» i minnet, forteller deg ingenting om de bytene som er på vei til signereren. Lagre først, kjør så preflight på den lagrede filen. Tilstanden i minnet er et utkast; bare filen på disk er virkelig

Preflight fra disk, og nullen som betyr to ting

Det flate inngangspunktet for preflight er CheckFileCompliance(FileName, Password, ComplianceTest, Options). Test 1 velger PDF/A (ISO 19005), test 2 velger PDF/UA (ISO 14289). Den åpner filen gjennom bibliotekets strømmende leser, så det er ikke nødvendig å kalle LoadFromFile først, og den returnerer et strenglistehåndtak som bærer ett funn per oppføring:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

Fellen sitter i returverdien, og den er av den typen som består enhver test langs den lykkelige stien. Null betyr «ingen funn». Null betyr også «filen kunne ikke åpnes», fordi implementasjonen returnerer 0 hver gang resultatlisten kommer tom tilbake, en lesefeil inkludert. En arbeidsbenk som leser 0 som grønt lys, godkjenner villig en fil som en annen prosess har låst. Å pare kallet med LastErrorCode, slik som ovenfor, er det som skiller de to tilfellene. Kontrolløren åpner dessuten filen med en delingsmodus som nekter skriving, så hvis utbedringstrinnet ditt fortsatt holder et skrivehåndtak, feiler preflighten av en grunn som ikke har noe med samsvar å gjøre, og alt med en strøm du glemte å frigjøre

Beslutningsdiagram som viser hvordan LastErrorCode skiller de to betydningene av en null-retur fra CheckFileCompliance i en Delphi PDF-preflight
En null fra CheckFileCompliance betyr ingenting før LastErrorCode skiller en tom funnliste fra en fil biblioteket ikke kunne åpne

Når et menneske og ikke en pipeline skal lese funnene, gjengir CreatePreflightReport dem som en lesbar rapport. ComparePreflightReports sammenligner to kjøringer, som er en ryddig måte å vise at utbedringen fjernet de opprinnelige funnene uten stilltiende å innføre nye

Signering av den kontrollerte revisjonen med en SignProcess

Når den lagrede revisjonen har bestått preflight og hashen er notert, signerer du nøyaktig den filen og ingen annen. SignProcess-API-et leser som en byggmester. Åpne et prosesshåndtak, konfigurer det linje for linje, bekreft, og les så resultatkoden tilbake

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // PAdES-baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // plass til et senere tidsstempel
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

To linjer i den sekvensen veier tyngre enn de ser ut til. SetSignProcessCustomSubFilter med ETSI.CAdES.detached velger en PAdES-signatur slik den er profilert i ETSI EN 319 142-1, framfor den eldre adbe.pkcs7.detached-familien, og det er forskjellen på en signatur en europeisk validator godtar, og en den flagger. SetSignProcessReserveContentsBytes polstrer plassholderen /Contents, og størrelsen du velger her, er en beslutning om framtiden: skal et signaturtidsstempel noen gang følge etter, må den utvidede CMS-en få plass i det du reserverer nå, for plassholderen kan ikke vokse senere uten at hele dokumentet signeres på nytt. Reserverer du rundhåndet, kaster du bort noen kilobyte. Reserverer du for knapt, feiler tidsstemplingstrinnet om mange måneder med et overløp du vil slite med å knytte tilbake til denne ene linjen

GetSignProcessResult svarer med en kode, ikke en boolsk verdi, og kodene er verdt å ta vare på. 1 er suksess. 4 er feil PDF-passord, 7 feil sertifikatpassord, 9 en PFX som ikke bærer noen privat nøkkel, 11 en feil mens signaturen ble påført. Slår du dem sammen til sant/usant, kaster du bort den ene opplysningen som skiller en støttesak om feil passord fra en om en nøkkel uten privat del. Logg heltallet

Tilbakelesing: å granske filen du nettopp produserte

Ingen arbeidsbenk bør stole på den skriveveien som produserte filen den er i ferd med å sertifisere. Granskingsklassen TPDFlibSignDoc åpner den signerte utdatafilen på nytt og leser oppføringene i signaturordboken rett fra disk:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Fang størrelsen før Open: granskingsobjektet holder en dellås på filen
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 betyr at feltet er signert
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

ValueKey-argumentene kartlegges mot oppføringer i ordboken. Nøkkel 0 returnerer rå CMS fra /Contents, nøkkel 2 og 3 navnene /Filter og /SubFilter, og 11 til 14 de fire ByteRange-tallene. Tekstverdier kommer i stedet tilbake gjennom GetSignatureTextValueByName: nøkkel 0 er det oppgitte signeringstidspunktet, og nøkkel 5 skiller en vanlig Sig fra en DocTimeStamp, noe som betyr noe så snart et dokument bærer begge

Innhentingen av filstørrelsen øverst i det eksempelet er bærende, ikke husholdning. TPDFlibSignDoc.Open holder filen under en restriktiv dellås gjennom hele sin levetid, så alt som trenger de rå bytene (hashing av det signerte området, ny beregning av CMS-digesten) må lese filen før Open blir kalt. Bibliotekets egen SigningWorkbench-demo leser hele filen inn i minnet først av nøyaktig denne grunnen, og en arbeidsbenk som overser rekkefølgen, feiler sporadisk, på den maskinen som tilfeldigvis taper kappløpet

ByteRange-aritmetikk som beviser dekning

En sunn fil med én signatur har et ByteRange på formen [0 a b c]: dekningen starter på offset 0, hopper over den heksadesimale plassholderen /Contents mellom a og b, og fortsetter så gjennom byte b+c. Når b+c er lik filstørrelsen, dekker signaturen alt til slutten av filen, og det er resultatet du vil ha. Når den kommer til kort, har noen lagt til en inkrementell oppdatering etter at signaturen ble skrevet. Det er fullt legitimt etter ISO 32000-1§12.8, siden senere skjemautfyllinger, en andre signatur og en DSS-ordbok alle kommer nøyaktig på denne måten. Det er også nettopp det faktumet et revisjonsspor bør notere på signeringstidspunktet, framfor å rekonstruere det under press i en tvist

PDF Library for Delphi: ByteRange-anatomi for en signert PDF som viser Contents-plassholderhullet, pluss et full dekningscase og et case med pålagt inkrementell oppdatering
En ByteRange på 0 a b c dekker filen bare når b + c når slutten av filen, så gjennomgangen logger enhver inkrementell oppdatering som er lagt til etter signeringen

Følg med på heltallsbredden mens du gjør denne aritmetikken. Det flate API-ets GetSignProcessByteRange gir tilbake en 32-bits Integer, men de underliggende verdiene er Int64, så på en fil over 2 GB avkorter den flate aksessoren stilltiende. Grip heller til klasselagets TPDFlibSigner.GetByteRange, som returnerer Int64, eller parse verdiene ut av GetSignatureValueByName slik granskingskoden ovenfor gjør

Hva biblioteket overlater til deg

To grenser læres bedre ved designbordet enn i sluttspurten. Det flate TPDFlib-API-et bærer ingen innpakning for signaturverifisering i det hele tatt. Kryptografisk verifisering ligger ett lag ned, i TPDFlibSignatureVerifier, hvis VerifySignature svarer gyldig, ugyldig eller ukjent. Det finnes heller ingen innebygd HTTP-klient for RFC 3161-tidsstempelmyndigheter. Biblioteket beregner hashen som skal sendes inn, og bygger den utvidede CMS-en inn igjen når et token kommer tilbake, men nettverksrunden til TSA-en må du skrive selv. Begge er greie å pakke inn og genuint ubehagelige å oppdage som manglende uken før en utgivelse, så tegn dem inn fra første skisse

Ett spørsmål om samsvar er verdt å avklare rett fram, fordi det avgjør hvor den siste porten skal stå: bryter det PDF/A å legge til en signatur? Ikke i seg selv. Signaturen kommer som en inkrementell oppdatering, og ISO 19005-2 og senere tillater eksplisitt signerte dokumenter. Haken er signaturens utseende, som følger de samme reglene som alt annet sideinnhold, innebygde skrifter og fravær av enhetsavhengige farger inkludert. Så den siste porten i arbeidsbenken er nok en preflight-kjøring, denne gangen mot den signerte utdatafilen. Behandle CheckFileCompliance som den raske kontrollen inne i pipelinen, og verifiser likevel utgivelseskandidater med et uavhengig verktøy som veraPDF, siden validatorer implementerer overlappende, men ikke identiske regelsett; når de to er uenige, navngir funnteksten som regel klausulen du bør lese

Ett rekkefølgepoeng faller ut av alt dette. Signering og tidsstempling er ikke én runde: baseline-signaturen skrives først, deretter utvider en separat tidsstemplingsprosess CMS-en inne i den reserverte /Contents-plassen, som er nøyaktig derfor linjen om reserverte byte tidligere veide så tungt. For tidsstempel- og langtidsvalideringslagene som bygger på denne arbeidsbenken, tar gjennomgangen av PAdES-signering og -validering signaturen fra baseline til B-LT, og preflight-halvdelen går dypere i guiden til PDF/A- og PDF/UA-preflight. Fullstendig API-dokumentasjon og prøveversjoner finnes på produktsiden for PDF Library for Delphi