Artykuł techniczny

PDF Library for Delphi: compliance and signing workbench in Delphi

Stanowisko robocze łączące walidację zgodności z podpisem cyfrowym musi koordynować cztery kroki, w tym właśnie porządku, i przez cały czas trzymać je przypięte do jednego zestawu bajtów. Uruchamia preflight PDF/A lub PDF/UA. Nakłada poprawki wymagane przez wyniki i zapisuje skorygowaną rewizję. Podpisuje dokładnie tę rewizję. Następnie odczytuje podpisany plik z powrotem i potwierdza, że podpis rzeczywiście go obejmuje. Kolejność nie jest kwestią kosmetyczną. Pomiń odczyt zwrotny, a zaufasz własnej ścieżce zapisu; pozwól, by preflight działał na niewłaściwej rewizji, a raport zgodności opisze plik, którego nigdy nie wysłano

Miejscem, które większość domowych potoków przetwarzania robi źle, jest szew między walidacją a podpisywaniem. Uruchom je jako dwa osobne narzędzia z przebiegiem naprawczym pomiędzy nimi, a powstaną co najmniej trzy odrębne rewizje pliku, każda z własnymi bajtami. Raport preflight, który wręczasz audytorowi, opisuje jedną z nich. Podpis zamraża inną. Nic w pliku nie mówi, że to ta sama rewizja, a często nią nie jest. PDF Library for Delphi, biblioteka losLab PDF Developer Library dla Delphi i C++Builder, umieszcza preflight i podpisywanie PAdES za jedną klasą fasadową, dzięki czemu cała sekwencja może żyć w jednym procesie, który nigdy nie gubi śladu, o jakich bajtach mowa. Każde wywołanie poniżej istnieje dziś w bibliotece, podobnie jak każda pułapka odnotowana obok niego

Diagram warsztatu zgodności i podpisywania w Delphi, w którym kroki preflight, naprawy, podpisywania PAdES i audytu ByteRange każdy zapisują SHA-256 nad dokładnie tą rewizją, którą dotykają
Skróty zapisywane przy każdym zapisie wiążą raport preflight, podpis PAdES i audyt z jedną identyczną rewizją

Trzy rewizje jednego dokumentu i jak powstaje luka

Policz zapisy. Oryginał przychodzi z systemu nadrzędnego. Przebieg naprawczy wczytuje go, włącza tryb zgodności i zapisuje skorygowaną rewizję. Przebieg podpisywania dopisuje podpis jako przyrostową aktualizację, co stanowi trzeci zapis. Trzy zapisy, trzy układy bajtów, a raport preflight nic nie znaczy, dopóki nie wskaże, którego z trzech dotyczy. SHA-256 pliku, odnotowany przy każdym uruchomieniu preflight i każdym podpisie, to tani punkt zaczepienia, który pozwala udowodnić, że zwalidowana rewizja jest tą samą, która została podpisana

Jedno zachowanie biblioteki dodatkowo zaostrza tę dyscyplinę. Poprawki zgodności zażądane przez SetPDFAMode lub SetPDFUAMode nie wchodzą w życie w chwili ich wywołania. Są stosowane podczas zapisu. Automatyczne naprawy, takie jak wymuszenie flag drukowania adnotacji czy przypisanie kolejności tabulacji PDF/UA, trafiają do pliku wyjściowego i nigdzie indziej, więc kontrola uruchomiona na dokumencie, który właśnie "naprawiłeś" w pamięci, nic nie mówi o bajtach kierowanych do podpisującego. Najpierw zapisz, potem uruchom preflight na zapisanym pliku. Stan w pamięci jest szkicem; realny jest tylko plik na dysku

Preflight z dysku i zero, które znaczy dwie rzeczy

Płaskim punktem wejścia dla preflight jest CheckFileCompliance(FileName, Password, ComplianceTest, Options). Test 1 wybiera PDF/A (ISO 19005), test 2 wybiera PDF/UA (ISO 14289). Otwiera plik przez strumieniowy czytnik biblioteki, więc nie trzeba najpierw wywoływać LoadFromFile, i zwraca uchwyt listy łańcuchów niosący jeden wynik na wpis:

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;

Pułapka tkwi w wartości zwracanej i jest to rodzaj pułapki, która przechodzi każdy test na szczęśliwej ścieżce. Zero oznacza "brak wyników." Zero oznacza też "nie udało się otworzyć pliku," ponieważ implementacja zwraca 0, gdy tylko lista wyników wraca pusta, w tym również przy błędzie odczytu. Stanowisko robocze, które odczytuje 0 jako zielone światło, z radością zatwierdzi plik zablokowany przez jakiś inny proces. Powiązanie tego wywołania z LastErrorCode, jak powyżej, to właśnie to, co rozdziela oba przypadki. Program sprawdzający otwiera też plik w trybie udostępniania blokującym zapis, więc jeśli twój krok naprawczy wciąż trzyma uchwyt zapisu, preflight zawiedzie z powodu niemającego nic wspólnego ze zgodnością, a mającego wszystko wspólnego ze strumieniem, o którego zwolnieniu zapomniałeś

Diagram decyzyjny pokazujący, jak LastErrorCode rozdziela dwa znaczenia zwracanego zera z CheckFileCompliance w preflight PDF Delphi
Zero z CheckFileCompliance samo w sobie nic nie znaczy, dopóki LastErrorCode nie odróżni pustej listy ustaleń od pliku, którego biblioteka nie mogła otworzyć

Gdy wyniki musi odczytać człowiek, a nie potok przetwarzania, CreatePreflightReport renderuje je jako czytelny raport. ComparePreflightReports porównuje dwa przebiegi, co jest zgrabnym sposobem na pokazanie, że naprawa usunęła pierwotne wyniki, po cichu nie wprowadzając nowych

Podpisywanie sprawdzonej rewizji za pomocą SignProcess

Gdy zapisana rewizja przejdzie preflight, a jej hash zostanie odnotowany, podpisz dokładnie ten plik i żaden inny. API SignProcess czyta się jak budowniczy (builder). Otwórz uchwyt procesu, skonfiguruj go linia po linii, zatwierdź, a następnie odczytaj z powrotem kod wyniku

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');  // poziom bazowy PAdES
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // miejsce na późniejszy znacznik czasu
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Dwie linie w tej sekwencji ważą więcej, niż się wydaje. SetSignProcessCustomSubFilter z ETSI.CAdES.detached wybiera podpis PAdES zgodnie z profilem ETSI EN 319 142-1, a nie starszą rodzinę adbe.pkcs7.detached, co stanowi różnicę między podpisem akceptowanym przez europejskiego walidatora a takim, który on oflaguje. SetSignProcessReserveContentsBytes wypełnia symbol zastępczy /Contents, a rozmiar wybrany w tym miejscu jest decyzją dotyczącą przyszłości: jeśli kiedyś ma nastąpić znacznik czasu podpisu, powiększony CMS musi zmieścić się w miejscu zarezerwowanym teraz, ponieważ symbol zastępczy nie może później urosnąć bez ponownego podpisania całości. Zarezerwuj hojnie, a stracisz kilka kilobajtów. Zarezerwuj zbyt ciasno, a krok znacznika czasu zawiedzie za kilka miesięcy z powodu przepełnienia, które trudno będzie połączyć z powrotem z tą jedną linią

GetSignProcessResult odpowiada kodem, a nie wartością logiczną, i te kody warto zachować. 1 oznacza sukces. 4 to błędne hasło PDF, 7 błędne hasło certyfikatu, 9 plik PFX bez klucza prywatnego, 11 błąd podczas nakładania podpisu. Zredukuj to do true/false, a wyrzucisz jedyną informację odróżniającą zgłoszenie wsparcia o błędnym haśle od takiego z kluczem bez części prywatnej. Zapisuj tę liczbę całkowitą w logu

Odczyt zwrotny: audyt pliku, który właśnie wyprodukowano

Żadne stanowisko robocze nie powinno ufać ścieżce, która zapisała plik mający właśnie zostać poświadczony. Klasa audytowa TPDFlibSignDoc otwiera ponownie podpisany plik wyjściowy i odczytuje wpisy słownika podpisu bezpośrednio z dysku:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Przechwyć rozmiar przed Open: obiekt audytu trzyma blokadę współdzielenia na pliku
  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 oznacza, że pole jest podpisane
      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;

Argumenty ValueKey odwzorowują się na wpisy słownika. Klucz 0 zwraca surowy CMS z /Contents, klucze 2 i 3 nazwy /Filter i /SubFilter, a 11 do 14 cztery liczby ByteRange. Wartości tekstowe wracają za pośrednictwem GetSignatureTextValueByName: klucz 0 to deklarowany czas podpisania, a klucz 5 odróżnia zwykły Sig od DocTimeStamp, co ma znaczenie, gdy dokument niesie oba naraz

Przechwycenie rozmiaru pliku na początku tego przykładu jest elementem nośnym, a nie porządkowym. TPDFlibSignDoc.Open trzyma plik pod restrykcyjną blokadą współdzielenia przez cały cykl życia obiektu, więc wszystko, co potrzebuje surowych bajtów (haszowanie podpisanego zakresu, ponowne obliczenie skrótu CMS), musi odczytać plik, zanim zostanie wywołane Open. Własne demo SigningWorkbench z biblioteki właśnie z tego powodu najpierw wczytuje cały plik do pamięci, a stanowisko robocze ignorujące tę kolejność zawodzi sporadycznie, na tej maszynie, która akurat przegra ten wyścig

Arytmetyka ByteRange dowodząca pokrycia

Zdrowy plik z pojedynczym podpisem ma ByteRange w postaci [0 a b c]: pokrycie zaczyna się od przesunięcia 0, pomija szesnastkowy symbol zastępczy /Contents między a i b, a następnie wznawia się aż do bajtu b+c. Gdy b+c równa się rozmiarowi pliku, podpis obejmuje wszystko aż do końca pliku, czyli wynik, którego oczekujesz. Gdy jest krótszy, ktoś dopisał przyrostową aktualizację po zapisaniu podpisu. Jest to całkowicie zgodne z prawem na mocy ISO 32000-1§12.8, ponieważ późniejsze wypełnienia formularza, drugi podpis i słownik DSS przychodzą właśnie w ten sposób. To także dokładnie ten fakt, który ślad audytowy powinien odnotować w chwili podpisywania, a nie odtwarzać pod presją podczas sporu

PDF Library for Delphi: Anatomia ByteRange podpisanego PDF pokazująca lukę zastępczą Contents oraz przypadek pełnego pokrycia i przypadek dołączonej aktualizacji przyrostowej
ByteRange o postaci 0 a b c obejmuje plik tylko wtedy, gdy b + c sięga końca pliku, więc audyt rejestruje każdą aktualizację przyrostową dołączoną po podpisaniu

Podczas tej arytmetyki miej oko na szerokość liczby całkowitej. GetSignProcessByteRange z płaskiego API zwraca 32-bitowy Integer, ale leżące u podstaw wartości są typu Int64, więc przy pliku przekraczającym 2 GB płaski akcesor po cichu je obcina. Sięgnij po warstwę klas, TPDFlibSigner.GetByteRange, która zwraca Int64, albo wyciągnij wartości z GetSignatureValueByName tak, jak robi to powyższy kod audytu

Co biblioteka pozostawia tobie

Dwie granice lepiej poznać na etapie projektowania niż w finałowym sprincie. Płaskie API TPDFlib w ogóle nie niesie żadnej otoczki weryfikacji podpisu. Weryfikacja kryptograficzna żyje jedną warstwę niżej, w TPDFlibSignatureVerifier, którego VerifySignature odpowiada: ważny, nieważny albo nieznany. Nie ma też wbudowanego klienta HTTP dla urzędów znakowania czasem RFC 3161. Biblioteka oblicza hash do wysłania i ponownie osadza rozszerzony CMS, gdy token wróci, ale przejazd sieciowy w obie strony do TSA musisz napisać sam. Oba elementy łatwo owinąć własną otoczką i naprawdę nieprzyjemnie odkryć ich brak na tydzień przed wydaniem, więc zaprojektuj je już od pierwszego szkicu

Jedno pytanie o zgodność warto rozstrzygnąć wprost, bo decyduje, gdzie umieścić ostatnią bramkę: czy dodanie podpisu psuje PDF/A? Nie samo w sobie. Podpis przychodzi jako przyrostowa aktualizacja, a ISO 19005-2 i nowsze wyraźnie dopuszczają podpisane dokumenty. Haczyk tkwi w wyglądzie podpisu, który podlega tym samym regułom co każda inna zawartość strony, łącznie z osadzonymi czcionkami i brakiem koloru zależnego od urządzenia. Dlatego ostatnią bramką w stanowisku roboczym jest jeszcze jedno uruchomienie preflight, tym razem na podpisanym pliku wyjściowym. Traktuj CheckFileCompliance jako szybką kontrolę wbudowaną w potok, a kandydatów do wydania i tak weryfikuj niezależnym narzędziem, takim jak veraPDF, ponieważ walidatory implementują nakładające się, lecz nie identyczne zestawy reguł; gdy oba się nie zgadzają, tekst wyniku zwykle wskazuje klauzulę, którą warto przeczytać

Z tego wszystkiego wynika jeszcze jedna kwestia kolejności. Podpisywanie i znakowanie czasem to nie jeden przebieg: najpierw zapisywany jest podpis bazowy, a następnie osobny proces znakowania czasem rozszerza CMS wewnątrz zarezerwowanej przestrzeni /Contents, co jest właśnie powodem, dla którego wcześniejsza linia rezerwacji bajtów ważyła tak wiele. W kwestii warstw znakowania czasem i długoterminowej walidacji, które nadbudowują się na tym stanowisku roboczym, przewodnik po podpisywaniu i walidacji PAdES prowadzi podpis od poziomu bazowego do B-LT, a połowa dotycząca preflight jest rozwinięta głębiej w przewodniku po preflight PDF/A i PDF/UA. Pełna dokumentacja API i wersje próbne do pobrania znajdują się na stronie produktu PDF Library for Delphi