Technical Article

PDF MAC Revision Chain Validation in Delphi (ISO 32004)

HotPDF validates an ISO/TS 32004 PDF MAC per revision rather than per file. THotPDF.ValidatePDFMACChain walks every incremental update from the chain anchor forward and verifies each MAC against a read-only prefix stream ending at that revision's own startxref and %%EOF. One valid MAC on the newest revision proves nothing about the revisions beneath it

Here is the scenario that motivates all of it. You ship an AES-256 encrypted PDF with a PDF MAC on it. Someone opens the file in a hex editor, flips a byte inside the first MAC-protected revision, then appends a brand new revision carrying a perfectly valid MAC of their own. Every viewer opens the file without complaint, and a naive checker that hashes the current byte range against the MAC in the active trailer reports success — because that MAC really is correct for the bytes it covers. The damage sits two revisions down, in a region nobody re-checked

Why does a valid top-level MAC not prove the file is intact?

Because a PDF MAC covers a prefix, not a document. Incremental update is a first-class part of the format: every save appends a new body, a new cross-reference section and a new trailer, while the older bytes stay exactly where they were. ISO/TS 32004 rides on that model, so each revision carries its own /AuthCode dictionary authenticating the file as it stood at that moment, and verifying only the newest one leaves every earlier revision unexamined. HotPDF therefore exposes the two questions as two calls, and the difference between them is the whole point of this article. ValidatePDFMAC answers "is the current revision authentic", filling a THPDFPDFMACValidationInfo record; ValidatePDFMACChain answers "is every MAC-protected revision in this file authentic", filling THPDFPDFMACChainValidationInfo with a per-revision array plus a machine-readable failure reason. On the tampered-then-re-MACed file above, the first call returns True and the second returns False against revision index 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // Failure is one of 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;

Each MAC verifies on its own prefix stream, never on the final file length

The most expensive bug in this area is using the final file size as the upper bound when re-hashing an older revision, which folds trailing bytes into every digest except the newest and reports tampering on a sound file. HotPDF instead reconstructs, for each revision, a bounded read-only stream ending at that revision's own startxref value followed by its %%EOF, and hashes only that. Locating the boundary is fussier than it looks: the literal %%EOF can appear inside a content stream or a string, so a candidate is accepted only when the immediately preceding startxref parses to a number equal to the cross-reference offset of the section being validated, with nothing but whitespace between them. The revision then absorbs exactly one end-of-line sequence after the marker — a single CR, a single LF, or one CRLF pair — and nothing more. That last rule bites in practice, because a writer that emits an extra blank line between two revisions has produced bytes belonging to the next revision, and swallowing all trailing whitespace into the previous one silently changes both digests. Section enumeration follows the same discipline: HotPDF walks the cross-reference sections oldest to newest exactly once, replaying free, direct and object-stream entries so later sections overwrite earlier state, which is the opposite of the first-seen-wins semantics an active-xref parser applies

HotPDF verifies each ISO 32004 PDF MAC against a prefix stream ending at that revision's own startxref and end-of-file marker, so a byte flipped inside revision 1 fails the chain even though the newest MAC still validates cleanly
Each revision's MAC is re-hashed over its own bounded prefix, so editing revision 1 and appending a freshly MACed revision still satisfies ValidatePDFMAC while ValidatePDFMACChain lands on revision 1

Where does the chain anchor, and what breaks it?

The first revision carrying a valid /AuthCode is the anchor, and FirstMACRevisionIndex reports where protection begins; anything before it is unprotected by construction, which is normal. Everything after it must be MAC-protected, so appending one plain incremental update to a MAC-protected file fails with pmcfRequiredRevisionMissing and the offending revision index — tolerating a gap would let an attacker strip protection simply by saving once more. Three further invariants hold across the chain, each with its own failure code

  • pmcfKDFSaltChanged — the /KDFSalt must stay stable from the anchor onward, since a rotating salt would let a forger re-derive keys under parameters of their own choosing
  • pmcfDigestDowngrade — digest strength is compared against the last verified MAC rather than the immediately preceding revision, so a chain that starts under the Modern profile at SHA-384 cannot quietly continue with SHA-256
  • pmcfPermissionDowngrade — a revision may not clear a PDF MAC requirement that an earlier revision had authenticated

The consequence worth internalising is that historical MACs are verified independently even once they are no longer the active trailer. That is why the edit-an-old-revision-then-append-a-fresh-MAC attack from the opening does not survive: the newest MAC checks out on its own, ValidatePDFMAC is happy, and the chain still lands on revision 1 with pmcfRevisionInvalid

Signature ordering: trailer keys first, signatureDigest last

When the MAC is attached to a CMS signature rather than standing alone, write order stops being a stylistic question. HotPDF requires /AuthCode, /KDFSalt, the ISO 32004 developer extension and /SigObjRef to be written into the same revision before the signature /ByteRange is computed; append any of them afterwards and those bytes land outside the range the signature covers, producing a file whose signature verifies while the MAC binding is unsigned. The two digests then run the other way, which looks circular at first glance and is not. The PDF MAC signatureDigest binds the raw content octets of the CMS SignerInfo.signature OCTET STRING — not the whole CMS DER, and not the signed attributes — so it is constructed after the raw signature value exists and injected as an id-attr-pdfMacData unsigned attribute. Since /Contents is excluded from the signature ByteRange and unsigned attributes never feed the signature computation, the sequence produce-signature, build-MAC, wrap-CMS closes cleanly with no cryptographic loop. Two corollaries follow: the /ByteRange sentinel and the /Contents placeholder must stay plaintext and outside object streams even in an encrypted file, or the fixed-width patcher cannot find them; and when the MAC digest is also SHA-256 the signing digest is reused outright, otherwise both digest contexts are updated in a single pass over the output stream

The HotPDF write order for a PDF MAC attached to a CMS signature: the MAC keys enter the revision before the ByteRange is measured, and the signature digest is built afterwards from the raw SignerInfo signature octets
Writing AuthCode, KDFSalt, SigObjRef and the developer extension before the ByteRange is measured is what keeps the MAC binding inside the range the signature covers
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, and the two digests
        // are reported separately
        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;

Validation retraces the same path from the other end: read the direct /AuthCode from the currently active classic cross-reference trailer, follow the generation-aware indirect /SigObjRef, confirm it binds the one signature field's /V, and report a document-digest failure separately from a signature-digest failure. Those are different diagnoses, and collapsing them into a single boolean discards the only information that says whether the page content or the signature value was touched. If you are already doing CMS work, this sits alongside the PAdES signing article and the guide to verifying signatures in loaded documents

Never trust /P: decrypt the 16-byte /Perms first

ISO/TS 32004 signals "this document requires a PDF MAC" through permission bit 13, and the obvious way to read it is the wrong one, because the /P integer in the encryption dictionary is plaintext and unauthenticated — anyone can flip that bit in a text editor and downgrade the requirement. ISO 32000-2 §7.6 supplies the answer in the /Perms entry, and HotPDF uses it: decrypt the 16-byte /Perms string with the file encryption key under AES-256 CBC, zero IV, no padding, then check every field of the plaintext before believing anything. Bytes 1 to 4 hold the permission value in little-endian order and must equal the /P integer exactly; bytes 5 to 8 are 0xFF; byte 9 is the T or F metadata-encryption flag; bytes 10 to 12 are the literal marker adb. Only when all of that holds does PermissionsAuthenticated become True and bit 13 get read — and mind its polarity, since the MAC requirement is asserted when the 0x1000 bit is clear. A mismatch between /P and the decrypted permissions is not a warning to log and move past; it is a forged permission set, and the right response is to fail closed

HotPDF authenticates PDF permissions by decrypting the sixteen-byte Perms string with the file encryption key and checking the little-endian permission value, the FF filler bytes, the metadata flag and the adb marker before reading bit 13
The plaintext /P integer is unauthenticated, so the PDF MAC requirement is read only after every field of the decrypted /Perms has been checked

Algorithm agility stops at the digest

ISO/TS 32004 lets you choose the document digest, and only the document digest. HotPDF keeps HMAC-SHA-256 for authentication, HKDF-SHA-256 per RFC 5869 for key derivation and AES-256 key wrap per RFC 3394 fixed underneath a variable THPDFPDFMACDigestAlgorithm spanning pmdaSHA256 through pmdaSHA3_512, because the natural mistake is to treat a "SHA3-512 profile" as licence to swap the HMAC too, which yields a file that is no longer a PDF MAC in any interoperable sense. One implementation detail is worth copying if you write your own verifier: read the digest OID out of the CMS AuthenticatedData before hashing the byte range, since hardcoding SHA-256 and reconciling afterwards turns agility into a label and lets a hostile file make you stream the whole document before you discover the algorithm was never supported. CMSAlgorithmProtection, the AuthenticatedData digest algorithm, the integrity-info messageDigest and the byte-range digest must all name one algorithm, and any disagreement fails closed

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, accepts all six
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, rejects 256-bit
  Options := THPDFPDFMACOptions.HighAssurance;  // SHA3-512 only, AES-GCM

  // A custom profile is legal, but the algorithm it generates with
  // must also appear in the validation allowlist, or the configuration
  // is rejected before a single byte is written
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

What a PDF MAC does and does not prove

A verified PDF MAC chain proves that every protected revision is byte-identical to what was written by someone holding the file encryption key, that no protected revision was removed or reordered, and that no unprotected revision was appended after the anchor — exactly the class of attack that plain AES-256 encryption leaves open, since confidentiality says nothing about integrity and an encrypted PDF with a spliced revision decrypts as happily as an intact one. What it does not prove is authorship. The MAC key derives from the file encryption key, so anyone who can open the document can also produce a valid MAC over a modified version, every legitimate recipient included; it is a symmetric primitive, and symmetric primitives cannot attribute. If you need to know who changed something you need a digital signature with a certificate behind it, and the PDF MAC then complements it by protecting the incremental structure the signature alone does not cover. Treat them as layers and let the two verdicts be reported independently rather than collapsed into one status icon

The PDF MAC entry points described here — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC and ValidatePDFMACChain — ship with the standard HotPDF Delphi Component for Delphi and C++Builder, where the product page carries the full reference for the options record, the status enumerations and the per-revision validation array