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

Створення середовища перевірки відповідності та підписання в Delphi з PDF Library for Delphi

Середовище, що зв'язує перевірку відповідності з цифровим підписанням, має узгодити чотири кроки саме в такому порядку й увесь час тримати їх прив'язаними до одного набору байтів. Воно виконує preflight за PDF/A або PDF/UA. Воно застосовує ті виправлення, яких вимагають знайдені зауваження, і зберігає виправлену ревізію. Воно підписує саме цю ревізію. Потім воно перечитує підписаний файл і підтверджує, що підпис справді його покриває. Порядок тут не косметичний. Пропустите перечитування — і ви покладаєтеся на власний шлях запису; дозвольте preflight відпрацювати не на тій ревізії — і ваш звіт про відповідність описує файл, який ви ніколи не відвантажували

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

Діаграма робочого місця відповідності та підписання в Delphi: кроки preflight, виправлення, підписання PAdES і аудиту ByteRange кожен записують SHA-256 над тією ревізією, якої торкаються
Хеші, зафіксовані поруч із кожним збереженням, прив'язують звіт preflight, підпис PAdES та аудит до однієї й тієї самої ревізії

Три ревізії одного документа і те, як виникає розрив

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

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

Preflight з диска і нуль, що означає дві різні речі

Пласка точка входу для preflight — це 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;

Пастка сидить у поверненому значенні, і вона з тих, що проходять будь-який тест щасливого шляху. Нуль означає «зауважень немає». Нуль також означає «файл не вдалося відкрити», бо реалізація повертає 0 щоразу, коли список результатів приходить порожнім, зокрема й через помилку читання. Середовище, яке читає 0 як зелене світло, радісно схвалить файл, заблокований якимось іншим процесом. Саме поєднання виклику з LastErrorCode, як вище, розрізняє ці два випадки. Перевіряч також відкриває файл у режимі спільного доступу із забороною запису, тож якщо ваш крок виправлення досі тримає дескриптор на запис, preflight падає з причини, що не має нічого спільного з відповідністю і має все спільне з потоком, який ви забули звільнити

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

Коли зауваження має читати людина, а не конвеєр, CreatePreflightReport подає їх у вигляді придатного для читання звіту. ComparePreflightReports зіставляє два запуски, і це охайний спосіб показати, що виправлення закрило початкові зауваження, тихцем не додавши нових

Підписання перевіреної ревізії через SignProcess

Щойно збережена ревізія пройшла preflight, а її геш зафіксовано, підписуйте саме цей файл і жодний інший. API SignProcess читається як будівник. Відкрийте дескриптор процесу, налаштуйте його рядок за рядком, зафіксуйте, а потім прочитайте код результату

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 сягає кінця файлу, тож аудит реєструє будь-яке інкрементне оновлення, додане після підписання

Слідкуйте за розрядністю цілих, поки виконуєте цю арифметику. Плаский API у GetSignProcessByteRange віддає 32-бітний Integer, тоді як самі значення мають тип Int64, тож на файлі понад 2 ГБ плаский аксесор мовчки обрізає їх. Беріть TPDFlibSigner.GetByteRange з рівня класів, який повертає Int64, або розбирайте значення з GetSignatureValueByName так, як це робить код аудиту вище

Що бібліотека залишає на вас

Дві межі краще вивчити на етапі проєктування, ніж у фінальному спринті. Плаский API TPDFlib не має жодної обгортки для перевірки підпису. Криптографічна перевірка живе рівнем нижче, у TPDFlibSignatureVerifier, чий метод VerifySignature відповідає «дійсний», «недійсний» або «невідомо». Немає також вбудованого HTTP-клієнта для служб штампів часу за RFC 3161. Бібліотека обчислює геш для надсилання і повторно вбудовує доповнений CMS, щойно повертається токен, але мережевий обмін із TSA писати вам. Обидві речі просто обгорнути й по-справжньому неприємно виявити відсутніми за тиждень до релізу, тож закладайте їх у проєкт із першого ескізу

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

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