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

DocMDP та FieldMDP: аудит ревізій PDF у Delphi

Підписаний PDF, що змінився після підписання, не є автоматично зламаним. ISO 32000-1 дозволяє інкрементні оновлення поверх підпису, і лише деякі з них порушують політику, встановлену підписантом. HotPDF Component для Delphi та C++Builder відповідає на це питання через AnalyzeLoadedSignatureRevisions, яка класифікує кожну ревізію після підписання та оцінює її за DocMDP і FieldMDP. Сценарій знайомий кожному, хто постачає програмне забезпечення для контрактів: ваш клієнт підписує договір купівлі-продажу, надсилає його, а отримує назад з доданою сторінкою додатку. Читач показує жовту смугу з написом, що підпис цілий, але документ був змінений після підписання, і ніхто в кімнаті не може сказати, чи це нормальний робочий процес контрпідпису, чи хтось тихо редагує підписаний контракт

Що вважається легальною зміною після підписання?

Зміна легальна, коли її семантична категорія потрапляє в межі дозволу, який декларував сертифікаційний підпис. ISO 32000-1 §12.8.2.2 визначає трансформацію DocMDP зі значенням /P 1, 2 або 3: 1 не дозволяє жодних змін, 2 дозволяє заповнення форми та підписання, 3 дозволяє заповнення форми, підписання та анотації. HotPDF надає їх як значення THPDFDocMDPPermissiondmpNoChanges, dmpFormFillAndSign та dmpFormFillSignAndAnnotate, з dmpNone, зарезервованим для результатів перевірки без трансформації DocMDP взагалі

Категорії впорядковані, і саме цей порядок є двигуном усієї перевірки. THPDFRevisionModificationLevel проходить rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther, навмисно впорядковані так, що більший ординал ніколи не є менш обмежувальним. Весь документ зводиться до максимального рівня, спостереженого по всіх ревізіях після підпису, і порівняння DocMDP стає одним цілочисельним тестом. Один нюанс важливий одразу: при dmpNoChanges аналіз все одно приймає rmlLongTermValidation. Додавання матеріалу перевірки DSS та VRI або документної позначки часу до сертифікованого файлу є обслуговуванням підпису, а не модифікацією документа, і трактування цього як порушення зламало б кожен процес довгострокового архівування, що існує

Як HotPDF відбудовує ланцюжок ревізій?

Структурно, не евристично. За ISO 32000-1 §7.5.6 інкрементне оновлення додає новий розділ перехресних посилань, чий /Prev вказує на попередній, тож HotPDF читає startxref з хвоста, розбирає розділ там, слідує /Prev назад і повторює, повертаючи розділи від найстарішого до найновішого. Два запобіжники розташовані в цьому циклі, і обидва варто знати при діагностиці файлу, що не проходить: /Prev, що вказує на вже відвіданий зсув, завершує обхід з явною діагностикою циклу замість того, щоб зациклитись, а ланцюжок довший за тисячу ревізій відхиляється повністю. Обидва проявляються в Analysis.Issue з функцією, що повертає False, і жоден із них не слід замащувати, бо циклічний /Prev — це деформований чи ворожий файл, а не просто незвичайний

Чотири історичні форми трапляються в реальних документах, і всі чотири оброблені: традиційні таблиці xref, розібрані рядок за рядком, потоки перехресних посилань, декомпресовані та декодовані через їхні поля /W та /Index, гібридні файли перехресних посилань, чий традиційний трейлер несе ключ /XRefStm, що розбирається й зливається в ту саму ревізію (випадок продюсера Office, охоплений у статті про гібридні потоки перехресних посилань), та об'єкти, що живуть усередині контейнера ObjStm, які мають значення, бо сучасне оновлення зазвичай кладе змінений словник у стиснений потік, а не пише його прямо, як описано в статті про потоки об'єктів та інкрементні оновлення. Підпис прив'язує розділення: /ByteRange[2] + /ByteRange[3] стає SignedRevisionLength, і кожен розділ на цьому зсуві чи за ним — постпідписний. Чи діапазон байтів все ще хешується правильно — окреме питання, на яке відповідає VerifyLoadedSignature, охоплене в статті про перевірку цифрових підписів PDF

Як класифікується кожен змінений об'єкт

Класифікація виконується по об'єктах, потім поширюється по посиланнях. Для кожного номера об'єкта, якого торкається постпідписний розділ, HotPDF читає нове тіло та тіло в тому вигляді, в якому воно було в підписаному знімку; ідентичне тіло — це rmlNone, бо продюсери справді переписують об'єкти, не змінюючи їх. Розпізнавачі навмисно вузькі. Об'єкт /Type /DocTimeStamp або той, чий /SubFilterETSI.RFC3161, — це rmlLongTermValidation, як і все, до чого можна дістатись з дерева /DSS каталогу; словник /Type /Sig — це rmlFormFillAndSign. Для контейнерів тест — це те, які ключі змінились, а не те, чим є об'єкт: каталог може лише отримати чи змінити /DSS, /Extensions або /AcroForm; словник AcroForm — лише /Fields, /SigFlags, /NeedAppearances, /DR, /DA або /Q; сторінка — лише /Annots; поле чи віджет — лише /V, /AP, /AS або /M. Усе поза цими наборами опускається до rmlOther, і саме так виявляється додана сторінка додатку: додавання сторінки перебудовує дерево сторінок способами, яких жоден білий список не покриває, і жодне легітимне заповнення форми на це не схоже

Потім рівні поширюються, кожен контейнер успадковує максимальний рівень змінених дочірніх елементів, на які він вказує, ітеративно, поки призначення не стабілізується. Саме це змушує потоки зовнішнього вигляду працювати. Заповнене текстове поле переписує /V і вказує на свіжий потік /AP, і цей потік сам по собі — анонімний блоб операторів вмісту без типу для розпізнавання; оскільки поле, якому він належить, — це rmlFormFillAndSign, потік успадковує той самий рівень, а не провалюється до rmlOther. Те саме поширення переносить контекст DSS на потоки сертифікатів та відкликання, які інакше були б некласифіковними

Чому нечитаний об'єкт рахується як порушення?

Тому що альтернатива — валідатор, переможений записом чогось, чого він не розуміє. Три ситуації закінчуються rmlOther без апеляції в HotPDF: об'єкт, чиє тіло не вдалося прочитати з ревізії, об'єкт, який ревізія позначає як вільний, та об'єкт, що не відповідає жодному з розпізнавачів вище. Кожна записує конкретну діагностику в поле Issue ревізії, тож оператор може побачити, який номер об'єкта дав вердикт

Звільнення — найгостріше з трьох. Постпідписна ревізія, що позначає раніше визначений об'єкт як вільний, видалила вміст з підписаного документа, і жоден рівень дозволу за §12.8.2.2 цього не дозволяє; номери об'єктів потрапляють у FreedObjectNumbers, а ревізія піднімається до rmlOther. Нечитані об'єкти слідують тій самій логіці з іншої причини. Валідатор, що не може розібрати об'єкт, не має підстав називати його нешкідливим, а чесна відповідь на це — не мовчання. Звітування незвичайної, але доброякісної конструкції як порушення коштує людського перегляду; протилежна помилка постачає підписаний контракт з непоміченою правкою всередині

Читання вердикту в Delphi

Виклик короткий. Завантажте документ, оберіть індекс підпису, прочитайте запис; перевантаження без параметрів повторно відкриває файл, з якого документ був завантажений, а перевантаження TStream бере надані викликачем байти й відновлює позицію потоку перед поверненням. PolicyCompliant — це єдиний булевий результат, потрібний більшості викликачів, що об'єднує три незалежні рішення: структурну валідність словників дозволів, DocMDPCompliant та FieldMDPCompliant. Тримайте компоненти видимими у вашому UI, а не згортайте їх, і зауважте, що документ без трансформації DocMDP залишає DocMDPCompliant зі значенням True, оскільки звичайний підпис-затвердження не декларує жодної політики для порушення, а сукупний ModificationLevel тоді описовий, а не вердикт

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

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

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP оцінюється окремо, і це навмисно

Документ може задовольняти DocMDP і все одно бути нелегітимним, тому FieldMDPCompliant — окремий булевий прапорець, а не згорнутий у порівняння рівнів. ISO 32000-1 §12.8.2.4 визначає трансформацію FieldMDP, а §12.7.5.5 — пов'язаний запис /SigFieldLock, щоб заморозити названі поля форми в момент підписання, навіть коли документ в цілому все ще дозволяє заповнення форми. Заповнення поля — дія рівня 2; заповнення поля, заблокованого підписантом, — порушення незалежно від рівня. HotPDF читає область дії в THPDFFieldLockAction як flaAll, flaInclude або flaExclude, з flaNone для результатів без політики блокування, а імена — в Permissions.FieldNames: flaAll блокує все, flaInclude блокує перелічені імена, flaExclude блокує все, крім них. Одна деталь важлива при читанні результатів: лише поля, вже присутні в підписаному знімку, звітуються в ChangedFieldNames, бо поле, створене повністю після підписання, не має підписаного стану, якому б суперечило, і його виявляє натомість шлях DocMDP

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

Чого цей аналіз вам не повідомить

Він не перевіряє підпис. AnalyzeLoadedSignatureRevisions міркує про структуру та дозволи; чи діапазон підписаних байтів все ще хешується до значення в блобі CMS, і чи сертифікат підписанта веде до чогось, чому ви довіряєте, — на це відповідають VerifyLoadedSignature та VerifyLoadedSignatureWithTrust. Файл може бути повністю політикоузгодженим і криптографічно нічого не вартим, тож обидві перевірки мають стояти поряд у будь-якому реальному шлюзі прийняття. Він також не читає намір усередині потоків вмісту: сторінка, чий потік вмісту замінено повністю, виявляється як зміна поза білим списком, але аналіз не скаже вам, що заміна підмінила суму платежу. Вердикт rmlOther означає, що людині слід подивитись, а не що стались шахрайство, а узгоджений вердикт означає, що зміна відповідає дозволеній категорії, а не що зміна була бажаною. Коли все, що потрібно, — це те, що задекларував підписант, без обходу ревізій, GetLoadedSignaturePermissions повертає словники політики самі по собі

Усе описане тут працює нативно в Delphi та C++Builder без зовнішньої служби підписання в циклі, і саме це робить практичним запускати це на кожному вхідному документі замість лише тих, які хтось вже підозрював. Повний API підписів та ревізій, включно з методами дозволів і перевірки, з якими він поєднується, є частиною HotPDF Component для Delphi та C++Builder