HotPDF validiert einen ISO/TS-32004-PDF-MAC je Revision statt je Datei. THotPDF.ValidatePDFMACChain geht jedes inkrementelle Update ab dem Kettenanker vorwärts durch und verifiziert jeden MAC gegen einen nur-lesbaren Präfix-Stream, der an der eigenen startxref-Position und dem %%EOF dieser Revision endet. Ein gültiger MAC auf der neuesten Revision beweist nichts über die Revisionen darunter
Hier ist das Szenario, das das alles motiviert. Sie liefern ein AES-256-verschlüsseltes PDF mit einem PDF-MAC aus. Jemand öffnet die Datei in einem Hex-Editor, kippt ein Byte innerhalb der ersten MAC-geschützten Revision und hängt eine nagelneue Revision mit einem tadellos gültigen eigenen MAC an. Jeder Viewer öffnet die Datei ohne Murren, und ein naiver Prüfer, der die aktuelle Byte-Range gegen den MAC im aktiven Trailer hasht, meldet Erfolg — denn dieser MAC ist wirklich korrekt für die Bytes, die er abdeckt. Der Schaden sitzt zwei Revisionen tiefer, in einer Region, die niemand nachgeprüft hat
Warum beweist ein gültiger MAC auf oberster Ebene nicht, dass die Datei unversehrt ist?
Weil ein PDF-MAC ein Präfix abdeckt, kein Dokument. Das inkrementelle Update ist ein Bürger erster Klasse des Formats: Jedes Speichern hängt einen neuen Body, eine neue Cross-Reference-Section und einen neuen Trailer an, während die älteren Bytes genau da bleiben, wo sie waren. ISO/TS 32004 reitet auf diesem Modell, also trägt jede Revision ihr eigenes /AuthCode-Dictionary, das die Datei so, wie sie in diesem Moment stand, authentifiziert, und wer nur die neueste verifiziert, lässt jede frühere Revision ungeprüft. HotPDF legt deshalb die beiden Fragen als zwei Aufrufe offen, und der Unterschied zwischen ihnen ist der ganze Punkt dieses Artikels. ValidatePDFMAC beantwortet „ist die aktuelle Revision authentisch" und füllt einen THPDFPDFMACValidationInfo-Record; ValidatePDFMACChain beantwortet „ist jede MAC-geschützte Revision in dieser Datei authentisch" und füllt THPDFPDFMACChainValidationInfo mit einem Array je Revision plus einer maschinenlesbaren Fehlgrundangabe. Auf der oben manipuliert-und-neu-MAC-ten Datei liefert der erste Aufruf True und der zweite False bei Revisionsindex 1
var
Pdf: THotPDF;
Chain: THPDFPDFMACChainValidationInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
begin
// Fehlschlag ist einer aus 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;
Jeder MAC verifiziert auf seinem eigenen Präfix-Stream, nie auf der finalen Dateilänge
Der teuerste Bug in diesem Bereich ist, die finale Dateigröße als Obergrenze zu nehmen, wenn eine ältere Revision neu gehasht wird; das faltet nachlaufende Bytes in jeden Digest außer den neuesten und meldet Manipulation an einer intakten Datei. HotPDF rekonstruiert stattdessen für jede Revision einen begrenzten, nur-lesbaren Stream, der an dem eigenen startxref-Wert dieser Revision gefolgt von ihrem %%EOF endet, und hasht nur diesen. Die Grenze zu finden ist mühsamer, als es aussieht: Das Literal %%EOF kann innerhalb eines Content-Streams oder eines Strings auftauchen, also wird ein Kandidat nur akzeptiert, wenn das unmittelbar vorausgehende startxref zu einer Zahl parsed, die gleich dem Cross-Reference-Offset der zu validierenden Section ist, mit nichts als Leerraum dazwischen. Die Revision absorbiert dann genau eine Zeilenendesequenz nach dem Marker — ein einzelnes CR, ein einzelnes LF oder ein CRLF-Paar — und nichts weiter. Diese letzte Regel beißt in der Praxis, denn ein Writer, der zwischen zwei Revisionen eine zusätzliche Leerzeile ausgibt, hat Bytes erzeugt, die der nächsten Revision gehören, und alles nachlaufende Leerraumzeichen in die vorherige zu schlucken, verändert stillschweigend beide Digests. Die Section-Aufzählung folgt derselben Disziplin: HotPDF geht die Cross-Reference-Sections genau einmal von der ältesten zur neuesten durch, indem es freie, direkte und Object-Stream-Einträge nachspielt, sodass spätere Sections früheren Zustand überschreiben — das Gegenteil der First-seen-wins-Semantik, die ein Active-Xref-Parser anwendet
Wo verankert sich die Kette, und was bricht sie?
Die erste Revision mit einem gültigen /AuthCode ist der Anker, und FirstMACRevisionIndex meldet, wo der Schutz beginnt; alles davor ist dem Aufbau nach ungeschützt, was normal ist. Alles danach muss MAC-geschützt sein, also scheitert das Anhängen eines schlichten inkrementellen Updates an einer MAC-geschützten Datei mit pmcfRequiredRevisionMissing und dem betreffenden Revisionsindex — eine Lücke zu tolerieren ließe einen Angreifer den Schutz abstreifen, indem er einfach noch einmal speichert. Drei weitere Invarianten gelten über die Kette hinweg, jede mit eigenem Fehlercode
pmcfKDFSaltChanged— das/KDFSaltmuss ab dem Anker stabil bleiben, denn ein rotierendes Salt ließe einen Fälscher Schlüssel unter selbst gewählten Parametern neu ableitenpmcfDigestDowngrade— die Digest-Stärke wird gegen den letzten verifizierten MAC verglichen statt gegen die unmittelbar vorherige Revision, sodass eine Kette, die unter dem Modern-Profil mit SHA-384 beginnt, nicht stillschweigend mit SHA-256 fortfahren kannpmcfPermissionDowngrade— eine Revision darf keine PDF-MAC-Anforderung zurücknehmen, die eine frühere Revision authentifiziert hatte
Die Konsequenz, die man sich zu eigen machen sollte: Historische MACs werden unabhängig verifiziert, selbst wenn sie nicht mehr der aktive Trailer sind. Darum überlebt der Angriff aus der Einleitung, eine alte Revision zu bearbeiten und einen frischen MAC anzuhängen, nicht: Der neueste MAC prüft für sich sauber, ValidatePDFMAC ist zufrieden, und die Kette landet dennoch bei Revision 1 mit pmcfRevisionInvalid
Signaturreihenfolge: erst die Trailer-Schlüssel, signatureDigest zuletzt
Hängt der MAC an einer CMS-Signatur statt allein zu stehen, hört die Schreibreihenfolge auf, eine Stilfrage zu sein. HotPDF verlangt, dass /AuthCode, /KDFSalt, die ISO-32004-Developer-Extension und /SigObjRef in dieselbe Revision geschrieben werden, bevor die Signatur-/ByteRange berechnet wird; hängt man eines davon nach, landen diese Bytes außerhalb der von der Signatur abgedeckten Range, und man erhält eine Datei, deren Signatur verifiziert, während die MAC-Bindung unsigniert ist. Die beiden Digests laufen dann in die andere Richtung, was auf den ersten Blick zirkulär wirkt und es nicht ist. Der PDF-MAC-signatureDigest bindet die rohen Inhalts-Oktetten des CMS-SignerInfo.signature-OCTET STRING — nicht den ganzen CMS-DER und nicht die signierten Attribute —, also wird er konstruiert, nachdem der rohe Signaturwert existiert, und als id-attr-pdfMacData-unsigned-Attribute injiziert. Da /Contents aus der Signatur-ByteRange ausgeschlossen ist und unsigned-Attributes nie in die Signaturberechnung einfließen, schließt die Folge Signatur-erzeugen, MAC-bauen, CMS-wrappen sauber ohne kryptografische Schleife. Zwei Korollare folgen: Das /ByteRange-Sentinel und der /Contents-Platzhalter müssen Klartext bleiben und außerhalb von Object-Streams liegen, selbst in einer verschlüsselten Datei, sonst findet der Fixed-Width-Patcher sie nicht; und ist der MAC-Digest ebenfalls SHA-256, wird der Signierungs-Digest schlicht wiederverwendet, sonst werden beide Digest-Kontexte in einem einzigen Durchlauf über den Ausgabestream aktualisiert
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-Dokument-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, und die beiden Digests
// werden getrennt gemeldet
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;
Die Validierung geht denselben Weg vom anderen Ende: das direkte /AuthCode aus dem aktuell aktiven klassischen Cross-Reference-Trailer lesen, der generationsbewussten indirekten /SigObjRef folgen, bestätigen, dass sie das /V des einen Signaturfelds bindet, und einen Dokument-Digest-Fehlschlag getrennt von einem Signatur-Digest-Fehlschlag melden. Das sind verschiedene Diagnosen, und sie in ein einziges Boolean zu falten, verwirft die einzige Information, die sagt, ob der Seiteninhalt oder der Signaturwert angefasst wurde. Wer bereits CMS-Arbeit macht, findet das neben dem PAdES-Signierartikel und der Anleitung zum Verifizieren von Signaturen in geladenen Dokumenten
Vertraue /P nie: erst die 16-Byte-/Perms entschlüsseln
ISO/TS 32004 signalisiert „dieses Dokument erfordert einen PDF-MAC" über das Permissions-Bit 13, und die naheliegende Art, es zu lesen, ist die falsche, denn der /P-Integer im Encryption-Dictionary ist Klartext und nicht authentifiziert — jeder kann dieses Bit in einem Texteditor kippen und die Anforderung herunterstufen. ISO 32000-2 §7.6 liefert die Antwort im /Perms-Eintrag, und HotPDF nutzt sie: den 16-Byte-/Perms-String mit dem Dateiverschlüsselungsschlüssel unter AES-256-CBC entschlüsseln, Null-IV, ohne Padding, dann jedes Feld des Klartexts prüfen, bevor man irgendetwas glaubt. Die Bytes 1 bis 4 halten den Permissions-Wert in Little-Endian-Reihenfolge und müssen exakt dem /P-Integer entsprechen; die Bytes 5 bis 8 sind 0xFF; Byte 9 ist das T- oder F-Metadatenverschlüsselungs-Flag; die Bytes 10 bis 12 sind das Literal-Marker adb. Nur wenn all das hält, wird PermissionsAuthenticated True und Bit 13 gelesen — und achten Sie auf seine Polarität, denn die MAC-Anforderung ist behauptet, wenn das 0x1000-Bit gelöscht ist. Eine Diskrepanz zwischen /P und den entschlüsselten Permissions ist keine Warnung zum Loggen und Weitermachen; sie ist ein gefälschtes Permission-Set, und die richtige Antwort ist, geschlossen zu scheitern
Algorithmus-Agilität endet am Digest
ISO/TS 32004 lässt Ihnen den Dokument-Digest wählen, und nur den Dokument-Digest. HotPDF hält HMAC-SHA-256 für die Authentifizierung, HKDF-SHA-256 nach RFC 5869 für die Schlüsselableitung und AES-256-Key-Wrap nach RFC 3394 fest unter einem variablen THPDFPDFMACDigestAlgorithm, der von pmdaSHA256 bis pmdaSHA3_512 reicht, denn der naheliegende Fehler ist, ein „SHA3-512-Profil" als Lizenz zu behandeln, auch den HMAC zu tauschen, was eine Datei ergibt, die in keinem interoperablen Sinn noch ein PDF-MAC ist. Ein Implementierungsdetail lohnt das Kopieren, wenn Sie Ihren eigenen Verifizierer schreiben: die Digest-OID aus dem CMS-AuthenticatedData lesen, bevor die Byte-Range gehasht wird, denn SHA-256 hart zu kodieren und nachträglich abzugleichen macht Agilität zum Etikett und lässt eine feindliche Datei Sie das ganze Dokument streamen lassen, bevor Sie merken, dass der Algorithmus nie unterstützt war. CMSAlgorithmProtection, der AuthenticatedData-Digest-Algorithmus, der Integrity-Info-messageDigest und der Byte-Range-Digest müssen alle einen Algorithmus nennen, und jede Diskrepanz scheitert geschlossen
var
Options: THPDFPDFMACOptions;
begin
Options := THPDFPDFMACOptions.Compatibility; // SHA-256, akzeptiert alle sechs
Options := THPDFPDFMACOptions.Modern; // SHA-384, weist 256-Bit zurück
Options := THPDFPDFMACOptions.HighAssurance; // nur SHA3-512, AES-GCM
// Ein benutzerdefiniertes Profil ist legal, aber der Algorithmus, mit dem es
// erzeugt, muss ebenfalls in der Validierungs-Allowlist stehen, sonst wird
// die Konfiguration zurückgewiesen, bevor ein einziges Byte geschrieben ist
Options.Profile := pmppCustom;
Options.DigestAlgorithm := pmdaSHA512;
Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
Options.RequireAESGCM := True;
end;
Was ein PDF-MAC beweist und was nicht
Eine verifizierte PDF-MAC-Kette beweist, dass jede geschützte Revision byteidentisch mit dem ist, was jemand mit dem Dateiverschlüsselungsschlüssel geschrieben hat, dass keine geschützte Revision entfernt oder umgeordnet wurde und dass keine ungeschützte Revision nach dem Anker angehängt wurde — genau die Angriffsklasse, die schlichte AES-256-Verschlüsselung offenlässt, denn Vertraulichkeit sagt nichts über Integrität, und ein verschlüsseltes PDF mit eingespleißter Revision entschlüsselt genauso fröhlich wie ein intaktes. Was sie nicht beweist, ist Urheberschaft. Der MAC-Schlüssel leitet sich aus dem Dateiverschlüsselungsschlüssel ab, also kann jeder, der das Dokument öffnen kann, auch einen gültigen MAC über eine modifizierte Version erzeugen, jede legitime Empfängerin eingeschlossen; er ist ein symmetrisches Primitiv, und symmetrische Primitive können nicht zuschreiben. Wer wissen muss, wer etwas geändert hat, braucht eine digitale Signatur mit einem Zertifikat dahinter, und der PDF-MAC ergänzt sie dann, indem er die inkrementelle Struktur schützt, die die Signatur allein nicht abdeckt. Behandeln Sie sie als Schichten und lassen Sie die beiden Urteile unabhängig melden, statt sie in ein Status-Icon zu falten
Die hier beschriebenen PDF-MAC-Einsprungpunkte — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC und ValidatePDFMACChain — kommen mit der Standard-HotPDF Delphi Component für Delphi und C++Builder, deren Produktseite die vollständige Referenz für den Options-Record, die Status-Enumerationen und das Validierungs-Array je Revision führt