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

PDF MAC в Delphi: проверка на веригата ревизии (ISO 32004)

HotPDF валидира PDF MAC по ISO/TS 32004 на ревизия, а не на файл. THotPDF.ValidatePDFMACChain минава всяко инкрементално обновяване от котвата на веригата напред и проверява всеки MAC срещу read-only prefix поток, завършващ на собствените startxref и %%EOF на съответната ревизия. Един валиден MAC на най-новата ревизия не доказва нищо за ревизиите под нея

Ето сценарият, който мотивира всичко това. Доставяте AES-256 шифрован PDF с PDF MAC върху него. Някой отваря файла в hex редактор, обръща един байт вътре в първата MAC-защитена ревизия, а после долепя съвсем нова ревизия, носеща съвършено валиден собствен MAC. Всеки viewer отваря файла без оплакване, а наивна проверка, която хешира текущия байтов диапазон спрямо 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 се проверява на собствения си prefix поток, никога на крайната дължина на файла

Най-скъпият бъг в тази област е използването на крайния размер на файла като горна граница при повторно хеширане на по-стара ревизия, което сгъва крайните байтове във всеки digest освен най-новия и докладва тампериране върху здрав файл. HotPDF вместо това възстановява за всяка ревизия ограничен read-only поток, завършващ на собствената стойност startxref на съответната ревизия, последвана от нейния %%EOF, и хешира само това. Намирането на границата е по-придирчиво, отколкото изглежда: буквалният %%EOF може да се появи вътре в content stream или низ, така че кандидат се приема само когато непосредствено предхождащият startxref се парсне до число, равно на крос-референтното отместване на секцията, която се валидира, без нищо освен whitespace между тях. Ревизията после поглъща точно една end-of-line последователност след маркера — единичен CR, единичен LF или една CRLF двойка — и нищо повече. Това последно правило хапе на практика, защото writer, който извежда празен ред излишък между две ревизии, е произвел байтове, принадлежащи на следващата ревизия, а поглъщането на цялото заден whitespace в предишната тихо променя и двата digest. Преброяването на секциите следва същата дисциплина: HotPDF минава секциите с крос-референции от най-старата към най-новата точно веднъж, повтаряйки записите free, direct и object-stream, така че по-късните секции презаписват по-ранното състояние — противоположно на семантиката first-seen-wins, която parser на активен xref прилага

HotPDF проверява всеки PDF MAC по ISO 32004 срещу prefix поток, завършващ на собствените startxref и end-of-file маркер на съответната ревизия, така че обрнат байт вътре в ревизия 1 проваля веригата, макар най-новият MAC да продължава да се валидира чисто
MAC на всяка ревизия се пре-хешира върху собствения ѝ ограничен префикс, така че редакция на ревизия 1 и долепяне на свежо MAC-ната ревизия все още задоволяват ValidatePDFMAC, докато ValidatePDFMACChain се приземява на ревизия 1

Къде се закотва веригата и какво я чупи?

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

  • pmcfKDFSaltChanged/KDFSalt трябва да остане стабилен от котвата нататък, защото въртяща се сол би позволила на фалшификатор да преизведе ключове при параметри по собствен избор
  • pmcfDigestDowngrade — силата на digest се сравнява спрямо последния проверен 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 връзката е неподписана. Двата digest после вървят другата посока, което на пръв поглед изглежда циклично и не е. signatureDigest на PDF MAC свързва суровите content octets на CMS SignerInfo.signature OCTET STRING — не целия CMS DER и не подписаните атрибути — така че той се конструира след като суровата стойност на подписа съществува и се инжектира като неподписан атрибут id-attr-pdfMacData. Понеже /Contents е изключен от ByteRange на подписа и неподписаните атрибути никога не хранят изчислението на подписа, последователността произведи-подпис, изгради-MAC, опаковай-CMS се затваря чисто без криптографски цикъл. Следват две следствия: сентинелът /ByteRange и placeholder-ът /Contents трябва да останат в plaintext и извън object streams дори в шифрован файл, иначе пачерът с фиксирана ширина не може да ги намери; и когато MAC digest също е SHA-256, digest за подписване се преизползва направо, в противен случай и двата digest контекста се обновяват в един проход по изходния поток

Редът на запис в HotPDF за PDF MAC, прикачен към CMS подпис: MAC ключовете влизат в ревизията, преди ByteRange да бъде измерен, а digest на подписа се изгражда после от суровите octets на 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 document digest
    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, а двата digest
        // се докладват поотделно
        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 на единственото поле за подпис, и докладва провал на document-digest поотделно от провал на signature-digest. Това са различни диагнози, а сгъването им в едно булево изхвърля единствената информация, която казва дали е пипнат page content или стойността на подписа. Ако вече правите CMS работа, това седи до статията за PAdES подписване и ръководството за проверка на подписи в заредени документи

Никога не вярвай на /P: дешифрирай първо 16-те байта /Perms

ISO/TS 32004 сигнализира „този документ изисква PDF MAC“ чрез permission бит 13, а очевидният начин да го прочетете е грешният, защото цялото число /P в encryption речника е plaintext и неудостоверено — всеки може да обърне този бит в текстов редактор и да смъкне изискването. ISO 32000-2 §7.6 дава отговора в записа /Perms, а HotPDF го използва: дешифрирайте 16-байтовия низ /Perms с ключа за шифроване на файла под AES-256 CBC, нулев IV, без padding, после проверете всяко поле на plaintext, преди да повярвате на каквото и да е. Байтове 1 до 4 държат стойността на разрешенията в little-endian ред и трябва да са точно равни на цялото /P; байтове 5 до 8 са 0xFF; байт 9 е флагът за metadata шифроване T или F; байтове 10 до 12 са буквалният маркер adb. Само когато всичко това важи, PermissionsAuthenticated става True и бит 13 бива прочетен — и внимавайте с полярността му, защото изискването за MAC се утвърждава, когато битът 0x1000 е изчистен. Несъответствие между /P и дешифрираните разрешения не е предупреждение за логване и минаване напред; то е фалшифициран набор разрешения, а верният отговор е fail closed

HotPDF удостоверява разрешенията на PDF, като дешифрира шестнадесетбайтовия низ Perms с ключа за шифроване на файла и проверява little-endian стойността на разрешенията, запълващите FF байтове, флага за metadata и маркера adb, преди да прочете бит 13
Цялото число /P в plaintext е неудостоверено, така че изискването за PDF MAC се прочита само след като всяко поле на дешифрирания /Perms е било проверено

Гъвкавостта на алгоритмите спира при digest

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

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 го допълва, защитавайки инкременталната структура, която самият подпис не покрива. Отнасяйте се към тях като към слоеве и оставете двете присъди да се докладват независимо, вместо да се сгънат в една status икона

Описаните тук entry points за PDF MAC — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC и ValidatePDFMACChain — идват със стандартния HotPDF Delphi Component за Delphi и C++Builder, където продуктовата страница носи пълната справка за записа с опции, изброяванията на статуси и масива за валидация на ревизия