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

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

Чтобы выяснить, что изменилось в PDF после подписания, PDFium Component для Delphi и Lazarus даёт TPdf.AnalyzeSignatureRevisions — анализатор пост-подписных ревизий, который восстанавливает каждую инкрементальную ревизию из исходных байтов файла, оценивает каждую последующую правку объектов против правил DocMDP и FieldMDP этой подписи и репортит теневые определения объектов отдельным риском. Ситуация, под которую он заточен, знакома каждому, кто имеет дело с договорами: сертифицированная форма уходит, возвращается с двумя дополнительными инкрементальными сохранениями, и все подписи по-прежнему верифицируются. Это ожидаемо, потому что подпись покрывает только байты собственной ревизии. Настоящий вопрос — были ли разрешены те поздние сохранения, и зелёная галочка на подписи на него не отвечает

Почему signature API PDFium не показывает, что изменилось после подписания?

Signature API PDFium не показывает пост-подписные изменения, потому что читает только словарь подписи: /Contents, /ByteRange, /SubFilter и значение разрешений DocMDP. У PDFium нет графа инкрементальных ревизий, он не парсит параметры transform у FieldMDP и не даёт объектного диффа между ревизиями, поэтому анализатор в FPdfPades.pas работает прямо по сырым байтам. Из этого практическое следствие, под которое стоит проектировать. TPdf.AnalyzeSignatureRevisions читает байты, удержанные при загрузке документа, и никогда — копию, произведённую SaveAs, потому что переписанный файл уже потерял саму ревизионную структуру, которую анализируем. Если документ пришёл из прогрессивного источника, не докачавшегося до конца, отчёт вернёт SourceStatus = pvssIncomplete и Status = prasIndeterminate вместо анализа обрезанного файла

Восстанавливаем границы ревизий из startxref, xref-стримов и /Prev

Анализатор восстанавливает границы ревизий, идя по каждому startxref назад через классические xref-таблицы, кросс-референсные стримы, гибридные записи /XRefStm и цепочку /Prev, как определено для инкрементальных обновлений в ISO 32000-1 §7.5.6 и §7.5.8. Покрытая длина каждой подписи — это конец её второго спана ByteRange, и анализатор мапит эту длину на ревизию, внутрь чьей xref-секции она попадает. Когда ни одна ревизия не подходит, подпись получает prrCoveredRevisionNotFound и статус Indeterminate. Затем состояние каждого объекта реплеится до покрытой ревизии, и каждая поздняя xref-запись сверяется с этим состоянием. Это важнее, чем звучит: некоторые райтеры переписывают полную xref-таблицу при каждом инкрементальном сохранении, и запись, всё ещё указывающая на тот же неизменённый объект, пропускается, а не репортится как модификация. Без этого сравнения абсолютно легальное заполнение формы утонуло бы в сотнях фейковых правок

Как AnalyzeSignatureRevisions восстанавливает инкрементальные ревизии из сырых байтов PDF в Delphi: ByteRange подписи кончается внутри покрытой ревизии, цепочка Prev xref идёт назад через каждое позднее сохранение, состояние объектов реплеится до покрытой ревизии, а неизменённые повторенные записи пропускаются вместо репорта как правок
Подпись покрывает только байты собственной ревизии, поэтому анализатор мапит второй спан ByteRange на ревизию и оценивает каждую позднюю xref-запись против реплеенного состояния объектов

Теневые определения — случай, заслуживающий больше всего внимания. Тело объекта, лежащее внутри байтового диапазона поздней ревизии, но не упомянутое в xref этой ревизии, невидимо обычному вьюеру, и это ровно та подготовка, на которой держатся shadow-атаки: скрытый контент закладывается до или после подписания и позже активируется переключением ссылки. AnalyzePadesSignatureRevisionsBytes записывает такой объект как неавторитетную правку с IsAuthoritative = False, оценивает его в prdSuspicious независимо от уровня разрешений и добавляет prrUnreferencedObjectDefinition в набор рисков. Два родственных риска покрывают другие структурные трюки: prrDuplicateObjectDefinition срабатывает, когда одна xref-секция перечисляет один объект больше одного раза, а prrSignatureObjectRedefined — когда поздняя ревизия переопределяет существующий объект подписи

Теневое определение объекта внутри байтового диапазона поздней ревизии PDF: тело объекта существует, но ни одна xref-запись на него не ссылается, поэтому вьюеры его никогда не показывают, а AnalyzeSignatureRevisions в PDFium Component записывает его как неавторитетное, оценивает в prdSuspicious и поднимает prrUnreferencedObjectDefinition рядом с рисками дубликата и переопределённого объекта подписи
Скрытый контент закладывается до или после подписания и позже активируется переключением ссылки, поэтому тело без ссылки оценивается как подозрительное независимо от уровня разрешений 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 применяются отдельно для каждой подписи, на её собственной покрытой ревизии, так что сертификационная подпись и поздняя утверждающая в одном файле могут вынести разные вердикты об одной и той же правке. Каждый поздний объект сначала классифицируется в TPadesRevisionChangeKind по его записям /Type, /Subtype и /FT и по роли в графах страниц, форм, аннотаций и DSS. Всё, что несёт /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia или /EmbeddedFile, становится prckActiveContent. Дальше решение следует ISO 32000-1 §12.8.2.2: при P=1 запрещено всё, кроме данных перекрёстных ссылок и материала валидации; P=2 разрешает заполнение форм и дальнейшие подписи, но отвергает правки аннотаций; P=3 разрешает и аннотации. Контент страниц, структура документа, метаданные, активный контент и удалённые объекты запрещены на любом уровне DocMDP, а при подписи вовсе без DocMDP оцениваются в prdSuspicious, ведь утверждающая подпись формально ничего не запрещает, но читатель больше не видит, что было подписано

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

Конвейер оценки, который AnalyzeSignatureRevisions применяет к каждой пост-подписной правке в Delphi: TPadesRevisionChangeKind по записям Type и Subtype, решение DocMDP на покрытой ревизии от P=1 до P=3, проверка блокировки FieldMDP по полностью квалифицированным именам полей и свёртка по худшему от prdSuspicious вниз до prdAllowed
Один теневой объект перевешивает любое количество легитимных правок полей, потому что Suspicious ранжируется выше Disallowed, Indeterminate и Allowed, при этом некоторые риски записываются рядом со статусом, не понижая его

Почему часть правок возвращается как Indeterminate, а не как безопасная?

Правки возвращаются как Indeterminate всякий раз, когда анализатор не может доказать, что правка разрешена, ведь в проверке подписи неизвестное никогда не репортится как разрешённое. Один частый случай обработан точно наоборот: long-term validation добавляет /DSS и переписывает каталог, что иначе считалось бы структурной правкой при P=1. Анализатор вырезает /DSS и /Extensions из старого и нового словарей каталога и сравнивает остаток; когда больше ничего не различается, переписывание трактуется как обновление материала валидации и разрешается, так что наращивание B-LT и B-LTA не ломает сертификационную подпись. Другие прорехи оставлены открытыми намеренно. Записи Type-2 в кросс-референсном стриме указывают в сжатые object streams, и анализатор не разворачивает object streams внутри этой границы безопасности, поэтому такие правки всплывают как prckCompressedObject с prrCompressedObjectUnresolved — запрещено при P=1, иначе Indeterminate. Жёсткие бюджеты в 1024 ревизии, 1 000 000 номеров объектов и 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 добавляется в набор рисков, сам по себе не понижая Status, а transform FieldMDP, который не удалось распарсить, влияет на статус только когда поле формы реально меняется, так что ворота, смотрящие лишь на Status, могут пропустить улики, уже лежащие в отчёте. Имейте в виду и то, чего отчёт не заявляет. TPadesRevisionAnalysisReport ничего не говорит о криптографической валидности CMS-подписи и о том, цепочится ли сертификат подписанта к корню, которому вы доверяете. Анализ ревизий отвечает на вопрос, что случилось после подписания, и ему место рядом со структурной и трастовой валидацией, а не вместо них

Пишем seed values и MDP-блокировки в момент подписания

Те же правила можно задать прямо при подписании через TPadesSignatureFieldOptions — это член FieldOptions и у TPadesSignOptions, и у TPadesRemoteSignOptions. PDFium умеет создавать виджет, но не умеет писать /SV, /Lock, transform FieldMDP или DocMDP, а также словарь /Perms каталога, поэтому собственный инкрементальный PAdES-райтер компонента порождает эти объекты внутри того же обновления xref, что и подпись. FieldName задаёт имя корневого поля, RequiredSeedValues превращается в биты /Ff словаря seed values из ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations и AcceptableCertificates ограничивают выбор будущего подписанта, LockAction с LockFields пишет непрямой /SigFieldLock, а CertificationPermission от 1 до 3 превращает подпись в сертификационную. Transform'ы DocMDP и FieldMDP оба идут в один массив /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;

Пара деталей, которые легко испортить, если лепить это руками. Каталожный /Perms /DocMDP должен ссылаться на словарь значения подписи, а не на аннотацию-виджет, и райтер именно поэтому держит значение подписи собственным непрямым объектом. Уже существующий словарь /Perms может нести права использования /UR3, так что райтер копирует его и вставляет /DocMDP, а не заменяет, следуя словарю разрешений из ISO 32000-1 §12.8.4. Документ, уже несущий /DocMDP, отказывает второй сертификационной подписи с EPadesCrypto, как и несогласованные опции: блокировка Include или Exclude без имён полей, блокировка All со списком полей, legal attestation на несертификационной подписи или точка в имени корневого поля. Удалённое подписание добавляет ещё одно правило, потому что сертификат подписанта неизвестен, когда бежит PreparePadesRemoteSignature: установка там CertificateRequired требует явного списка AcceptableCertificates, тогда как локальное подписание может откатиться к разрешённому сертификату подписанта

Анализ ревизий достраивает подписной инструментарий, а не заменяет какую-то его часть. Начните со статьи про инспекцию PDF-подписей и уровней PAdES с PDFium Component, чтобы прочитать словарь и базовый уровень, посмотрите, почему валидаторы отвергают подписи PAdES — про структурные отказы, случающиеся до всякого ревизионного вопроса, — и влейте вердикт в более широкий аудит рисков безопасности PDF рядом с проверками JavaScript и embedded files. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions и показанный здесь инкрементальный PAdES-райтер поставляются с PDFium Component для Delphi, C++Builder и Lazarus