Weryfikator odrzuca podpis PAdES z jednego z trzech powodów w niemal każdym przypadku, jaki debugowaliśmy za pomocą komponentu PDFium Component w Delphi: tablica /ByteRange nadal zawiera zerowe symbole zastępcze (placeholders), czas podpisania /M nie jest poprawny sformatowanym ciągiem daty PDF, lub aktualizacja przyrostowa usunęła wpis /Encrypt z zaszyfrowanego dokumentu. Wszystkie te trzy błędy tworzą pliki, które otwierają się i renderują poprawnie, lecz ulegają awarii w momencie, gdy zgodny ze specyfikacją weryfikator odczytuje słownik podpisu
Scenariusz motywujący powstanie tego artykułu jest bolesny w swej prostocie. Podpisujesz umowę zgodnie z procedurą opisaną w artykule o podpisach PAdES B-B, Twój własny kod weryfikacyjny informuje, że podpis jest obecny i poprawny strukturalnie, a wszystkie testy lokalne przechodzą pomyślnie. Następnie kontrahent przesyła plik do swojej platformy walidacyjnej i otrzymuje czerwony krzyżyk. Błąd ten nie jest widoczny w przeglądarce, ponieważ żaden z tych trzech defektów nie wpływa na zawartość stron. Istnieją one wyłącznie w słowniku podpisu oraz sekcji trailer pliku — czyli dokładnie tam, gdzie weryfikatory szukają problemów, a przeglądarki zazwyczaj tego nie robią
Dlaczego zakres ByteRange podpisu PDF jest nieprawidłowy?
Odrzucenie zakresu ByteRange prawie zawsze oznacza, że te cztery pola nigdy nie zostały wypełnione, a nie że same zakresy są nieznacznie błędne. Tablica /ByteRange [A B C D] deklaruje dwa przedziały: od bajtu A o długości B oraz od bajtu C o długości D. Norma EN 319 142-1 §6.3 (wymóg k) nakłada obowiązek, aby wspólnie pokrywały one cały plik z wyjątkiem zakodowanego szesnastkowo ciągu /Contents. Liczbowo: A wynosi 0, C >= A+B, a suma C+D odpowiada całkowitej długości pliku. Przerwa między B a C zawiera dokładnie ciąg szesnastkowy <...> — dwa bajty nawiasów oraz dwa znaki szesnastkowe na każdy bajt danych CMS. Weryfikator odczytujący wartości [0 0 0 0] uznaje, że podpis niczego nie obejmuje i odrzuca go, niezależnie od poprawności struktury CMS wewnątrz /Contents
Warto poznać mechanizm powstawania tego problemu, ponieważ ten sam schemat obowiązuje w każdym kodzie podpisującym. Moduł podpisujący nie zna ostatecznych offsetów przed rozmieszczeniem struktury pliku, więc generator zapisuje tablicę z zerowymi symbolami zastępczymi o stałej szerokości i uzupełnia je po zakończeniu układu pliku. W wersjach komponentu PDFium Component przed wydaniem v2.14.1 to uzupełnianie szukało wzorca symbolu zastępczego zaczynając od pozycji /Contents — lecz klucz /ByteRange znajduje się w słowniku przed /Contents, stąd przeszukiwanie nic nie znalazło i wszystkie trzy podmiany kończyły się cichym niepowodzeniem. Skrót kryptograficzny CMS był obliczany dla właściwych zakresów, więc kryptografia była poprawna; jednak deklaracja tych zakresów pozostawała zerowa, co powodowało odrzucenie pliku przez każdy zgodny weryfikator. Znaczniki czasu dokumentu PAdES B-LTA, korzystające z tego samego układu słownika, ulegały awarii w ten sam sposób — co jest istotne, jeśli korzystasz z długoterminowych podpisów B-LT i B-LTA. W wersji v2.14.1 uzupełnianie odbywa się od początku obiektu podpisu, a poprawka jest objęta testem regresyjnym analizującym wygenerowany plik i sprawdzającym poniższe warunki
function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
Raw: TBytes;
Text: AnsiString;
P, N: Integer;
F: array[0..3] of Int64;
begin
Raw := TFile.ReadAllBytes(FileName);
SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
P := Pos('/ByteRange', Text); // wyłącznie pierwszy podpis
Result := P > 0;
if not Result then Exit;
Inc(P, Length('/ByteRange'));
for N := 0 to 3 do
begin
while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
F[N] := 0;
while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
begin
F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
Inc(P);
end;
end;
// EN 319 142-1 §6.3 wymóg k: zakresy obejmują wszystko z wyjątkiem /Contents
Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
(F[2] + F[3] = Int64(Length(Raw)));
end;
Dwadzieścia linii prostego kodu RTL, bez wywołań bibliotecznych, a pozwala wykryć cały ten problem już na etapie generowania pliku. Jeśli masz zapamiętać jedną asercję z tego artykułu, niech to będzie ta: przeanalizuj wyjściowy podpisany plik i sprawdź te trzy równości, zanim plik opuści proces Twojej aplikacji
Dlaczego weryfikatory oznaczają czas podpisania /M jako niepoprawny?
Rygorystyczne weryfikatory odrzucają czas podpisania, który nie jest kompletnym ciągiem daty formatu PDF. Najczęściej brakuje przedrostka D: oraz znacznika przesunięcia UTC. Standard ISO 32000-1 §7.9.4 definiuje format daty jako D:YYYYMMDDHHmmSS, po którym następuje przesunięcie czasu — Z dla UTC lub podpisana relacja +HH'mm'. Czysty zapis 20260709143000 zostanie zinterpretowany jako data przez tolerancyjne czytniki, lecz weryfikator stosujący specyfikację dosłownie uzna go za niepoprawny ciąg w polu o wymaganym formacie i oznaczy podpis jako wadliwy. Wersje komponentu PDFium Component przed wydaniem v2.14.4 zapisywały klucz /M metod TPdf.SignPades oraz SignPadesBytes właśnie w tej uproszczonej postaci; od wersji v2.14.4 klucz ten zawiera przedrostek D: i znacznik Z, stąd deklarowany czas podpisania ma postać D:20260709143000Z
Z tym polem wiążą się dwie praktyczne uwagi. Po pierwsze, zapisuj czas UTC i oznaczaj go: znacznik czasu bez określenia przesunięcia zmusza weryfikator do zgadywania strefy czasowej, a sekcja §7.9.4 traktuje to przesunięcie jako część formatu, a nie opcjonalny dodatek. Po drugie, pamiętaj, czym jest pole /M — to deklarowany przez podpisującego czas, czyli deklaracja, a nie dowód. Weryfikator sprawdza tutaj format daty, a nie jej prawdziwość. Wiarygodny dowód czasu pochodzi ze znacznika czasu RFC 3161 na poziomie PAdES B-T i wyższym. Poprawne formatowanie pola a zaufanie do jego wartości to dwie różne kwestie, a weryfikatory egzekwują jedynie poprawność formatu
Dlaczego SignPades zgłasza wyjątek EPadesCrypto dla zaszyfrowanego pliku PDF?
Komponent PDFium Component odmawia podpisania zaszyfrowanego dokumentu, ponieważ alternatywą byłoby wygenerowanie pliku, który zgodne czytniki uszkodzą przy otwarciu. Podpis PAdES jest dołączany jako aktualizacja przyrostowa (incremental update), a standard ISO 32000-1 §7.5.6 wymaga, aby nowy trailer sekcji aktualizacji zawierał każdy wpis poprzedniego trailera z wyjątkiem /Prev — w zaszyfrowanym dokumencie dotyczy to również klucza /Encrypt. Pominięcie go sprawia, że najnowszy trailer deklaruje plik jako niezaszyfrowany, przez co zgodny czytnik próbuje odczytać zaszyfrowaną treść jako otwarty tekst i otrzymuje uszkodzone dane. Co gorsza, strumienie i ciągi dodawane przez moduł podpisujący musiałyby same zostać zaszyfrowane kluczem dokumentu, aby były poprawne, czego prosty moduł wprowadzania podpisów nie potrafi wykonać. Nie ma możliwości dodania poprawnego podpisu otwartym tekstem do zaszyfrowanego pliku, stąd od wersji v2.14.2 metody TPdf.SignPades, SignPadesBytes oraz InjectPadesDssMarkers zgłaszają wyjątek EPadesCrypto zamiast generować uszkodzony lub nieweryfikowalny wynik
try
if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
Writeln('Signing reported failure');
except
on E: EPadesCrypto do
begin
// np. 'SignPadesBytes: dokument źródłowy jest zaszyfrowany;
// usuń szyfrowanie przed podpisaniem'
Writeln('Cannot sign: ', E.Message);
end;
end;
Wyjątek ten to prawidłowy rezultat działania, stąd proces należy zaprojektować z jego uwzględnieniem, a nie próbować ponawiać operację w pętli. Najpierw usuń szyfrowanie za pomocą uprawnień właściciela, podpisz plik otwartym tekstem, a jeśli kanał dystrybucji wymaga szyfrowania, zaakceptuj fakt, że szyfrowanie-po-podpisaniu i podpisanie-po-szyfrowaniu dają odmienne rezultaty z różnymi procesami walidacji. Podpis wyliczony dla bajtów otwartego tekstu nie przetrwa ich ponownego zaszyfrowania, stąd realnymi opcjami są podpisany plik otwartym tekstem lub decyzja projektowa udokumentowana obok kodu
Jak dwa błędy mogą wzajemnie potwierdzać swoją poprawność
Błąd ByteRange pozostawał niewykryty tak długo, ponieważ nasz własny walidator zawierał komplementarny błąd — i to jest najcenniejsza lekcja płynąca z tego zdarzenia. Test pokrycia w funkcji ValidatePadesCompliance wymagał, aby drugi przedział rozpoczynał się dokładnie w punkcie A+B — czyli zakładał brak przerwy — co błędnie klasyfikowało standardowy układ, w którym przerwa mieści szesnastkowy ciąg /Contents. W efekcie wewnętrzny walidator odrzucał właśnie poprawny układ, a wewnętrzny generator nigdy go nie tworzył, co pozwalało zachować poprawność testów w całym procesie. Każdy błąd uniemożliwiał zestawowi testowemu wykazanie niepoprawności drugiego. W wersji v2.14.1 naprawiono obie te kwestie w ramach jednego wydania: generator poprawnie uzupełnia wszystkie cztery pola, a walidator akceptuje zależność C >= A+B przy sumie C+D równej długości pliku
Metodologicznym rozwiązaniem jest weryfikacja krzyżowa (cross-validation) z użyciem oprogramowania innej firmy. Generator i walidator współdzielące kod źródłowy, autora lub choćby tylko to samo wyobrażenie o formacie mogą trwać w błędnym przekonaniu bez końca; niezależny walidator przełamuje tę symetrię. Przetestuj wyjściowy podpisany plik w przynajmniej jednym zewnętrznym programie walidacyjnym przed wydaniem wersji i zachowaj test automatyczny w procesie budowania jako pierwszą linię obrony — metoda TPdf.ValidatePades, opisana w artykule o inspekcji podpisów, zgłasza błąd pokrycia jako określony problem
// Zakres ByteRange nie pokrywa całego pliku
if ppeiByteRangeNotCoveringFile in R.Issues then
Writeln('ByteRange does not cover the file');
// Wykryto problemy strukturalne: nie udostępniaj tego pliku
if not R.IsCompliant then
Writeln('Structural issues present: do not ship this file');
Lista kontrolna dla podpisanego pliku
Gdy platforma odrzuca Twój podpis PAdES, sprawdź proste przyczyny strukturalne, zanim zaczniesz szukać problemów w certyfikatach czy łańcuchach zaufania. Odczytaj tablicę /ByteRange i zweryfikuj trzy zależności: pierwsze pole równe zero, drugi przedział rozpoczynający się w punkcie lub po zakończeniu pierwszego, a suma drugiego przedziału równa całkowitej długości pliku. Odczytaj klucz /M i sprawdź, czy zawiera pełny ciąg daty zgodny z sekcją §7.9.4 z przedrostkiem D: i znacznikiem przesunięcia. Upewnij się, że dokument źródłowy nie był zaszyfrowany w momencie nakładania podpisu — a jeśli Twój kod podpisał go mimo to bez zgłoszenia błędu, potraktuj to milczenie jako błąd w kodzie. Te trzy testy na surowych bajtach wykonują się w ułamku sekundy i w naszym doświadczeniu wyjaśniają odrzucenie podpisu znacznie częściej niż przyczyny kryptograficzne
Opisane tu metody podpisania, inspekcji i walidacji — SignPades, ValidatePades oraz testy zgodności PAdES z poprawionym przeliczaniem ByteRange, formatowaniem daty i blokadą szyfrowania — wchodzą w skład PDFium Component dla Delphi, C++Buildera i Lazarusa