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
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/KDFSaltmust stay stable from the anchor onward, since a rotating salt would let a forger re-derive keys under parameters of their own choosingpmcfDigestDowngrade— 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-256pmcfPermissionDowngrade— 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
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
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