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 прилага
Къде се закотва веригата и какво я чупи?
Първата ревизия, носеща валиден /AuthCode, е котвата, а FirstMACRevisionIndex докладва къде започва защитата; всичко преди нея е незащитено по конструкция, което е нормално. Всичко след нея трябва да е MAC-защитено, така че долепянето на едно обикновено инкрементално обновяване към MAC-защитен файл се проваля с pmcfRequiredRevisionMissing и индекса на виновната ревизия — толерирането на пропуск би позволило на нападател да свали защитата, като просто запише още веднъж. Три по-нататъшни инварианта важат по цялата верига, всяка със собствен код на провал
pmcfKDFSaltChanged—/KDFSaltтрябва да остане стабилен от котвата нататък, защото въртяща се сол би позволила на фалшификатор да преизведе ключове при параметри по собствен изборpmcfDigestDowngrade— силата на digest се сравнява спрямо последния проверен MAC, а не спрямо непосредствено предхождащата ревизия, така че верига, започнала под профила Modern при SHA-384, не може тихо да продължи със SHA-256pmcfPermissionDowngrade— ревизия не може да изчисти изискване за 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 контекста се обновяват в един проход по изходния поток
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
Гъвкавостта на алгоритмите спира при 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, където продуктовата страница носи пълната справка за записа с опции, изброяванията на статуси и масива за валидация на ревизия