Technischer Artikel

SHA-3-PAdES-Signaturen aus Delphi nach ISO/TS 32001

Sie stellen eine Delphi-Signaturroutine auf SHA3-512 um, und OpenSSL weist das Ergebnis zurück: Das CMS deklariert SHA3-512, aber sein messageDigest-Attribut hält 32 Bytes, wo dieser Algorithmus 64 produziert. Ja, Delphi-Code kann mit SHA-3 signieren: SignPadesForDigest in PDFium Component akzeptiert SHA3-256, SHA3-384 oder SHA3-512 und schreibt jedes digest-abhängige CMS-Feld für genau diese eine Wahl, wie ISO/TS 32001 es erwartet

Was ISO/TS 32001 zu einer PDF-2.0-Signatur beiträgt

ISO/TS 32001 ist eine kurze Erweiterung zu ISO 32000-2. Sie erweitert die Menge der Hash-Funktionen, die eine PDF-Signatur deklarieren darf. Die Basisliste steht in Tabelle 260 von ISO 32000-2 §12.8.3.1, und für die CMS-basierten Subfilter endet sie bei SHA-256, SHA-384, SHA-512 und RIPEMD-160, mit SHA-1 als veraltet. Die technische Spezifikation ergänzt die SHA-3-Familie aus FIPS 202. SHA-3 ist eine Sponge-Konstruktion, die keine interne Struktur mit SHA-2 teilt, und genau das ist der Reiz für eine Signaturpolitik, die eine zweite Hash-Familie im Regal haben will, falls SHA-2 je schwächelt. In CMS ist jede Variante eine Objekt-Identifikator unter dem NIST-Arc. Die kombinierten Signaturalgorithmus-Identifikatoren liegen direkt daneben:

TCmsDigestAlgorithmDigest-OIDAusgabeRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 Bytes1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 Bytes2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948 Bytes2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064 Bytes2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

In PDFium Component ist die ganze Wahl eine einzige Enumeration, TCmsDigestAlgorithm aus der Unit FPdfCms. Überlassen Sie die Auswahl dem Schlüssel. Eine NIST-Kurve erwartet einen Digest ihrer eigenen Breite, also paaren sich P-256 mit SHA3-256, P-384 mit SHA3-384 und P-521 mit SHA3-512, und ein Validator, der die Kurven- und Digest-Politik aus ISO/TS 32002 anwendet, markiert jede andere Paarung als ungültig. RSA kennt eine solche Regel nicht. Das Stärke-Argument ist ohnehin kurz: Ein RSA-2048-Schlüssel liefert rund 112 Bit Sicherheit und RSA-3072 etwa 128, also läuft SHA3-256 dem Schlüssel schon voraus, während SHA3-512 auf einem RSA-2048-Schlüssel 32 Bytes zum CMS beiträgt und nichts zur Sicherheit. Wir empfehlen SHA3-256 für RSA, sofern keine schriftliche Politik eine größere Variante nennt

ISO/TS-32001-SHA-3-Ergänzungen für PDF-Signaturen aus Delphi: EC-Schlüssel paaren sich mit dem gleich breiten Digest, und RSA bekommt in PDFium Component standardmäßig SHA3-256
Die SHA-3-Familie stößt als zweite Hash-Familie zu Tabelle 260, EC-Kurven paaren sich mit der passenden Breite, und SHA3-256 läuft einem RSA-2048-Schlüssel schon voraus

Fünf CMS-Felder, die sich mit dem Digest ändern

Die Hash-Funktion auszutauschen ist der kleinste Teil der Arbeit. Eine detached CAdES-Signatur nennt ihren Digest an fünf Stellen, und ein strenger Verifizierer gleicht sie gegenseitig ab. Sagt auch nur eine noch SHA-256, scheitert die Signatur, obwohl die RSA- oder ECDSA-Arithmetik stimmt:

  1. digestAlgorithm in der SignerInfo, gespiegelt in der SignedData-Menge digestAlgorithms, trägt die Digest-OID, zum Beispiel 2.16.840.1.101.3.4.2.9 für SHA3-384
  2. Das signierte Attribut messageDigest (RFC 5652) hält den Hash der beiden /ByteRange-Spannen, sein OCTET STRING ist also 32, 48 oder 64 Bytes lang
  3. signatureAlgorithm nennt das kombinierte Schema aus der Tabelle oben. RSASSA-PSS ist die Ausnahme: Seine OID bleibt id-RSASSA-PSS (1.2.840.113549.1.1.10), und der Digest wandert in die Parameter
  4. Die RSASSA-PSS-Parameter wiederholen den Digest zweimal, als hashAlgorithm und als MGF1-Hash, und setzen saltLength auf die Digest-Länge. Lässt man sie weg, nimmt ein Verifizierer SHA-1, MGF1 mit SHA-1 und einen 20-Byte-Salt an, was ein anderer Algorithmus ist; auch die Tagging-Regeln für RSASSA-PSS-params aus RFC 4055 spielen hier hinein
  5. Das signierte Attribut CMSAlgorithmProtection (RFC 6211) kopiert die Digest- und Signaturalgorithmus-Identifikatoren in die signierten Attribute, wo niemand sie unbemerkt tauschen kann. Es muss Feld 1 und 3 byteweise entsprechen

Ein Feld ändert sich gar nicht. Das ESSCertIDv2 innerhalb des signing-certificate-v2-Attributs (RFC 5035) hasht das Signaturzertifikat mit seinem eigenen hashAlgorithm, der standardmäßig SHA-256 ist und unabhängig vom SignedData-Digest, also hält PDFium Component ihn in der Standardkodierung bei SHA-256. Der Fehler, der in handgeschriebenem CMS-Code am häufigsten auftaucht, ist die messageDigest-Länge. Für SHA-256 geschriebener Code neigt dazu, den Hash in einem festen 32-Byte-Record zu halten und diesen Record ins Attribut zu kopieren. Schieben Sie ein SHA3-512-Ergebnis hindurch, bekommt das Attribut die ersten 32 Bytes, während digestAlgorithm .10 sagt: die OpenSSL-Rückweisung aus der Einleitung. Derselbe Fehler wohnt auch eine Ebene tiefer, wo OpenSSLs EVP_PKEY_sign die Digest-Länge als tbslen nimmt und sie gegen den auf dem Kontext gesetzten Message Digest prüft, also bekommt ein Signierer, der stets 32 übergibt, einen Längenfehler, der SHA-3 nie erwähnt. PDFium Component berechnet den Dokument-Digest aus dem Algorithmus, den der aufgelöste Signierer deklariert, und leitet die Attribute, die PSS-Parameter und das Algorithmus-Schutz-Attribut aus derselben Deklaration ab, also kann kein Feld von den anderen abdriften

Die fünf CMS-Felder, die sich mit einem SHA-3-Digest in einer PAdES-Signatur aus Delphi ändern, von strengen Verifizierern gegengeprüft, wie PDFium Component sie schreibt
digestAlgorithm, messageDigest, signatureAlgorithm, die PSS-Parameter und CMSAlgorithmProtection müssen einen Digest nennen, während ESSCertIDv2 sein eigenes SHA-256 behält

Ein lokales PDF mit SignPadesForDigest signieren

Auf der TPdf-Komponente sind die SHA-3-Einstiegspunkte SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean und sein Stream-Zwilling SignPadesToStreamForDigest. Beide nehmen denselben TPadesSignOptions-Record wie SignPades, und cdaSha256 erzeugt dieselbe Signatur wie die bestehende Überladung, also bedient ein Codepfad beide Familien. Unter Windows wählt ein SHA-1-Fingerabdruck ein Zertifikat im Current-User-MY-Speicher. Das OpenSSL-Backend auf anderen Plattformen signiert mit dem Schlüssel und dem Zertifikat, die an ConfigureSslSigningCredentials gegeben wurden, und weist einen Fingerabdruck zurück. Prüfen Sie auch die Dokumentversion. ISO/TS 32001 erweitert PDF 2.0, und die Signatur geht als inkrementelles Update hinein, das den ursprünglichen Header hält, also braucht eine vertrauende Partei, die gegen PDF-2.0-Regeln validiert, vor dem Signieren eine 2.0-Datei

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // CNG-SHA-3-Provider, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // False, bis Credentials konfiguriert sind
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // OpenSSL signiert mit explizit konfiguriertem Material; in dieser Skizze keine Kette
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B, RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // Zertifikat im Current-User-MY-Speicher
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

Wenn das Backend kein SHA-3 erzeugen kann, wirft der Aufruf EPadesCryptoUnavailable, statt auf SHA-256 zurückzufallen. Genau das ist das gewollte Verhalten. Jemand hat SHA3-256 aus einem Grund verlangt, meist einem Politikdokument der vertrauenden Partei, und eine stattdessen produzierte SHA-256-Signatur würde jede Prüfung auf Ihrer Seite bestehen und auf ihrer abgewiesen werden, Wochen später, wenn niemand in Ihrem Team mehr weiß, welche Maschine signiert hat. Fangen Sie die Exception dort, wo Sie dem Operator noch sagen können, welcher Host den Algorithmus vermissen lässt

Wo SHA-3 verfügbar ist: Windows CNG und OpenSSL

Die Verfügbarkeit gehört zur Maschine. Unter Windows ist das Standard-Backend CNG, und CNG liefert SHA-3-Algorithmus-Provider ab Windows 11 24H2; auf früheren Builds schlägt BCryptOpenAlgorithmProvider für "SHA3-256" mit STATUS_NOT_FOUND fehl. WinSha3DigestAvailable öffnet die SHA3-256-, SHA3-384- und SHA3-512-Provider einmal und cached die Antwort, denn sie kann sich nicht ändern, solange der Prozess läuft. Überall sonst signiert das OpenSSL-Backend, und was es kann, hängt vom libcrypto ab, das es findet:

  • OpenSSL 3.x exportiert stets EVP_sha3_256, EVP_sha3_384 und EVP_sha3_512, SHA-3-Signieren funktioniert also auf jeder Plattform, auf der das Backend lädt
  • Der Loader akzeptiert auch eine Bibliothek der 1.1-Serie (libcrypto-1_1.dll, libcrypto.so.1.1). SHA-3 kam in OpenSSL 1.1.1 und fehlt in 1.1.0, also entscheidet auf älteren Linux-Images SslSha3SigningAvailable
  • SslSha3SigningAvailable verlangt zudem konfigurierte Signing-Credentials. Gefragt, bevor ConfigureSslSigningCredentials lief, antwortet es False, selbst wenn SslLibraryVersion eine tadellose 3.x-Bibliothek meldet. Das sieht nach fehlendem Backend aus. Es ist nur ein Reihenfolgeproblem
  • Ein 32-Bit-Executable kann libcrypto-3-x64.dll nicht laden. Als dieses Backend hochgezogen wurde, meldete ein Win32-Build, der auf die x64-DLL zeigte, SslAvailable = False, während SslMissingSymbols nichts als den Bibliothekspfad ausspuckte — ein Symptom, das wie eine kaputte Symbolbindung liest; die Ursache war die Bitness, und eine zum Executable passende DLL behob es

Eine Windows-Flotte, die noch Maschinen vor 24H2 umfasst, kann CNG behalten, wo es SHA-3 hat, und den Rest über OpenSSL mit einem Signer-Provider leiten. IPadesSignerProvider hat zwei Methoden. ResolveSslSignerForDigest in FPdfCryptoSsl liefert einen Signer, dessen deklarierter Digest, Signaturalgorithmus, Digest-Länge und PSS-Salt alle dem angeforderten Algorithmus folgen, und mit konfiguriertem Provider prüft ResolvePadesSignerForDigest, dass der zurückgegebene Signer den Digest deklariert, um den der Aufruf bat, und wirft andernfalls EPadesCrypto. Ein für SHA3-256 gebauter Provider kann einen SHA3-384-Request nicht stempeln. Der Provider gilt allerdings prozessweit, also sollte ein Server, der nebenläufig signiert, ihn einmal beim Start konfigurieren statt pro Aufruf wie in dieser Skizze; ConfigureSslLibraryPath weist den Loader auf libcrypto hin, wenn die DLL nicht auf dem Suchpfad liegt

Verfügbarkeitsfluss für SHA-3-Signieren in Delphi: Windows-CNG-Provider ab 24H2, OpenSSL-Versions- und Bitness-Wachen und der EPadesCryptoUnavailable-Fehlerpfad in PDFium Component
Die Verfügbarkeit gehört zur Maschine, also gehört die Prüfung an den Start: CNG, wo es SHA-3 hat, OpenSSL sonst, und kein stiller Fallback auf SHA-256
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // CNG-Pfad
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // OpenSSL signiert mit dem konfigurierten Zertifikat
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Wie erfährt ein entfernter oder HSM-Signer den Digest?

Remote-Signieren teilt die Arbeit in zwei Hälften. PreparePadesRemoteSignature schreibt ein PDF mit leerem /Contents-Slot plus einen TPadesRemoteSigningRequest, und der Schlüsselhalter liefert später ein detached CMS, oft aus einem anderen Prozess oder einer anderen Maschine, über die Prepare-und-Complete-Sitzung für HSM- und Cloud-Schlüssel. Auf der Prepare-Seite setzen Sie TPadesRemoteSignOptions.DigestAlgorithm, sagen wir auf cdaSha3_384, und der Request verzeichnet die Wahl in TPadesRemoteSigningRequest.DigestAlgorithm neben DocumentDigest, dem Wert, den das CMS-messageDigest tragen muss. Von da an ist der Request die Autorität. CompletePadesRemoteSignature berechnet den /ByteRange-Digest mit dem im Request gespeicherten Algorithmus neu und fragt die Optionen nie wieder; sonst würde eine Konfigurationsänderung zwischen Prepare und Complete einen alten Request nach einem neuen Algorithmus beurteilen. Ein Signaturdienst, der auf der Komponente baut, sollte den Algorithmus von derselben Stelle nehmen:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // bindet die Enterprise-Implementierung ein

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // leerer Fingerabdruck für OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // Der Request entscheidet über den Digest; nicht die Konfiguration dieses Dienstes
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

Im Request ist die Digest-Länge der einzige Beweis für den Algorithmus. Sie dient nebenbei als Fingerabdruck. Bevor irgendein Stream gelesen wird, vergleicht CompletePadesRemoteSignature Length(DocumentDigest) mit CmsDigestByteLength(DigestAlgorithm), also scheitert ein abgeschnittener Digest oder ein umetikettierter Algorithmus an dieser Stelle, und der Ziel-Stream wird nie angerührt. Die Länge allein kann SHA3-256 nicht von SHA-256 unterscheiden, beide sind 32 Bytes, weshalb der Digest-Wert zusätzlich mit dem messageDigest im zurückgegebenen CMS verglichen wird. Das vorbereitete PDF dagegen ist für SHA-256 und SHA3-384 byteweise identisch: Der Platzhalter hält nur /ByteRange-Zahlen und keinen Digest. Identische Dateien liest man leicht als ignorierte Einstellung miss. Remote-Signieren kommt in der Enterprise-Edition über die Unit FPdfPadesRemote

Die Variante ist selten die harte Entscheidung; für einen RSA-Schlüssel ist es meist cdaSha3_256, und was in Produktion scheitert, ist ein Host, dessen CNG oder libcrypto es nicht erzeugen kann, weshalb die Verfügbarkeitsprüfung in jeder Bereitstellung der PDFium Component für Delphi an den Anwendungsstart gehört