Technický článek

Analýza změn PDF po podpisu s PDFium v Delphi

Abychom zjistili, co se v PDF změnilo po podepsání, poskytuje PDFium Component pro Delphi a Lazarus TPdf.AnalyzeSignatureRevisions, analyzátor změn revizí po podpisu, který znovu sestaví každou inkrementální revizi z původních bytů souboru, ohodnotí každou pozdější objektovou změnu podle DocMDP a FieldMDP pravidel toho podpisu a hlásí shadow definice objektů jako samostatné riziko. Situace, na kterou cílí, je povědomá každému, kdo zachází se smlouvami: certifikovaný formulář odejde, vrátí se se dvěma dalšími inkrementálními úložbami a každý podpis stále projde verifikací. To je očekávané, protože podpis pokrývá jen byty své vlastní revize. Skutečná otázka je, zda ty pozdější úložby byly povolené — a zelená fajfka na podpisu na ni neodpoví

Proč signature API PDFium neukáže, co se změnilo po podpisu?

Signature API PDFium nedokáže ukázat změny po podpisu, protože čte jen slovník podpisu: /Contents, /ByteRange, /SubFilter a hodnotu oprávnění DocMDP. PDFium nemá graf inkrementálních revizí, neparsuje transform parametry FieldMDP a nenabízí žádný diff mezi revizemi na úrovni objektů, takže analyzátor v FPdfPades.pas pracuje rovnou na syrových bytech. Má to praktický důsledek, kolem kterého byste měli navrhovat. TPdf.AnalyzeSignatureRevisions čte byty uchované při načtení dokumentu, nikdy kopii vyprodukovanou SaveAs, protože přepsaný soubor přišel právě o revizní strukturu, která se analyzuje. Pokud dokument přišel z progresivního zdroje, který nedoběhl se stahováním, vrátí report SourceStatus = pvssIncomplete a Status = prasIndeterminate místo analýzy useknutého souboru

Sestavení hranic revizí z startxref, xref streamů a /Prev

Analyzátor sestaví hranice revizí tak, že sleduje každý startxref zpět skrz klasické xref tabulky, cross-reference streamy, hybridní položky /XRefStm a řetěz /Prev, jak jsou definovány pro inkrementální update v ISO 32000-1 §7.5.6 a §7.5.8. Pokrytá délka každého podpisu je konec jeho druhého rozpětí ByteRange a analyzátor namapuje tu délku na revizi, do jejíž xref sekce spadá. Když žádná revize nesedí, podpis dostane prrCoveredRevisionNotFound a status Indeterminate. Stav každého objektu se pak přehraje až do pokryté revize a každá pozdější xref položka se porovná s tím stavem. Tohle má větší váhu, než zní: někteří writery znovu zapisují kompletní xref tabulku při každé inkrementální úložbě a položka, která dál míří na tentýž nezměněný objekt, se přeskočí místo nahlášení jako modifikace. Bez toho porovnání by zcela legální vyplnění formuláře se utopilo ve stovkách falešných změn

Jak AnalyzeSignatureRevisions znovu sestaví inkrementální revize ze syrových PDF bytů v Delphi: ByteRange podpisu končí uvnitř pokryté revize, řetěz xref Prev projde zpět každou pozdější úložbou, stav objektů se přehraje do pokryté revize a nezměněné znovu zapsané položky se přeskočí místo hlášení jako změny
Podpis pokrývá jen byty své vlastní revize, takže analyzátor namapuje druhé rozpětí ByteRange na revizi a ohodnotí každou pozdější xref položku proti přehranému stavu objektů

Shadow definice jsou případ, který si zaslouží nejvíc pozornosti. Tělo objektu, které se objeví uvnitř bajtového rozpětí pozdější revize, ale není odkazované xrefem té revize, je pro obyčejný prohlížeč neviditelné — a je to přesně ten druh přípravy, na kterém stojí shadow útoky: skrytý obsah se zasadí před nebo po podpisu a později se aktivuje překlopením reference. AnalyzePadesSignatureRevisionsBytes zaznamená takový objekt jako non-authoritative změnu s IsAuthoritative = False, ohodnotí ji prdSuspicious bez ohledu na úroveň oprávnění a přidá prrUnreferencedObjectDefinition do sady rizik. Dvě související rizika pokrývají další strukturální triky: prrDuplicateObjectDefinition vystřelí, když jedna xref sekce vyjmenuje tentýž objekt víc než jednou, a prrSignatureObjectRedefined vystřelí, když pozdější revize předefinuje existující objekt podpisu

Shadow definice objektu uvnitř bajtového rozpětí pozdější PDF revize: tělo objektu existuje, ale žádná xref položka na něj neodkazuje, takže ho prohlížeče nikdy neukáží, a AnalyzeSignatureRevisions v PDFium Component ho zaznamená jako non-authoritative, ohodnotí prdSuspicious a vyvolá prrUnreferencedObjectDefinition vedle rizik duplicitního a předefinovaného podpisu
Skrytý obsah se zasadí před nebo po podpisu a aktivuje se později překlopením reference, a proto se neodkazované tělo ohodnotí jako suspicious bez ohledu na úroveň oprávnění DocMDP
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 se DocMDP a FieldMDP vynucují pro každý podpis?

DocMDP a FieldMDP se vynucují odděleně pro každý podpis, na jeho vlastní pokryté revizi, takže certifikační podpis a pozdější schvalovací podpis v témže souboru mohou dospět k různým verdiktům nad tutéž editaci. Každý pozdější objekt se nejdřív klasifikuje do TPadesRevisionChangeKind podle svých položek /Type, /Subtype a /FT a podle role, kterou hraje v grafech stránek, formulářů, anotací a DSS. Cokoli nesoucí /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia nebo /EmbeddedFile se stane prckActiveContent. Rozhodnutí pak následuje ISO 32000-1 §12.8.2.2: s P=1 je zakázáno všechno kromě cross-reference dat a validačního materiálu; P=2 povoluje vyplňování formulářů a další podpisy, ale odmítá změny anotací; P=3 povoluje i anotace. Obsah stránek, struktura dokumentu, metadata, aktivní obsah a smazané objekty jsou zakázané pod jakoukoli úrovní DocMDP a ohodnotí se prdSuspicious, když podpis nenese DocMDP vůbec, protože schvalovací podpis formálně nezakazuje nic, ale čtenář už nevidí, co bylo podepsáno

FieldMDP z ISO 32000-1 §12.8.2.4 zužuje rozhodnutí o polích formuláře dál. pfmaAll zamkne každé pole, pfmaInclude zamkne jen vyjmenovaná pole a pfmaExclude zamkne všechno kromě vyjmenovaných. Aby se Include nebo Exclude aplikoval, rozliší analyzátor každé změněné pole na jeho plně kvalifikované jméno přes řetěz /Parent a porovná ho se seznamem zámků exaktní shodou, takže vyjmenovávejte terminální názvy polí, místo abyste čekali, že rodičovský název pokryje děti. Když se název rozlišit nepodaří nebo transform používá akci, kterou parser nepozná, změna se stane prdIndeterminate a vyvolá se prrFieldMdpUnresolved. Rozhodnutí per změna se pak sbalí nejhorší-dřív, se Suspicious nad Disallowed, Disallowed nad Indeterminate a Indeterminate nad Allowed, takže jeden shadow objekt přebije jakýkoli počet legitimních aktualizací polí

Ohodnocovací pipeline, kterou AnalyzeSignatureRevisions aplikuje na každou změnu po podpisu v Delphi: TPadesRevisionChangeKind podle položek Type a Subtype, DocMDP rozhodnutí na pokryté revizi od P=1 do P=3, kontrola zámku FieldMDP na plně kvalifikovaných názvech polí a sbalení nejhorší-dřív od prdSuspicious až po prdAllowed
Jeden shadow objekt přebije jakýkoli počet legitimních aktualizací polí, protože Suspicious se řadí nad Disallowed, Indeterminate i Allowed, zatímco některá rizika se zaznamenají vedle statusu bez jeho snížení

Proč některé změny vychází jako Indeterminate místo bezpečné?

Změny vychází jako Indeterminate vždy, když analyzátor nedokáže prokázat, že změna je povolená, protože ve verifikaci podpisu se neznámé nikdy nesmí hlásit jako povolené. Jeden obvyklý případ se řeší přesně naopak: long-term validace přidá /DSS a přepíše katalog, což by jinak pod P=1 počítalo jako strukturální změna. Analyzátor odřízne /DSS a /Extensions ze starého i nového katalogového slovníku a porovná zbytek; když se nic dalšího neliší, přepis se bere jako aktualizace validačního materiálu a povolí se, takže augmentace B-LT a B-LTA nerozbije certifikační podpis. Jiné mezery zůstávají záměrně otevřené. Položky Type-2 v cross-reference streamu míří do komprimovaných object streamů a analyzátor object streamy uvnitř této bezpečnostní hranice neexpanduje, takže tyhle změny vyjdou jako prckCompressedObject s prrCompressedObjectUnresolved, zakázané pod P=1 a jinde Indeterminate. Tvrdé rozpočty 1024 revizí, 1 000 000 objektových čísel a 2 000 000 hlášených změn produkují prrResourceLimitExceeded a rozbitý řetěz xref produkuje prrMalformedRevisionChain; obojí končí jako Indeterminate, nikdy jako průchod

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Některá rizika se zaznamenají beze změny Status, takže je testujte nejdřív
  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;

Pořadí v té bráně je záměrné. prrDuplicateObjectDefinition se do sady rizik přidá bez toho, že by sám snižoval Status, a FieldMDP transform, který nejde parsovat, ovlivní status až ve chvíli, kdy se reálně změní pole formuláře, takže brána koukající jen na Status může přehlédnout důkaz, který report už obsahuje. Mějte na paměti i to, co report netvrdí. TPadesRevisionAnalysisReport neříká nic o tom, zda je CMS podpis kryptograficky validní, nebo zda certifikát podepisujícího řetězí ke kořeni, kterému věříte. Analýza revizí odpovídá na otázku, co se stalo po podpisu, a patří vedle strukturální a trust validace, ne na její místo

Zápis seed hodnot a zámků MDP při podepisování

Tytéž pravidla lze zapsat při podepisování přes TPadesSignatureFieldOptions, což je člen FieldOptions jak TPadesSignOptions, tak TPadesRemoteSignOptions. PDFium umí vytvořit widget, ale nedokáže zapsat /SV, /Lock, transform FieldMDP nebo DocMDP ani katalogový slovník /Perms, takže vlastní inkrementální PAdES writer komponenty produkuje tyhle objekty uvnitř téhož xref updatu jako podpis. FieldName nastaví kořenový název pole, RequiredSeedValues se stane bity /Ff slovníku seed hodnot popsaného v ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations a AcceptableCertificates omezí, co si pozdější podepisující může vybrat, LockAction s LockFields zapíše nepřímý /SigFieldLock a CertificationPermission od 1 do 3 udělá z podpisu certifikační podpis. Transformy DocMDP a FieldMDP míří obě do jednoho pole /Reference na hodnotě podpisu, každá s /Data mířícím 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;   // jen vyplňování formuláře a podepisování
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // zamkne jen tato pole
  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;

Pár detailů se snadno pokazí, když tohle stavíte ručně. Katalogový /Perms /DocMDP musí odkazovat na slovník hodnoty podpisu, ne na widget anotaci, a writer proto drží hodnotu podpisu jako vlastní nepřímý objekt. Existující slovník /Perms už může nést usage rights /UR3, takže writer ho zkopíruje a vloží /DocMDP místo výměny, dle slovníku oprávnění v ISO 32000-1 §12.8.4. Dokument, který už nese /DocMDP, odmítne druhý certifikační podpis s EPadesCrypto a odmítnou i nekonzistentní volby: zámek Include nebo Exclude bez názvů polí, zámek All se seznamem polí, legal attestation na necertifikačním podpisu nebo tečka v kořenovém názvu pole. Vzdálené podepisování přidává další pravidlo, protože podepisující certifikát není známý, když běží PreparePadesRemoteSignature: nastavení CertificateRequired tam vyžaduje explicitní seznam AcceptableCertificates, zatímco lokální podepisování může jako zálohu použít rozlišený certifikát podepisujícího

Analýza revizí doplňuje sadu nástrojů kolem podpisů, místo aby cokoli z ní nahradila. Začněte článkem o prohlížení PDF podpisů a úrovní PAdES s PDFium Component, abyste přečetli slovník a baseline úroveň, podívejte se na proč validátory odmítají PAdES podpisy kvůli strukturálním selháním, která přicházejí dřív než jakákoli otázka revizí, a zapojte verdikt do širšího auditu PDF bezpečnostních rizik vedle kontrol JavaScriptu a embedded souborů. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions a inkrementální PAdES writer ukázaný tady dodává PDFium Component pro Delphi, C++Builder a Lazarus