Техническая статья

Валидация цепочки ревизий 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 в активном trailer, сообщает успех — ведь тот MAC действительно верен для байтов, которые он покрывает. Повреждение сидит двумя ревизиями ниже, в области, которую никто не перепроверил

Почему валидный MAC верхнего уровня не доказывает целостность файла?

Потому что PDF MAC покрывает префикс, а не документ. Инкрементальное обновление — первоклассная часть формата: каждое сохранение добавляет новое тело, новую секцию перекрёстных ссылок и новый trailer, тогда как старые байты остаются ровно там, где были. 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, чтобы поздние секции перезаписывали раннее состояние, — противоположность семантике «первый увиденный побеждает», которую применяет парсер активного xref

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

Где якорится цепочка и что её ломает?

Первая ревизия с валидным /AuthCode — якорь, а FirstMACRevisionIndex сообщает, где начинается защита; всё до неё по построению незащищено, и это норма. Всё после неё должно быть защищено MAC, поэтому добавление одного обычного инкрементального обновления к защищённому MAC файлу падает с pmcfRequiredRevisionMissing и индексом виновной ревизии — терпимость к разрыву позволила бы атакующему снять защиту простым повторным сохранением. Три дальнейших инварианта держатся через цепочку, у каждого свой код сбоя

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

Следствие, которое стоит усвоить: исторические MAC проверяются независимо, даже когда перестали быть активным trailer. Именно поэтому атака из вступления — правка старой ревизии с добавлением свежего MAC — не выживает: новейший MAC сходится сам по себе, ValidatePDFMAC доволен, а цепочка всё равно приземляется на ревизии 1 с pmcfRevisionInvalid

Порядок подписания: ключи trailer сначала, signatureDigest последним

Когда MAC прикреплён к подписи CMS, а не стоит сам по себе, порядок записи перестаёт быть вопросом стиля. HotPDF требует, чтобы /AuthCode, /KDFSalt, developer extension ISO 32004 и /SigObjRef были записаны в ту же ревизию до вычисления /ByteRange подписи; дописать что-то из них потом — и эти байты оказываются вне диапазона, покрываемого подписью, давая файл, чья подпись валидна, пока привязка MAC не подписана. Два дайджеста затем идут в разные стороны, что на первый взгляд выглядит кругом, но им не является. PDF MAC signatureDigest связывает сырые октеты содержимого CMS SignerInfo.signature OCTET STRING — не весь CMS DER и не подписанные атрибуты, — поэтому он строится после существования сырого значения подписи и внедряется как неподписанный атрибут id-attr-pdfMacData. Поскольку /Contents исключён из ByteRange подписи, а неподписанные атрибуты никогда не входят в вычисление подписи, последовательность создать-подпись, собрать-MAC, завернуть-CMS закрывается чисто, без криптографического цикла. Два следствия: часовой /ByteRange и заглушка /Contents должны оставаться открытым текстом и вне объектных потоков даже в зашифрованном файле, иначе патчер фиксированной ширины их не найдёт; а когда дайджест MAC тоже SHA-256, подписной дайджест используется повторно целиком, иначе оба дайджест-контекста обновляются за один проход по выходному потоку

Порядок записи HotPDF для PDF MAC, прикреплённого к подписи CMS: ключи MAC входят в ревизию до измерения ByteRange, а дайджест подписи строится затем из сырых октетов SignerInfo
Запись AuthCode, KDFSalt, SigObjRef и developer extension до измерения 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 из текущего активного классического trailer перекрёстных ссылок, проследовать за генерационно-осведомлённым косвенным /SigObjRef, подтвердить, что он связывает /V единственного поля подписи, и сообщить о сбое дайджеста документа отдельно от сбоя дайджеста подписи. Это разные диагнозы, и схлопывание их в один boolean выбрасывает единственную информацию о том, что было тронуто — содержимое страницы или значение подписи. Если вы уже работаете с 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, расшифровывая 16-байтовую строку 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, где страница продукта несёт полный справочник записи опций, перечислений статусов и массива валидации по ревизиям