Technický článek

Validace řetězce revizí PDF MAC v Delphi (ISO 32004)

HotPDF validuje PDF MAC podle ISO/TS 32004 na revizi, ne na soubor. THotPDF.ValidatePDFMACChain projde každý inkrementální update od kotvy řetězce vpřed a ověří každý MAC proti read-only prefixovému proudu končícímu na vlastním startxref a %%EOF dané revize. Jeden validní MAC na nejnovější revizi nic nedokáže o revizích pod ní

Toto je scénář, který to všechno motivuje. Expedujete AES-256 šifrované PDF s PDF MAC. Někdo otevře soubor v hex editoru, překlopí bajt uvnitř první revize chráněné MACem, pak připojí úplně novou revizi nesoucí dokonalý validní MAC svůj vlastní. Každý prohlížeč otevře soubor bez reptání a naivní kontrolor, který hashuje aktuální bajtový rozsah proti MACu v aktivním traileru, hlásí úspěch — protože ten MAC je skutečně správný pro bajty, které kryje. Škoda sedí o dvě revize níž, v oblasti, kterou nikdo znovu nezkontroloval

Proč validní MAC nejvyšší úrovně nedokazuje, že je soubor v pořádku?

Protože PDF MAC kryje prefix, ne dokument. Inkrementální update je prvotřídní část formátu: každé uložení připojí nové tělo, novou sekci křížových odkazů a nový trailer, zatímco starší bajty zůstávají přesně tam, kde byly. ISO/TS 32004 jede na tom modelu, takže každá revize nese vlastní slovník /AuthCode autentizující soubor, jak stál v tu chvíli, a ověření jen toho nejnovějšího nechá každou dřívější revizi neprozkoumanou. HotPDF proto vystavuje obě otázky jako dvě volání a rozdíl mezi nimi je celý smysl tohoto článku. ValidatePDFMAC odpovídá „je aktuální revize autentická" a plní záznam THPDFPDFMACValidationInfo; ValidatePDFMACChain odpovídá „je každá revize chráněná MACem v tomto souboru autentická" a plní THPDFPDFMACChainValidationInfo polem na revizi plus strojově čitelným důvodem selhání. Na výše popsaném souboru překlopeném a znovu MACnutém vrátí první volání True a druhé False proti indexu revize 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // Selhání je jedno z 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;

Každý MAC se ověřuje na vlastním prefixovém proudu, nikdy na délce finálního souboru

Nejdražší chyba v téhle oblasti je brát finální velikost souboru jako horní mez při opětovném hashování starší revize, což zalije koncové bajty do každého digestu kromě nejnovějšího a hlásí manipulaci na zdravém souboru. HotPDF místo toho pro každou revizi rekonstruuje ohraničený read-only proud končící na vlastní hodnotě startxref dané revize následované jejím %%EOF a hashuje jen to. Najít hranici je zajímavější, než vypadá: literál %%EOF se může objevit uvnitř content streamu nebo řetězce, takže kandidát se přijímá jen tehdy, když bezprostředně předcházející startxref parsuje na číslo rovné offsetu křížových odkazů sekce, která se validuje, s ničím mezitím kromě mezer. Revize pak absorbuje přesně jednu sekvenci konce řádku po značce — jediný CR, jediný LF nebo jednu dvojici CRLF — a nic víc. To poslední pravidlo kousne v praxi, protože zapisovač, který emituje mezi dvěma revizemi prázdný řádek navíc, vyrobil bajty patřící další revizi a polykání veškerého koncového whitespace do předchozí tiše změní oba digesty. Výčet sekcí dodržuje tutéž disciplínu: HotPDF projde sekce křížových odkazů od nejstarší k nejnovější přesně jednou, přehrávající volné, přímé i objektoproudové položky, takže pozdější sekce přepisují dřívější stav, což je opak sémantiky první-vidí-vítězí, kterou aplikuje parser aktivního xrefu

HotPDF ověřuje každý PDF MAC ISO 32004 proti prefixovému proudu končícímu na vlastním startxref a značce konce souboru dané revize, takže bajt překlopený uvnitř revize 1 zlomí řetězec, i když nejnovější MAC validuje čistě
MAC každé revize se znovu hashuje přes vlastní ohraničený prefix, takže úprava revize 1 a připojení čerstvě MACované revize stále uspokojí ValidatePDFMAC, zatímco ValidatePDFMACChain dopadne na revizi 1

Kde se řetězec kotví a co ho zlomí?

První revize nesoucí validní /AuthCode je kotva a FirstMACRevisionIndex hlásí, kde ochrana začíná; všechno před ní je konstručně nechráněno, což je normální. Všechno po ní musí být chráněno MACem, takže připojení jednoho prostého inkrementálního update k souboru chráněnému MACem selže s pmcfRequiredRevisionMissing a indexem provinilé revize — tolerování mezery by dovolilo útočníkovi odnést ochranu prostým uložením ještě jednou. Další tři invarianty drží napříč řetězcem, každý se svým kódem selhání

  • pmcfKDFSaltChanged/KDFSalt musí zůstat stabilní od kotvy dál, protože rotující sůl by dovolila padělateli odvozovat klíče znovu pod parametry vlastní volby
  • pmcfDigestDowngrade — síla digestu se srovnává s posledním ověřeným MACem, ne s bezprostředně předcházející revizí, takže řetězec startující pod profilem Modern na SHA-384 nemůže tiše pokračovat na SHA-256
  • pmcfPermissionDowngrade — revize nesmí vymazat požadavek PDF MAC, který dřívější revize autentizovala

Důsledek, který stojí za vstřebání, je, že historické MACy se ověřují nezávisle, i když už nejsou aktivním trailerem. Proto útok uprav-starší-revizi-a-připoj-čerstvý-MAC z úvodu nepřežije: nejnovější MAC projde na svém vlastním vzorku, ValidatePDFMAC je spokojen a řetězec i tak dopadne na revizi 1 s pmcfRevisionInvalid

Pořadí podpisu: klíče traileru nejdřív, signatureDigest naposledy

Když je MAC připojen k CMS podpisu místo toho, aby stál sám, přestává pořadí zápisu být stylistickou otázkou. HotPDF vyžaduje, aby /AuthCode, /KDFSalt, rozšíření vývojáře ISO 32004 a /SigObjRef byly zapsány do téže revize před spočítáním /ByteRange podpisu; připojíte-li cokoli z toho potom, ty bajty dopadnou mimo rozsah, který podpis kryje, a vznikne soubor, jehož podpis projde, zatímco vazba MACu je nepodepsaná. Dva digesty pak běží opačným směrem, což vypadá cyklicky na první pohled a není. PDF MAC signatureDigest váže syrové obsahové oktety CMS SignerInfo.signature OCTET STRING — ne celý CMS DER a ne podepsané atributy — takže se konstruuje poté, co syrová hodnota podpisu existuje, a vstřikuje se jako nepodepsaný atribut id-attr-pdfMacData. Protože /Contents je z podpisového ByteRange vyloučeno a nepodepsané atributy nikdy nekrmí výpočet podpisu, sekvence vyrob-podpis, postav-MAC, zabal-CMS se uzavírá čistě bez kryptografické smyčky. Z toho plynou dva důsledky: sentinel /ByteRange a zástupný symbol /Contents musejí zůstat v čistém textu a mimo objektové proudy, i v šifrovaném souboru, jinak je patcher pevné šířky nenajde; a když je MAC digest rovněž SHA-256, podepisovací digest se použije rovnou znovu, jinak se oba digest kontexty aktualizují v jediném průchodu výstupním proudem

Pořadí zápisu HotPDF pro PDF MAC připojený k CMS podpisu: klíče MACu vstoupí do revize, než se změří ByteRange, a digest podpisu se postaví poté ze syrových oktetů SignerInfo podpisu
Zápis AuthCode, KDFSalt, SigObjRef a rozšíření vývojáře před změřením ByteRange je to, co drží vazbu MACu uvnitř rozsahu, který podpis kryje
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 digest dokumentu
    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 a dva digesty
        // se hlásí odděleně
        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;

Validace sleduje tutéž cestu z opačného konce: přečte přímé /AuthCode z právě aktivního klasického traileru křížových odkazů, následuje generaci-respektující nepřímé /SigObjRef, potvrdí, že váže /V jediného pole podpisu, a hlásí selhání digestu dokumentu odděleně od selhání digestu podpisu. To jsou odlišné diagnózy a slepení do jediného booleanu vyhodí jedinou informaci, která říká, zda byl dotčen obsah stránky, nebo hodnota podpisu. Děláte-li už práci s CMS, sedí to vedle článku o podepisování PAdES a průvodce ověřováním podpisů v načtených dokumentech

Nikdy nevěřte /P: nejdřív dešifrujte 16bajtové /Perms

ISO/TS 32004 signalizuje „tento dokument vyžaduje PDF MAC" bitem oprávnění 13 a zjevný způsob, jak ho číst, je ten špatný, protože celé číslo /P ve šifrovacím slovníku je čistý text bez autentizace — kdokoli může ten bit překlopit v textovém editoru a downgradeovat požadavek. ISO 32000-2 §7.6 dodává odpověď v položce /Perms a HotPDF ji používá: dešifrujte 16bajtový řetězec /Perms souborovým šifrovacím klíčem pod AES-256 CBC, nulovým IV, bez paddingu, pak zkontrolujte každé pole čistého textu, než něčemu uvěříte. Bajty 1 až 4 drží hodnotu oprávnění v little-endian pořadí a musí se rovnat celému číslu /P přesně; bajty 5 až 8 jsou 0xFF; bajt 9 je příznak šifrování metadat T nebo F; bajty 10 až 12 jsou literální značka adb. Jen když všechno toto drží, stává se PermissionsAuthenticated True a bit 13 se čte — a dejte pozor na jeho polaritu, protože požadavek MACu se tvrdí, když je bit 0x1000 vypnutý. Nesoulad mezi /P a dešifrovanými oprávněními není varování, které se zaloguje a přejde; je to padělaná sada oprávnění a správnou odpovědí je selhání zavřeně

HotPDF autentizuje oprávnění PDF dešifrováním šestnáctibajtového řetězce Perms souborovým šifrovacím klíčem a kontrolou little-endian hodnoty oprávnění, výplňových bajtů FF, příznaku metadat a značky adb, než přečte bit 13
Celé číslo /P v čistém textu není autentizované, takže požadavek PDF MAC se čte až poté, co se zkontrolovalo každé pole dešifrovaného /Perms

Agilita algoritmů končí u digestu

ISO/TS 32004 vám nechá zvolit digest dokumentu a jen digest dokumentu. HotPDF drží HMAC-SHA-256 pro autentizaci, HKDF-SHA-256 podle RFC 5869 pro odvozování klíčů a AES-256 key wrap podle RFC 3394 pevně pod proměnným THPDFPDFMACDigestAlgorithm rozpínajícím pmdaSHA256pmdaSHA3_512, protože přirozená chyba je brát „profil SHA3-512" jako licenci vyměnit i HMAC, což vyrábí soubor, který už není PDF MAC v žádném interoperabilním smyslu. Jeden implementační detail stojí za okopírování, píšete-li vlastní verifikátor: přečtěte digest OID z CMS AuthenticatedData před hashováním bajtového rozsahu, protože zadrátování SHA-256 a smířování potom promění agilitu v etiketu a nechá nepřátelský soubor nechat vás streamovat celý dokument, než zjistíte, že algoritmus nebyl nikdy podporován. CMSAlgorithmProtection, digest algoritmus AuthenticatedData, integrity-info messageDigest a digest bajtového rozsahu musejí všechny pojmenovávat jeden algoritmus a jakýkoli nesoulad selhává zavřeně

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, přijímá všech šest
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, odmítá 256bitové
  Options := THPDFPDFMACOptions.HighAssurance;  // jen SHA3-512, AES-GCM

  // Vlastní profil je legální, ale algoritmus, který generuje s,
  // musí být rovněž v allowlistu validace, jinak je konfigurace
  // odmítnuta, než se zapíše jediný bajt
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

Co PDF MAC dokazuje a nedokazuje

Ověřený řetězec PDF MAC dokazuje, že každá chráněná revize je bajt za bajt identická s tím, co zapsal někdo držící souborový šifrovací klíč, že žádná chráněná revize nebyla odstraněna ani přeřazena a že po kotvě nebyla připojena žádná nechráněná revize — přesně ta třída útoků, kterou otevřenou nechává prosté šifrování AES-256, protože důvěrnost neříká nic o integritě a šifrované PDF s napíchanou revizí dešifruje stejně spokojeně jako neporušené. Co nedokazuje, je autorství. MAC klíč se odvozuje ze souborového šifrovacího klíče, takže kdokoli, kdo může otevřít dokument, může vyrobit i validní MAC přes upravenou verzi, každý legitimní příjemce nevyjímaje; je to symetrická primitiva a symetrická primitiva nepřipisují. Potřebujete-li vědět kdo něco změnil, potřebujete digitální podpis s certifikátem za ním a PDF MAC ho pak doplňuje tím, že chrání inkrementální strukturu, kterou samotný podpis nekryje. Berte je jako vrstvy a nechte oba rozsudky hlásit nezávisle místo slepení do jedné ikonky stavu

Vstupní body PDF MAC popsané zde — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC a ValidatePDFMACChain — putují se standardní HotPDF Delphi Component pro Delphi a C++Builder, kde produktová stránka nese kompletní referenci záznamu voleb, výčtů stavů a pole validace na revizi