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

DocMDP и FieldMDP: аудит ревизий PDF в Delphi

Подписанный 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