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