Żeby ustalić, co zmieniło się w PDF po podpisaniu, PDFium Component dla Delphi i Lazarusa dostarcza TPdf.AnalyzeSignatureRevisions — analizatora zmian w rewizjach po podpisie, który odbudowuje każdą rewizję przyrostową z oryginalnych bajtów pliku, ocenia każdą późniejszą zmianę obiektu względem reguł DocMDP i FieldMDP tego podpisu i raportuje definicje obiektów-cieni jako osobne ryzyko. Sytuacja, na którą celuje, jest znajoma każdemu, kto obraca kontraktami: certyfikowany formularz wychodzi, wraca z dwoma kolejnymi zapisami przyrostowymi i każdy podpis dalej się weryfikuje. To oczekiwane, bo podpis pokrywa wyłącznie bajty swojej własnej rewizji. Prawdziwe pytanie brzmi, czy te późniejsze zapisy były dozwolone — a zielony ptaszek przy podpisie na nie nie odpowiada
Dlaczego API podpisów PDFium nie pokaże, co zmieniło się po podpisaniu?
API podpisów PDFium nie pokaże zmian po podpisie, bo czyta wyłącznie słownik podpisu: /Contents, /ByteRange, /SubFilter i wartość uprawnień DocMDP. PDFium nie ma grafu rewizji przyrostowych, nie parsuje parametrów transformacji FieldMDP i nie oferuje diffa na poziomie obiektów między rewizjami, więc analizator w FPdfPades.pas działa wprost na surowych bajtach. Z tego płynie praktyczna konsekwencja, którą warto uwzględnić w projekcie. TPdf.AnalyzeSignatureRevisions czyta bajty zatrzymane przy wczytywaniu dokumentu, nigdy kopię wyprodukowaną przez SaveAs, bo przepisany plik stracił właśnie tę strukturę rewizji, którą analizujesz. Jeśli dokument przyszedł z progresywnego źródła, które nie skończyło się pobierać, raport zwraca SourceStatus = pvssIncomplete i Status = prasIndeterminate, zamiast analizować obcięty plik
Odbudowa granic rewizji ze startxref, strumieni xref i /Prev
Analizator odbudowuje granice rewizji, podążając za każdym startxref wstecz przez klasyczne tabele xref, strumienie odwołań krzyżowych, wpisy hybrydowe /XRefStm i łańcuch /Prev, zgodnie z definicją aktualizacji przyrostowych w ISO 32000-1 §7.5.6 i §7.5.8. Pokryta długość każdego podpisu to koniec jego drugiego zakresu ByteRange, a analizator mapuje tę długość na rewizję, wewnątrz której leży jego sekcja xref. Gdy żadna rewizja nie pasuje, podpis dostaje prrCoveredRevisionNotFound i status Indeterminate. Stan każdego obiektu jest potem odtwarzany aż do pokrytej rewizji, a każdy późniejszy wpis xref jest porównywany z tym stanem. To ważniejsze, niż brzmi: część zapisujących powtarza pełną tabelę xref przy każdym zapisie przyrostowym, a wpis, który wciąż wskazuje na ten sam niezmieniony obiekt, jest pomijany, zamiast zostać zaraportowany jako modyfikacja. Bez tego porównania całkowicie legalne wypełnienie formularza utonęłoby w setkach fałszywych zmian
Definicje-cienie to przypadek zasługujący na największą uwagę. Ciało obiektu, które pojawia się wewnątrz zakresu bajtów późniejszej rewizji, ale nie jest referencjonowane przez xref tej rewizji, jest niewidzialne dla zwykłej przeglądarki — a to dokładnie ten rodzaj rozstawiania dekoracji, na którym polegają ataki cieniami: ukryta treść jest sadzona przed podpisaniem albo po i później aktywowana przekręceniem referencji. AnalyzePadesSignatureRevisionsBytes zapisuje taki obiekt jako zmianę nieautorytatywną z IsAuthoritative = False, ocenia go na prdSuspicious niezależnie od poziomu uprawnień i dokłada prrUnreferencedObjectDefinition do zbioru ryzyk. Dwa pokrewne ryzyka obejmują inne sztuczki strukturalne: prrDuplicateObjectDefinition odpala, gdy jedna sekcja xref wylicza ten sam obiekt więcej niż raz, a prrSignatureObjectRedefined — gdy późniejsza rewizja przedefiniowuje istniejący obiekt podpisu
uses
SysUtils, TypInfo, PDFium, FPdfPades;
const
ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-returned.pdf';
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions;
Writeln('Revisions: ', Report.RevisionCount,
' Signatures: ', Report.SignatureCount,
' Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
Ord(Report.Status)));
for i := 0 to High(Report.Signatures) do
with Report.Signatures[i] do
begin
Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
[SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
DocMdpPermission,
GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
for j := 0 to High(Changes) do
Writeln(Format(' rev %d obj %d %s -> %s%s',
[Changes[j].RevisionIndex, Changes[j].ObjectNumber,
GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
ShadowTag[not Changes[j].IsAuthoritative]]));
end;
finally
Pdf.Free;
end;
end;
Jak DocMDP i FieldMDP są egzekwowane dla każdego podpisu?
DocMDP i FieldMDP są egzekwowane osobno dla każdego podpisu, na jego własnej pokrytej rewizji, więc podpis certyfikujący i późniejszy podpis zatwierdzający w tym samym pliku mogą dojść do różnych werdyktów o tej samej edycji. Każdy późniejszy obiekt jest najpierw klasyfikowany do TPadesRevisionChangeKind na podstawie swoich wpisów /Type, /Subtype i /FT oraz roli, jaką odgrywa w grafach strony, formularza, adnotacji i DSS. Cokolwiek niosące /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia albo /EmbeddedFile staje się prckActiveContent. Decyzja idzie potem za ISO 32000-1 §12.8.2.2: przy P=1 wszystko poza danymi odwołań krzyżowych i materiałem walidacyjnym jest zabronione; P=2 pozwala na wypełnianie formularzy i kolejne podpisy, ale odrzuca zmiany adnotacji; P=3 pozwala też na adnotacje. Treść strony, struktura dokumentu, metadane, treść aktywna i usunięte obiekty są zabronione na każdym poziomie DocMDP, a oceniane na prdSuspicious, gdy podpis w ogóle nie niesie DocMDP, bo podpis zatwierdzający formalnie niczego nie zabrania, ale czytelnik już nie widzi, co było podpisane
FieldMDP, z ISO 32000-1 §12.8.2.4, zawęża decyzję dotyczącą pól formularza jeszcze bardziej. pfmaAll blokuje każde pole, pfmaInclude blokuje tylko wyliczone pola, a pfmaExclude blokuje wszystko poza wyliczonymi. Żeby zastosować Include albo Exclude, analizator rozwiązuje każde zmienione pole do jego w pełni kwalifikowanej nazwy przez łańcuch /Parent i porównuje ją z listą blokad dokładnym dopasowaniem, więc wyliczaj nazwy pól terminalnych, a nie oczekuj, że nazwa rodzica pokryje dzieci. Gdy nazwy nie da się rozwiązać albo transformacja używa akcji, której parser nie rozpoznaje, zmiana staje się prdIndeterminate i podnoszone jest prrFieldMdpUnresolved. Decyzje per zmiana składają się potem od najgorszego, z Suspicious ponad Disallowed, Disallowed ponad Indeterminate i Indeterminate ponad Allowed, więc jeden obiekt-cień przeważa dowolną liczbę legalnych aktualizacji pól
Dlaczego niektóre zmiany wracają jako Indeterminate zamiast bezpieczne?
Zmiany wracają jako Indeterminate zawsze, gdy analizator nie potrafi udowodnić, że zmiana jest dozwolona, bo w sprawdzeniu podpisu nieznane nigdy nie może zostać zaraportowane jako dozwolone. Jeden częsty przypadek jest obsłużony precyzyjnie: walidacja długoterminowa dokłada /DSS i przepisuje katalog, co inaczej liczyłoby się jako zmiana strukturalna przy P=1. Analizator zdejmuje /DSS i /Extensions ze starych i nowych słowników katalogu i porównuje resztę; gdy nic innego się nie różni, przepisanie jest traktowane jako aktualizacja materiału walidacyjnego i dozwolone, więc augmentacja B-LT i B-LTA nie łamie podpisu certyfikującego. Inne luki są zostawione otwarte celowo. Wpisy typu 2 w strumieniu odwołań krzyżowych wskazują w skompresowane strumienie obiektów, a analizator nie rozpakowuje strumieni obiektów wewnątrz tej granicy bezpieczeństwa, więc te zmiany wychodzą jako prckCompressedObject z prrCompressedObjectUnresolved, zabronione przy P=1 i Indeterminate poza tym. Twarde budżety 1024 rewizji, 1 000 000 numerów obiektów i 2 000 000 zaraportowanych zmian produkują prrResourceLimitExceeded, a zepsuty łańcuch xref produkuje prrMalformedRevisionChain; oba kończą się jako Indeterminate, nigdy jako zaliczone
const
StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrResourceLimitExceeded];
function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
// Część ryzyk jest zapisywana bez zmiany Status, więc przetestuj je najpierw
if R.Risks * StructuralRisks <> [] then
Exit('review: structural risk in the revision chain');
case R.Status of
prasNoLaterChanges: Result := 'accept: nothing was added after signing';
prasAllowed: Result := 'accept: every later change is permitted';
prasDisallowed: Result := 'reject: a change violates DocMDP or FieldMDP';
prasSuspicious: Result := 'reject: shadow or unconstrained content change';
prasIndeterminate: Result := 'review: the analyzer could not decide';
else
Result := 'not checked: no signatures or no original bytes';
end;
end;
Kolejność w tej bramce jest celowa. prrDuplicateObjectDefinition jest dokładane do zbioru ryzyk bez samodzielnego obniżania Status, a transformacja FieldMDP, której nie da się sparsować, wpływa na status dopiero, gdy pole formularza faktycznie się zmienia, więc bramka patrząca wyłącznie na Status może przegapić dowody, które raport już zawiera. Miej też na uwadze, czego raport nie twierdzi. TPadesRevisionAnalysisReport nie mówi nic o tym, czy podpis CMS jest kryptograficznie ważny, ani czy certyfikat podpisującego łańcuchuje się do zaufanego przez ciebie korzenia. Analiza rewizji odpowiada na pytanie, co stało się po podpisaniu — i należy obok walidacji strukturalnej i zaufania, a nie na ich miejsce
Zapis wartości seed i blokad MDP w momencie podpisywania
Te same reguły można nadać przy podpisywaniu przez TPadesSignatureFieldOptions, czyli człon FieldOptions zarówno TPadesSignOptions, jak i TPadesRemoteSignOptions. PDFium potrafi stworzyć widget, ale nie umie zapisać /SV, /Lock, transformacji FieldMDP ani DocMDP ani słownika /Perms katalogu, więc własny przyrostowy zapisujący PAdES w komponencie produkuje te obiekty wewnątrz tej samej aktualizacji xref co podpis. FieldName ustawia nazwę pola korzenia, RequiredSeedValues staje się bitami /Ff słownika wartości seed opisanego w ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations i AcceptableCertificates zawężają, co późniejszy podpisujący może wybrać, LockAction z LockFields zapisuje pośredni /SigFieldLock, a CertificationPermission od 1 do 3 czyni z podpisu podpis certyfikujący. Transformacje DocMDP i FieldMDP trafiają obie do jednej tablicy /Reference na wartości podpisu, każda z /Data wskazującą na katalog
var
Options: TPadesSignOptions;
begin
Options := TPadesSignOptions.Default;
Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
Options.Reason := 'Approved for release';
Options.FieldOptions.FieldName := 'Certification';
Options.FieldOptions.CertificationPermission := 2; // tylko wypełnianie formularza i podpisywanie
Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
Options.FieldOptions.LockAction := pfmaInclude; // blokuj tylko te pola
SetLength(Options.FieldOptions.LockFields, 2);
Options.FieldOptions.LockFields[0] := 'Total';
Options.FieldOptions.LockFields[1] := 'IBAN';
if not Pdf.SignPades('contract-certified.pdf', Options) then
Writeln('Signing failed');
end;
Kilka szczegółów łatwo spartaczyć, jeśli robisz to ręcznie. Katalogowe /Perms /DocMDP musi referencjonować słownik wartości podpisu, nie adnotację widgetu, i właśnie dlatego zapisujący trzyma wartość podpisu jako własny obiekt pośredni. Istniejący słownik /Perms może już trzymać prawa użycia /UR3, więc zapisujący go kopiuje i wstawia /DocMDP, zamiast go podmieniać, zgodnie ze słownikiem uprawnień z ISO 32000-1 §12.8.4. Dokument, który już niesie /DocMDP, odmawia drugiego podpisu certyfikującego z EPadesCrypto — i to samo robią niespójne opcje: blokada Include albo Exclude bez nazw pól, blokada All z listą pól, atestacja prawna na podpisie niecertyfikującym albo kropka w nazwie pola korzenia. Podpisywanie zdalne dokłada jeszcze jedną regułę, bo certyfikat podpisującego jest nieznany, gdy biegnie PreparePadesRemoteSignature: ustawienie tam CertificateRequired wymaga jawnej listy AcceptableCertificates, podczas gdy podpisywanie lokalne może spaść na rozwiązany certyfikat podpisującego
Analiza rewizji dopełnia skrzynkę narzędziową podpisów, zamiast zastępować którąkolwiek jej część. Zacznij od przeglądania podpisów PDF i poziomów PAdES z PDFium Component, żeby odczytać słownik i poziom bazowy, spójrz na tekst o tym, czemu walidatory odrzucają podpisy PAdES po awarie strukturalne, które przychodzą przed jakimkolwiek pytaniem o rewizje, i wtop werdykt w szerszy audyt ryzyk bezpieczeństwa PDF obok sprawdzeń JavaScriptu i plików osadzonych. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions i pokazany tu przyrostowy zapisujący PAdES są w PDFium Component dla Delphi, C++Buildera i Lazarusa