Договор подписан, затем получает ещё одно обновление, и аудит-скрипт либо приписывает это обновление подписанной ревизии, либо выдаёт переписанную страницу за валидационный материал. Ничего из этого не происходит, если ревизия кончается там, где 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, называющий настоящую секцию
Байты между этими тремя лексемами устроены менее аккуратно, чем обещает §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 | Только хозяйственные дела: объекты, которые не меняют ничего из того, что видит reader | Cross-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:
- замена существующего 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 для случая вне границ ревизии. Оба отвечают на вопрос, отличный от вопроса статьи о классификации изменений против сохранённой копии документа: карте ревизий не нужен ничего, кроме файла перед ней
Добавление идёт по той же цепочке. Trailer инкрементального обновления обязан нести запись /Prev с расположением предыдущей cross-reference секции (§7.5.6), а документ, который возможно открыть только пересборкой cross-reference таблицы полным сканом, не имеет такого расположения, чтобы его отдать. Для него AppendToFile возвращает 0 и оставляет целевой файл нетронутым. Сохраните его целиком и подпишите чистую копию — полная перезапись не переносит существующие подписи
Для архива подписанных договоров гоняйте UpdatesWithinPolicy один раз на поле подписи прежде, чем что-то новое будет добавлено. Файл, чья более поздняя ревизия читается как вид 7, должен первым делом попадать к человеку, раньше любого шага подписания или long-term validation в PDFlibPas, который похоронил бы нечитаемую ревизию под очередным обновлением