Техническа статия

Анализ на промени в PDF след подпис с PDFium в Delphi

За да разберете какво се е променило в PDF след подписването му, PDFium Component за Delphi и Lazarus предоставя TPdf.AnalyzeSignatureRevisions — анализатор на промени по revision-и след подпис, който възстановява всеки incremental revision от оригиналните файлови байтове, оценява всяка по-късна промяна на обект спрямо DocMDP и FieldMDP правилата на съответния подпис и докладва shadow дефиниции на обекти като отделен риск. Ситуацията, която таргетира, е позната на всеки, който се занимава с договори: сертифицирана форма заминава, връща се с още два incremental записа и всеки подпис пак се валидира. Това е очаквано, защото подписът покрива само байтовете на собствения си revision. Истинският въпрос е дали по-късните записи са били разрешени, а зелената отметка на подписа не отговаря на него

Защо signature API-ят на PDFium не може да покаже какво се е променило след подписването?

Signature API-ят на PDFium не може да покаже промени след подписа, защото чете само signature речника: /Contents, /ByteRange, /SubFilter и DocMDP стойността за права. PDFium няма графа на incremental revision-и, не парсва FieldMDP transform параметри и не предлага diff на ниво обект между revision-и, затова анализаторът в FPdfPades.pas работи направо върху сурови байтове. Оттук следва практическо последствие, което си струва да заложите в дизайна. TPdf.AnalyzeSignatureRevisions чете байтовете, задържани при зареждането на документа, никога копие, произведено от SaveAs, защото пренаписан файл е изгубил самата revision структура, която се анализира. Ако документът идва от progressive източник, който не е свалил докрая, report-ът връща SourceStatus = pvssIncomplete и Status = prasIndeterminate, вместо да анализира отрязан файл

Възстановяване на revision граници от startxref, xref stream-ове и /Prev

Анализаторът възстановява revision границите, следвайки всеки startxref назад през класически xref таблици, cross-reference stream-ове, hybrid-reference /XRefStm записи и веригата /Prev, както е дефинирано за incremental updates в ISO 32000-1 §7.5.6 и §7.5.8. Покритата дължина на всеки подпис е краят на втория му ByteRange span, а анализаторът картира тази дължина към revision-а, в чиято xref секция тя попада. Когато никой revision не съвпадне, подписът получава prrCoveredRevisionNotFound и статус Indeterminate. Състоянието на всеки обект после се възпроизвежда до покрития revision и всеки по-късен xref запис се сравнява с това състояние. Това има значение повече, отколкото звучи: някои writer-и преписват пълната xref таблица при всяко incremental записване, а запис, който все още сочи към същия непроменен обект, се прескача, вместо да се докладва като модификация. Без това сравнение съвсем легитимно попълване на форма би се удавило в стотици фалшиви промени

Как AnalyzeSignatureRevisions възстановява incremental revision-и от сурови PDF байтове в Delphi: ByteRange на подписа свършва вътре в покрития revision, xref Prev веригата минава назад през всеки по-късен запис, състоянието на обектите се възпроизвежда до покрития revision, а непроменени преписани записи се прескачат, вместо да се докладват като промени
Подпис покрива само байтовете на собствения си revision, затова анализаторът картира втория ByteRange span към revision и оценява всеки по-късен xref запис спрямо възпроизведеното състояние на обектите

Shadow дефинициите са случайят, който заслужава най-много внимание. Тяло на обект, появило се вътре в байтовия диапазон на по-късен revision, но нереферирано от xref-а на този revision, е невидимо за нормален viewer, а точно на този вид подготвяне опират shadow атаките: скрито съдържание се засажда преди или след подписване и по-късно се активира с обръщане на референция. AnalyzePadesSignatureRevisionsBytes записва такъв обект като неавторитетна промяна с IsAuthoritative = False, оценява го prdSuspicious независимо от нивото на права и добавя prrUnreferencedObjectDefinition към risk set-а. Два сродни риска покриват други структурни трикове: prrDuplicateObjectDefinition пали, когато една xref секция изброи същия обект повече от веднъж, а prrSignatureObjectRedefined — когато по-късен revision пре-дефинира съществуващ signature обект

Shadow дефиниция на обект вътре в байтовия диапазон на по-късен PDF revision: тялото на обекта съществува, но никой xref запис не го реферира, така че viewer-и никога не го показват, а AnalyzeSignatureRevisions в PDFium Component го записва като неавторитетен, оценява го prdSuspicious и вдига prrUnreferencedObjectDefinition до рисковете дублиран и пре-дефиниран signature обект
Скрито съдържание се засажда преди или след подписване и се активира по-късно с обръщане на референция, затова нереферирано тяло се оценява подозрително независимо от 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;

Как DocMDP и FieldMDP се налагат за всеки подпис?

DocMDP и FieldMDP се налагат поотделно за всеки подпис, на собствения му покрит revision, така че сертифициращ подпис и по-късен одобряващ подпис в един файл могат да стигнат до различни присъди за една и съща промяна. Всеки по-късен обект първо се класифицира в TPadesRevisionChangeKind от записите си /Type, /Subtype и /FT и от ролята, която играе в графите на страниците, формите, анотациите и DSS. Всичко, носещо /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia или /EmbeddedFile, става prckActiveContent. Решението после следва ISO 32000-1 §12.8.2.2: при P=1 всичко освен cross-reference данни и validation материал е забранено; P=2 позволява попълване на форми и още подписи, но отхвърля промени по анотации; P=3 позволява и анотации. Съдържание на страница, структура на документа, метаданни, активно съдържание и изтрити обекти са забранени под всяко DocMDP ниво и се оценяват prdSuspicious, когато подписът изобщо не носи DocMDP, тъй като одобряващ подпис формално не забранява нищо, но читателят вече не вижда какво е било подписано

FieldMDP, от ISO 32000-1 §12.8.2.4, стеснява решението за form полета още повече. pfmaAll заключва всяко поле, pfmaInclude заключва само изброените полета, а pfmaExclude заключва всичко освен изброените. За да приложи Include или Exclude, анализаторът resolve-ва всяко променено поле до пълното му квалифицирано име през веригата /Parent и го сравнява със списъка за заключване чрез точно съвпадение, така че изброявайте крайни имена на полета, вместо да разчитате име на родител да покрие децата му. Когато име не може да се resolve-не или transform-ът ползва действие, което parser-ът не разпознава, промяната става prdIndeterminate и се вдига prrFieldMdpUnresolved. Решенията по промяна после се трупат най-лошото отпред, като Suspicious над Disallowed, Disallowed над Indeterminate и Indeterminate над Allowed, така че един shadow обект надвиева какъвто и да е брой легитимни обновявания на полета

Grading pipeline-ът, който AnalyzeSignatureRevisions прилага върху всяка промяна след подпис в Delphi: TPadesRevisionChangeKind от записите Type и Subtype, DocMDP решение на покрития revision от P=1 до P=3, FieldMDP проверка за заключване върху напълно квалифицирани имена на полета и трупане най-лошото отпред от prdSuspicious надолу до prdAllowed
Един shadow обект надвиева какъвто и да е брой легитимни обновявания на полета, защото Suspicious стои над Disallowed, Indeterminate и Allowed, докато някои рискове се записват до статуса, без да го смъкват

Защо някои промени се връщат Indeterminate вместо безопасни?

Промените се връщат Indeterminate щом анализаторът не може да докаже, че промяната е разрешена, защото в проверка на подпис непознатото никога не бива да се докладва като разрешено. Един чест случай се обработва вместо това прецизно: long-term validation добавя /DSS и пренаписва каталога, което иначе би броило като структурна промяна при P=1. Анализаторът смъква /DSS и /Extensions от стария и новия catalog речник и сравнява останалото; когато нищо друго не се различава, пренаписването се третира като update на validation материал и се разрешава, така че B-LT и B-LTA augmentация не чупи сертифициращ подпис. Други празноти са оставени отворени нарочно. Type-2 записи в cross-reference stream сочат в компресирани object stream-ове, а анализаторът не разширява object stream-ове вътре в тази security граница, така че тези промени излизат като prckCompressedObject с prrCompressedObjectUnresolved, забранени при P=1 и Indeterminate иначе. Твърди бюджети от 1024 revision-а, 1 000 000 object номера и 2 000 000 докладвани промени дават prrResourceLimitExceeded, а счупена xref верига дава prrMalformedRevisionChain; и двете свършват като Indeterminate, никога като минаване

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

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Някои рискове се записват без промяна на Status, затова ги тествайте първи
  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;

Подредбата в този гейт е нарочна. prrDuplicateObjectDefinition се добавя към risk set-а без самото тяло да смъква Status, а FieldMDP transform, който не може да бъде парснат, се отразява на статуса едва когато form поле реално се промени, така че гейт, гледащ само на Status, може да пропусне доказателства, които report-ът вече съдържа. Имайте предвид и какво report-ът не претендира. TPadesRevisionAnalysisReport не казва нищо дали CMS подписът е криптографски валиден или дали сертификатът на подписващия се верига към root, на който вярвате. Revision анализът отговаря на въпроса какво се е случило след подписването и принадлежи до структурната и trust валидация, не вместо тях

Писане на seed стойности и MDP заключвания при подписване

Същите правила могат да бъдат написани при подписване чрез TPadesSignatureFieldOptions, което е членът FieldOptions и на TPadesSignOptions, и на TPadesRemoteSignOptions. PDFium може да създаде widget, но не може да напише /SV, /Lock, FieldMDP или DocMDP transform, или catalog речника /Perms, затова собственият incremental PAdES writer на компонента произвежда тези обекти в същия xref update като подписа. FieldName задава името на root полето, RequiredSeedValues става битовете /Ff на seed-value речника, описан в ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations и AcceptableCertificates стесняват какво може да избере по-късен подписващ, LockAction с LockFields записва индиректен /SigFieldLock, а CertificationPermission от 1 до 3 превръща подписа в сертифициращ. DocMDP и FieldMDP transform-ите отиват и двете в един масив /Reference върху стойността на подписа, всеки с /Data, сочащ каталога

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // само попълване на форми и подписване
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // заключи само тези полета
  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;

Няколко детайла лесно се объркват, ако си ги пишете на ръка. Catalog /Perms /DocMDP трябва да реферира речника със стойността на подписа, не widget анотацията, и writer-ът пази стойността на подписа като собствен индиректен обект именно заради това. Съществуващ /Perms речник може вече да държи usage права /UR3, затова writer-ът го копира и вмъква /DocMDP, вместо да го замени, следвайки permissions речника в ISO 32000-1 §12.8.4. Документ, който вече носи /DocMDP, отказва втори сертифициращ подпис с EPadesCrypto, както и несъответстващи опции: Include или Exclude заключване без имена на полета, All заключване със списък полета, legal attestation върху несертифициращ подпис или точка в името на root полето. Remote подписването добавя още едно правило, защото сертификатът на подписващия е непознат, когато PreparePadesRemoteSignature върви: задаването на CertificateRequired там иска изричен списък AcceptableCertificates, докато локалното подписване може да падне обратно на resolve-натия сертификат на подписващия

Revision анализът допълва toolbox-а за подписи, вместо да замени която и да е негова част. Започнете с инспектиране на PDF подписи и PAdES нива с PDFium Component, за да прочетете речника и baseline нивото, погледнете защо валидатори отхвърлят PAdES подписи за структурните провали, идващи преди всеки въпрос за revision, и вплетете присъдата в по-широк PDF security risk одит заедно с проверки за JavaScript и embedded файлове. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions и incremental PAdES writer-ът, показан тук, се доставят с PDFium Component за Delphi, C++Builder и Lazarus