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

Перевірка ланцюга ревізій PDF MAC у Delphi (ISO 32004)

HotPDF перевіряє PDF MAC з ISO/TS 32004 по ревізіях, а не по файлу. THotPDF.ValidatePDFMACChain проходить кожне інкрементне оновлення від якоря ланцюга вперед і звіряє кожен MAC із префіксним потоком лише для читання, що закінчується на власних startxref і %%EOF тієї ревізії. Один валідний MAC на найновішій ревізії нічого не доводить про ревізії під нею

Ось сценарій, який усе це мотивує. Ви постачаєте зашифрований AES-256 PDF із PDF MAC на борту. Хтось відкриває файл у hex-редакторі, перевертає байт усередині першої ревізії, захищеної MAC, а потім додає цілком нову ревізію зі справно валідним власним MAC. Кожен переглядач відкриває файл без ремарок, а наївний перевірник, що хешує поточний діапазон байтів проти MAC в активному трейлері, звітує про успіх — бо той MAC справді правильний для байтів, які він покриває. Пошкодження сидить на дві ревізії нижче, у регіоні, який ніхто не перевіряв повторно

Чому валідний MAC верхнього рівня не доводить цілісність файлу?

Бо PDF MAC покриває префікс, а не документ. Інкрементне оновлення — повноцінна частина формату: кожне збереження додає нове тіло, нову секцію перехресних посилань і новий трейлер, тоді як старіші байти лишаються точно там, де були. ISO/TS 32004 виїжджає на цій моделі, тож кожна ревізія несе власний словник /AuthCode, що автентифікує файл станом на той момент, і перевірка лише найновішої лишає всі попередні ревізії непроглянутими. Тому HotPDF виставляє два питання двома викликами, і різниця між ними — суть цієї статті. ValidatePDFMAC відповідає на «чи автентична поточна ревізія», заповнюючи запис THPDFPDFMACValidationInfo; ValidatePDFMACChain відповідає на «чи автентична кожна ревізія з MAC-захистом у цьому файлі», заповнюючи THPDFPDFMACChainValidationInfo масивом по ревізіях і машинозчитуваною причиною відмови. На файлі вище — спочатку зіпсованому, потім пере-MAC-леному — перший виклик повертає True, а другий повертає False на індексі ревізії 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // Відмова — одна з pmcfRevisionBoundary, pmcfNoPDFMAC,
      // pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
      // pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

Кожен MAC звіряється на власному префіксному потоці, ніколи не на фінальній довжині файлу

Найдорожчий баг у цій галузі — використання фінального розміру файлу як верхньої межі при повторному хешуванні старішої ревізії: це підмішує хвостові байти в кожен дайджест, окрім найновішого, і звітує про підробку на здоровому файлі. HotPDF натомість реконструює для кожної ревізії обмежений потік лише для читання, що закінчується на власному значенні startxref тієї ревізії, за яким слідує її %%EOF, і хешує лише це. Знахідка межі клопітніша, ніж здається: літерал %%EOF може зустрітися всередині потоку вмісту або рядка, тож кандидат приймається лише тоді, коли безпосередньо попередній startxref парситься в число, рівне зсуву перехресних посилань секції, що валідується, і між ними немає нічого, крім пробільних символів. Ревізія потім поглинає рівно одну послідовність кінця рядка після маркера — одиночний CR, одиночний LF або одну пару CRLF — і більше нічого. Це останнє правило кусається на практиці, бо записувач, що видає зайвий порожній рядок між двома ревізіями, народив байти, що належать наступній ревізії, і проковтування всього хвостового пробілу попередньою мовчки змінює обидва дайджести. Перелік секцій дотримується тієї ж дисципліни: HotPDF проходить секції перехресних посилань від найстарішої до найновішої рівно один раз, програваючи записи free, прямі та object-stream, тож пізніші секції перезаписують раніший стан — усе навпаки семантиці first-seen-wins, яку застосовує парсер активного xref

HotPDF звіряє кожен PDF MAC з ISO 32004 із префіксним потоком, що закінчується на власних startxref і маркері кінця файлу тієї ревізії, тож перевернутий байт усередині ревізії 1 валить ланцюг, хоча найновіший MAC досі проходить чисто
MAC кожної ревізії повторно хешується по власному обмеженому префіксу, тож правка ревізії 1 і додавання свіжо-MAC-леної ревізії досі задовольняють ValidatePDFMAC, тоді як ValidatePDFMACChain приземляється на ревізії 1

Де ланцюг має якір і що його ламає?

Перша ревізія, що несе валідний /AuthCode, є якорем, і FirstMACRevisionIndex звітує, де починається захист; усе до нього не захищене за конструкцією, і це нормально. Усе після нього мусить бути під MAC-захистом, тож додавання одного звичайного інкрементного оновлення до файлу з MAC-захистом падає з pmcfRequiredRevisionMissing та індексом провинної ревізії — терпимість до прогалини дозволила б зловмисникові зняти захист простим повторним збереженням. Ще три інваріанти тримаються вздовж ланцюга, кожен зі своїм кодом відмови

  • pmcfKDFSaltChanged/KDFSalt мусить лишатися стабільним від якоря й далі, бо salt, що обертається, дозволила б підробнику виводити ключі заново за параметрами власного вибору
  • pmcfDigestDowngrade — сила дайджесту порівнюється з останнім перевіреним MAC, а не з безпосередньо попередньою ревізією, тож ланцюг, що почався під профілем Modern на SHA-384, не може тихо продовжитися з SHA-256
  • pmcfPermissionDowngrade — ревізія не може зняти вимогу PDF MAC, яку автентифікувала попередня ревізія

Висновок, який варто засвоїти: історичні MAC перевіряються незалежно, навіть коли вони вже не активний трейлер. Саме тому атака «правка старої ревізії, потім додавання свіжого MAC» із початку статті не виживає: найновіший MAC проходить сам по собі, ValidatePDFMAC задоволений, а ланцюг все одно приземляється на ревізії 1 з pmcfRevisionInvalid

Порядок підписання: ключі трейлера спершу, signatureDigest останнім

Коли MAC прикріплений до підпису CMS, а не стоїть сам, порядок запису перестає бути питанням стилю. HotPDF вимагає, щоб /AuthCode, /KDFSalt, розширення розробника ISO 32004 і /SigObjRef були записані в ту саму ревізію до обчислення /ByteRange підпису; додайте будь-що з них потім, і ті байти опиняться поза діапазоном, який покриває підпис, — вийде файл, чий підпис проходить, тоді як привʼязка MAC не підписана. Два дайджести потім біжать у протилежний бік, що на перший погляд здається циклом, але ним не є. signatureDigest PDF MAC звʼязує сирі октети вмісту CMS SignerInfo.signature OCTET STRING — не весь CMS DER і не підписані атрибути — тож він конструюється після того, як сире значення підпису існує, і вводиться як непідписаний атрибут id-attr-pdfMacData. Оскільки /Contents виключений із ByteRange підпису, а непідписані атрибути ніколи не входять у обчислення підпису, послідовність «створити підпис, зібрати MAC, загорнути в CMS» замикається чисто, без криптографічної петлі. З цього два наслідки: сторожовий /ByteRange і заповнювач /Contents мусять лишатися відкритим текстом і поза object streams навіть у зашифрованому файлі, інакше патчер фіксованої ширини їх не знайде; а коли дайджест MAC теж SHA-256, дайджест підписання перевикористовується прямо, інакше обидва контексти дайджесту оновлюються одним проходом по вихідному потоці

Порядок запису HotPDF для PDF MAC, прикріпленого до підпису CMS: ключі MAC входять у ревізію до вимірювання ByteRange, а дайджест підпису збирається після, із сирих октетів підпису SignerInfo
Запис AuthCode, KDFSalt, SigObjRef і розширення розробника до вимірювання ByteRange — це те, що тримає привʼязку MAC усередині діапазону, який покриває підпис
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // дайджест документу SHA-384
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature, і два дайджести
        // звітуються окремо
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

Валідація повторює той самий шлях з іншого кінця: читає прямий /AuthCode із нині активного класичного трейлера перехресних посилань, слідує за залежним від покоління непрямим /SigObjRef, підтверджує, що він звʼязує /V єдиного поля підпису, і звітує про відмову дайджесту документу окремо від відмови дайджесту підпису. Це різні діагнози, і злиття їх в один булів знаменник викидає єдину інформацію, що каже, чи чіпали вміст сторінки, чи значення підпису. Якщо ви вже робите роботу з CMS, це лежить поруч із статтею про підписання PAdES та посібником із перевірки підписів у завантажених документах

Ніколи не довіряйте /P: спершу розшифруйте 16-байтовий /Perms

ISO/TS 32004 сигналізує «цей документ вимагає PDF MAC» через біт дозволів 13, і очевидний спосіб його прочитати — неправильний, бо ціле /P у словнику шифрування — відкритий текст і без автентифікації: будь-хто може перевернути той біт у текстовому редакторі й знизити вимогу. ISO 32000-2 §7.6 дає відповідь у записі /Perms, і HotPDF нею користується: розшифруйте 16-байтовий рядок /Perms ключем шифрування файлу під AES-256 CBC, нульовим IV, без доповнення, потім перевірте кожне поле відкритого тексту, перш ніж вірити хоч чомусь. Байти 1–4 тримають значення дозволів у порядку little-endian і мусять точно дорівнювати цілому /P; байти 5–8 — це 0xFF; байт 9 — прапорець шифрування метаданих T або F; байти 10–12 — літеральний маркер adb. Лише коли все це тримається, PermissionsAuthenticated стає True і біт 13 читається — і зважте на його полярність, бо вимога MAC стверджується, коли біт 0x1000 скинутий. Розбіжність між /P і розшифрованими дозволами — не попередження, щоб занотувати й рухатися далі; це підроблений набір дозволів, і правильною відповіддю є відмова в закритому стані

HotPDF автентифікує дозволи PDF, розшифровуючи шістнадцятибайтовий рядок Perms ключем шифрування файлу і перевіряючи значення дозволів little-endian, байти-заповнювачі FF, прапорець метаданих і маркер adb перед читанням біта 13
Ціле /P у відкритому тексті не автентифіковане, тож вимога PDF MAC читається лише після перевірки кожного поля розшифрованого /Perms

Агільність алгоритмів зупиняється на дайджесті

ISO/TS 32004 дозволяє обрати дайджест документу, і лише дайджест документу. HotPDF тримає HMAC-SHA-256 для автентифікації, HKDF-SHA-256 за RFC 5869 для виведення ключів і AES-256 key wrap за RFC 3394 фіксованими під змінною THPDFPDFMACDigestAlgorithm, що тягнеться від pmdaSHA256 до pmdaSHA3_512, бо природна помилка — сприйняти «профіль SHA3-512» як ліцензію поміняти й HMAC, а це дає файл, що більше не є PDF MAC у жодному сумісному сенсі. Одна деталь реалізації варта копіювання, якщо пишете власний перевірник: прочитайте OID дайджесту з CMS AuthenticatedData до хешування діапазону байтів, бо зашите SHA-256 з подальшим звірюванням перетворює агільність на етикетку і дозволяє ворожому файлу змусити вас прокачати весь документ, перш ніж ви виявите, що алгоритм ніколи й не підтримувався. CMSAlgorithmProtection, алгоритм дайджесту AuthenticatedData, integrity-info messageDigest і дайджест діапазону байтів мусять назвати один алгоритм, і будь-яка розбіжність означає відмову в закритому стані

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, приймає всі шість
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, відхиляє 256-бітні
  Options := THPDFPDFMACOptions.HighAssurance;  // лише SHA3-512, AES-GCM

  // Власний профіль легальний, але алгоритм, яким він генерує,
  // мусить також бути в allowlist валідації, інакше конфігурація
  // відхиляється до того, як буде записано хоч один байт
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

Що PDF MAC доводить, а що ні

Перевірений ланцюг PDF MAC доводить, що кожна захищена ревізія байт-у-байт збігається з тим, що записав хтось, хто тримав ключ шифрування файлу, що жодна захищена ревізія не була вилучена чи переставлена, і що жодна незахищена ревізія не була додана після якоря — саме той клас атак, який лишає відкритим звичайне шифрування AES-256, адже конфіденційність не каже нічого про цілісність, а зашифрований PDF зі вставленою ревізією розшифровується так само весело, як і цілий. Чого він не доводить, то авторства. Ключ MAC виводиться з ключа шифрування файлу, тож будь-хто, хто може відкрити документ, здатен зробити валідний MAC над модифікованою версією — включно з кожним легітимним отримувачем; це симетричний примітив, а симетричні примітиви не атрибутують. Якщо вам треба знати хто щось змінив, потрібен цифровий підпис із сертифікатом позаду, а PDF MAC тоді доповнює його, захищаючи інкрементну структуру, яку сам підпис не покриває. Ставтеся до них як до шарів і дайте двом вердиктам звітуватися незалежно, а не злитися в одну іконку статусу

Точки входу PDF MAC, описані тут, — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC і ValidatePDFMACChain — постачаються зі стандартним HotPDF Delphi Component для Delphi і C++Builder, де продуктова сторінка несе повний довідник запису опцій, переліків статусів і масиву валідації по ревізіях