Technischer Artikel

FDF Differences in Delphi: Signierte Revisionen anhängen

HotPDF Component wendet einen FDF-/Differences-Stream aus Delphi über THPDFHeadlessDocument.SaveFDFDifferencesToFile an: Die Methode prüft, dass das geladene PDF noch zu einer Basisrevision passt, die Sie über exakte Bytelänge und SHA-256 identifizieren, hängt die kumulativen Revisionsbytes genau einmal an, lässt das Ergebnis durch Verschlüsselungs-Berechtigungs- und DocMDP/FieldMDP-Zulassung laufen und veröffentlicht es atomar. Dieselbe Einreichung zu wiederholen ist ein Byte-für-Byte-No-Op

Der Workflow, für den es das gibt, ist unspektakulär und häufig. Sie schicken einen Vertrag oder ein Prüfprotokoll als PDF raus, jemand füllt es in seinem eigenen Viewer aus und signiert es, und was zurückkommt, ist nicht das PDF, sondern eine FDF-Datei. Die Feldwerte fahren in /Fields, aber die signierte Revision selbst, die Bytes, die die CMS-Signatur tatsächlich abdeckt, fährt in /Differences. Machen Sie das Merging falsch, brechen Sie entweder die Signatur oder — schlimmer — akzeptieren Sie Feldwerte, die keine Signatur je abgedeckt hat

Was trägt ein FDF-/Differences-Stream tatsächlich?

Ein FDF-/Differences-Stream trägt rohe Incremental-Update-Bytes für das Ziel-PDF, keine FDF-level-Historie. ISO 32000-1 §12.7.7.3, Tabelle 243, definiert den Differences-Eintrag des FDF-Dictionarys als die inkrementellen Änderungen am zugrunde liegenden PDF, die seit dem Öffnen vorgenommen wurden, in derselben Form wie ein PDF- Incremental Update nach §7.5.6: neue und ersetzte Objekte, eine Cross-Reference-Section und ein Trailer, dessen /Prev zurück in die Basisdatei zeigt

Der FDF-Container selbst ist das Gegenteil. §12.7.7.1 verbietet Incremental Updates an FDF-Dateien, also hat ein FDF einen einzigen Trailer, nur Generation-0-Objekte und eindeutige Objektnummern. HotPDF Component setzt genau das in seinem FDF-Parser durch und lockert es nie auf, um /Differences einfacher zu machen. Die inkrementellen Daten leben innerhalb eines Streams eines strikt nicht-inkrementellen Containers, und diese beiden Ebenen getrennt zu halten ist die halbe Arbeit

HotPDF-Anatomiediagramm von FDF /Differences für Delphi: ein strikt nicht-inkrementeller FDF-Container mit einzelnem Trailer und Generation-0-Objekten trägt einen kumulativen /Differences-Stream aus rohen Incremental-Update-Bytes, während /Fields und /Annots unsigniert und unvermengt bleiben
Die signierte Revision fährt im /Differences-Stream, nicht in /Fields: der Container selbst verbietet Incremental Updates, und nur die Byte-Nutzlast, die die CMS-Signatur abdeckt, ist vertrauenswürdig

Zwei Eigenschaften des Streams prägen jede Designentscheidung dahinter:

  • Der Stream ist kumulativ: Er hält jedes Byte, das seit dem Öffnen des Ziel-PDFs angehängt wurde, also wiederholt eine zweite Einreichung aus derselben Sitzung die bereits committede Revision und hängt die neue dahinter an
  • Die äußeren /Fields- und /Annots-Einträge desselben FDF stimmen nicht garantiert mit /Differences überein, also sind die Feldwerte, die Sie leicht lesen können, nicht die Daten, die die Signatur abdeckt

Dieser zweite Punkt verdient Offenheit. Wenn Ihr Import-Code /V aus /Fields liest und das Ergebnis als signiert behandelt, weil eine Signatur in derselben Datei ankam, haben Sie eine Fälschungsschleuse gebaut. HotPDF Component hält die Pfade absichtlich getrennt: Der FDF-Annotation-Importer weist /Differences rundheraus ab, und nur der dedizierte Differences-Transport fasst ihn an

Den Append an eine explizite Basisrevision binden

SaveFDFDifferencesToFile weigert sich zu raten, zu welcher Revision das FDF gehört. Der Aufrufer übergibt BaseBytes, die exakte Länge der vollständigen PDF-Revision, aus der das Formular geöffnet wurde, und BaseSHA256, seine 64-stellige Hexadezimal-SHA-256. Die Methode hasht diesen Präfix des geladenen Dokuments neu, weist ein Fehlverhalten mit "The loaded PDF does not match the explicitly selected base" ab und prüft, dass der Präfix an einer echten Revisionsendstelle endet, deren startxref auf ihre tatsächlichen Cross-Reference-Daten zeigt

Die praktische Regel lautet: Beide Zahlen beim Absenden des Formulars notieren, nicht wenn das FDF zurückkommt. Ein kleiner Helfer über die Routinen SHA256Init / SHA256Update / SHA256Final in HPDFCrypt genügt:

uses
  SysUtils, Classes, HPDFCrypt;

function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
  Stream: TFileStream;
  Ctx: TSHA256Context;
  Digest: TSHA256Digest;
  Buffer: array[0..65535] of Byte;
  Remaining: Int64;
  Count, I: Integer;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
      raise EArgumentException.Create('Base length is outside the file');
    SHA256Init(Ctx);
    Remaining := PrefixBytes;
    while Remaining > 0 do
    begin
      Count := SizeOf(Buffer);
      if Remaining < Count then
        Count := Integer(Remaining);
      Stream.ReadBuffer(Buffer, Count);
      SHA256Update(Ctx, Buffer, Count);
      Dec(Remaining, Count);
    end;
    SHA256Final(Ctx, Digest);
  finally
    Stream.Free;
  end;
  Result := '';
  for I := 0 to High(Digest) do
    Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;

// Beim Absenden: die gesamte ausgehende Datei ist die Basisrevision
// BaseLength := Größe von 'out\contract-0042.pdf' in Bytes
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Das geladene Dokument muss nicht exakt die Basis sein. Es darf die Basis plus Revisionen sein, die Sie aus früheren Einreichungen bereits committed haben — so bleibt der kumulative Stream handhabbar: BaseBytes benennt weiterhin den ursprünglichen Präfix, und alles danach wird als bereits angehängte Bytes behandelt

HotPDF-Byte-Layout von FDF Differences für Delphi: das geladene PDF zerfällt in die durch BaseBytes-Länge und BaseSHA256 verifizierte Basisrevision plus bereits committede vorangehende Append-Bytes, und eine kumulative /Differences-Nutzlast, die diese Bytes wiederholen muss, bevor sie ein neues Suffix ergänzt
Der Basispräfix ist durch Länge und SHA-256 festgenagelt, committede Bytes wirken als Append-only-Vertrag, und nur das nachlaufende Suffix ist neu - eine wiederholte Einreichung landet dann als Byte-für-Byte-No-Op

Die Differences mit SaveFDFDifferencesToFile anwenden

THPDFHeadlessDocument.SaveFDFDifferencesToFile nimmt den Ausgabedateinamen, die FDF-Bytes, Basislänge und Hash, eine optionale Embedded-FDF-Auswahl und einen THPDFFDFPackageOptions-Limitsatz entgegen und gibt ein THPDFHeadlessFDFDifferencesResult zurück. Im Inneren öffnet es das FDF als THPDFFDFPackage und ruft selbst DecodeDifferences auf, Sie übergeben also die FDF-Datei, keinen vordekodierten Stream. Ein leeres Selection bedeutet das Top-Level-FDF; ein nicht leeres steigt zuerst in ein eingebettetes Child-FDF hinab

uses
  SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;

procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
  BaseLength: Int64; const BaseHash: AnsiString);
var
  Doc: THPDFHeadlessDocument;
  Policy: THPDFHeadlessCMSOptions;
  Selection: THPDFFDFChildSelections;
  Applied: THPDFHeadlessFDFDifferencesResult;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Policy := THPDFHeadlessCMSOptions.Default;
    Policy.TrustedCAFile := 'trust\issuing-ca.pem';
    Doc.SetSignaturePolicyOptions(Policy);
    Doc.LoadFromFile(CurrentPdf);

    Selection := nil;  // Top-Level-FDF, kein eingebettetes Child
    Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
      TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
      Selection, THPDFFDFPackageOptions.Default);

    Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
      [Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
       Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
  finally
    Doc.Free;
  end;
end;

Die Reihenfolge der Prüfungen zählt mehr als der Aufruf selbst. Nachdem der Basis-Hash bestanden ist, vergleicht die Methode die bereits committeden Append-Bytes — alles zwischen BaseBytes und dem Ende der geladenen Datei — mit dem Anfang der dekodierten Nutzlast. Eine Einreichung, die kürzer ist als das bereits Committe, scheitert mit "A cumulative submission cannot remove committed append bytes", und eine, die damit kollidiert, scheitert mit "The cumulative Differences conflict with the committed revision". Nur das verbleibende Suffix ist neu, und nur dieses Suffix landet in AddedBytes

HotPDF-Prüfpipeline von SaveFDFDifferencesToFile in Delphi: Basislänge und SHA-256, kumulativer Präfixvergleich, Validierung des temporären Kandidaten, /ID- und Signaturidentität, Berechtigungsbits plus DocMDP- und FieldMDP-Zulassung, dann eine atomare Veröffentlichung der angehängten Revision
Sechs Fail-closed-Schleusen laufen in fester Reihenfolge, bevor irgendein Byte der angehängten Revision veröffentlicht wird, sodass ein fehlerhaftes FDF oder eine nicht erlaubte Änderung das geladene Dokument und die bestehende Ausgabe unberührt lässt

Der zusammengesetzte Kandidat wird in eine exklusive temporäre Datei im Zielverzeichnis geschrieben, geflusht, als unabhängige Revisionsquelle wieder geöffnet und validiert, bevor er die Ausgabe ersetzt. Parsing-, Abbruch-, Policy-, Budget- oder Veröffentlichungsfehler lassen sowohl das geladene Dokument als auch jede bestehende Ausgabedatei unberührt. Zwei Vorbedingungen erwischen Leute früh: Die Ausgabe darf nicht mit der geladenen Quelle aliasiert sein, und das Dokument darf keine ungespeicherten Bearbeitungen haben, denn ein opaker Append würde sie still verwerfen ("Publish pending document edits before applying Differences")

Warum ist ein wiederholtes Anwenden ein Byte-für-Byte-No-Op?

Ein wiederholtes Anwenden desselben kumulativen FDF ist ein No-Op, weil jedes Byte seiner Nutzlast bereits in der Datei steckt. Wenn die Nutzlast exakt den vorangehenden Append-Bytes entspricht, meldet SaveFDFDifferencesToFile NoOp = True und AddedBytes = 0, und die veröffentlichte Ausgabe ist identisch mit dem aktuellen PDF. Das ist eine stärkere Garantie als "Append-only", und sie macht Wiederholungsversuche nach einem Netzwerk-Timeout oder einem abgestürzten Worker sicher

// Rev 1 bereits in contract-0042-r1.pdf committed; dasselbe FDF trifft zweimal ein
// (Doc, Again, Fdf, Selection, BaseLength und BaseHash wie oben deklariert)
Doc := THPDFHeadlessDocument.Create;
try
  Doc.LoadFromFile('store\contract-0042-r1.pdf');
  Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
    Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
  // Again.NoOp = True, Again.AddedBytes = 0,
  // und die Retry-Datei ist byte-identisch mit contract-0042-r1.pdf
finally
  Doc.Free;
end;

Schreiben Sie Ihre Abnahmetests passend dazu. Fordern Sie volle Byte-Gleichheit zwischen der Retry-Ausgabe und der aktuellen Revision, nicht bloß, dass die Datei nicht geschrumpft ist. Ein Test, der nur Append-only-Verhalten prüft, lässt eine Implementierung gern durchrutschen, die dieselbe Revision ein zweites Mal anhängt — was ein gültig aussehendes PDF mit duplizierter Update-Section produziert, das eine spätere DocMDP-Audit erklären muss

Welche Zulassungsprüfungen laufen, bevor die Revision veröffentlicht wird?

Vor der Veröffentlichung muss der Kandidat Identitäts-, Signatur- und Berechtigungsprüfungen bestehen, und keine davon vertraut den äußeren FDF-Feldern. Das erste Element des Trailer-/ID muss unverändert überleben, und jedes bereits im geladenen Dokument vorhandene Signaturfeld muss mit identischem Signature-Dictionary und derselben Signaturwert-Identität weiterexistieren. Eine Differences-Nutzlast, die eine bestehende Signatur fallen lässt, ersetzt oder umschreibt, scheitert mit hdSignatureModificationUnsupported

Dann kommen die Autorisierungsebenen. Bei einem nicht leeren Append an einem verschlüsselten Dokument werden die geänderten Objekte nach Rolle klassifiziert (Katalog und Seiten, Annotationen, AcroForm-Felder, Appearance-Streams) und gegen die authentifizierten User-Permission-Bits geprüft, sodass eine Revision, die Annotationen editiert, die Annotation-Berechtigung braucht, und so weiter. Danach läuft der Kandidat durch dieselbe Zertifizierungs- und Feldsperren-Durchsetzung, die HotPDF Component sonst verwendet: Eine DocMDP-Zertifizierungssignatur (ISO 32000-1 §12.8.2.2) begrenzt, welche Arten von Änderungen spätere Revisionen vornehmen dürfen, und FieldMDP-Sperren (§12.8.2.4) schützen benannte Felder. Die DocMDP- und FieldMDP-Revisionsanalyse in HotPDF behandelt, wie diese Berechtigungen pro Revision klassifiziert werden

Fehlerbehandlung sollte "dieses FDF ist fehlerhaft" von "diese Revision ist nicht erlaubt" trennen, denn der zweite Fall braucht meist einen Menschen:

try
  Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
    Selection, THPDFFDFPackageOptions.Default);
except
  on E: EHPDFFDFPackageError do
    QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
  on E: EHPDFHeadlessDocumentError do
    case E.Code of
      hdPermissionDenied, hdSignatureModificationUnsupported:
        EscalateForReview(FdfFile, E.Message);
      hdLimitExceeded:
        QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
    else
      QuarantineFdf(FdfFile, E.Message);  // Basis-Mismatch, Konflikt, fehlerhafte Endstelle
    end;
end;

Was die Operation nie tut, ist genauso wichtig wie was sie prüft. SaveFDFDifferencesToFile holt niemals die FDF-/F-File-Specification, also kann das im FDF genannte PDF das Merging weder auf eine andere Datei auf der Platte noch ins Netzwerk umleiten, und es führt niemals JavaScript oder Aktionen aus. Jede Eingabe kommt explizit an: die geladene Basis, die FDF-Bytes und das Trust-Material, das Sie konfiguriert haben

Windows-Transaktionen und die C-ABI fdf.differences.apply

Derselbe Transport ist verfügbar, ohne Headless-Code direkt zu schreiben. THPDFWindowsFormDataTransaction.ApplyFDFDifferences nimmt die FDF-Bytes, Basislänge, Basis-SHA-256, Auswahl, Package-Optionen und ein Kennwort entgegen, delegiert an SaveFDFDifferencesToFile und ergänzt die Byte-Abrechnung, Rollback, Abbruch und das Same-Handle-Retry-Verhalten der Transaktion. Für Nicht-Delphi-Aufrufer legt die C-ABI die JSON-Operation fdf.differences.apply offen, bei der base Länge und Hash trägt:

{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
  "base": { "bytes": 673, "sha256": "..." },
  "signaturePolicy": { "trustedCAPEM": "..." } }

Die JSON-Antwort meldet baseBytes, priorAppendBytes, totalAppendBytes, addedBytes und noop, dieselben Felder wie THPDFHeadlessFDFDifferencesResult. Die veröffentlichten Abnahme-Gates für diesen Transport laufen unter Linux FPC 3.3.1, Windows Delphi 32-Bit und 64-Bit sowie Windows FPC 32-Bit und 64-Bit; jedes Sicherheitsziel umfasst 21 Fälle mit 1.244 nativen Prüfungen und 382 Callback-Befehlen

Grenzen und Fixture-Fallen, die man kennen sollte

Der Differences-Transport ist bewusst schmal gehalten, und ein paar seiner Kanten haben echte Testsuiten gebissen:

  • Die Basis muss eine vollständige PDF-Revision sein. Eine Länge, die mitten in der Datei landet, oder deren startxref nicht auf ihre tatsächlichen Cross-Reference-Daten zeigt, wird abgewiesen, bevor irgendein Byte kopiert wird
  • Signatur-Fixtures brauchen ein indirektes /Lock. Der Windows-Permission-Reader für geladene Dokumente weist ein Signaturfeld ab, dessen /Lock ein Inline-Dictionary ist, mit "Signature field /Lock must be an indirect reference", auch wenn manche Signierpfade die Inline-Form tolerieren — schreiben Sie das Lock-Dictionary also als eigenes Objekt
  • Eine Test-CA, erzeugt mit nacktem openssl req -x509, fehlen CA:TRUE und keyCertSign, also scheitert die Chain-Validation mit einem Certificate-Verify-Fehler, der wie ein Bibliotheksbug aussieht und keiner ist
  • Die äußeren /Fields-Werte werden von diesem Aufruf nicht vermischt. Wenn Sie sie ebenfalls im Dokument wollen, entscheiden Sie explizit, ob sie informativ sind, und präsentieren Sie sie niemals als signiert
  • Das FDF selbst bleibt nicht-inkrementell. Eine FDF-Datei mit mehreren Trailern, von null verschiedenen Generationen oder doppelten Objektnummern ist ein fehlerhaftes FDF, kein reicheres

Für die reine Wertseite von FDF und XFDF, ohne signierte Revisionen im Spiel, sind der Multi-Select-FDF- und XFDF-Round-Trip in HotPDF und der XFDF-Annotation-Import und -Export in Delphi die besseren Einstiegspunkte

FAQ

Kann ich den dekodierten Differences-Stream statt der FDF-Datei übergeben?

Nein. SaveFDFDifferencesToFile parst das übergebene FDF als THPDFFDFPackage, wendet die optionale Embedded-Auswahl an und ruft intern DecodeDifferences auf. Verwenden Sie THPDFFDFPackage.DecodeDifferences für sich, wenn Sie die rohen Revisionsbytes inspizieren oder archivieren wollen, aber übergeben Sie die FDF-Datei dem Apply-Aufruf

Was passiert, wenn der Client ein zweites, größeres kumulatives FDF schickt?

Laden Sie das PDF, das bereits die erste committede Revision enthält, und behalten Sie dieselben BaseBytes und BaseSHA256. Die Methode verifiziert, dass die neue Nutzlast exakt mit den bereits committeden Bytes beginnt, hängt nur das neue Suffix an und meldet es in AddedBytes, dann läuft die volle Berechtigungs- und DocMDP/FieldMDP-Zulassung über das Ergebnis

Bedeutet ein erfolgreiches Anwenden, dass die Feldwerte in /Fields signiert sind?

Nein. ISO 32000-1 verlangt nicht, dass /Fields, /Annots und /Differences in einem FDF übereinstimmen, und HotPDF Component gleicht sie nicht ab. Signiert ist das, was die CMS-Signaturen in der angehängten Revision abdecken — lesen Sie signierte Werte also aus dem vermengten PDF, nicht aus dem FDF-Wrapper

Der FDF-Differences-Transport, die Headless-Signatur-Policy-Engine und die Windows-Form-Data-Transaktionen kommen alle mit HotPDF Component für Delphi, C++Builder und Free Pascal