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
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ś
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
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