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

Почему валидаторы отклоняют подписи PAdES: PDFium в Delphi

Валидатор отклоняет подпись PAdES почти всегда по одной из трех причин, с которыми мы сталкивались при отладке PDFium Component в Delphi: массив /ByteRange все еще содержит нулевые заполнители, время подписания /M не является корректной строкой даты PDF или инкрементное обновление удалило запись /Encrypt из зашифрованного документа. Во всех трех случаях файлы открываются и отображаются нормально, но сбой происходит в тот момент, когда совместимый валидатор считывает словарь подписи

Сценарий, побудивший к написанию этой статьи, весьма специфичен. Вы подписываете договор, используя рабочий процесс из статьи о подписании PAdES B-B, ваш собственный код проверки сообщает о наличии структуры подписи и ее корректности, все локальные тесты пройдены — но затем контрагент загружает файл на свою платформу проверки и получает сообщение об ошибке. Никакие признаки сбоя не видны в программе просмотра, поскольку ни один из этих трех дефектов не влияет на содержимое страниц. Они находятся целиком в словаре подписи и трейлере файла — именно там, куда смотрят валидаторы, а программы просмотра обычно игнорируют

Почему ByteRange моей подписи PDF недействителен?

Отклонение по причине ByteRange почти всегда означает, что четыре поля диапазона так и не были заполнены, а не то, что они слегка неточны. Массив /ByteRange [A B C D] объявляет два диапазона: байты с A по A+B и байты с C по C+D. Стандарт EN 319 142-1 §6.3 (требование k) требует, чтобы вместе они охватывали весь файл за исключением шестнадцатеричной строки /Contents. В числовом выражении: A равен 0, C >= A+B, C+D равен длине файла, а промежуток между B и C содержит исключительно строку <...> — два символа скобок плюс по два шестнадцатеричных символа на каждый байт данных CMS. Валидатор, считывающий [0 0 0 0], делает вывод, что подпись ничего не охватывает, и отклоняет ее, независимо от корректности структуры CMS внутри /Contents

Понимание механизмов этого процесса важно, так как этот шаблон присутствует во всех инструментах подписи. Модуль подписи не может знать итоговые смещения до завершения разметки файла, поэтому программа записывает массив с нулевыми заполнителями фиксированной длины и заполняет их реальными значениями после компоновки. В версиях PDFium Component до v2.14.1 это заполнение искало шаблон заглушки, начиная с позиции /Contents — но так как /ByteRange находится перед /Contents в словаре, поиск ничего не находил и замена тихо завершалась неудачей. Хэш CMS вычислялся по правильным диапазонам, то есть криптография была верна; однако объявление этих диапазонов оставалось нулевым, поэтому все совместимые валидаторы отклоняли файл. Метки времени документов PAdES B-LTA, использующие ту же структуру словаря, ломались аналогичным образом — это важно, если вы используете долгосрочные подписи уровней B-LT и B-LTA. Начиная с версии v2.14.1, заполнение выполняется от начала объекта подписи, и это исправление покрыто регрессионным тестом, который анализирует созданный файл и проверяет указанную ниже математику

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Двадцать строк стандартного кода RTL без внешних вызовов библиотек позволяют полностью предотвратить этот класс ошибок на этапе генерации. Если вы решите использовать хотя бы одну проверку из этой статьи, выберите эту: парсите ваш собственный подписанный файл и проверяйте три этих равенства перед тем, как файл покинет вашу систему

Почему валидаторы отмечают время подписания /M как некорректное?

Строгие валидаторы отклоняют время подписания, если оно не является полной строкой даты PDF. Чаще всего отсутствуют две части: префикс D: и маркер смещения UTC. Раздел 7.9.4 стандарта ISO 32000-1 определяет формат даты как D:YYYYMMDDHHmmSS со смещением — Z для UTC или знаковым отношением +HH'mm' к нему. Простая строка 20260709143000 может быть прочитана менее строгой программой, но валидатор, буквально следующий правилам грамматики, увидит некорректный формат в обязательном поле и забракует подпись. В версиях PDFium Component до v2.14.4 запись /M в методах TPdf.SignPades и SignPadesBytes записывалась именно в таком простом виде; начиная с версии v2.14.4, запись содержит префикс D: и маркер Z, то есть время подписания выглядит как D:20260709143000Z

К этому полю относятся два практических замечания. Во-первых, записывайте UTC и указывайте это явно: метка времени без маркера смещения заставляет валидатор угадывать часовой пояс, а раздел 7.9.4 рассматривает смещение как часть формата, а не как необязательное дополнение. Во-вторых, помните, чем является /M — это заявленное подписантом время, то есть утверждение, а не доказательство. Валидатор проверяет здесь его формат, а не достоверность; доказанное время предоставляется меткой времени RFC 3161 на уровне PAdES B-T и выше. Корректное форматирование поля и доверие к нему — это разные вещи, и валидаторы контролируют только первое

Почему метод SignPades вызывает исключение EPadesCrypto на зашифрованном PDF?

Компонент PDFium Component отказывается подписывать зашифрованный документ, так как альтернативой является создание файла, который совместимые программы чтения не смогут открыть. Подпись PAdES добавляется в виде инкрементного обновления, и раздел 7.5.6 стандарта ISO 32000-1 требует, чтобы новый трейлер обновленного раздела содержал все записи предыдущего трейлера, кроме /Prev — в зашифрованном документе сюда входит и /Encrypt. Если отбросить его, последний трейлер объявит файл незашифрованным, из-за чего программа чтения попытается прочесть зашифрованное тело как открытый текст и получит мусор. Более того, добавляемые потоки и строки сами должны быть зашифрованы ключом документа для соблюдения валидности, чего инжектор подписей сделать не может. Способа добавить валидную подпись в виде открытого текста к зашифрованному файлу не существует, поэтому начиная с версии v2.14.2 методы TPdf.SignPades, SignPadesBytes и InjectPadesDssMarkers вызывают исключение EPadesCrypto вместо создания поврежденного файла

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

Вызов исключения является правильным поведением системы, поэтому проектируйте рабочий процесс с учетом этого ограничения вместо простого перехвата ошибки. Сначала расшифруйте файл с правами владельца, подпишите незашифрованную копию, и если канал распространения требует защиты, примите то, что шифрование после подписания и подписание после шифрования дают разные результаты с разной логикой валидации. Подпись, вычисленная по байтам открытого текста, не сохранится при повторном шифровании этих байт, поэтому единственными валидными вариантами являются подписанный открытый файл или организационное решение, задокументированное рядом с кодом

Как два бага могут маскировать друг друга

Дефект ByteRange оставался незамеченным долгое время из-за ошибки в нашем собственном валидаторе, и это самый важный урок данной истории. Проверка покрытия в ValidatePadesCompliance требовала, чтобы второй диапазон начинался точно в точке A+B (с нулевым зазором), что неверно классифицировало стандартную структуру, где в промежутке находится шестнадцатеричная строка /Contents. В результате локальный валидатор отклонял корректные файлы, а локальный генератор никогда их не создавал, но при проверках в связке "генерация-валидация" все тесты оставались успешными. Каждый баг лишал тестовый набор данных того примера, который мог бы выявить другую ошибку. Версия v2.14.1 исправила обе стороны: генератор теперь заполняет все четыре поля, а валидатор принимает условие C >= A+B при C+D, равном длине файла

Методологическим решением является кросс-валидация с использованием независимой реализации. Генератор и валидатор, разработанные в рамках одного проекта или автора, могут бесконечно соглашаться с общим ошибочным пониманием формата; независимый валидатор нарушает эту симметрию. Проверяйте сгенерированные файлы хотя бы одним внешним валидатором перед выпуском и используйте встроенные проверки сборки — метод TPdf.ValidatePades, описанный в статье об инспектировании цифровых подписей, сообщает о неверном покрытии как о конкретной зарегистрированной проблеме

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Чек-лист для проверки подписанных файлов при ошибках валидации

Если платформа отклоняет вашу подпись PAdES, сначала проверьте простые структурные причины, прежде чем искать проблемы в сертификатах или цепочках доверия. Считайте массив /ByteRange и проверьте три равенства: первое поле равно нулю, второй диапазон начинается в конце первого или позже, второй диапазон заканчивается точно в конце файла. Проверьте запись /M на соответствие формату даты раздела 7.9.4 с обязательным префиксом D: и маркером смещения часового пояса. Убедитесь, что исходный документ не был зашифрован на момент добавления подписи. Если используемая библиотека подписала его без вывода предупреждений, рассматривайте это молчание как ошибку в самой библиотеке. Все три проверки выполняются по исходным байтам за миллисекунды, и на практике именно они чаще всего объясняют причину отклонения файлов

Вызовы подписания, инспектирования и валидации, использованные здесь (SignPades, ValidatePades и проверки соответствия PAdES с их корректной математикой ByteRange, форматированием дат и фильтрацией шифрования), поставляются в составе компонента PDFium Component для Delphi, C++Builder и Lazarus