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

PDF Library for Delphi: compliance and signing workbench в Delphi

Рабочий стенд, который связывает проверку соответствия с цифровой подписью, должен координировать четыре шага именно в этом порядке и удерживать их привязанными к одному и тому же набору байт на всём протяжении процесса. Сначала выполняется предпечатная проверка PDF/A или PDF/UA. Затем применяются исправления, которых требуют найденные замечания, и сохраняется исправленная редакция. Именно эта редакция подписывается. После этого подписанный файл считывается заново, чтобы убедиться, что подпись действительно его покрывает. Порядок здесь не косметика: пропустите повторное чтение — и вы вслепую доверяете собственному пути записи; дайте предпечатной проверке отработать не на той редакции — и отчёт о соответствии будет описывать файл, который вы никогда не отправляли

Место, где большинство самодельных конвейеров ошибается, — это шов между проверкой и подписанием. Запустите их как два отдельных инструмента с этапом исправления между ними, и на свет появятся как минимум три разные редакции файла, каждая со своим набором байт. Отчёт о предпечатной проверке, который вы передаёте аудитору, описывает одну из них. Подпись фиксирует другую. Ничто в самом файле не утверждает, что это одна и та же редакция, и часто это действительно не так. PDF Library for Delphi, PDF-библиотека разработчика losLab для Delphi и C++Builder, скрывает предпечатную проверку и подписание PAdES за единым классом-фасадом, так что вся последовательность может выполняться в одном процессе, который никогда не теряет из виду, о каких именно байтах идёт речь. Каждый вызов ниже уже существует в библиотеке сегодня, как и каждая ловушка, отмеченная рядом с ним

Диаграмма рабочего места проверки соответствия и подписания в Delphi, где шаги preflight, исправления, подписания PAdES и аудита ByteRange каждый записывают SHA-256 по той конкретной ревизии, которой касаются
Хеши, записываемые при каждом сохранении, привязывают preflight-отчёт, подпись PAdES и аудит к одной и той же ревизии

Три редакции одного документа и как возникает разрыв

Посчитайте сохранения. Оригинал поступает из вышестоящей системы. Этап исправления загружает его, включает режим соответствия и записывает исправленную редакцию. Этап подписания добавляет подпись в виде инкрементального обновления — это уже третья запись. Три сохранения, три раскладки байт, и отчёт о предпечатной проверке ничего не значит, если в нём не указано, какую из трёх редакций он охватывает. SHA-256 файла, зафиксированный рядом с каждым запуском проверки и каждой подписью, — дешёвый якорь, который позволяет доказать, что проверенная редакция и есть подписанная

Одна особенность библиотеки ужесточает эту дисциплину ещё сильнее. Исправления соответствия, запрошенные через SetPDFAMode или SetPDFUAMode, не вступают в силу в момент вызова — они применяются во время сохранения. Автоисправления вроде принудительной установки флагов печати аннотаций или назначения порядка обхода полей для PDF/UA попадают только в итоговый файл и никуда больше, поэтому проверка, запущенная против документа, который вы только что «исправили» в памяти, ничего не скажет о байтах, которые уйдут к подписывающему модулю. Сначала сохраните, затем выполните предпечатную проверку сохранённого файла. Состояние в памяти — черновик; реален только файл на диске

Предпечатная проверка с диска, и ноль, который означает две разные вещи

Точка входа плоского API для предпечатной проверки — CheckFileCompliance(FileName, Password, ComplianceTest, Options). Тест 1 выбирает PDF/A (ISO 19005), тест 2 — PDF/UA (ISO 14289). Файл открывается через потоковый ридер библиотеки, поэтому предварительный вызов LoadFromFile не требуется, а возвращается дескриптор списка строк с одним замечанием на запись:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

Ловушка кроется в возвращаемом значении, причём именно такая, что проходит любой happy-path тест. Ноль означает «замечаний нет». Ноль также означает «файл не удалось открыть», потому что реализация возвращает 0 всякий раз, когда список результатов оказывается пустым, включая случай ошибки чтения. Рабочий стенд, который трактует 0 как зелёный свет, с готовностью одобрит файл, заблокированный каким-то другим процессом. Разделить эти два случая позволяет связка вызова с LastErrorCode, как показано выше. Кроме того, проверяющий модуль открывает файл в режиме совместного доступа, запрещающем запись, поэтому если ваш этап исправления всё ещё удерживает дескриптор на запись, предпечатная проверка завершится ошибкой по причине, не имеющей отношения к соответствию, а имеющей отношение к потоку, который вы забыли освободить

Диаграмма решений, показывающая, как LastErrorCode разделяет два смысла нулевого возврата из CheckFileCompliance при PDF-preflight в Delphi
Ноль от CheckFileCompliance не значит ничего, пока LastErrorCode не отличит пустой список замечаний от файла, который библиотеке не удалось открыть

Когда замечания нужно прочитать человеку, а не конвейеру, CreatePreflightReport оформляет их в виде читаемого отчёта. ComparePreflightReports сравнивает два запуска — аккуратный способ показать, что исправление устранило исходные замечания, незаметно не добавив новых

Подписание проверенной редакции через SignProcess

Как только сохранённая редакция проходит предпечатную проверку, а её хэш зафиксирован, подписывайте именно этот файл и никакой другой. API SignProcess читается как builder: открываете дескриптор процесса, настраиваете его строка за строкой, фиксируете и считываете код результата

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // базовый уровень PAdES
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // место для будущей метки времени
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Две строки в этой последовательности значат больше, чем кажется на первый взгляд. SetSignProcessCustomSubFilter со значением ETSI.CAdES.detached выбирает подпись PAdES по профилю ETSI EN 319 142-1, а не устаревшее семейство adbe.pkcs7.detached — это и есть разница между подписью, которую примет европейский валидатор, и той, на которую он выдаст флаг. SetSignProcessReserveContentsBytes расширяет заполнитель /Contents, и выбранный здесь размер — это решение, обращённое в будущее: если позже последует метка времени подписи, увеличенный CMS должен поместиться в зарезервированное сейчас место, потому что заполнитель не может вырасти позже без повторного подписания всего документа. Зарезервируете щедро — потеряете несколько килобайт. Зарезервируете впритык — и через месяцы шаг с меткой времени завершится переполнением, которое будет трудно связать именно с этой строкой

GetSignProcessResult отвечает кодом, а не булевым значением, и эти коды стоит сохранять. 1 — успех. 4 — неверный пароль PDF, 7 — неверный пароль сертификата, 9 — PFX без закрытого ключа, 11 — сбой в процессе наложения подписи. Сверните всё это в true/false — и вы выбросите ровно ту информацию, которая отличает обращение в поддержку из-за неверного пароля от обращения из-за ключа без закрытой части. Записывайте в лог именно целое число

Повторное чтение: аудит только что созданного файла

Ни один рабочий стенд не должен доверять тому же пути, которым был записан файл, который он собирается заверить. Класс аудита TPDFlibSignDoc заново открывает подписанный файл и считывает записи словаря подписи прямо с диска:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Зафиксировать размер до Open: объект аудита удерживает блокировку совместного доступа к файлу
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 означает, что поле подписано
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

Аргументы ValueKey соответствуют записям словаря. Ключ 0 возвращает исходный CMS из /Contents, ключи 2 и 3 — имена /Filter и /SubFilter, а 11–14 — четыре числа ByteRange. Текстовые значения возвращаются через GetSignatureTextValueByName: ключ 0 — заявленное время подписания, а ключ 5 отличает обычную Sig от DocTimeStamp, что важно, когда документ содержит и то, и другое

Фиксация размера файла в начале этого примера — не формальность, а несущая конструкция. TPDFlibSignDoc.Open удерживает файл под ограничительной блокировкой совместного доступа на всё время своей жизни, поэтому всё, чему нужны сырые байты (хеширование подписанного диапазона, пересчёт дайджеста CMS), должно прочитать файл до вызова Open. Именно поэтому собственная демонстрация SigningWorkbench из библиотеки сначала целиком читает файл в память, а рабочий стенд, игнорирующий этот порядок, будет падать нестабильно — на той машине, которой не повезёт проиграть эту гонку

Арифметика ByteRange, доказывающая покрытие

У корректного файла с одной подписью ByteRange имеет вид [0 a b c]: покрытие начинается со смещения 0, пропускает шестнадцатеричный заполнитель /Contents между a и b, а затем возобновляется и идёт до байта b+c. Когда b+c равно размеру файла, подпись покрывает всё вплоть до конца файла — это и есть нужный результат. Когда покрытие не дотягивает, значит, после записи подписи кто-то добавил инкрементальное обновление. Это совершенно законно по ISO 32000-1§12.8, поскольку именно так приходят последующее заполнение полей формы, вторая подпись и словарь DSS. И это как раз тот факт, который журнал аудита должен зафиксировать в момент подписания, а не восстанавливать под давлением во время спора

PDF Library for Delphi: анатомия ByteRange подписанного PDF: пропуск-заполнитель Contents, случай полного покрытия и случай добавленного инкрементального обновления
ByteRange вида 0 a b c покрывает файл только тогда, когда b + c достигает конца файла, поэтому аудит регистрирует любое добавочное обновление, дописанное после подписания

Следя за этой арифметикой, обращайте внимание на разрядность целых чисел. GetSignProcessByteRange плоского API возвращает 32-битный Integer, но исходные значения — Int64, поэтому на файле свыше 2 GB плоский аксессор молча усекает значение. Используйте вместо этого TPDFlibSigner.GetByteRange уровня классов, который возвращает Int64, либо разбирайте значения из GetSignatureValueByName так же, как это делает код аудита выше

Что библиотека оставляет на ваше усмотрение

Две границы лучше узнать на этапе проектирования, чем в финальном спринте. Плоский API TPDFlib вообще не содержит обёртки для проверки подписи. Криптографическая проверка находится на слой ниже, в TPDFlibSignatureVerifier, чей VerifySignature отвечает valid, invalid или unknown. Также нет встроенного HTTP-клиента для служб меток времени по RFC 3161. Библиотека вычисляет хеш для отправки и повторно встраивает дополненный CMS, когда приходит токен, но сетевой обмен со службой TSA нужно написать самостоятельно. Обе вещи несложно обернуть, и обе крайне неприятно обнаружить отсутствующими за неделю до релиза — поэтому закладывайте их уже в первый набросок

Один вопрос о соответствии стоит прояснить прямо, потому что от ответа зависит, где ставить последний контрольный рубеж: ломает ли добавление подписи PDF/A? Само по себе — нет. Подпись приходит как инкрементальное обновление, и начиная с ISO 19005-2 подписанные документы прямо разрешены. Загвоздка — во внешнем виде подписи: он подчиняется тем же правилам, что и любое другое содержимое страницы, включая встроенные шрифты и отсутствие цвета, зависящего от устройства. Поэтому финальный рубеж в рабочем стенде — это ещё один запуск предпечатной проверки, на этот раз против подписанного файла. Относитесь к CheckFileCompliance как к быстрой проверке внутри конвейера, но всё равно проверяйте релиз-кандидаты независимым инструментом вроде veraPDF, поскольку валидаторы реализуют пересекающиеся, но не идентичные наборы правил; когда они расходятся во мнениях, текст замечания обычно указывает, какой пункт стоит прочитать

Из всего этого следует ещё один момент о последовательности. Подписание и проставление метки времени — не один проход: сначала записывается базовая подпись, а затем отдельный процесс метки времени дополняет CMS внутри зарезервированного пространства /Contents, и именно поэтому строка с резервированием байт раньше значила так много. Про слой метки времени и долгосрочной проверки, надстраиваемый над этим рабочим стендом, рассказывает пошаговое руководство по подписанию и проверке PAdES, которое доводит подпись от базового уровня до B-LT, а половина про предпечатную проверку раскрыта глубже в руководстве по предпечатной проверке PDF/A и PDF/UA. Полная документация по API и пробные версии для скачивания — на странице продукта PDF Library for Delphi