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

Аналіз змін PDF після підпису з PDFium у Delphi

Щоб дізнатися, що змінилося в PDF після підпису, PDFium Component для Delphi та Lazarus дає TPdf.AnalyzeSignatureRevisions — аналізатор змін ревізій після підпису, який перебудовує кожну інкрементальну ревізію з початкових байтів файлу, оцінює кожну пізнішу зміну об'єктів проти правил DocMDP і FieldMDP того підпису і звітує shadow-визначення об'єктів як окремий ризик. Ситуація, під яку він цілиться, знайома кожному, хто має справу з контрактами: сертифікована форма їде, повертається ще з двома інкрементальними збереженнями, і кожен підпис усе ще верифікується. Це очікувано, бо підпис покриває лише байти власної ревізії. Справжнє питання — чи були ті пізніші збереження дозволені, і зелена галочка на підписі на нього не відповідає

Чому signature API PDFium не показує, що змінилося після підпису?

Signature API PDFium не показує післяпідписних змін, бо він лише читає словник підпису: /Contents, /ByteRange, /SubFilter і значення дозволів DocMDP. У PDFium немає графа інкрементальних ревізій, він не парсить параметри transform FieldMDP і не дає об'єктного diff між ревізіями, тож аналізатор у FPdfPades.pas працює прямо на сирих байтах. З цього випливає практичний наслідок, який варто закласти в дизайн. TPdf.AnalyzeSignatureRevisions читає байти, збережені при завантаженні документа, ніколи не копію, зроблену через SaveAs, бо переписаний файл загубив саме ту структуру ревізій, яку аналізують. Якщо документ прийшов з прогресивного джерела, що ще не докачалося, звіт повертає SourceStatus = pvssIncomplete і Status = prasIndeterminate замість аналізу обрізаного файлу

Перебудова меж ревізій зі startxref, xref-потоків і /Prev

Аналізатор перебудовує межі ревізій, йдучи за кожним startxref назад крізь класичні таблиці xref, потоки перехресних посилань, hybrid-записи /XRefStm і ланцюг /Prev, як визначено для інкрементальних апдейтів в ISO 32000-1 §7.5.6 і §7.5.8. Покрита довжина кожного підпису — це кінець його другого проміжку ByteRange, і аналізатор мапить ту довжину на ревізію, всередині якої лежить його секція xref. Коли ревізія не збігається, підпис отримує prrCoveredRevisionNotFound і статус Indeterminate. Стан кожного об'єкта потім реплеїться до покритої ревізії, і кожен пізніший запис xref порівнюється з тим станом. Це важливіше, ніж звучить: деякі письменники переписують повну таблицю xref при кожному інкрементальному збереженні, і запис, який усе ще вказує на той самий незмінений об'єкт, пропускається замість звітування як модифікація. Без того порівняння цілком легальне заповнення форми тонуло б у сотнях фейкових змін

Як AnalyzeSignatureRevisions перебудовує інкрементальні ревізії з сирих байтів PDF у Delphi: ByteRange підпису закінчується всередині покритої ревізії, ланцюг Prev xref іде назад крізь кожне пізніше збереження, стан об'єктів реплеїться до покритої ревізії, а незмінені переписані записи пропускаються замість звітування як змін
Підпис покриває лише байти власної ревізії, тож аналізатор мапить другий проміжок ByteRange на ревізію і оцінює кожен пізніший запис xref проти реплейнутого стану об'єктів

Shadow-визначення — випадок, який заслуговує найбільшої уваги. Тіло об'єкта, що лежить усередині байтового проміжку пізнішої ревізії, але не послане її xref, невидиме звичайному глядачу, і це рівно той вид staging-у, на який спираються shadow-атаки: прихований вміст висаджується до чи після підпису і пізніше активується перевертанням посилання. AnalyzePadesSignatureRevisionsBytes записує такий об'єкт як неавторитетну зміну з IsAuthoritative = False, оцінює його prdSuspicious поза залежністю від рівня дозволів і додає prrUnreferencedObjectDefinition до набору ризиків. Дві споріднені небезпеки покривають інші структурні трюки: prrDuplicateObjectDefinition спрацьовує, коли одна секція xref перелічує той самий об'єкт більше ніж раз, а prrSignatureObjectRedefined — коли пізніша ревізія перевизначає наявний об'єкт підпису

Shadow-визначення об'єкта всередині байтового проміжку пізнішої ревізії PDF: тіло об'єкта існує, але жоден запис xref на нього не посилається, тож глядачі ніколи його не показують, і AnalyzeSignatureRevisions у PDFium Component записує його як неавторитетний, оцінює prdSuspicious і піднімає prrUnreferencedObjectDefinition поруч із ризиками дубліката та перевизначеного об'єкта підпису
Прихований вміст висаджується до чи після підпису і активується пізніше перевертанням посилання, саме тому неописане тіло оцінюється як підозріле поза залежністю від рівня дозволів DocMDP
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

Як DocMDP і FieldMDP застосовуються для кожного підпису?

DocMDP і FieldMDP застосовуються окремо для кожного підпису, на його власній покритій ревізії, тож сертифікаційний підпис і пізніший підпис погодження в тому самому файлі можуть дійти різних вердиктів щодо однієї зміни. Кожен пізніший об'єкт спершу класифікується в TPadesRevisionChangeKind за його записами /Type, /Subtype і /FT і за роллю, яку він грає в графах сторінок, форм, анотацій і DSS. Усе, що несе /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia чи /EmbeddedFile, стає prckActiveContent. Рішення потім іде за ISO 32000-1 §12.8.2.2: з P=1 усе, крім даних перехресних посилань і матеріалу валідації, заборонене; P=2 дозволяє заповнення форм і подальші підписи, але відхиляє зміни анотацій; P=3 дозволяє ще й анотації. Вміст сторінок, структура документа, метадані, активний вміст і видалені об'єкти заборонені на будь-якому рівні DocMDP і оцінюються prdSuspicious, коли підпис не несе DocMDP взагалі, бо підпис погодження формально не забороняє нічого, але читач більше не бачить, що було підписано

FieldMDP, з ISO 32000-1 §12.8.2.4, звужує рішення по полях форми далі. pfmaAll замикає кожне поле, pfmaInclude замикає лише перелічені поля, а pfmaExclude замикає все, крім перелічених. Щоб застосувати Include чи Exclude, аналізатор розв'язує кожну змінену пару в її повністю кваліфіковане ім'я крізь ланцюг /Parent і порівнює її зі списком замків точним збігом, тож у список кладіть термінальні імена полів, а не сподівайтеся, що батьківське ім'я покриє нащадків. Коли ім'я не розв'язується чи transform використовує дію, яку парсер не розпізнає, зміна стає prdIndeterminate, і піднімається prrFieldMdpUnresolved. По-змінні рішення потім згортаються гірше-першим, із Suspicious вище за Disallowed, Disallowed вище за Indeterminate, а Indeterminate вище за Allowed, тож один shadow-об'єкт переважає будь-яку кількість легітимних оновлень полів

Конвеєр оцінювання, який AnalyzeSignatureRevisions застосовує до кожної післяпідписної зміни в Delphi: TPadesRevisionChangeKind за записами Type і Subtype, рішення DocMDP на покритій ревізії від P=1 до P=3, перевірка замка FieldMDP на повністю кваліфікованих іменах полів і згортання гірше-першим від prdSuspicious вниз до prdAllowed
Один shadow-об'єкт переважає будь-яку кількість легітимних оновлень полів, бо Suspicious рангується вище за Disallowed, Indeterminate і Allowed, тоді як частина ризиків записується поруч зі статусом, не знижуючи його

Чому деякі зміни повертаються Indeterminate, а не безпечними?

Зміни повертаються Indeterminate щоразу, коли аналізатор не може довести, що зміна дозволена, бо в перевірці підпису невідоме ніколи не мусить звітуватися як дозволене. Один поширений випадок обробляється точно: довгострокова валідація додає /DSS і переписує каталог, що інакше рахувалося б структурною зміною при P=1. Аналізатор здирає /DSS і /Extensions зі старого й нового словників каталогу і порівнює решту; коли більше нічого не відрізняється, переписування трактується як оновлення матеріалу валідації і дозволяється, тож нарощування B-LT і B-LTA не ламає сертифікаційний підпис. Інші прогалини лишаються відкритими навмисне. Записи Type-2 у потоці перехресних посилань вказують у стиснуті об'єктні потоки, а аналізатор не розгортає об'єктні потоки всередині цієї межі безпеки, тож ті зміни виходять на поверхню як prckCompressedObject з prrCompressedObjectUnresolved — заборонені при P=1 і Indeterminate інакше. Жорсткі бюджети у 1024 ревізії, 1 000 000 номерів об'єктів і 2 000 000 звітних змін дають prrResourceLimitExceeded, а зламаний ланцюг xref дає prrMalformedRevisionChain; обидва закінчуються Indeterminate, ніколи не проходом

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Частина ризиків записується без зміни Status, тож перевіряйте їх першими
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

Порядок у тому гейті навмисний. prrDuplicateObjectDefinition додається до набору ризиків без самостійного зниження Status, а transform FieldMDP, який не вдається розпарсити, впливає на статус лише коли поле форми справді змінюється, тож гейт, що дивиться лише на Status, може пропустити докази, які звіт уже містить. Пам'ятайте й те, чого звіт не стверджує. TPadesRevisionAnalysisReport не каже нічого про те, чи CMS-підпис криптографічно валідний, чи сертифікат підписувача веде ланцюгом до кореня, якому ви довіряєте. Аналіз ревізій відповідає на питання, що сталося після підпису, і стоїть поруч зі структурною валідацією та валідацією довіри, а не замість них

Запис seed values і замків MDP у момент підпису

Ті самі правила можна задати при підписанні через TPadesSignatureFieldOptions — це член FieldOptions і TPadesSignOptions, і TPadesRemoteSignOptions. PDFium уміє створити віджет, але не вміє писати /SV, /Lock, transform FieldMDP чи DocMDP або словник /Perms каталогу, тож власний інкрементальний PAdES-письменник компонента породжує ці об'єкти всередині того самого xref-апдейту, що й підпис. FieldName виставляє кореневе ім'я поля, RequiredSeedValues стає бітами /Ff словника seed values, описаного в ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations і AcceptableCertificates обмежують, що може обрати пізніший підписувач, LockAction із LockFields пише непрямий /SigFieldLock, а CertificationPermission від 1 до 3 перетворює підпис на сертифікаційний. Transform-и DocMDP і FieldMDP обидва йдуть в один масив /Reference на значенні підпису, кожен із /Data, що вказує на каталог

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // лише заповнення форм і підпис
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // замкнути лише ці поля
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

Кілька деталей легко зіпсувати, якщо робити це руками. Каталоговий /Perms /DocMDP має посилатися на словник значення підпису, а не на анотацію-віджет, і письменник тримає значення підпису власним непрямим об'єктом саме тому. Наявний словник /Perms може вже тримати права використання /UR3, тож письменник копіює його і вставляє /DocMDP, а не замінює, слідуючи словнику дозволів з ISO 32000-1 §12.8.4. Документ, який уже несе /DocMDP, відмовляє другий сертифікаційний підпис з EPadesCrypto, і те саме роблять неузгоджені опції: замок Include чи Exclude без імен полів, замок All зі списком полів, legal attestation на несертифікаційному підписі чи крапка в кореневому імені поля. Віддалене підписання додає ще одне правило, бо сертифікат підписувача невідомий, коли запускається PreparePadesRemoteSignature: виставлення CertificateRequired там вимагає явного списку AcceptableCertificates, тоді як локальне підписання може впасти назад на розв'язаний сертифікат підписувача

Аналіз ревізій доповнює toolbox підписів, а не замінює жодну його частину. Почніть зі інспекції PDF-підписів і рівнів PAdES з PDFium Component, щоб прочитати словник і базовий рівень, гляньте на те, чому валідатори відхиляють підписи PAdES за структурні відмови, що передують будь-якому питанню про ревізії, і вплетіть вердикт у ширший аудит ризиків безпеки PDF поруч із перевірками JavaScript і вбудованих файлів. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions і показаний тут інкрементальний PAdES-письменник їдуть разом із PDFium Component для Delphi, C++Builder і Lazarus