Technischer Artikel

PDF-Zertifikatsverschlüsselung in Delphi: RSA-OAEP und ECDH

HotPDF verschlüsselt eine PDF für konkrete Zertifikatsinhaber über den ISO-32000-Public-Key-Security-Handler: EnablePubKeyEncryption nimmt einen 20-Byte-Zufalls-Seed, und jeder Empfänger bekommt seinen eigenen CMS-Envelope, gebaut von AddPubKeyRecipientCertificate für RSA-Keys (RSA-OAEP-Key-Transport) oder AddPubKeyAgreementRecipientWithSecret für Elliptic-Curve-Keys (ECDH auf P-256, P-384, P-521, X25519 oder X448). Niemand teilt ein Passwort; wer den passenden Private Key hält, öffnet die Datei

Der Anwendungsfall ist immer eine Variante derselben Geschichte. Ein Quartals-Auditpaket geht an drei externe Reviewer, die Rechtsabteilung will, dass jeder es liest, nur einer darf es drucken, und niemand will ein Passwort in einem Mail-Thread direkt neben dem Attachment liegen haben. Passwort-Verschlüsselung kann das nicht ausdrücken. Zertifikatsverschlüsselung kann es, denn jeder Empfänger schließt das Dokument mit einem Key auf, den er ohnehin hat, und jeder Empfänger kann sein eigenes Permission-Set in seinem eigenen Envelope tragen

Wie unterscheidet sich zertifikatsbasierte PDF-Verschlüsselung von einem Passwort?

Eine Public-Key-verschlüsselte PDF leitet ihren File Key aus einem Zufalls-Seed plus den exakten Bytes jedes Empfänger-Envelopes ab, nicht aus irgendetwas, das ein Mensch tippt. Der Handler ist in ISO 32000-1 §7.6.4 beschrieben (§7.6.5 in ISO 32000-2), und die Envelopes sind CMS-EnvelopedData-Strukturen nach RFC 5652. HotPDF schreibt /Filter /Adobe.PubSec mit /SubFilter /adbe.pkcs7.s5; bei AES-256 heißt das /V 5 und ein /DefaultCryptFilter-Eintrag unter /CF mit /CFM /AESV3, und das /Recipients-Array wohnt in diesem Crypt Filter. Jeder Envelope verschlüsselt 24 Bytes: den 20-Byte-Seed gefolgt vom 32-Bit-Permission-Wort dieses Empfängers. Der /P-Wert im Encryption-Dictionary ist nur ein Platzhalter, denn die echten Permissions reisen in jedem Envelope. Zur Ladezeit entpackt ein Reader einen Envelope, holt den Seed zurück und hasht ihn zusammen mit jedem Envelope in /Recipients-Reihenfolge (SHA-256 für AES-256, SHA-1 für die älteren Chiffren), um den File Key zu rekonstruieren. Wenn Sie noch zwischen diesem Modell und gewöhnlichen Passwörtern abwägen, behandelt der AES-256-Passwortverschlüsselungs- und Permission-Flags-Guide die andere Seite dieses Trade-offs

HotPDF-Diagramm der Public-Key-Verschlüsselung: EnablePubKeyEncryption legt einen 20-Byte-Seed fest, jeder CMS-EnvelopedData-Envelope verschlüsselt diese 20 Bytes plus ein 32-Bit-Permission-Wort unter /Filter /Adobe.PubSec mit /SubFilter /adbe.pkcs7.s5 und /CFM /AESV3, und der Reader entpackt einen Envelope, holt den Seed zurück und hasht ihn mit jedem /Recipients-Eintrag in Array-Reihenfolge, um den File Key zu rekonstruieren
Der /P-Wert im Encryption-Dictionary ist nur ein Platzhalter, denn die echten Permissions reisen in jedem Envelope, und nichts downstream darf das Array, über das der Digest läuft, umordnen oder neu kodieren

RSA-Empfänger mit EnablePubKeyEncryption schreiben

Für RSA-Zertifikate rufen Sie EnablePubKeyEncryption mit aes256 auf und dann AddPubKeyRecipientCertificate einmal pro DER-kodiertem Zertifikat vor BeginDoc. Der Helfer baut einen RSAES-OAEP-Envelope im Prozess mit THPDFRSAOAEPHash-Werten für den OAEP-Digest und den MGF1-Digest (rohSHA256, rohSHA384 oder rohSHA512) und verschlüsselt den Envelope-Inhalt mit AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // exakt 20 Bytes, auch bei AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // der Default-Key-Type ist aes128
    // Reviewer A darf drucken; Reviewer B darf nur lesen und extrahieren
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Drei Details in diesem Listing tragen Last. Erstens ist die Seed-Länge für jeden Key-Typ auf 20 Bytes festgenagelt, AES-256 eingeschlossen; EnablePubKeyEncryption wirft bei jeder anderen Länge. Zweitens ist der Default von EnablePubKeyEncryption aes128, und beide Zertifikats-Helfer weigern sich zu laufen, sofern der Key-Typ nicht aes256 ist, das Vergessen des zweiten Arguments liefert Ihnen also die Exception „certificate envelopes require aes256“. Die Legacy-Chiffren (k40, k128, aes128) funktionieren weiterhin, aber nur über AddPubKeyRecipient mit einem Envelope, den Sie woanders gebaut haben. Drittens ist Public-Key-Verschlüsselung mit AES-256 ein PDF-2.0-Feature, HotPDF hebt die Dokumentversion also automatisch auf 2.0. Mit gesetztem StrictVersionLock auf einer niedrigeren Version kehrt EnablePubKeyEncryption zurück, ohne irgendetwas einzuschalten, und der Fehler erscheint erst in der nächsten Zeile als „call EnablePubKeyEncryption first“. Die Verschlüsselung während eines inkrementellen Updates zu wechseln, wirft sofort ein EInvalidOpException

ECDH-Empfänger hinzufügen: P-256, P-384, P-521, X25519 und X448

Für Elliptic-Curve-Zertifikate schreibt AddPubKeyAgreementRecipientWithSecret einen CMS-Key-Agreement-Empfänger (KeyAgreeRecipientInfo, die KARI-Struktur aus RFC 5753, mit dem X25519- und X448-Profil aus RFC 8418) und berechnet das ECDH-Shared Secret im Prozess. Die Kurve wählen Sie mit einem THPDFPubKeyAgreementScheme-Wert: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 oder pkasX448. Das Schema muss zum Key im Zertifikat passen, sonst wirft der Aufruf „Certificate key does not match the requested agreement scheme“. Unter der Haube bekommt jeder Envelope einen frischen zufälligen 32-Byte-UKM, einen mit dem stdDH-KDF abgeleiteten Key-Encryption-Key (SHA-256 für P-256 und X25519, SHA-384 für P-384, SHA-512 für P-521 und X448) und einen AES-256-Key-Wrap nach RFC 3394. Das Shared Secret selbst stammt aus reinem Pascal-Kurven-Code, ohne jeden Plattform-Krypto-Provider; der Artikel zur reinen Pascal-NIST-Kurven-Arithmetik erklärt, wie diese Schicht gebaut und verifiziert wurde. Für die Montgomery-Kurven lässt sich das komplette ephemerale Key-Paar lokal erzeugen:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Frischer ephemeraler Skalar pro Envelope; das Clamping passiert in der Leiter
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: nur bei den NIST-Kurven von Bedeutung
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Die NIST-Kurven verlangen dem Aufrufer mehr ab. HotPDF liefert Public-Key-Helfer nur für X25519 und X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), für P-256, P-384 und P-521 erzeugen Sie das ephemerale Key-Paar also mit Ihrem eigenen Werkzeug und übergeben einen Big-Endian-Skalar von exakt der Feldgröße (32, 48 oder 66 Bytes) plus dem passenden unkomprimierten Punkt 0x04||X||Y als OriginatorPublicKey. HotPDF validiert den Empfänger-Punkt gegen die Kurvengleichung, aber es kann nicht prüfen, dass Ihr Originator-Public-Key tatsächlich zu Ihrem Skalar gehört. Falsch gepaarte Hälften ergeben trotzdem einen tadellos geformten Envelope, den kein Empfänger öffnen kann – deshalb gehört ein Round-Trip-Load in Ihre Test-Suite, nicht nur ein Dateigrößen-Check

HotPDF-Diagramm der ECDH-Vereinbarung: AddPubKeyAgreementRecipientWithSecret leitet das Shared Secret mit reinem Pascal-Kurven-Code ab, mischt einen frischen 32-Byte-UKM durch den stdDH-KDF mit SHA-256 für P-256 und X25519, SHA-384 für P-384, SHA-512 für P-521 und X448 und wickelt dann den Content Key in den AES-256-Key-Wrap nach RFC 3394, um den KeyAgreeRecipientInfo-Envelope zu bauen
Der Scheme-Wert von pkasECDHP256 bis pkasX448 muss zum Zertifikats-Key passen, und falsch gepaarte Skalar- und Public-Point-Hälften ergeben trotzdem einen wohlgeformten Envelope, den kein Empfänger öffnen kann

Warum kommt es auf die Reihenfolge von /Recipients an?

Die Reihenfolge von /Recipients zählt, weil der File Key ein Digest über den Seed und jeden Envelope in Array-Reihenfolge ist, Writer und Reader müssen also dieselben Bytes in derselben Sequenz hashen. HotPDF hält die Envelopes in der Reihenfolge, in der Sie sie hinzufügen, und schreibt sie unverändert – Sie dürfen Empfänger in beliebiger Reihenfolge hinzufügen, aber nichts downstream darf dieses Array umordnen, neu kodieren oder „aufräumen“. Die meisten echten Bugs in diesem Bereich waren eine Variante dieses Themas, bei der zwei Seiten minimal unterschiedliche Bytes hashten:

  • Dynamische Arrays über Add in einer TList zu speichern hält nur einen rohen Pointer fest, während der Referenzzähler bei der lokalen Variablen bleibt. Das nächste SetLength gibt den Buffer frei und kann ihn wiederverwenden, jeder Slot aliaste also den letzten Envelope, und Multi-Empfänger-Dateien leiteten den falschen Key ab. Der Fix: eine eigene Kopie mit List.Add(Pointer(System.Copy(Bytes))) speichern
  • Das Envelope-Entpacken parst das DER an Ort und Stelle, und der Key-Recovery-Durchlauf hashte ursprünglich ebendiese lebenden Arrays. Der Reader snapshotet jetzt saubere Kopien jedes Envelopes, bevor irgendein Entpacken sie anfasst, und der Digest läuft über die Snapshots
  • Binäres DER durch eine Unicode-TStringList bekommt Bytes ab $80 über die Codepage neu kodiert, HotPDF speichert Envelopes deshalb intern als Hex-Text
  • Verschlüsselte und binäre Strings müssen als Hex-Strings geschrieben werden. Ein Literal-String unterliegt der End-of-Line-Normalisierung, bei der CR, LF und CRLF alle zu einem einzelnen LF werden (ISO 32000-1 §7.3.4.2), und das schreibt Ciphertext still um. HotPDF gibt jeden /Recipients-Eintrag als Hex-String aus und nimmt ihn von der String-Verschlüsselung aus, denn jeder Reader braucht die Envelopes, bevor er irgendeinen Key hält
  • Das erste Byte eines DER-BIT STRING zählt unbenutzte Bits und muss bei byte-alignten Keys null sein. Nach SetLength uninitialisiert gelassen, schrieb es, was gerade auf dem Stack lag, und ein strenger Entpacker wies den Originator-Key zurück, sodass eine Datei gelegentlich mit genau dem Key scheiterte, für den sie geschrieben war
  • Wenn derselbe Key weiterhin nicht entschlüsseln kann, vergleichen Sie Schicht für Schicht: den File Key, dann das Ciphertext-Präfix (die IV), dann den Objekt-Key, dann den Plaintext. Der Bug wohnt direkt hinter der ersten Schicht, die nicht übereinstimmt

Wie öffnen Sie eine zertifikatsverschlüsselte PDF mit einem Private Key?

Um eine zertifikatsverschlüsselte PDF zu öffnen, registrieren Sie das Private-Key-Material, bevor Sie LoadFromFile aufrufen, denn HotPDF holt den File Key schon im strukturellen Durchlauf zurück. Weisen Sie einen mit HPDFParsePFX geparsten RSA- oder EC-Key an PubSecKeyMaterial zu, fügen Sie weitere RSA-Keys mit AddPubSecKeyMaterial hinzu und registrieren Sie rohe ECDH-Skalare mit AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), unter Verwendung der Konstanten HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 oder HPDFOIDECP521. Die NIST-Kurven verlangen den eigenen unkomprimierten Public Point des Empfängers; die Montgomery-Kurven ignorieren ihn

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Optional: den Envelope direkt wählen, statt alle durchzuprobieren
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = jeden Envelope der Reihe nach versuchen
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Ohne Callback probiert HotPDF jeden Envelope gegen jeden registrierten Key: zuerst den Primär-Key, dann jeden zusätzlichen RSA-Key, dann das EC-Material. PubSecRecipientQuery bekommt die Envelope-Anzahl und gibt einen 0-basierten Index oder -1 zurück, ein Index außerhalb des Arrays wirft eine Exception, statt geklemmt zu werden. Beachten Sie, dass AddPubSecKeyMaterial nur RSA-Material akzeptiert (es besteht auf einem Modulus und einem privaten Exponenten), EC-Keys gehören also in PubSecKeyMaterial oder AddPubSecAgreementKeyMaterial. Wenn kein Key irgendeinen Envelope entpackt, kehrt der Recovery-Schritt ohne File Key zurück, statt zu werfen – prüfen Sie also, ob der erwartete Inhalt tatsächlich entschlüsselt wurde, statt darauf zu vertrauen, dass der Load-Aufruf zurückkehrte

HotPDF-Diagramm des Private-Key-Ladens: PubSecKeyMaterial trägt den primären RSA- oder EC-Key aus HPDFParsePFX, AddPubSecKeyMaterial fügt nur RSA-Keys hinzu, AddPubSecAgreementKeyMaterial registriert rohe ECDH-Skalare unter den Kurven-OIDs von HPDFOIDX25519 bis HPDFOIDP521, und bei LoadFromFile probiert der Provider den Primär-Key, dann jeden zusätzlichen RSA-Key, dann das EC-Material gegen jeden Envelope
Wenn kein Key irgendeinen Envelope entpackt, kehrt der Recovery-Schritt ohne File Key zurück, statt zu werfen, prüfen Sie also, ob der Inhalt tatsächlich entschlüsselt wurde, oder nageln Sie den Envelope über PubSecRecipientQuery fest

Was HotPDF nicht garantiert

HotPDF garantiert, dass sein eigener Writer und Reader byte für byte übereinstimmen, und es baut Envelopes, die den zitierten CMS-Strukturen folgen. Es garantiert nicht, dass jeder PDF-Viewer jede Kombination öffnet. Der Support für RSA-OAEP-Key-Transport und für X25519- oder X448-Empfänger variiert zwischen Readern und Versionen, und wir haben keine Kompatibilitätsergebnisse für diese Kombinationen veröffentlicht. Muss ein Dokument in einem konkreten Viewer aufgehen, verschlüsseln Sie eine Testdatei für ein Testzertifikat desselben Key-Typs und öffnen Sie sie dort, bevor Sie sich auf ein Schema festlegen. Permissions, die im Envelope reisen, bleiben Policy, die konforme Software ehrt, exakt wie unter Passwortverschlüsselung. Die Seed-Qualität ist ebenfalls Ihre Verantwortung: AESGenerateRandomBytes ist genau für diesen Job da, und HotPDF wischt seine Kopie des Seeds weg, sobald der File Key abgeleitet ist. Wenn Sie auch einen String, Stream oder ein Attachment über ein anderes Crypt Filter laufen lassen wollen, zeigt der Crypt-Filter-Policy-Guide für StmF, StrF und EFF, welche Filternamen der Public-Key-Handler akzeptiert

Zertifikatsverschlüsselung, RSA-OAEP- und ECDH-Empfänger-Envelopes und Private-Key-Laden erscheinen alle in der HotPDF Delphi PDF component, neben Passwortverschlüsselung, digitalen Signaturen und dem Rest des ISO-32000-Werkzeugkastens für Delphi und C++Builder