技術文章

SHA-3 PAdES 簽章:ISO/TS 32001 與 Delphi

把 Delphi 簽署常式換成 SHA3-512,OpenSSL 拒收成果:CMS 宣告的是 SHA3-512,它的 messageDigest 屬性裡卻只裝了 32 位元組,而該演算法產出的是 64 位元組。答案是肯定的:Delphi 程式碼可以用 SHA-3 簽章。PDFium Component 的 SignPadesForDigest 接受 SHA3-256、SHA3-384 或 SHA3-512,並為這一個選擇寫好每一個依賴摘要的 CMS 欄位,正如 ISO/TS 32001 所期待

ISO/TS 32001 給 PDF 2.0 簽章增添了什麼

ISO/TS 32001 是 ISO 32000-2 的一份簡短延伸,拓寬了 PDF 簽章可以宣告的雜湊函式集合。基礎清單在 ISO 32000-2 §12.8.3.1 的表 260,對 CMS 型 subfilter 而言止於 SHA-256、SHA-384、SHA-512 與 RIPEMD-160,SHA-1 已棄用。這份技術規格加入 FIPS 202 的 SHA-3 家族。SHA-3 是一種海綿結構,與 SHA-2 沒有任何共通的內部結構,這正是想在架上備妥第二個雜湊家族、以防 SHA-2 有朝一日轉弱的簽章政策所看上的全部理由。在 CMS 裡,每個變體都是 NIST arc 之下的一個物件識別碼,組合簽章演算法識別碼就排在它們旁邊:

TCmsDigestAlgorithm摘要 OID輸出RSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 位元組1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 位元組2.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 位元組2.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 位元組2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

在 PDFium Component 裡,整個選擇收斂成一個列舉:FPdfCms 單元裡的 TCmsDigestAlgorithm。讓金鑰決定成員。NIST 曲線預期摘要寬度與自身相同,所以 P-256 配 SHA3-256、P-384 配 SHA3-384、P-521 配 SHA3-512,套用ISO/TS 32002 曲線與摘要政策的驗證器會把任何其他配對判為無效。RSA 沒有這條規則。安全強度的論證本來就短:RSA-2048 金鑰大約給出 112 位元的安全強度,RSA-3072 約 128 位元,SHA3-256 已經超過金鑰本身;把 SHA3-512 套在 RSA-2048 金鑰上,只是給 CMS 多添 32 位元組,安全卻分毫未增。除非書面政策指名更大的變體,RSA 我們建議 SHA3-256

ISO/TS 32001 為 Delphi PDF 簽章加入的 SHA-3:EC 金鑰與同寬度摘要配對,PDFium Component 中 RSA 預設拿到 SHA3-256
SHA-3 家族以第二個雜湊家族之姿加入表 260,EC 曲線與相同寬度配對,而 SHA3-256 早已超過 RSA-2048 金鑰

隨摘要而變的五個 CMS 欄位

換掉雜湊函式是整件工作中最小的部分。分離式 CAdES 簽章在五個地方指名它的摘要,嚴格的驗證器會逐一交叉核對。任何一處還寫著 SHA-256,簽章就過不了,即使 RSA 或 ECDSA 的算術全對:

  1. SignerInfo 裡的 digestAlgorithm,映照在 SignedData 的 digestAlgorithms 集合中,承載摘要 OID,例如 SHA3-384 是 2.16.840.1.101.3.4.2.9
  2. messageDigest 簽署屬性(RFC 5652)裝著兩段 /ByteRange 範圍的雜湊,所以它的 OCTET STRING 長 32、48 或 64 位元組
  3. signatureAlgorithm 指名上表中的組合方案。RSASSA-PSS 是例外:其 OID 維持 id-RSASSA-PSS(1.2.840.113549.1.1.10),摘要則移進參數
  4. RSASSA-PSS 參數把摘要重複兩次——一次是 hashAlgorithm,一次是 MGF1 雜湊——並把 saltLength 設為摘要長度。漏掉它們,驗證器會假定 SHA-1、MGF1 用 SHA-1、鹽值 20 位元組,那是另一個演算法;RFC 4055 的 RSASSA-PSS-params 編碼規則在此也同樣要緊
  5. CMSAlgorithmProtection 簽署屬性(RFC 6211)把摘要與簽章演算法識別碼複製進簽署屬性,讓誰也無法神不知鬼不覺地調包。它必須與第 1、3 項逐位元組一致

有一個欄位完全不變。signing-certificate-v2 屬性(RFC 5035)裡的 ESSCertIDv2 用自己的 hashAlgorithm 雜湊簽署者憑證,預設 SHA-256,與 SignedData 摘要無關,所以 PDFium Component 在預設編碼中讓它維持 SHA-256。手寫 CMS 程式碼最常見的錯誤是 messageDigest 的長度。為 SHA-256 寫的程式碼往往把雜湊放在固定 32 位元組的 record 裡,再把整個 record 複製進屬性。把 SHA3-512 的結果推過去,屬性收到前 32 位元組,而 digestAlgorithm 寫著 .10——正是開頭那記 OpenSSL 拒收。同樣的錯誤還藏在下一層:OpenSSL 的 EVP_PKEY_sign 把摘要長度當 tbslen 收下,並與 context 上設定的訊息摘要核對,於是永遠傳 32 的簽署器拿到一個隻字不提 SHA-3 的長度錯誤。PDFium Component 依解析出的簽署器所宣告的演算法計算文件摘要,屬性、PSS 參數與演算法保護屬性全部從同一份宣告推導,任何欄位都無法與其他欄位漂移

Delphi PAdES 簽章中隨 SHA-3 摘要而變的五個 CMS 欄位,PDFium Component 寫出時便受嚴格驗證器交叉核對
digestAlgorithm、messageDigest、signatureAlgorithm、PSS 參數與 CMSAlgorithmProtection 必須指名同一個摘要,ESSCertIDv2 則守著自己的 SHA-256

用 SignPadesForDigest 簽署本機 PDF

在 TPdf 元件上,SHA-3 的進入點是 SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean 與其串流版本 SignPadesToStreamForDigest。兩者都接受與 SignPades 相同的 TPadesSignOptions record,而 cdaSha256 產出的簽章與既有多載相同,所以一條程式碼路徑同時服務兩個家族。在 Windows 上,SHA-1 憑證指紋可從目前使用者的 MY 存放區選出憑證。其他平台的 OpenSSL 後端用交給 ConfigureSslSigningCredentials 的金鑰與憑證簽署,並拒絕指紋。文件版本也要檢查。ISO/TS 32001 是 PDF 2.0 的延伸,簽章以保留原始檔頭的增量更新形式進入,所以按 PDF 2.0 規則驗證的信賴方,需要您在簽署前就備妥 2.0 檔案

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 提供者,Windows 11 24H2 以上
{$ELSE}
  Result := SslSha3SigningAvailable;  // 設好憑證資料之前都是 False
{$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 用明確設定的金鑰材料簽署;此示範不含憑證鏈
  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;   // 憑證位於目前使用者的 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;

後端產不出 SHA-3 時,呼叫會拋出 EPadesCryptoUnavailable,而不是悄悄退回 SHA-256。這正是該要的行為。有人指名 SHA3-256 必有原因,通常來自信賴方的政策文件;用 SHA-256 頂替的簽章會通過您這邊的每一項檢查,卻在對方那邊被退回——而且是在幾週之後、團隊裡已沒人記得哪台機器簽的時候。要在您還來得及告訴操作員哪台主機缺這個演算法的地方,接住這個例外

SHA-3 哪裡可用:Windows CNG 與 OpenSSL

可用性屬於機器。Windows 上預設後端是 CNG,而 CNG 從 Windows 11 24H2 起內附 SHA-3 演算法提供者;更早的組建上,為 "SHA3-256" 呼叫 BCryptOpenAlgorithmProvider 會以 STATUS_NOT_FOUND 失敗。WinSha3DigestAvailable 只開一次 SHA3-256、SHA3-384 與 SHA3-512 提供者並快取答案,因為程序運行期間它不可能改變。其他地方由 OpenSSL 後端簽署,能做什麼取決於它找到的 libcrypto:

  • OpenSSL 3.x 一定匯出 EVP_sha3_256、EVP_sha3_384 與 EVP_sha3_512,所以後端載得起來的平台上,SHA-3 簽章都能用
  • 載入器也接受 1.1 系列的程式庫(libcrypto-1_1.dll、libcrypto.so.1.1)。SHA-3 在 OpenSSL 1.1.1 才登場、1.1.0 沒有,較舊的 Linux 映像上由 SslSha3SigningAvailable 說了算
  • SslSha3SigningAvailable 還要求先設定好簽署憑證資料。在 ConfigureSslSigningCredentials 之前問它,即使 SslLibraryVersion 回報的是完好無缺的 3.x 程式庫,它也答 False。看起來像後端缺失,其實只是順序問題
  • 32 位元執行檔載不動 libcrypto-3-x64.dll。這個後端剛接上時,指向 x64 DLL 的 Win32 組建回報 SslAvailable = False,而 SslMissingSymbols 只吐出程式庫路徑,症狀讀起來像符號綁定斷了;元兇是位元數,換上與執行檔相符的 DLL 就好了

仍含 24H2 之前機器的 Windows 機群,可以讓有 SHA-3 的機器繼續用 CNG,其餘透過 OpenSSL 交給簽署器提供者。IPadesSignerProvider 有兩個方法。FPdfCryptoSsl 裡的 ResolveSslSignerForDigest 傳回一個簽署器,其宣告的摘要、簽章演算法、摘要長度與 PSS 鹽值全都跟隨所請求的演算法;設好提供者之後,ResolvePadesSignerForDigest 會核對傳回的簽署器宣告的正是呼叫所要求的摘要,不符則拋出 EPadesCrypto。為 SHA3-256 打造的提供者,蓋不出 SHA3-384 的請求。不過提供者是全處理程序共用的,並發簽署的伺服器應該在啟動時設定一次,而不是像這段示範那樣每次呼叫都設;DLL 不在搜尋路徑上時,ConfigureSslLibraryPath 替載入器指路到 libcrypto

Delphi 的 SHA-3 簽章可用性流程:Windows CNG 提供者自 24H2 起提供、OpenSSL 版本與位元數防護,以及 PDFium Component 的 EPadesCryptoUnavailable 失敗路徑
可用性屬於機器,檢查就該放在啟動時:有 SHA-3 的用 CNG,其餘用 OpenSSL,絕不靜默退回 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 路徑
    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 用設定的憑證簽署
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

遠端或 HSM 簽署器如何得知摘要?

遠端簽署把工作拆成兩半。PreparePadesRemoteSignature 寫出一份帶空 /Contents 槽位的 PDF,外加一個 TPadesRemoteSigningRequest;金鑰持有方稍後——往往來自另一個處理程序或另一台機器——透過HSM 與雲端金鑰所用的準備與完成工作階段交回分離式 CMS。準備端您設定 TPadesRemoteSignOptions.DigestAlgorithm,比如 cdaSha3_384,request 便把這個選擇記在 DocumentDigest 旁的 TPadesRemoteSigningRequest.DigestAlgorithm 裡——那是 CMS messageDigest 必須承載的值。從此刻起,request 就是唯一依據。CompletePadesRemoteSignature 用 request 裡儲存的演算法重算 /ByteRange 摘要,從此不再過問選項;否則準備與完成之間的設定變動,就會拿新演算法去審判舊 request。建在元件之上的簽署服務,也應該從同一個地方取演算法:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // 鏈結 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;   // OpenSSL 用空指紋
  SignerOpts.PaddingScheme := Padding;
  // 摘要由 request 決定,這個服務的設定說了不算
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

request 之內,摘要長度是演算法唯一的證據,同時兼任指紋。在讀取任何串流之前,CompletePadesRemoteSignature 會把 Length(DocumentDigest) 與 CmsDigestByteLength(DigestAlgorithm) 相比,被截斷的摘要或被改標的演算法在此刻就失敗,目的串流從頭到尾沒被碰過。光憑長度分不出 SHA3-256 與 SHA-256——都是 32 位元組——所以摘要值還會與傳回 CMS 內的 messageDigest 比對。相較之下,準備好的 PDF 對 SHA-256 與 SHA3-384 是逐位元組相同的:佔位內容只有 /ByteRange 數字,沒有摘要。檔案相同,很容易被誤讀成設定被無視。遠端簽署隨 Enterprise 版出貨,單元是 FPdfPadesRemote

變體本身很少是最難的決定;RSA 金鑰通常就是 cdaSha3_256,正式環境裡出問題的是 CNG 或 libcrypto 產不出它的主機,這正是可用性檢查該放在應用程式啟動時的原因——凡是部署PDFium Component for Delphi 的地方都是