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

Обгортання підпису PDF: проміжки ByteRange і другі підписи

HotPDF, Delphi PDF-компонент, тепер відкидає signature wrapping: з v2.759.0 і VerifyLoadedSignatureEx, і батчевий валідатор вимагають, щоб проміжок між двома сегментами /ByteRange був точно /Contents-hex-рядком, розділювачі включно, а v2.761.0 додає AddLoadedSignedSignatureField, тож другий підпис можна додати до вже підписаного PDF як чисту інкрементальну ревізію. Дві зміни належать разом, бо коректний другий підпис — це рівно той макет, якого очікує строгіший валідатор

Ситуація, що оголила проблему, звичайна. Контракт підписує постачальник, потім він їде до погоджувача, який мусить поставити другий підпис, не турбуючи першого. Друга ревізія додається після першої, її власний /ByteRange вкриває весь вирісший файл, і обидва підписи мають верифікуватися. Дійти туди вручну означало писати інкрементальну секцію самому, і тестова фікстура, що робила рівно це, виявилася підручниковою структурою signature wrapping, яку старий валідатор щасливо приймав. Якщо ви ще не дивилися на API верифікації, посібник із верифікації цифрових підписів PDF з HotPDF покриває основи, на яких будується ця стаття

Що саме належить до проміжку ByteRange?

Проміжок мусить містити повне значення /Contents і нічого іншого: ISO 32000-1 §12.8.3.3 каже, що hex-рядок, зі своїми розділювачами < і >, вміщається точно в простір між двома байтовими діапазонами, і ISO 32000-2 §12.8.1 несе те саме правило далі. Table 252 і документи PAdES кажуть лише, що дайджест виключає значення Contents, що легко прочитати як виключення самих hex-цифр. Раніші релізи HotPDF читали саме так: PreparePDFForSigning і потокова CMS-підготовка хешували й кутові дужки, з коментарем у сирці, що наполягав, ніби дужки мають бути покриті. Валідатори, що звіряють проміжок зі значенням підпису, позначають той макет як недійсний байтовий діапазон, тож v2.759.0 виносить обидва розділювачі з підписаних діапазонів. Швидка незалежна перевірка будь-якого підписаного файлу — глянути на два байти: байт на зміщенні ByteRange[1] мусить бути <, а байт на зміщенні ByteRange[2] - 1 — >

Анатомія коректно заповненого ByteRange підпису PDF у HotPDF: перший діапазон вкриває файл від нульового байта, проміжок тримає повний hex-рядок /Contents разом із розділювачами less-than і greater-than, другий діапазон вкриває trailer до кінця, а дві однобайтові перевірки на ByteRange[1] і ByteRange[2] - 1 підтверджують макет на будь-якому підписаному файлі
З v2.759.0 розділювачі сидять поза підписаними діапазонами, тож дайджест вкриває лише цифри, а проміжок можна перевіряти байт-за-байтом

Чому перевірка непорожнього проміжку пропускає signature wrapping?

Перевірка непорожнього проміжку лише доводить, що щось було виключено з дайджесту, а не що саме, і це весь простір атаки. Заповнювач /Contents резервирується тисячами нульових цифр, тоді як реальний CMS-контейнер рідко заповнює його. Атакувальник може закрити hex-рядок раніше часу всередині того нульового доповнення через >, вписати нові об'єкти чи підроблену ревізію в решту зарезервованого простору і лишити байтові діапазони недоторканими. CMS-підпис усе ще верифікується, бо кожен підписаний байт незмінний, діапазони все ще починаються з 0 і закінчуються розміром файлу, а старий валідатор HotPDF звітував svValid з CoversWholeDocument, виставленим у True. PDF-читач, своєю чергою, розбирає все, що сидить у тій непідписаній дірі

HotPDF тепер трактує проміжок як дані, що перевіряються байт-за-байтом. Валідатор читає проміжок, зрізає розділювачі, приймає лише hex-цифри плюс PDF-пробіли (табуляція, переведення рядка, form feed, повернення каретки, пробіл), декодує цифри і вимагає, щоб результат точно дорівнював /Contents словника підпису. Все інше понижує результат до svInvalidByteRange. Перевірка працює і в шляху одиночного підпису, і в ValidateLoadedSignatureBatch, яка тримала власну логіку покриття і потребувала того самого виправлення. Файли, породжені HotPDF до v2.759.0, чиї проміжки тримали лише цифри з дужками щойно всередині діапазонів, усе ще верифікуються, тож архівні документи раптом не червоніють

Як signature wrapping експлуатує нестрого перевіряний ByteRange PDF у Delphi: атакувальник закриває hex-рядок раніше часу всередині тисяч зарезервованих нульових цифр, вписує підроблену ревізію в непідписаний проміжок, не торкаючись жодного покритого байта, а стара перевірка HotPDF звітувала svValid з CoversWholeDocument true, доки v2.759.0 не почала валідувати проміжок байт-за-байтом
Непорожній проміжок лише доводить, що щось було виключено з дайджесту, а не що саме — доповнена діра і є весь простір атаки
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Як додати другий підпис до вже підписаного PDF?

Відкрийте підписаний файл через BeginIncrementalUpdate, викличте AddLoadedSignedSignatureField, збережіть через SaveIncrementalUpdate, потім підпишіть підготовлений файл класовою функцією THotPDF.SignPDFWithPFX. До v2.761.0 задокументований рецепт виклику THPDFPage.AddSignedSignatureField після BeginIncrementalUpdate не міг працювати, бо CurrentPage в інкрементальному режимі — nil, і ніщо не могло причепити заповнювач /V до поля на завантаженому документі. Новий метод створює віджет на завантаженій сторінці і підвішує під /V той самий словник-заповнювач, який уживає шлях нового документа, тож обидва підписувальні маршрути ділять одну серіалізацію. Щодо самого першого підпису, стаття про створення цифрових підписів PAdES у Delphi проходить PFX-конвеєр

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Сторінка 0, прямокутник віджета в пунктах, 8192 байтів зарезервовано під CMS
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField навмисно тихіша за своїх сестер. Інші творці полів AddLoaded* ставлять /NeedAppearances true на AcroForm, що велить переглядачу регенерувати appearances полів; на підписаному документі та регенерація може переписати підписаний вміст, тож новий метод знімає прапорець назад, якщо джерело його вже не несло. /SigFlags тримає своє початкове значення OR 3 (SignaturesExist плюс AppendOnly, ISO 32000-1 Table 219). Вам також не треба викликати MarkDirty на сторінці: додавання в /Annots і /Fields поширює брудний прапорець на володіючий непрямий об'єкт, а явна позначка сторінки лише втягнула б незмінений словник сторінки в нову ревізію, яку аналіз ревізій потім звітував би як модифікацію сторінки. Нарешті, заповнювач пише /ByteRange перед /Contents, бо латальник знаходить сентинел /ByteRange першим і шукає вперед за парним hex-рядком

Робочий процес HotPDF Delphi для другого підпису на вже підписаному PDF: BeginIncrementalUpdate відкриває файл, AddLoadedSignedSignatureField створює віджет і резервує заповнювач /Contents, SaveIncrementalUpdate додає другу ревізію, а SignPDFWithPFX заповнює її, лишаючи перший підпис чинним з UnsignedTrailingBytes, тоді як новий ByteRange вкриває весь вирісший файл
Один заповнювач на ревізію, підготовлений і залатаний тією самою серіалізацією на обох підписувальних маршрутах — чистий макет, якого очікує строгіший валідатор

Що змінюється, коли CMS породжує зовнішній підписувач чи HSM?

У робочому процесі нічого не змінюється, але зміщення тепер означають те, що каже специфікація. PreparePDFForSigning повертає два 0-базованих діапазони, чиїм проміжком є весь рядок /Contents, а ContentsHexStart — 1-базований індекс першої hex-цифри в AnsiString. Коротший CMS доповнюється 0 в кінці, перед закривною >. Оскільки PreparePDFForSigning латаль перший нелатаний сентинел, якого знайде, готуйте рівно один заповнювач на ревізію і віддавайте перевагу InsertSignatureHexAt з поверненими зміщеннями над пошуковим InsertSignatureHex, коли в файлі вже є раніші підписи

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // ваш хелпер
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // Проміжок — увесь hex-рядок: '<' закінчує діапазон 1, '>' передує діапазону 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // ваш CMS-підписувач, hex DER
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // ваш хелпер
end;

Де межі нових перевірок?

Перевірка проміжку закриває одну конкретну діру і не мусить продаватися понад міру. svValid досі означає байтову цілісність плюс ключ, що збігається з вбудованим сертифікатом; довіра до того сертифіката — окреме рішення. Проміжок валідується лише тоді, коли валідатор має байти джерела, які VerifyLoadedSignatureEx читає з завантаженого файлу, а перевантаження з TStream беруть від вас. Для першого підпису у файлі з другим підписом CoversWholeDocument коректно False, а чи додала додана ревізія лише підпис чи ще й змінила сторінки — питання до аналізу DocMDP, FieldMDP і ревізій у HotPDF. Зауважте ще, що перевірка приєднаного PDF MAC звіряє зміщення з позиціями < і >, тож приймає і старий, і новий макет; будь-який ваш власний інструмент, що захардкодив зміщення до v2.759.0, впаде першим, зустрівши свіжо підписаний файл

Якщо ваша програма на Delphi чи C++Builder підписує, ставить другі підписи чи аудитує PDF, найбезпечніший шлях — дозволити одній бібліотеці породжувати і верифікувати той самий макет. HotPDF, нативний Delphi PDF-компонент постачає строгішу валідацію проміжку, інкрементальні другі підписи і гачки зовнішнього підписувача, показані вище, в одному компоненті