Artykuł techniczny

Dlaczego walidatory odrzucają podpisy PAdES w Delphi

Walidator odrzuca podpis PAdES z jednego z trzech powodów w niemal każdym przypadku, jaki debugowaliśmy z PDFium Component w Delphi: tablica /ByteRange wciąż trzyma swoje zerowe wartości zastępcze, czas podpisania /M nie jest poprawnie zbudowanym ciągiem daty PDF, albo aktualizacja przyrostowa zgubiła wpis /Encrypt z zaszyfrowanego dokumentu. Wszystkie trzy produkują pliki, które otwierają się bez zarzutu, renderują bez zarzutu i padają w chwili, gdy zgodny walidator odczyta słownik podpisu

Scenariusz motywujący ten artykuł jest boleśnie konkretny. Podpisujesz umowę przepływem z artykułu o podpisywaniu PAdES B-B, twój własny kod inspekcyjny raportuje podpis jako obecny i strukturalnie poprawny, wszystkie lokalne testy świecą na zielono — a potem druga strona wysyła plik na swoją platformę walidacyjną i dostaje czerwony krzyżyk. Nic z tej awarii nie jest widoczne w przeglądarce, bo żaden z tych trzech defektów nie dotyka treści strony. Mieszkają w całości w słowniku podpisu i w zwiastunie pliku, czyli dokładnie tam, gdzie patrzą walidatory, a przeglądarki w większości nie

Dlaczego ByteRange mojego podpisu PDF jest nieprawidłowy?

Odrzucenie z powodu ByteRange prawie zawsze oznacza, że cztery pola nigdy nie zostały wypełnione, a nie że zakresy są subtelnie błędne. Tablica /ByteRange [A B C D] deklaruje dwa zasięgi, bajty od A do A+B oraz bajty od C do C+D, a EN 319 142-1 §6.3 (wymaganie k) żąda, by razem pokrywały cały plik z wyjątkiem zakodowanego szesnastkowo ciągu /Contents. W liczbach: A to 0, C >= A+B, C+D równa się długości pliku, a przerwa między B a C mieści dokładnie ciąg szesnastkowy <...> — dwa bajty nawiasów plus dwa znaki szesnastkowe na każdy bajt danych CMS. Walidator, który odczyta [0 0 0 0], wnioskuje, że podpis nie pokrywa niczego, i odrzuca go, niezależnie od tego, jak poprawna jest struktura CMS wewnątrz /Contents

Warto zrozumieć mechanikę tego, jak do tego dochodzi, bo ten sam wzorzec istnieje w każdym stosie podpisującym. Podpisujący nie może znać ostatecznych przesunięć, dopóki plik nie zostanie rozłożony, więc zapisujący emituje tablicę z zerowymi wartościami zastępczymi o stałej szerokości i uzupełnia je po rozłożeniu. W wydaniach PDFium Component przed 2.14.1 to uzupełnianie szukało wzorca zastępczego, startując od pozycji /Contents — ale /ByteRange siedzi w słowniku przed /Contents, więc wyszukiwanie nic nie znajdowało i wszystkie trzy podmiany po cichu zawodziły. Skrót CMS był liczony nad właściwymi zakresami, więc kryptografia była w porządku; deklaracja tych zakresów zostawała na zerach, więc każdy zgodny walidator odrzucał plik. Znaczniki czasu dokumentu w PAdES B-LTA, które używają tego samego układu słownika, zawodziły tak samo — co ma znaczenie, jeśli budujesz na długoterminowych podpisach B-LT i B-LTA. Wersja 2.14.1 uzupełnia od początku obiektu podpisu, a poprawkę pokrywa test regresyjny, który parsuje wyprodukowany plik i sprawdza poniższą arytmetykę

Układ pokrycia ByteRange dla podpisów PAdES budowanych z PDFium Component w Delphi: dwa zadeklarowane zasięgi bajtów muszą pokryć cały plik wokół szesnastkowej przerwy Contents, a pozostawiona zerowa wartość zastępcza powoduje odrzucenie przez walidatory
Dwa zasięgi muszą pokryć każdy bajt pliku poza szesnastkowym ciągiem Contents, a pozostawiona wartość zastępcza [0 0 0 0] deklaruje zerowe pokrycie, które odrzuca każdy zgodny walidator
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);          // tylko 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. k: zasięgi pokrywają wszystko poza /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Dwadzieścia linii zwykłego kodu RTL, bez wywołań biblioteki, a łapie całą tę klasę awarii w punkcie produkcji. Jeśli masz zachować z tego artykułu jedną asercję, zachowaj tę: sparsuj własne podpisane wyjście i sprawdź trzy równości, zanim plik opuści twój proces

Dlaczego walidatory oznaczają czas podpisania /M jako zniekształcony?

Surowe walidatory odrzucają czas podpisania, który nie jest kompletnym ciągiem daty PDF, a dwiema najczęściej brakującymi częściami są przedrostek D: i znacznik przesunięcia UTC. ISO 32000-1 §7.9.4 definiuje format daty jako D:YYYYMMDDHHmmSS, po którym następuje przesunięcie — Z dla UTC albo relacja do niego ze znakiem, +HH'mm'. Gołe 20260709143000 parsuje się jako data u pobłażliwego czytnika, ale walidator stosujący gramatykę dosłownie widzi zniekształcony ciąg w polu o obowiązkowym formacie i oznacza podpis. Wydania PDFium Component przed 2.14.4 zapisywały wpis /M w TPdf.SignPades i SignPadesBytes dokładnie w tej gołej postaci; od 2.14.4 wpis niesie przedrostek D: i znacznik Z, więc zadeklarowany czas podpisania czyta się jako D:20260709143000Z

Do tego pola dopinają się dwie praktyczne uwagi. Po pierwsze, zapisuj UTC i mów o tym wprost: znacznik czasu bez znacznika przesunięcia zmusza walidator do zgadywania relacji strefowej, a §7.9.4 traktuje przesunięcie jako część formatu, a nie opcjonalny dodatek. Po drugie, pamiętaj, czym jest /M — to czas zadeklarowany przez podpisującego, twierdzenie, a nie dowód. Walidator sprawdza tu jego format, a nie prawdziwość; dowodliwy czas pochodzi ze znacznika czasu RFC 3161 na poziomie PAdES B-T i wyżej. Poprawne sformatowanie pola i zaufanie mu to osobne decyzje, a walidatory egzekwują tylko pierwszą

Anatomia czasu podpisania /M w PDF dla PAdES w Delphi: akceptowana postać D:YYYYMMDDHHmmSSZ z przedrostkiem i znacznikiem przesunięcia UTC wobec gołego ciągu cyfr, który surowe walidatory oznaczają przy podpisywaniu z PDFium Component
Kompletny ciąg daty wg §7.9.4 niesie przedrostek D: i znacznik przesunięcia, a gołą postać cyfrową, którą przyjmują pobłażliwe przeglądarki, surowe walidatory oznaczają

Dlaczego SignPades zgłasza EPadesCrypto na zaszyfrowanym PDF?

PDFium Component odmawia podpisania zaszyfrowanego dokumentu, bo alternatywą jest wyprodukowanie pliku, który zgodne czytniki zniszczą przy otwarciu. Podpis PAdES dołącza się jako aktualizację przyrostową, a ISO 32000-1 §7.5.6 wymaga, by nowy zwiastun sekcji aktualizacji niósł każdy wpis poprzedniego zwiastuna z wyjątkiem /Prev — w dokumencie zaszyfrowanym obejmuje to /Encrypt. Upuść go, a najnowszy zwiastun deklaruje plik jako niezaszyfrowany, więc zgodny czytnik parsuje zaszyfrowane ciało jako tekst jawny i dostaje śmieci. Co gorsza, strumienie i ciągi dołączane przez podpisującego same musiałyby zostać zaszyfrowane kluczem dokumentu, żeby były legalne, a tego wstrzykiwacz podpisów działający na tekście jawnym nie potrafi. Nie da się dołączyć poprawnego podpisu w tekście jawnym do pliku zaszyfrowanego, więc od wersji 2.14.2 TPdf.SignPades, SignPadesBytes i InjectPadesDssMarkers zgłaszają EPadesCrypto, zamiast emitować wynik uszkodzony albo nieweryfikowalny

Dlaczego SignPades zgłasza EPadesCrypto na zaszyfrowanym PDF w Delphi: zwiastun aktualizacji przyrostowej musi powtórzyć /Encrypt, ale wstrzykiwacz podpisów w tekście jawnym nie potrafi zaszyfrować tego, co dołącza, więc PDFium Component odmawia, zanim uszkodzi plik
Zwiastun aktualizacji przyrostowej musi powtórzyć /Encrypt, a wstrzykiwacz podpisów w tekście jawnym nie potrafi zaszyfrować tego, co dołącza, więc komponent zgłasza wyjątek zamiast emitować plik, który czytniki by zniszczyły
try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // np. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

Wyjątek jest poprawnym wynikiem, więc zaprojektuj przepływ wokół niego, zamiast przechwytywać go i ponawiać. Odszyfruj najpierw z uprawnieniami właściciela, podpisz kopię w tekście jawnym, a jeśli kanał dystrybucji wymaga szyfrowania, przyjmij do wiadomości, że szyfruj-potem-podpisz i podpisz-potem-zaszyfruj produkują różne artefakty o różnych historiach walidacji. Podpis policzony nad bajtami jawnymi nie przeżyje ponownego zaszyfrowania tych bajtów, więc uczciwymi opcjami są podpisany plik jawny albo decyzja polityczna udokumentowana obok kodu

Jak dwa błędy mogą sobie nawzajem wystawiać certyfikaty

Defekt ByteRange ukrywał się tak długo, bo nasz własny walidator był błędny w komplementarny sposób, i to najbardziej przenośna lekcja z tego artykułu. Kontrola pokrycia w ValidatePadesCompliance wymagała, by drugi zasięg zaczynał się dokładnie na A+B — z zerową przerwą — co błędnie klasyfikuje standardowy układ, w którym przerwa mieści szesnastkowy ciąg /Contents. Nasz walidator odrzucał więc dokładnie ten układ, który jest zgodny, a nasz generator nigdy takiego nie produkował, i potoki podpisz-i-zwaliduj od końca do końca przypadkiem świeciły na zielono. Każdy z tych błędów odmawiał zestawowi testów kontrprzykładu, który obnażyłby drugi. Wersja 2.14.1 naprawiła obie strony w tym samym wydaniu: generator uzupełnia wszystkie cztery pola, a walidator przyjmuje C >= A+B przy C+D równym długości pliku

Poprawką metodologiczną jest walidacja krzyżowa wobec implementacji, której sam nie napisałeś. Generator i walidator dzielące bazę kodu, autora albo choćby tylko mentalny model formatu mogą zgadzać się co do wspólnego nieporozumienia w nieskończoność; niezależny walidator łamie tę symetrię. Przepuść swoje podpisane wyjście przez co najmniej jeden zewnętrzny sprawdzacz zgodności przed wydaniem, a strukturalną autokontrolę trzymaj w kompilacji jako szybką pierwszą linię — TPdf.ValidatePades, opisane w artykule o inspekcji podpisów, raportuje awarię pokrycia jako nazwany problem

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Lista kontrolna odrzuceń dla podpisanego wyjścia

Gdy platforma odrzuca twój podpis PAdES, sprawdź tanie przyczyny strukturalne, zanim zaczniesz podejrzewać certyfikaty albo łańcuchy zaufania. Odczytaj tablicę /ByteRange i zweryfikuj trzy równości: pierwsze pole zerowe, drugi zasięg zaczynający się w miejscu końca pierwszego albo za nim, drugi zasięg kończący się dokładnie na końcu pliku. Odczytaj wpis /M i zweryfikuj, że jest pełnym ciągiem daty wg §7.9.4 z przedrostkiem D: i znacznikiem przesunięcia. Potwierdź, że dokument źródłowy nie był zaszyfrowany w chwili dołączania podpisu — a jeśli twój stos podpisał go mimo to bez skargi, potraktuj to milczenie jako błąd stosu. Wszystkie trzy kontrole biegną na surowych bajtach w milisekundach, a z naszego doświadczenia wyjaśniają odrzucenie znacznie częściej niż jakakolwiek przyczyna kryptograficzna

Użyte tutaj wywołania podpisujące, inspekcyjne i walidujące — SignPades, ValidatePades oraz kontrole zgodności PAdES z poprawioną arytmetyką ByteRange, formatowaniem daty i bramkowaniem szyfrowania — są częścią PDFium Component dla Delphi, C++Builder i Lazarusa