Artykuł techniczny

PDFium: podpisy PAdES z SHA-3 według ISO/TS 32001

Przełączasz procedurę podpisującą w Delphi na SHA3-512, a OpenSSL odrzuca wynik: CMS deklaruje SHA3-512, ale jego atrybut messageDigest trzyma 32 bajty tam, gdzie ten algorytm produkuje 64. Tak, kod w Delphi potrafi podpisywać SHA-3: SignPadesForDigest w PDFium Component przyjmuje SHA3-256, SHA3-384 albo SHA3-512 i zapisuje każde pole CMS zależne od skrótu pod ten jeden wybór, dokładnie tak, jak oczekuje tego ISO/TS 32001

Co ISO/TS 32001 dodaje do podpisu PDF 2.0

ISO/TS 32001 to krótkie rozszerzenie ISO 32000-2. Poszerza zbiór funkcji skrótu, które podpis PDF może zadeklarować. Bazowa lista siedzi w tabeli 260 normy ISO 32000-2 §12.8.3.1 i dla subfiltrów opartych na CMS zatrzymuje się na SHA-256, SHA-384, SHA-512 i RIPEMD-160, z SHA-1 odesłanym na emeryturę. Specyfikacja techniczna dodaje rodzinę SHA-3 z FIPS 202. SHA-3 to konstrukcja gąbkowa, która nie dzieli z SHA-2 żadnej wewnętrznej struktury — i o to chodzi w polityce podpisów, która chce mieć drugą rodzinę funkcji skrótu na półce na wypadek, gdyby SHA-2 kiedyś osłabł. W CMS każdy wariant jest identyfikatorem obiektu pod gałęzią NIST. Identyfikatory złożonych algorytmów podpisu siedzą tuż obok:

TCmsDigestAlgorithmOID skrótuWynikRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 bajty1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 bajty2.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 bajtów2.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 bajty2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

W PDFium Component cały wybór to jedna enumeracja, TCmsDigestAlgorithm z modułu FPdfCms. Niech klucz wybiera członka. Krzywa NIST oczekuje skrótu własnej szerokości, więc P-256 paruje się z SHA3-256, P-384 z SHA3-384, a P-521 z SHA3-512, a walidator stosujący politykę krzywych i skrótów z ISO/TS 32002 oznacza każdą inną parę jako nieważną. RSA nie ma takiej reguły. Argument siły jest i tak krótki: klucz RSA-2048 dostarcza z grubsza 112 bitów bezpieczeństwa, a RSA-3072 około 128, więc SHA3-256 już prześciga klucz, podczas gdy SHA3-512 na kluczu RSA-2048 dodaje do CMS 32 bajty i nic do bezpieczeństwa. Dla RSA rekomendujemy SHA3-256, chyba że pisemna polityka wymienia większy wariant

Dodatki SHA-3 z ISO/TS 32001 dla podpisów PDF z Delphi: klucze EC parują się ze skrótem tej samej szerokości, a RSA dostaje domyślnie SHA3-256 w PDFium Component
Rodzina SHA-3 dołącza do tabeli 260 jako druga rodzina skrótów, krzywe EC parują się z pasującą szerokością, a SHA3-256 już prześciga klucz RSA-2048

Pięć pól CMS, które zmieniają się ze skrótem

Podmiana funkcji skrótu to najmniejsza część roboty. Podpis CAdES w trybie detached wymienia swój skrót w pięciu miejscach, a rygorystyczny weryfikator sprawdza je krzyżowo. Jeśli choć jedno dalej mówi SHA-256, podpis pada, choć arytmetyka RSA albo ECDSA jest poprawna:

  1. digestAlgorithm w SignerInfo, odbity w zbiorze digestAlgorithms SignedData, niesie OID skrótu, na przykład 2.16.840.1.101.3.4.2.9 dla SHA3-384
  2. Podpisany atrybut messageDigest (RFC 5652) trzyma skrót dwóch zakresów /ByteRange, więc jego OCTET STRING ma 32, 48 albo 64 bajty
  3. signatureAlgorithm nazywa złożony schemat z tabeli wyżej. Wyjątkiem jest RSASSA-PSS: jego OID zostaje id-RSASSA-PSS (1.2.840.113549.1.1.10), a skrót przenosi się do parametrów
  4. Parametry RSASSA-PSS powtarzają skrót dwukrotnie, jako hashAlgorithm i jako skrót MGF1, i ustawiają saltLength na długość skrótu. Pomiń je, a weryfikator założy SHA-1, MGF1 ze SHA-1 i 20-bajtową sól, co jest innym algorytmem; reguły tagowania RSASSA-PSS-params z RFC 4055 mają tu też znaczenie
  5. Podpisany atrybut CMSAlgorithmProtection (RFC 6211) kopiuje identyfikatory skrótu i algorytmu podpisu do atrybutów podpisanych, gdzie nikt nie podmieni ich niezauważenie. Musi zgadzać się z polami 1 i 3 bajt w bajt

Jedno pole nie zmienia się wcale. ESSCertIDv2 wewnątrz atrybutu signing-certificate-v2 (RFC 5035) liczy skrót certyfikatu podpisującego własnym hashAlgorithm, który domyślnie ma SHA-256 i jest niezależny od skrótu SignedData, więc PDFium Component trzyma go na SHA-256 w domyślnym kodowaniu. Błąd, który najczęściej wychodzi w CMS pisanym ręcznie, to długość messageDigest. Kod napisany pod SHA-256 ma zwyczaj trzymać skrót w stałym rekordzie 32-bajtowym i kopiować ten rekord do atrybutu. Przepchnij przez niego wynik SHA3-512, a atrybut dostaje pierwsze 32 bajty, podczas gdy digestAlgorithm mówi .10 — czyli odrzucenie od OpenSSL z otwarcia artykułu. Ten sam błąd mieszka też o warstwę niżej, gdzie EVP_PKEY_sign w OpenSSL bierze długość skrótu jako tbslen i sprawdza ją wobec skrótu wiadomości ustawionego na kontekście, więc podpisujący, który zawsze podaje 32, dostaje błąd długości, który nigdy nie wspomina o SHA-3. PDFium Component liczy skrót dokumentu algorytmem, który zadeklarował rozwiązany podpisujący, i wyprowadza atrybuty, parametry PSS oraz atrybut ochrony algorytmu z tej samej deklaracji, więc żadne pole nie może odjechać od innych

Pięć pól CMS zmieniających się ze skrótem SHA-3 w podpisie PAdES z Delphi, sprawdzanych krzyżowo przez rygorystyczne weryfikatory tak, jak zapisuje je PDFium Component
digestAlgorithm, messageDigest, signatureAlgorithm, parametry PSS i CMSAlgorithmProtection muszą wymieniać jeden skrót, a ESSCertIDv2 trzyma swój własny SHA-256

Podpisywanie lokalnego PDF przez SignPadesForDigest

W komponencie TPdf punktami wejścia SHA-3 są SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean i jego bliźniak strumieniowy SignPadesToStreamForDigest. Oba biorą ten sam rekord TPadesSignOptions co SignPades, a cdaSha256 produkuje ten sam podpis co istniejące przeciążenie, więc jedna ścieżka kodu obsługuje obie rodziny. Na Windows certyfikat w magazynie Current User MY wybiera odcisk SHA-1. Backend OpenSSL na innych platformach podpisuje kluczem i certyfikatem podanymi do ConfigureSslSigningCredentials i odrzuca odcisk. Sprawdź też wersję dokumentu. ISO/TS 32001 rozszerza PDF 2.0, a podpis wchodzi jako aktualizacja przyrostowa, która zachowuje oryginalny nagłówek, więc strona polegająca walidująca według reguł PDF 2.0 potrzebuje pliku 2.0, zanim podpiszesz

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;   // dostawcy SHA-3 w CNG, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // False, dopóki nie podano poświadczeń
{$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 podpisuje jawnie podanym materiałem; bez łańcucha w tym szkicu
  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;   // certyfikat w magazynie Current User MY
{$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;

Gdy backend nie potrafi wyprodukować SHA-3, wywołanie wyrzuca EPadesCryptoUnavailable zamiast spadać na SHA-256. Takie zachowanie jest tym, czego chcesz. Ktoś zażądał SHA3-256 nie bez powodu — zwykle to dokument polityki od strony polegającej — a podpis SHA-256 wyprodukowany na jego miejscu przejdzie każdą kontrolę u ciebie i zostanie odesłany u nich, tygodnie później, gdy nikt w twoim zespole nie pamięta, która maszyna podpisywała. Łap wyjątek tam, gdzie wciąż możesz powiedzieć operatorowi, który host nie ma algorytmu

Gdzie SHA-3 jest dostępne: CNG na Windows i OpenSSL

Dostępność należy do maszyny. Na Windows domyślny backend to CNG, a CNG dostarcza dostawców algorytmów SHA-3 od Windows 11 24H2 wzwyż; na wcześniejszych buildach BCryptOpenAlgorithmProvider dla "SHA3-256" pada z STATUS_NOT_FOUND. WinSha3DigestAvailable otwiera dostawców SHA3-256, SHA3-384 i SHA3-512 raz i trzyma odpowiedź w cache, bo ta nie może się zmienić, dopóki proces żyje. Wszędzie indziej podpisuje backend OpenSSL, a co potrafi, zależy od libcrypto, którą znajdzie:

  • OpenSSL 3.x zawsze eksportuje EVP_sha3_256, EVP_sha3_384 i EVP_sha3_512, więc podpisywanie SHA-3 działa na każdej platformie, gdzie backend się ładuje
  • Loader przyjmuje też bibliotekę serii 1.1 (libcrypto-1_1.dll, libcrypto.so.1.1). SHA-3 dotarł do OpenSSL w 1.1.1 i nie ma go w 1.1.0, więc na starszych obrazach Linuksa decyduje SslSha3SigningAvailable
  • SslSha3SigningAvailable wymaga też skonfigurowanych poświadczeń podpisujących. Zapytany przed ConfigureSslSigningCredentials odpowiada False, nawet gdy SslLibraryVersion raportuje całkiem sprawną bibliotekę 3.x. Wygląda to na brakujący backend. To tylko problem kolejności
  • Wykonywalny 32-bitowy nie załaduje libcrypto-3-x64.dll. Gdy ten backend był dociągany, build Win32 wskazujący na DLL x64 raportował SslAvailable = False, a SslMissingSymbols wypluwał nic poza ścieżką biblioteki — objaw, który czyta się jak zerwane wiązanie symboli; przyczyną była bitowość, a DLL pasujący do wykonywalnego załatwił sprawę

Park maszyn Windows, który dalej zawiera maszyny sprzed 24H2, może trzymać CNG tam, gdzie ma SHA-3, a resztę kierować przez OpenSSL z dostawcą podpisującego. IPadesSignerProvider ma dwie metody. ResolveSslSignerForDigest w FPdfCryptoSsl zwraca podpisującego, którego zadeklarowany skrót, algorytm podpisu, długość skrótu i sól PSS — wszystko idzie za żądanym algorytmem, a przy skonfigurowanym dostawcy ResolvePadesSignerForDigest sprawdza, czy zwrócony podpisujący deklaruje skrót, o który prosiło wywołanie, wyrzucając EPadesCrypto, jeśli nie. Dostawca zbudowany pod SHA3-256 nie obsłuży żądania SHA3-384. Dostawca jest jednak procesowy w całości, więc serwer podpisujący współbieżnie powinien skonfigurować go raz na starcie, a nie per wywołanie jak ten szkic; ConfigureSslLibraryPath kieruje loadera na libcrypto, gdy DLL nie ma na ścieżce wyszukiwania

Przepływ dostępności podpisywania SHA-3 w Delphi: dostawcy CNG Windows od 24H2, strażnicy wersji i bitowości OpenSSL oraz ścieżka porażki EPadesCryptoUnavailable w PDFium Component
Dostępność należy do maszyny, więc kontrola należy na start: CNG tam, gdzie ma SHA-3, OpenSSL gdzie indziej i żaden cichy fallback na 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);   // ścieżka CNG
    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 podpisuje skonfigurowanym certyfikatem
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Skąd zdalny albo HSM-owy podpisujący dowiaduje się o skrócie?

Zdalne podpisywanie dzieli robotę na dwoje. PreparePadesRemoteSignature zapisuje PDF z pustym slotem /Contents plus TPadesRemoteSigningRequest, a posiadacz klucza odsyła CMS w trybie detached później, często z innego procesu albo maszyny, przez sesję prepare i complete używaną dla kluczy HSM i chmurowych. Po stronie prepare ustawiasz TPadesRemoteSignOptions.DigestAlgorithm, powiedzmy na cdaSha3_384, a żądanie odnotowuje wybór w TPadesRemoteSigningRequest.DigestAlgorithm obok DocumentDigest, wartości, którą musi nieść CMS-owy messageDigest. Od tej chwili żądanie jest władzą. CompletePadesRemoteSignature przelicza skrót /ByteRange algorytmem zapisanym w żądaniu i już nigdy więcej nie zagląda do opcji; inaczej zmiana konfiguracji między prepare a complete oceniłaby stare żądanie nowym algorytmem. Usługa podpisywania zbudowana na komponencie powinna brać algorytm z tego samego miejsca:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // podpina implementację Enterprise

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;   // pusty odcisk dla OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // O skrócie rozstrzyga żądanie; konfiguracja tej usługi nie
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

Wewnątrz żądania długość skrótu to jedyny dowód na algorytm, przy okazji odcisk rozpoznawczy. Zanim przeczyta jakikolwiek strumień, CompletePadesRemoteSignature porównuje Length(DocumentDigest) z CmsDigestByteLength(DigestAlgorithm), więc ucięty skrót albo algorytm podmieniony pod starą etykietą wywali się w tym miejscu, a strumień docelowy nie zostanie nigdy tknięty. Długość sama nie odróżni SHA3-256 od SHA-256 — oba mają po 32 bajty — dlatego wartość skrótu jest też porównywana z messageDigest wewnątrz zwracanego CMS. Przygotowany PDF jest w przeciwieństwie bajt w bajt identyczny dla SHA-256 i SHA3-384: placeholder trzyma tylko liczby /ByteRange i żaden skrót. Identyczne pliki łatwo błędnie odczytać jako ignorowanie ustawienia. Zdalne podpisywanie jedzie w wydaniu Enterprise przez moduł FPdfPadesRemote

Wariant rzadko bywa twardą decyzją; dla klucza RSA to zwykle cdaSha3_256, a w produkcji pada host, którego CNG albo libcrypto nie potrafi go wyprodukować — dlatego kontrola dostępności należy na start aplikacji w każdym wdrożeniu PDFium Component dla Delphi