HotPDF, komponent PDF dla Delphi, odrzuca teraz podmienianie podpisów: od v2.759.0 zarówno VerifyLoadedSignatureEx, jak i walidator wsadowy wymagają, żeby przerwa między dwoma segmentami /ByteRange była dokładnie szesnastkowym ciągiem /Contents, delimiter włącznie, a v2.761.0 dodaje AddLoadedSignedSignatureField, dzięki któremu drugi podpis można dokleić do już podpisanego PDF-a jako czystą rewizję przyrostową. Te dwie zmiany należą do siebie, bo poprawny drugi podpis to dokładnie ten układ, jakiego oczekuje surowszy weryfikator
Sytuacja, która obnażyła problem, jest zwyczajna. Umowę podpisuje dostawca, a potem trafia ona do akceptującego, który musi dodać kontrpodpis, nie ruszając pierwszego podpisu. Druga rewizja jest doklejana po pierwszej, jej własny /ByteRange obejmuje cały urośnięty plik i oba podpisy powinny się weryfikować. Ręczne dojście do tego oznaczało pisanie sekcji przyrostowej samemu, a testowy fixture robiący dokładnie to okazał się podręcznikową strukturą podmieniania podpisów, którą stary weryfikator przyjmował z uśmiechem. Jeśli nie zaglądałeś wcześniej do API weryfikacji, przewodnik po weryfikacji podpisów cyfrowych PDF w HotPDF omawia podstawy, na których buduje ten artykuł
Co dokładnie należy do przerwy ByteRange?
Przerwa musi zawierać kompletną wartość /Contents i nic poza tym: ISO 32000-1 §12.8.3.3 mówi, że szesnastkowy ciąg, wraz z delimiterami < i >, mieści się dokładnie w przestrzeni między dwoma zakresami bajtów, a ISO 32000-2 §12.8.1 niesie tę samą regułę dalej. Tabela 252 i dokumenty PAdES mówią tylko, że skrót wyklucza wartość Contents, co łatwo przeczytać jako wykluczenie samych cyfr szesnastkowych. Wcześniejsze wydania HotPDF czytały to tak: PreparePDFForSigning i strumieniowe przygotowanie CMS hashowały też nawiasy kątowe, z komentarzem w źródłach upierającym się, że nawiasy muszą być objęte. Walidatory porównujące przerwę z wartością podpisu flagują taki układ jako niepoprawny zakres bajtów, więc v2.759.0 wynosi oba delimitery poza podpisane zakresy. Szybkie niezależne sprawdzenie na każdym podpisanym pliku to spojrzenie na dwa bajty: bajt pod offsetem ByteRange[1] musi być <, a bajt pod offsetem ByteRange[2] - 1 musi być >
Dlaczego sprawdzenie niepustej przerwy nie łapie podmieniania podpisów?
Sprawdzenie niepustej przerwy dowodzi tylko, że coś zostało wyłączone ze skrótu, ale nie co, i to jest cała powierzchnia ataku. Placeholder /Contents jest rezerwowany tysiącami zerowych cyfr, podczas gdy prawdziwy kontener CMS rzadko go zapełnia. Atakujący może zamknąć szesnastkowy ciąg wcześnie wewnątrz tego zerowego paddingu >, wpisać nowe obiekty albo sfabrykowaną rewizję do reszty zarezerwowanej przestrzeni i zostawić zakresy bajtów nietknięte. Podpis CMS nadal się weryfikuje, bo każdy podpisany bajt jest bez zmian, zakresy nadal zaczynają się od 0 i kończą na rozmiarze pliku, a stary weryfikator HotPDF raportował svValid z CoversWholeDocument ustawionym na True. Czytnik PDF parsuje w międzyczasie cokolwiek siedzi w tej niepodpisanej dziurze
HotPDF traktuje teraz przerwę jako dane do walidacji bajt po bajcie. Weryfikator czyta przerwę, zdejmuje delimitery, przyjmuje tylko cyfry szesnastkowe plus białe znaki PDF (tab, line feed, form feed, carriage return, spacja), dekoduje cyfry i wymaga, by wynik równał się dokładnie /Contents słownika podpisu. Cokolwiek innego degraduje wynik do svInvalidByteRange. Sprawdzenie biegnie zarówno w ścieżce pojedynczego podpisu, jak i w ValidateLoadedSignatureBatch, które trzymało własną logikę pokrycia i potrzebowało tej samej poprawki. Pliki wyprodukowane przez HotPDF przed v2.759.0, których przerwa trzymała same cyfry z nawiasami tuż wewnątrz zakresów, nadal się weryfikują, więc zarchiwizowane dokumenty nagle nie zrobią się czerwone
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
Jak dodać drugi podpis do już podpisanego PDF-a?
Otwórz podpisany plik przez BeginIncrementalUpdate, wołaj AddLoadedSignedSignatureField, zapisz przez SaveIncrementalUpdate, a potem podpisz przygotowany plik funkcją klasową THotPDF.SignPDFWithPFX. Przed v2.761.0 udokumentowany przepis wołania THPDFPage.AddSignedSignatureField po BeginIncrementalUpdate nie mógł działać, bo CurrentPage jest nil w trybie przyrostowym i nic nie mogło doczepić placeholdera /V do pola na wczytanym dokumencie. Nowa metoda tworzy widżet na wczytanej stronie i wiesza pod /V ten sam słownik placeholdera, którego używa ścieżka nowego dokumentu, więc obie trasy podpisywania dzielą jedną serializację. Co do pierwszego podpisu samego w sobie, artykuł o tworzeniu podpisów cyfrowych PAdES w Delphi przeprowadza przez potok PFX
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// Strona 0, prostokąt widżetu w punktach, 8192 bajtów zarezerwowane na CMS
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField jest z premedytacją spokojniejsze od rodzeństwa. Pozostali twórcy pól AddLoaded* ustawiają /NeedAppearances true na AcroForm, co każe przeglądarce regenerować wyglądy pól; na podpisanym dokumencie ta regeneracja może przepisać podpisaną treść, więc nowa metoda zdejmuje flagę z powrotem, chyba że źródło już ją niosło. /SigFlags zachowuje swoją pierwotną wartość OR 3 (SignaturesExist plus AppendOnly, ISO 32000-1 tabela 219). Nie musisz też wołać MarkDirty na stronie: dodanie do /Annots i /Fields propaguje flagę brudu do posiadającego obiektu pośredniego, a jawne oznaczenie strony wlokłoby do nowej rewizji jedynie niezmieniony słownik strony, którą analiza rewizji raportowałaby potem jako modyfikację strony. Wreszcie placeholder pisze /ByteRange przed /Contents, bo łatacz najpierw lokalizuje wartownika /ByteRange i szuka w przód pasującego szesnastkowego ciągu
Co się zmienia, gdy CMS produkuje zewnętrzny podpisujący albo HSM?
Nic się nie zmienia w przepływie pracy, ale offsety znaczą teraz to, co mówi specyfikacja. PreparePDFForSigning zwraca dwa zakresy 0-bazowe, których przerwą jest cały ciąg /Contents, a ContentsHexStart to indeks 1-bazowy pierwszej cyfry szesnastkowej w AnsiString. Krótszy CMS jest wypychany zerami 0 na końcu, przed zamykającym >. Ponieważ PreparePDFForSigning łata pierwszego niezałatanego wartownika, jakiego znajdzie, przygotuj dokładnie jeden placeholder na rewizję i przedkładaj InsertSignatureHexAt ze zwróconymi offsetami nad wyszukiwaniowe InsertSignatureHex, gdy wcześniejsze podpisy już istnieją w pliku
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // twój helper
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// Przerwa to cały szesnastkowy ciąg: '<' kończy zakres 1, '>' poprzedza zakres 2
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // twój podpisujący CMS, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // twój helper
end;
Gdzie są granice nowych sprawdzeń?
Sprawdzenie przerwy zamyka jedną konkretną dziurę i nie powinno być przeceniane. svValid nadal znaczy integralność bajtów plus klucz pasujący do osadzonego certyfikatu; zaufanie temu certyfikatowi to osobna decyzja. Przerwa jest walidowana tylko wtedy, gdy weryfikator ma bajty źródłowe, które VerifyLoadedSignatureEx czyta z wczytanego pliku, a przeciążenia TStream biorą od ciebie. Dla pierwszego podpisu w pliku z kontrpodpisem CoversWholeDocument jest poprawnie False, a to, czy doklejona rewizja tylko dodała podpis, czy też zmieniła strony, to pytanie dla analizy DocMDP, FieldMDP i rewizji w HotPDF. Zauważ też, że sprawdzenie podpiętego PDF MAC porównuje offsety z pozycjami < i >, więc przyjmuje i stary, i nowy układ; własne narzędzie zaszyte na sztywno z offsetami sprzed v2.759.0 wywali się najpierw, gdy spotka świeżo podpisany plik
Jeśli twoja aplikacja w Delphi albo C++Builderze podpisuje, kontrpodpisuje albo audytuje PDF-y, najbezpieczniejszą drogą jest pozwolenie jednej bibliotece produkować i weryfikować ten sam układ. HotPDF, natywny komponent PDF dla Delphi dowozi surowszą walidację przerwy, przyrostowe drugie podpisy i haki zewnętrznego podpisującego pokazane wyżej w jednym komponencie