Технічна стаття

Чому валідатори відхиляють підписи PAdES: PDFium у Delphi

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

Сценарій, що став причиною написання цієї статті, є дуже конкретним. Ви підписуєте договір за схемою, описаною в статті про підписання PAdES B-B, ваш власний перевірочний код показує наявність підпису та його структурну цілісність, усі локальні тести проходять успішно — але потім клієнт завантажує файл на свою платформу валідації та отримує червоний хрестик. У переглядачі цей збій абсолютно непомітний, оскільки жоден із цих трьох дефектів не впливає на вміст сторінки. Вони знаходяться виключно у словнику підпису та в кінцевику файлу (trailer) — тобто саме там, куди дивляться валідатори та куди зазвичай не звертають уваги переглядачі

Чому 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;

Двадцять рядків коду, жодних викликів бібліотек, і це дозволяє виявити проблему на етапі створення файлу. Якщо ви хочете зберегти хоча б одну перевірку з цієї статті — виберіть саме цю: перевіряйте створений вами файл перед надсиланням користувачам

Чому валідатори позначають час підписання /M як некоректний?

Суворі валідатори відхиляють час підписання, якщо він не є повним рядком дати PDF, і найчастіше в ньому відсутні дві речі: префікс D: та маркер зміщення UTC. Стандарт ISO 32000-1 §7.9.4 визначає формат дати як 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 додається як інкрементне оновлення, а стандарт ISO 32000-1 §7.5.6 вимагає, щоб новий кінцевик (trailer) розділу оновлення містив кожен запис попереднього кінцевика, за винятком /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