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

Где кончается ревизия PDF? Подделки после подписания

Договор подписан, затем получает ещё одно обновление, и аудит-скрипт либо приписывает это обновление подписанной ревизии, либо выдаёт переписанную страницу за валидационный материал. Ничего из этого не происходит, если ревизия кончается там, где startxref называет секцию, до которой добралась цепочка /Prev, а переписанный объект сохраняет свою прежнюю классификацию, — два правила, на которых стоит карта ревизий PDFlibPas

Почему %%EOF — неправильное место, чтобы резать файл

Каждое инкрементальное обновление завершается собственным маркером конца файла (ISO 32000-1 §7.5.6), поэтому резать файл по каждому %%EOF выглядит очевидным способом найти ревизии. Маркер — всего пять байтов, и ничто не мешает этим байтам оказаться внутри content stream, встроенного файла или шрифтовой программы. Линеаризованные файлы добавляют вторую проблему: Annex F (§F.3.4) позволяет trailer первой страницы закончиться собственным startxref и %%EOF ближе к началу файла, задолго до главной cross-reference таблицы. Резка по каждому маркеру изобретает ревизии, за которыми не стоит ни одной cross-reference секции. Угадывать, какие маркеры фальшивые, ещё хуже — именно эту догадку подделыватель и получает в свои руки. Карта ревизий PDFlibPas работает наоборот. Она идёт по цепочке /Prev, запоминает байтовое смещение каждой cross-reference секции, которую цепочка реально прочла, и только затем ищет последовательности startxref N … %%EOF, оставляя границу лишь когда N — одно из запомненных смещений. Чтобы заблудший маркер внутри потока что-то значил, перед ним должен стоять startxref, называющий настоящую секцию

Карта ревизий PDFlibPas на Delphi идёт по цепочке /Prev и сверяет смещения startxref, чтобы найти границы ревизий инкрементальных обновлений, игнорируя заблудшие маркеры %%EOF внутри потоков и trailer линеаризованной первой страницы
Конец ревизии отмечают только те смещения startxref, которые цепочка /Prev реально прочла, и заблудшие байты %%EOF внутри потоков не влияют на то, где PDFlibPas режет файл

Байты между этими тремя лексемами устроены менее аккуратно, чем обещает §7.5.5. Стандарт рисует startxref, смещение и маркер на соседних строках, однако маркеры конца строки и прочие white-space символы из §7.2.2 (таблица 1) могут повторяться, и никакое правило не ограничивает их количество. Пропустите один обложенный пробелами маркер — и две ревизии сольются. Подписанная теперь словно кончается после байтов, которые никто не подписывал. Карта ревизий PDFlibPas идёт от ключевого слова к операнду и маркеру, сколько бы white space их ни разделяло и где бы эта обкладка ни лежала в файле, и считает каждое ключевое слово один раз. У ключевого слова trailer есть похожий разболтанный сустав: << — разделитель (§7.2.2, таблица 2), и §7.5.5 никогда не требует разрыва строки после trailer, так что trailer<< на одной строке — корректный синтаксис. При построчном чтении такая строка выглядит как заголовок cross-reference подсекции — и цепочка останавливается на полдороге

Идём по карте ревизий с PDFlibPas

Аксессоры ревизий объявлены на TPDFlib и реализованы в редакции Small Business: создайте TPDFlibSmallBusiness из модуля PDFlibSmallBusiness (Enterprise наследуется от него и несёт те же вызовы). На голом TPDFlib они возвращают своё значение неудачи (0, 7 или -1) и ставят LastErrorCode в PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Ревизии нумеруются с 0 — исходного документа. Каждый вызов принимает имя файла и пароль и строит карту из байтов на диске, так что ответы описывают файл в том виде, в каком он сохранён, а не документ, который вам случилось загрузить и отредактировать. GetDocumentRevisionKind сообщает самое далеко идущее изменение в ревизии. Обновление, добавившее OCSP-ответ и заодно переписавшее страницу, отчётится как переписывание страницы:

ВидЗначениеТипичный источник
0Только хозяйственные дела: объекты, которые не меняют ничего из того, что видит readerCross-reference поток, новый контейнер object stream
1Валидационный материалDocument security store, VRI, сертификаты, CRL, OCSP-ответы
2Штамп времени документаПодпись /DocTimeStamp и поле, которое её несёт
3ПодписьСуществующее поле подписи, получающее значение
4Поле формыЗначение поля или его внешний вид
5АннотацияЛюбая аннотация, кроме widget
6Содержимое документаВсё, что может изменить то, что документ показывает
7Определить не удалосьЧто-то в ревизии прочесть не удалось; считайте её небезопасной
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // одна строка 'objnum,change,class,detail' на объект
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // элементы считаются от 1
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete — вызов, который стоит прочесть прежде, чем доверять остальным. Он возвращает 1, только когда цепочка пройдена до самого исходного раздела; разорванная или циклическая /Prev либо цепь глубже 256 звеньев оставляет старейшие обновления невидимыми, и каждое число и индекс становятся тогда нижней оценкой. Глубина 256 оставляет запас под историю сохранений и подписаний реальных файлов, а цепочка, указывающая сама на себя, всё равно заканчивается почти сразу. Следите за индексацией в цикле: GetStringListItem считает от 1, тогда как ревизии — от 0, и внутренний цикл, стартовавший с 0, молча теряет первый изменённый объект каждой ревизии

Как поддельное обновление выдаёт себя за валидационный материал

PAdES long-term validation вполне легально добавляет обновления после последней подписи: document security store (/Type /DSS), словари /VRI на каждую подпись, потоки сертификатов и отзывов, штампы времени документа. Уровни сертификации их пропускают, что делает валидационный материал самым ценным ярлыком, который может носить обновление. И при этом самым дешёвым для подделки. /Type — это то, что обновление решило написать. Viewer, рисующий страницу, никогда не смотрит на /Type content stream, словаря ресурсов или шрифта, поэтому обновление может переписать content stream страницы 3, добавить в его словарь /Type /DSS — и каждый рендерер всё равно нарисует новый текст, тогда как классификатор, верящий /Type, увидит housekeeping. /VRI, /Sig и /DocTimeStamp работают точно так же

Карта ревизий PDFlibPas игнорирует ярлык, который обновление пишет на переписываемый им объект. Переписанный объект считается валидационным материалом, штампом времени или подписью только когда более ранняя версия того же самого объекта уже была им. Потоки сертификатов, CRL и OCSP-ответов не несут собственного типа, поэтому распознаются по document security store, который на них указывает, — и считается лишь хранилище, записанное в судимой ревизии или более старой. Хранилище, добавленное позже, не может дотянуться назад и поручиться за объект, который записала подписанная ревизия. Изменения структуры документа считаются содержимым, как только они меняют то, что разрешил бы reader:

PDFlibPas GetDocumentRevisionKind отчётит поддельное обновление /Type /DSS, переписавшее content stream страницы, как содержимое документа, потому что карта ревизий классифицирует переписанный объект по его прежней версии
Ярлык /Type /DSS на переписанном content stream страницы ничего не меняет для рендерера, и PDFlibPas всё равно отчётит обновление как содержимое документа, ведь до этого объект был содержимым страницы
  • замена существующего object stream, ведь каждый сжатый объект внутри него теперь разрешается в замену (совершенно новый контейнер остаётся housekeeping, поскольку каждый его член получает собственную запись и судится по ней)
  • добавление catalog или страницы
  • trailer, чей /Root называет другой catalog, — даже если обновление не переопределило ничего из покрытого подписью, поскольку смена корня заменяет весь документ, не тронув ни одного подписанного объекта
  • переписанный поток, чей словарь побайтово идентичен старому: косвенный /Length или тело замены, набитое до того же размера, оставляет словарь неизменным при изменившейся отрисовке, поэтому на этом основании неизменными считаются только объекты без тела потока

Членам object stream нечего срезать — у них нет заголовка

Начиная с PDF 1.5 обновление может хранить переписанные объекты внутри object stream, и §7.5.7 беспощадно прямолинейна насчёт формата: хранятся только значения объектов, а ключевые слова obj и endobj использовать нельзя. Код, читающий объект по его смещению и срезающий заголовок поиском строки obj, у члена потока не найдёт ничего в начале и продолжит поиск уже в теле. Дайте словарю страницы, переписанному после подписания, безобидную на вид строку вроде (obj<</Type/DSS>>) — и тот поиск остановится внутри строки. Декодер получит <</Type/DSS>> — словарь хранилища, которого ревизия никогда не писала. Карта ревизий PDFlibPas разбирает N G obj с начала несжатого объекта, проверяет, что числа называют ожидаемый объект, и считает отсутствующий или несовпавший заголовок нечитаемым, вместо того чтобы рыть дальше

Cross-reference потоки несут собственную неоднозначность на уровне полей. По §7.5.8.3 (таблица 18) только запись типа 1 хранит байтовое смещение. Второе поле записи типа 2 — номер объекта содержащего object stream, третье — индекс внутри него, а второе поле записи типа 0 — номер следующего свободного объекта. Прочтите этот номер объекта как смещение — и сжатая запись попадёт в ту ревизию, что накрывает эти маленькие байтовые позиции, то есть почти всегда в исходный документ, который прячет переписывание внутри подписанных байтов. Карта ревизий размещает каждую запись по той cross-reference секции, которая её объявила. Та же оговорка говорит, что тип записи, отличный от 0, 1 или 2, — это ссылка на null-объект, так что такие записи ничего не добавляют и не освобождают и остаются за пределами карты

Когда аудиту подписей стоит отказаться отвечать?

Всякий раз, когда файл утаивает часть собственной истории. Карта ревизий сообщает об этом тремя способами: GetDocumentRevisionsComplete возвращает 0, GetDocumentRevisionKind возвращает 7 для какой-то ревизии после подписи, или GetSignatureRevisionIndex возвращает -1. Последнее значение покрывает и ByteRange, не кончающийся на границе ревизии, — байты лежат внутри интервала, который подпись заявляет покрываемым, — и равным образом поле, которого не существует, не несёт значения подписи или сидит в файле, который не удалось прочесть. Отправляйте все три случая на ручную проверку с индексом ревизии и приложенными строками GetDocumentRevisionChanges, чтобы тот, кто откроет файл, стартовал с номеров объектов

// Сверяет обновления после одной подписи с политикой в духе DocMDP.
// Виды упорядочены по тяжести, поэтому каждый уровень политики — потолок:
// P 1 терпит housekeeping, валидационный материал и штампы времени (0..2),
// P 2 добавляет подписание и заполнение форм (..4), P 3 добавляет аннотации (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // часть истории так и не была увидена
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // вне границ ревизии, нет такого подписанного
                                 // поля, либо файл не удалось прочесть
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 выше любого потолка; сказано для читателя
  end;
  Result := True;
end;

Потолки следуют ISO 32000-2 §12.8.2.2 (таблица 257): P 1 не разрешает никаких изменений, 2 разрешает заполнение форм и подписание, 3 добавляет аннотации, а инкрементальные обновления, лишь добавляющие DSS-данные или штампы времени документа, на любом уровне изменениями вовсе не считаются. ISO 32000-1 §12.8.2.2 определяет те же три значения без этой оговорки. Пишите цикл сами, когда политика ваша, а не сертификатора, — скажем, правило приёмки, отвергающее аннотированные договоры, что бы ни носил файл. Когда политика должна приходить из файла, GetSignatureModificationVerdict на том же классе читает уровень сертификации, применяет блокировки полей, описанные в статье о выявлении правок полей форм под FieldMDP, и возвращает 5 для случая вне границ ревизии. Оба отвечают на вопрос, отличный от вопроса статьи о классификации изменений против сохранённой копии документа: карте ревизий не нужен ничего, кроме файла перед ней

Аудит подписей в духе DocMDP в PDFlibPas на Delphi с потолками GetDocumentRevisionKind для P 1, P 2 и P 3, причём GetDocumentRevisionsComplete и GetSignatureRevisionIndex проверяются первыми
Каждый уровень DocMDP становится потолком для вида ревизии: P 1 терпит виды 0–2, P 2 — до 4, P 3 — до 5, а неопределённая ревизия проваливает любой уровень

Добавление идёт по той же цепочке. Trailer инкрементального обновления обязан нести запись /Prev с расположением предыдущей cross-reference секции (§7.5.6), а документ, который возможно открыть только пересборкой cross-reference таблицы полным сканом, не имеет такого расположения, чтобы его отдать. Для него AppendToFile возвращает 0 и оставляет целевой файл нетронутым. Сохраните его целиком и подпишите чистую копию — полная перезапись не переносит существующие подписи

Для архива подписанных договоров гоняйте UpdatesWithinPolicy один раз на поле подписи прежде, чем что-то новое будет добавлено. Файл, чья более поздняя ревизия читается как вид 7, должен первым делом попадать к человеку, раньше любого шага подписания или long-term validation в PDFlibPas, который похоронил бы нечитаемую ревизию под очередным обновлением