Подписанный PDF, изменившийся после подписания, не обязательно сломан. ISO 32000-1 допускает инкрементные обновления поверх подписи, и лишь некоторые из них нарушают политику, заданную подписантом. HotPDF Component для Delphi и C++Builder отвечает на этот вопрос через AnalyzeLoadedSignatureRevisions, который классифицирует каждую ревизию после подписания и оценивает её по DocMDP и FieldMDP. Сценарий знаком любому, кто поставляет ПО для работы с контрактами: клиент подписывает договор купли-продажи, отправляет его и получает обратно с приложенной страницей приложения. Читалка показывает жёлтую полосу, сообщающую, что подпись цела, но документ изменился после подписания, и никто в комнате не может сказать, обычный ли это процесс контрподписания или кто-то тихо правит подписанный контракт
Что считается легальным изменением после подписания?
Изменение легально, когда его семантическая категория попадает внутрь разрешения, заявленного заверяющей подписью. ISO 32000-1 §12.8.2.2 определяет преобразование DocMDP со значением /P равным 1, 2 или 3: 1 не разрешает никаких изменений, 2 разрешает заполнение форм и подписание, 3 разрешает заполнение форм, подписание и аннотации. HotPDF выражает это через значения THPDFDocMDPPermission: dmpNoChanges, dmpFormFillAndSign и dmpFormFillSignAndAnnotate, а dmpNone зарезервировано для результатов проверки, вообще не несущих преобразования DocMDP
Категории упорядочены, и именно этот порядок — двигатель всей проверки. THPDFRevisionModificationLevel перечисляет rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther, намеренно расставленные так, чтобы больший порядковый номер никогда не оказывался менее ограничительным. Весь документ сводится к максимальному уровню, наблюдаемому по всем ревизиям после подписи, и сравнение с DocMDP превращается в единую проверку целого числа. Один нюанс важен с самого начала: при dmpNoChanges анализ всё равно допускает rmlLongTermValidation. Добавление материала проверки DSS и VRI или временной метки документа в заверенный файл — это обслуживание подписи, а не модификация документа, и трактовать это как нарушение сломало бы любой существующий процесс долгосрочного архивирования
Как HotPDF восстанавливает цепочку ревизий?
Структурно, а не эвристически. Согласно ISO 32000-1 §7.5.6 инкрементное обновление дописывает новую секцию перекрёстных ссылок, чей /Prev указывает на предыдущую, поэтому HotPDF читает startxref с конца, разбирает найденную там секцию, идёт назад по /Prev и повторяет, возвращая секции от самой старой к самой новой. В этом цикле есть два защитных ограничения, и оба стоит знать при разборе файла, который даёт сбой: /Prev, указывающий на уже посещённое смещение, завершает обход явной диагностикой цикла вместо зацикливания, а цепочка длиннее тысячи ревизий отклоняется без разговоров. Оба случая проявляются в Analysis.Issue с возвратом функцией False, и ни один не следует замалчивать, потому что циклический /Prev — это признак повреждённого или враждебного файла, а не просто необычного
В реальных документах встречаются четыре исторические формы, и все четыре обрабатываются: традиционные таблицы xref, разбираемые построчно, потоки перекрёстных ссылок, распакованные и декодированные через их поля /W и /Index, гибридно-ссылочные файлы, чей традиционный трейлер несёт ключ /XRefStm, который разбирается и объединяется в ту же ревизию (случай продуктов Office, разобранный в статье о гибридных потоках перекрёстных ссылок), и объекты, живущие внутри контейнера ObjStm, что важно, потому что современное обновление обычно кладёт изменённый словарь в сжатый поток вместо прямой записи, как описано в статье о потоках объектов и инкрементных обновлениях. Подпись служит якорем разделения: /ByteRange[2] + /ByteRange[3] становится SignedRevisionLength, и всё, что начинается на этом смещении или позднее, — это ревизии после подписания. Вопрос о том, продолжает ли диапазон байтов корректно хешироваться, — отдельный, на него отвечает VerifyLoadedSignature, разобранный в статье о проверке цифровых подписей PDF
Как классифицируется каждый изменённый объект
Классификация выполняется по объектам, а затем распространяется по ссылкам. Для каждого номера объекта, затронутого секцией после подписания, HotPDF читает новое тело и тело, каким оно было в подписанном снимке; идентичное тело даёт rmlNone, потому что производители действительно переписывают объекты, не меняя их. Распознаватели намеренно узкие. Объект /Type /DocTimeStamp или объект, чей /SubFilter равен ETSI.RFC3161, получает rmlLongTermValidation, как и всё достижимое из дерева /DSS каталога; словарь /Type /Sig получает rmlFormFillAndSign. Для контейнеров проверка касается того, какие ключи сдвинулись, а не того, чем является сам объект: каталог может лишь получить или изменить /DSS, /Extensions или /AcroForm; словарь AcroForm — только /Fields, /SigFlags, /NeedAppearances, /DR, /DA или /Q; страница — только /Annots; поле или виджет — только /V, /AP, /AS или /M. Всё, что выходит за эти множества, опускается до rmlOther, и именно так ловится добавленная страница приложения: добавление страницы перестраивает дерево страниц способами, которых не покрывает ни один белый список, и никакое законное заполнение форм на это не похоже
Затем уровни распространяются: каждый контейнер наследует максимальный уровень изменённых потомков, на которые он указывает, и это повторяется, пока присвоение не стабилизируется. Именно это заставляет работать потоки внешнего вида. Заполненное текстовое поле переписывает /V и указывает на свежий поток /AP, а этот поток сам по себе — анонимный набор операторов содержимого без типа, который можно было бы распознать; поскольку владеющее им поле имеет уровень rmlFormFillAndSign, поток наследует тот же уровень вместо того, чтобы провалиться в rmlOther. То же распространение переносит контекст DSS на потоки сертификатов и отзыва, которые иначе оказались бы неклассифицируемыми
Почему нечитаемый объект считается нарушением?
Потому что альтернатива — валидатор, обманутый записью чего-то, чего он не понимает. Три ситуации в HotPDF безоговорочно приводят к rmlOther: объект, чьё тело не удалось прочитать из ревизии, объект, который ревизия помечает как освобождённый, и объект, не соответствующий ни одному из распознавателей выше. Каждая записывает конкретную диагностику в поле Issue ревизии, так что оператор может увидеть, номер какого объекта дал такой вердикт
Освобождение — самое резкое из трёх. Ревизия после подписания, помечающая ранее определённый объект как свободный, удалила содержимое из подписанного документа, и ни один уровень разрешения по §12.8.2.2 этого не допускает; номера объектов попадают в FreedObjectNumbers, а ревизия поднимается до rmlOther. Нечитаемые объекты следуют той же логике по другой причине. У валидатора, который не может разобрать объект, нет оснований называть его безобидным, и честный ответ на это — не молчание. Пометка необычной, но безобидной конструкции как нарушения стоит человеку время на проверку; противоположная ошибка отправляет подписанный контракт с незамеченной правкой внутри
Чтение вердикта в Delphi
Вызов короткий. Загрузите документ, выберите индекс подписи, прочитайте запись; перегрузка без параметров заново открывает файл, из которого был загружен документ, а перегрузка с TStream принимает байты, переданные вызывающим кодом, и восстанавливает позицию потока перед возвратом. PolicyCompliant — единственный булев результат, нужный большинству вызывающих, объединяющий три независимых решения: структурную корректность словарей разрешений, DocMDPCompliant и FieldMDPCompliant. Держите эти компоненты видимыми в интерфейсе, а не сворачивайте их, и учтите, что документ без преобразования DocMDP оставляет DocMDPCompliant равным True, поскольку обычная одобрительная подпись не заявляет политику, которую можно было бы нарушить, и совокупный ModificationLevel становится тогда описательным, а не вердиктом
var
Pdf: THotPDF;
Analysis: THPDFSignatureRevisionAnalysis;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
begin
if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
begin
if Analysis.PolicyCompliant then
Writeln('Post-signature changes stay inside the signing policy')
else
Writeln('Policy violation: ', string(Analysis.Issue));
end
else
Writeln('Analysis could not run: ', string(Analysis.Issue));
end;
finally
Pdf.Free;
end;
end;
Для разбора обычно нужна не сводка, а разбивка по ревизиям, потому что она показывает, в какой момент истории документа что-то пошло не так. Каждая запись в Analysis.Revisions несёт свой индекс в цепочке, смещение перекрёстных ссылок, на котором она записана, собственный уровень модификации и задействованные номера объектов
const
LevelNames: array[THPDFRevisionModificationLevel] of string =
('none', 'long-term validation', 'form fill and sign',
'annotations', 'other');
var
I: Integer;
begin
Writeln(Format('%d revisions in chain, signature sits at index %d',
[Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
for I := 0 to High(Analysis.Revisions) do
Writeln(Format(' rev %d at offset %d: %s (%d changed, %d freed) %s',
[Analysis.Revisions[I].RevisionIndex,
Analysis.Revisions[I].XRefOffset,
LevelNames[Analysis.Revisions[I].ModificationLevel],
Length(Analysis.Revisions[I].ChangedObjectNumbers),
Length(Analysis.Revisions[I].FreedObjectNumbers),
string(Analysis.Revisions[I].Issue)]));
end;
FieldMDP оценивается отдельно, и это осознанное решение
Документ может удовлетворять DocMDP и всё равно быть нелегитимным, поэтому FieldMDPCompliant — отдельный булев признак, а не часть сравнения уровней. ISO 32000-1 §12.8.2.4 определяет преобразование FieldMDP, а §12.7.5.5 — связанную запись /SigFieldLock, чтобы замораживать поименованные поля формы в момент подписания даже там, где документ в целом всё ещё разрешает заполнение форм. Заполнение поля — действие уровня 2; заполнение поля, заблокированного подписантом, — нарушение независимо от уровня. HotPDF считывает область действия в THPDFFieldLockAction как flaAll, flaInclude или flaExclude, с flaNone для результатов без политики блокировки, а имена — в Permissions.FieldNames: flaAll блокирует всё, flaInclude блокирует перечисленные имена, flaExclude блокирует всё, кроме них. При чтении результатов важна одна деталь: в ChangedFieldNames сообщаются только поля, уже присутствовавшие в подписанном снимке, потому что у поля, созданного целиком после подписания, нет подписанного состояния, которому можно было бы противоречить, и оно ловится через путь DocMDP
var
Source: TFileStream;
Analysis: THPDFSignatureRevisionAnalysis;
I: Integer;
begin
Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
try
if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
for I := 0 to High(Analysis.ChangedFieldNames) do
Writeln('modified after locking: ',
string(Analysis.ChangedFieldNames[I]));
finally
Source.Free; // stream position was restored before the call returned
end;
end;
О чём этот анализ не расскажет
Он не проверяет подпись. AnalyzeLoadedSignatureRevisions рассуждает о структуре и разрешениях; на вопрос, продолжает ли подписанный диапазон байтов хешироваться в значение из блока CMS и ведёт ли цепочка сертификата подписанта к чему-то доверенному, отвечают VerifyLoadedSignature и VerifyLoadedSignatureWithTrust. Файл может быть безупречно соответствующим политике и криптографически бесполезным, поэтому обе проверки должны стоять рядом в любом реальном шлюзе приёмки. Он также не читает намерение внутри потоков содержимого: страница, чей поток содержимого был заменён целиком, ловится как изменение вне белого списка, но анализ не скажет вам, что замена подменила сумму платежа. Вердикт rmlOther означает, что должен посмотреть человек, а не то, что произошло мошенничество, а соответствующий политике вердикт означает, что изменение вписывается в разрешённую категорию, а не то, что изменение было желаемым. Когда нужно только то, что заявил подписант, без обхода ревизий, GetLoadedSignaturePermissions возвращает словари политики сами по себе
Всё описанное здесь работает нативно в Delphi и C++Builder без внешней службы подписания в цепочке, что и делает практичным запускать это на каждом входящем документе, а не только на тех, что уже вызвали подозрение. Полный API подписей и ревизий, включая методы разрешений и проверки, с которыми он работает в паре, — часть HotPDF Component для Delphi и C++Builder