Teknisk artikel

Byg en overholdelses- og signeringsarbejdsbænk i Delphi med PDF Library for Delphi

En arbejdsbænk, der kæder compliance-validering sammen med digital signering, skal koordinere fire trin i netop denne rækkefølge og holde dem bundet til ét og samme sæt bytes hele vejen igennem. Den kører en PDF/A- eller PDF/UA-preflight. Den anvender de rettelser, fundene kræver, og gemmer en korrigeret revision. Den signerer præcis den revision. Derefter læser den den signerede fil tilbage og bekræfter, at signaturen faktisk dækker den. Rækkefølgen er ikke kosmetik. Springer du tilbagelæsningen over, stoler du på din egen skrivesti; lader du preflight køre mod den forkerte revision, beskriver din compliance-rapport en fil, du aldrig har leveret

Det, de fleste hjemmebyggede pipelines rammer forkert, er sømmen mellem validering og signering. Kører man dem som to separate værktøjer med en afhjælpningsrunde imellem, opstår der mindst tre forskellige revisioner af filen, hver med sine egne bytes. Preflight-rapporten, du rækker en revisor, beskriver den ene af dem. Signaturen fryser en anden. Intet i filen fastslår, at de er samme revision, og ofte er de det ikke. PDF Library for Delphi, losLabs PDF Developer Library til Delphi og C++Builder, lægger preflight og PAdES-signering bag én facadeklasse, så hele sekvensen kan leve i én proces, der aldrig mister overblikket over, hvilke bytes den taler om. Hvert eneste kald nedenfor findes i biblioteket i dag, og det samme gør hver eneste faldgrube, der nævnes undervejs

Diagram over en Delphi-compliance- og signeringsarbejdsbænk, hvor preflight-, afhjælpnings-, PAdES-signerings- og ByteRange-audittrin hver især registrerer en SHA-256 over præcis den revision, de rører
Hashværdier gemt ved hver gemning knytter preflight-rapporten, PAdES-signaturen og auditen til én identisk revision

Tre revisioner af ét dokument, og hvordan hullet opstår

Tæl gemmeoperationerne. Originalen ankommer fra et opstrømssystem. Afhjælpningsrunden indlæser den, slår en compliance-tilstand til og skriver en korrigeret revision. Signeringsrunden tilføjer en signatur som en inkrementel opdatering, hvilket er en tredje skrivning. Tre gemmeoperationer, tre byte-layouts, og en preflight-rapport betyder intet, medmindre den navngiver, hvilken af de tre den dækker. En SHA-256 af filen, noteret ved siden af hver preflight-kørsel og hver signatur, er det billige anker, der lader dig bevise, at den revision, du validerede, er den revision, du signerede

Én adfærd i biblioteket strammer den disciplin yderligere. Compliance-rettelser, der anmodes om via SetPDFAMode eller SetPDFUAMode, træder ikke i kraft, når du kalder dem. De anvendes under gemmeoperationen. Automatiske reparationer som at gennemtvinge print-flag på annotationer eller tildele en PDF/UA-tabulatorrækkefølge lander i outputfilen og intet andet sted, så et tjek kørt mod det dokument, du lige har "rettet" i hukommelsen, fortæller dig intet om de bytes, der er på vej til signeringen. Gem først, og kør så preflight på den gemte fil. Tilstanden i hukommelsen er et udkast; kun filen på disken er virkelig

Preflight fra disk, og nullet der betyder to ting

Det flade indgangspunkt til preflight er CheckFileCompliance(FileName, Password, ComplianceTest, Options). Test 1 vælger PDF/A (ISO 19005), test 2 vælger PDF/UA (ISO 14289). Den åbner filen gennem bibliotekets streaming-læser, så der er ingen grund til at kalde LoadFromFile først, og den returnerer et handle til en strengliste med ét fund per post:

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;

Fælden ligger i returværdien, og det er den slags, der består enhver happy path-test. Nul betyder "ingen fund". Nul betyder også "filen kunne ikke åbnes", for implementeringen returnerer 0, hver gang resultatlisten kommer tom tilbage — en læsefejl inklusive. En arbejdsbænk, der læser 0 som grønt lys, vil glad og gerne godkende en fil, som en anden proces har låst. At parre kaldet med LastErrorCode, sådan som ovenfor, er det, der skiller de to tilfælde ad. Checkeren åbner desuden filen med en deny-write share-tilstand, så hvis dit afhjælpningstrin stadig holder et skrive-handle, fejler preflight af en grund, der intet har med compliance at gøre og alt med en stream, du glemte at frigive

Beslutningsdiagram, der viser, hvordan LastErrorCode adskiller de to betydninger af en nulreturværdi fra CheckFileCompliance i en Delphi PDF-preflight
En nulværdi fra CheckFileCompliance betyder ingenting, før LastErrorCode adskiller en tom fundliste fra en fil, biblioteket ikke kunne åbne

Når det er et menneske og ikke en pipeline, der skal læse fundene, gengiver CreatePreflightReport dem som en læsbar rapport. ComparePreflightReports sammenligner to kørsler, hvilket er en ryddelig måde at vise, at afhjælpningen fjernede de oprindelige fund uden stille og roligt at indføre nye

Signering af den tjekkede revision med en SignProcess

Når den gemte revision består preflight, og dens hash er noteret, så signér præcis den fil og ingen anden. SignProcess-API'et læses som en builder. Åbn et proces-handle, konfigurér det linje for linje, commit, og læs så resultatkoden tilbage

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);              // plads 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 sekvens vejer tungere, end de ser ud til. SetSignProcessCustomSubFilter med ETSI.CAdES.detached vælger en PAdES-signatur som profileret i ETSI EN 319 142-1 frem for den ældre adbe.pkcs7.detached-familie, og det er forskellen på en signatur, en europæisk validator accepterer, og en, den flagger. SetSignProcessReserveContentsBytes polstrer /Contents-pladsholderen, og den størrelse, du vælger her, er en beslutning om fremtiden: skal der nogensinde følge et signaturtidsstempel efter, skal den forstørrede CMS kunne være i den plads, du reserverer nu, for pladsholderen kan ikke vokse senere uden at hele molevitten signeres om. Reservér rundhåndet, og du spilder nogle få kilobyte. Reservér for stramt, og tidsstempeltrinnet fejler om flere måneder med et overløb, du vil have svært ved at koble tilbage til netop denne ene linje

GetSignProcessResult svarer med en kode, ikke en boolean, og koderne er værd at holde fast i. 1 er succes. 4 er en forkert PDF-adgangskode, 7 en forkert certifikatadgangskode, 9 en PFX uden privat nøgle, 11 en fejl, mens signaturen blev påført. Klapper du dem sammen til true/false, smider du netop den ene oplysning væk, der skiller en supportsag om forkert adgangskode fra en om en nøgle uden privat del. Log heltallet

Tilbagelæsning: revision af den fil, du netop har produceret

Ingen arbejdsbænk bør stole på den sti, der skrev den fil, den er ved at certificere. Revisionsklassen TPDFlibSignDoc genåbner det signerede output og læser signaturordbogens poster direkte fra disken:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Fang størrelsen før Open: revisionsobjektet holder en share-lå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 betyder, at feltet er signeret
      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-argumenterne peger på poster i ordbogen. Nøgle 0 returnerer den rå CMS fra /Contents, nøgle 2 og 3 navnene /Filter og /SubFilter, og 11 til 14 de fire ByteRange-tal. Tekstværdier kommer i stedet tilbage via GetSignatureTextValueByName: nøgle 0 er det angivne signeringstidspunkt, og nøgle 5 skiller en almindelig Sig fra en DocTimeStamp, hvilket betyder noget, så snart et dokument bærer begge

Opsamlingen af filstørrelsen øverst i det eksempel er bærende, ikke oprydning. TPDFlibSignDoc.Open holder filen under en restriktiv share-lås i hele sin levetid, så alt, der har brug for de rå bytes (hashing af det signerede område, genberegning af CMS-digestet), skal læse filen, før Open bliver kaldt. Bibliotekets egen SigningWorkbench-demo indlæser hele filen i hukommelsen først af netop den grund, og en arbejdsbænk, der ignorerer rækkefølgen, fejler sporadisk — på den maskine, der tilfældigvis taber kapløbet

ByteRange-aritmetik, der beviser dækningen

En sund fil med én signatur har en ByteRange på formen [0 a b c]: dækningen begynder ved offset 0, springer den hexadecimale /Contents-pladsholder mellem a og b over og fortsætter derefter til og med byte b+c. Når b+c svarer til filstørrelsen, dækker signaturen alt til filens ende, og det er det resultat, du vil have. Når den kommer til kort, har nogen tilføjet en inkrementel opdatering, efter signaturen blev skrevet. Det er fuldt ud legitimt under ISO 32000-1§12.8, eftersom senere formularudfyldninger, en anden signatur og en DSS-ordbog alle ankommer på præcis den måde. Det er også netop den kendsgerning, et revisionsspor bør registrere på signeringstidspunktet frem for at rekonstruere under pres i en tvist

PDF Library for Delphi: ByteRange-anatomi af en signeret PDF, der viser Contents-placeholderhullet plus et fulddækningstilfælde og et tilfælde med tilføjet inkrementel opdatering
En ByteRange på 0 a b c dækker kun filen, når b + c når frem til filslutningen, så revisionen logger enhver trinvis opdatering, der tilføjes efter signeringen

Hold øje med heltalsbredden, mens du regner. GetSignProcessByteRange i det flade API giver en 32-bit Integer tilbage, men de underliggende værdier er Int64, så på en fil over 2 GB afkorter den flade accessor stiltiende. Grib i stedet efter klasselagets TPDFlibSigner.GetByteRange, der returnerer Int64, eller parse værdierne ud af GetSignatureValueByName, sådan som revisionskoden ovenfor gør

Hvad biblioteket overlader til dig

To grænser læres bedre i designfasen end i den sidste sprint. Det flade TPDFlib-API har slet ingen wrapper til signaturverifikation. Den kryptografiske verifikation bor et lag længere nede, i TPDFlibSignatureVerifier, hvis VerifySignature svarer gyldig, ugyldig eller ukendt. Der er heller ingen indbygget HTTP-klient til RFC 3161-tidsstempelmyndigheder. Biblioteket beregner den hash, der skal indsendes, og genindlejrer den udvidede CMS, når et token kommer tilbage, men netværksturen til TSA'en skal du selv skrive. Begge dele er ligetil at pakke ind og oprigtigt ubehagelige at opdage manglende ugen før en udgivelse, så tegn dem ind fra den første skitse

Ét spørgsmål om compliance er værd at afklare klart, for det afgør, hvor den sidste port skal ligge: bryder det PDF/A at tilføje en signatur? Ikke i sig selv. Signaturen ankommer som en inkrementel opdatering, og ISO 19005-2 og fremefter tillader udtrykkeligt signerede dokumenter. Hagen er signaturens udseende, som spiller efter samme regler som alt andet sideindhold, indlejrede skrifttyper og ingen enhedsafhængige farver inklusive. Så den sidste port i arbejdsbænken er endnu en preflight-kørsel, denne gang mod det signerede output. Betragt CheckFileCompliance som det hurtige tjek inde i pipelinen, og verificér stadig release-kandidater med et uafhængigt værktøj som veraPDF, eftersom validatorer implementerer overlappende, men ikke identiske regelsæt; når de to er uenige, navngiver fundets tekst som regel den klausul, man skal slå op

Én pointe om rækkefølgen falder ud af det hele. Signering og tidsstempling er ikke ét gennemløb: baseline-signaturen skrives først, og derefter udvider en separat tidsstempelproces den CMS, der ligger i den reserverede /Contents-plads, hvilket er præcis derfor, linjen med reserverede bytes tidligere vejede så tungt. For de lag med tidsstempling og langtidsvalidering, der bygger oven på denne arbejdsbænk, fører gennemgangen af PAdES-signering og -validering signaturen fra baseline til B-LT, og preflight-halvdelen går i dybden i guiden til PDF/A- og PDF/UA-preflight. Fuld API-dokumentation og prøveversioner findes på PDF Library for Delphi-produktsiden