技術文章

HotPDF CSC 遠端簽章:在 Delphi 中做雲端 PDF 簽章

一個發給遠端簽章服務的 signatures/signHash 請求,可能在 HSM 已經產出簽章之後才逾時,而用戶端無從知道自己落在那條線的哪一側。盲目重試,一次核准可能在同一份合約上留下兩個簽章;拒絕重試,夜間批次死在一個 503 上。一次一個雜湊地簽四十張發票,簽署人要打四十次一次性密碼

任何 Cloud Signature Consortium(CSC)整合都得同時解掉這兩件事。發合格金鑰的信任服務提供商,越來越常給的是 CSC API 端點與 OAuth 用戶端,而不是 PFX 檔或 USB token——沒有東西可以像透過 CNG 與 CAPI 做的 Windows 憑證存放區簽章那樣匯入本機存放區。金鑰留在提供商的 HSM 裡,每個簽章都是一趟 HTTPS 來回

HotPDF 為什麼把 HTTP 留給您的應用?

HotPDF 用 THPDFCSCSignatureProvider 走遠端 CSC 服務簽章——一個跑 CSC v2 協定(credential info、authorization、signatures/signHash、輪詢)的簽章提供者,HTTP 傳輸與 OAuth 權杖則歸您的 Delphi 程式碼管。代理、TLS pinning、用戶端憑證、權杖保管庫與請求日誌全部住在 HTTP 層,而它們在每個部署裡都不一樣

每個請求,提供者組一個 THPDFCSCTransportRequest、呼叫您的 THPDFCSCTransport 函式。記錄帶完整 URL、現成的 Authorization bearer 標頭、JSON Body、IdempotencyKey、OperationIdentifier 與 Attempt 次數;您的函式填一個帶 StatusCode、Body、RetryAfterMS 的 THPDFCSCTransportResponse,然後回傳 ctsSuccess、ctsTemporaryFailure、ctsPermanentFailure 或 ctsCancelled

HotPDF 的 CSC 傳輸邊界示意圖:THPDFCSCSignatureProvider 編排協定,把帶 POST 方法、完整 URL、現成 Authorization bearer 標頭、JSON body、IdempotencyKey 與嘗試次數的 THPDFCSCTransportRequest 交給您的程式碼,您回傳 StatusCode、Body、RetryAfterMS 與四個 cts 狀態值之一,金鑰始終不出 HSM
狀態碼由提供者自己分類,把應答過的 503 變成永久失敗的傳輸層,會悄悄廢掉重試邏輯;代理與 TLS 政策則留在您自己的程式碼裡

用 RTL 的 THTTPClient 建傳輸層,四十行上下就夠。body 以原始位元組送出,JSON 不經過任何代碼頁轉換:

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  HPDFSignatureProvider, HPDFCSCSignatureProvider;

function MakeCSCTransport(Client: THTTPClient): THPDFCSCTransport;
begin
  Result :=
    function(const Request: THPDFCSCTransportRequest;
      out Response: THPDFCSCTransportResponse): THPDFCSCTransportStatus
    var
      Body, Reply: TMemoryStream;
      Headers: TNetHeaders;
      HttpResp: IHTTPResponse;
    begin
      Response := Default(THPDFCSCTransportResponse);
      Body := TMemoryStream.Create;
      Reply := TMemoryStream.Create;
      try
        if Request.Body <> '' then
          Body.WriteBuffer(Request.Body[1], Length(Request.Body));
        Body.Position := 0;
        Headers := [TNameValuePair.Create('Authorization', string(Request.Authorization)),
                    TNameValuePair.Create('Content-Type', string(Request.ContentType))];
        if Request.IdempotencyKey <> '' then   // 標頭名稱照您的服務文件寫
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // DNS、TLS 或 socket 問題:可重試
        end;
        Response.StatusCode := HttpResp.StatusCode; // 429 與 503 原樣上報、不做分類
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After 以秒計;HTTP-date 形式的值在此回退為 0
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

要記住的一條規則:伺服器有應答就回傳 ctsSuccess——哪怕回的是 503。狀態碼由提供者自己分類,把 429 映射成 ctsPermanentFailure 的傳輸層等於關掉下文的重試邏輯。大於 MaxResponseBytes(預設 2 MB)的 body,無論傳輸層回什麼,都以 spsMalformed 拒收

建構子在另一個方向上同樣嚴格:傳輸層缺失、ServiceBaseURL 或 CredentialID 為空、AccessToken 與權杖回呼一個都沒給、預算超出範圍、或 URL 不是 HTTPS,都丟 EHPDFCSCSignatureProviderError。純 http:// 只在設了 AllowInsecureHTTP 時放行——那個旗標屬於指向本機 mock 服務的測試架具

SAD 是什麼,為什麼只能用一次?

THPDFCSCSignatureProvider 把 Signature Activation Data(SAD)當一次性憑據:signatures/signHash 被接受的那一刻就從提供者狀態裡清除——即使簽章本身稍後才經非同步輪詢回來。SAD 是服務端「簽署人核准了這一批特定雜湊」的證明;賴在記憶體裡的 SAD,是一筆等著被花在錯誤文件上的授權

用 THPDFCSCOptions.Default 的預設(RequireSAD 與 AutoAuthorize 都是 True),提供者載入一次 credentials/info、向您的 THPDFCSCAuthenticationCallback 要憑證 auth 區塊要求的 authData 值——通常是一組 OTP 或 PIN——然後送出 credentials/authorize。200 答案直接帶 SAD;202 帶一個 handle,提供者經 credentials/authorizeCheck 輪詢,除非服務送來 Retry-After,至多 MaxPollAttempts(60)次、每次間隔 PollIntervalMS(250 ms)。回呼至多回傳 32 個值,每個 ID 非空且至多 256 位元組、值至多 4,096 位元組

清除發生在接受之後。網路在 signHash 被接受之前斷線時,自動取得的 SAD 留在原地、仍然綁著同一個批次,重試因此不需要第二次 OTP 提示:簽署人核准了一次,提供者就把那次核准花一次

HotPDF 的 SAD 生命週期示意圖:RequireSAD 與 AutoAuthorize 開啟時,提供者載入一次 credentials/info,向驗證回呼要 OTP 或 PIN,發出 credentials/authorize,答案為 202 時以 250 ms 間隔輪詢 credentials/authorizeCheck 最多 60 次,signatures/signHash 被接受的那一刻清掉 Signature Activation Data,接受前網路斷線則保留已取得的 SAD
賴在記憶體裡的 SAD 是一筆等著花在錯誤文件上的授權;經選項預先塞入的 SAD 只用於單雜湊請求
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, async mode, 2 retries
  Options.ServiceBaseURL := 'https://csc.example.com/csc/v2';
  Options.CredentialID := 'contracts-signing-01';
  Options.ClientData := 'invoice-run-2026-09';

  Provider := THPDFCSCSignatureProvider.Create(Options, MakeCSCTransport(HttpClient),
    function(ForceRefresh: Boolean; const OperationIdentifier: AnsiString;
      out AccessToken: AnsiString; out ExpiresAtUTC: TDateTime): THPDFSignatureProviderStatus
    begin
      // TokenVault 是您自己的 OAuth 用戶端;401 之後 ForceRefresh 為 True
      if not TokenVault.Acquire(ForceRefresh, AccessToken, ExpiresAtUTC) then
        Exit(spsProviderError);
      Result := spsValid;
    end,
    function(const CredentialID, CredentialInfoJSON, OperationIdentifier: AnsiString;
      out Values: THPDFCSCAuthenticationValues): THPDFSignatureProviderStatus
    var
      Otp: string;
    begin
      if not AskSignerForOtp(Otp) then   // 您自己的介面
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

權杖回呼在快取權杖缺失、或距您回報的到期時間不足 TokenRefreshSkewSeconds(30 秒)時觸發;ExpiresAtUTC 為零表示權杖一直重用、直到 401 強制刷新

自己經 Options.SAD 塞進去的 SAD 行為不同——而且是有意的。沒有任何東西告訴提供者那個 SAD 涵蓋哪些雜湊,所以預設的 SAD 只用於單雜湊請求;開著 AutoAuthorize 的多雜湊批次,它丟棄預設值、為批次另行授權;關掉 AutoAuthorize 時,它以「CSC SAD is not pinned to the requested hash batch」失敗、不猜。只靠 OAuth scope 授權、從不簽發 SAD 的服務,把 RequireSAD 設成 False 即可,此時 signHash body 裡不帶 SAD 成員

一次 OTP 提示,簽四十張發票

SignHashBatch 為至多 MaxBatchSignatures(64)個摘要送一次 credentials/authorize 與一次 signatures/signHash,兩個 body 由同一個陣列組出,所以 numSignatures、hashes 的順序與 hashAlgorithmOID 在兩個呼叫之間一致。把單雜湊的 Sign 方法迴圈四十次,得到的是四十次授權;送出一對互不吻合的 authorize 與 signHash,服務可能把 SAD 花在錯的批次上

驗證在任何網路流量之前就跑。每個請求必須是 1 到 1,024 位元組、帶摘要 OID 的摘要(sikDigest),所有請求必須共用同一個簽章演算法、同一個摘要 OID,RSASSA-PSS 還要同一個鹽長度。多雜湊批次也會載入 credentials/info,憑證的 multisign 值小於批次大小時回傳 spsUnsupported。授權接著綁到請求清單的順序敏感 SHA-256 指紋上——對調兩個雜湊就是另一個批次、需要重新核准

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests:您算好的 SHA-256 值
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;   // AlgorithmOID 為空時推導 signAlgo
    Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
    Requests[I].InputKind := sikDigest;
    Requests[I].Input := Digests[I];
  end;

  // 從您自己的執行紀錄導出 ID,別每次呼叫都生一個新 GUID
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // 同一個 OpId:完成的批次從快取回來、掛起的非同步作業續接輪詢、
    // 未送出的請求重用同一把等冪鍵
    Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status <> spsValid then
    raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
      [Provider.LastHTTPStatus, Provider.LastError]);
  // Signatures[I] 對應 Digests[I];數量已對著請求檢查過
end;

RSASSA-PSS 時,提供者附上 signAlgoParams——一個帶雜湊演算法、MGF1 與鹽長度的 base64 DER RSASSA-PSS-params 結構。X.690 §8.19.4 把前兩個 OID 弧摺成一個值(40 × 第一弧 + 第二弧),在 2 這個根下,第二弧超過 39 就把總值推過 127、需要 base-128 多位元組形式。沒有任何 SHA-2 OID 走到那條路——2.16 摺成 96——所以一個編碼器可以通過所有 SHA-2 測試、卻在這裡是錯的。提供者寫多位元組形式,畸形 OID 回報自己的 EHPDFCSCSignatureProviderError、而不是 EConvertError

重試的請求為什麼不會多產出一個簽章?

THPDFCSCSignatureProvider 給每個可重試呼叫一把確定性的等冪鍵、快取完成的結果,所以丟了回應之後的重試,拿回的是原始簽章、而不是跟 HSM 要新的。鍵是 csc- 接上操作識別碼與協定階段的十六進位 SHA-256;authorize 與 signHash 的階段還含批次指紋。用雜湊而非截斷,鍵保持固定長度,兩個共用前綴的長操作識別碼也不會相撞

共用請求路徑裡的重試政策很窄:

  • HTTP 401 經存取權杖回呼強制刷新權杖恰好一次、請求重送一次;第二個 401 是終局,沒有權杖回呼時第一個就是
  • 其他 4xx 與 ctsPermanentFailure 以 spsProviderError 結束呼叫,服務的 error_description(或 error)落進 LastError
  • 408、429、5xx 與 ctsTemporaryFailure 至多重試 RetryLimit(預設 2)次,等待 Retry-After 或每次翻倍的 RetryBaseDelayMS(100 ms),上限 MaxRetryAfterMS(5,000 ms)
  • 等待以 25 ms 的切片進行、每片檢查 Cancel,使用者按取消不必陪坐五秒退避
  • signHash 只在 EnableIdempotency 開啟時重試;關掉它,送出之後的逾時就是終局——沒有人說得準那個雜湊是否已經簽過
HotPDF 的重試政策示意圖:每個可重試呼叫帶由操作識別碼與階段雜湊出的確定性 csc- 等冪鍵,HTTP 401 強制刷新權杖恰好一次,其他 4xx 以 spsProviderError 結束,408、429、5xx 或暫時性傳輸失敗最多重試 RetryLimit 2 次,等待 Retry-After 或指數退避,上限 5,000 ms
完成的批次按操作識別碼、憑證與指紋快取;非同步模式下,存下的 responseID 讓重複呼叫接續輪詢,而不是重送雜湊

非同步簽章(operationMode "A",預設,validity_period 用 AsyncValidityPeriodMS 的 60,000 ms)多一道防護:responseID 在提供者開始輪詢 signatures/signPolling 之前就存下來,同一個操作識別碼的再次呼叫接手掛起的作業、而不是重送雜湊。完成的批次按提供者實例快取,至多 MaxOperationCacheEntries(128)條,重啟之後快取消失。等冪鍵活得下來,因為它是導出的、不是隨機的:重啟後重用操作識別碼的行程送出同一把鍵,服務要不要據此去重,屬於服務契約的一部分

這一切都懸在操作識別碼上,而這個參數很容易留空。給 SignHashBatch 傳 ',提供者退回到請求上的 OperationIdentifier、再退回到新生成的 GUID。每次呼叫一個新 GUID,等於您自己的重試迴圈每次都開新操作:新快取槽、新等冪鍵,可能還多一次 OTP 提示;也意味著 Provider.Cancel 沒有目標可打——您的程式碼從沒見過那個 GUID。識別碼請從已經持久化的東西導出,例如批次號或文件 ID,並讓 EnableIdempotency 保持預設的 True。服務若拒收那個標頭,在傳輸層把它拿掉即可,別關選項

把 CSC 簽章嵌進 PDF

把提供者連同 GetCertificateChain 取得的末端實體憑證一起交給 HPDFCMSSignPDFStreamWithProvider;HotPDF 建 CMS SignedData,提供者簽 signed attributes 的摘要。輸入的 PDF 需要有 THPDFPage.AddSignedSignatureField 寫出的 /ByteRange 與 /Contents 佔位,與HotPDF 的 PAdES 簽章工作流程裡的一模一樣;提供者模型則與HotPDF 可插拔簽章提供者支援 ML-DSA 與 EdDSA 背後的是同一套

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC 把末端實體憑證排在第一個
  if Length(Chain) = 0 then
    raise Exception.Create('Credential returned no certificate');

  SignOpts := HPDFCMSDefaultOptions(palBaseline_B_B);
  SignOpts.DigestAlgorithm := cmsdaSHA256;
  SignOpts.SignatureScheme := cmsRSAPKCS1v15;

  Src := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  Dst := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    if not HPDFCMSSignPDFStreamWithProvider(Src, Dst, Chain[0], Provider, '', SignOpts) then
      raise Exception.Create('PDF signing failed');
  finally
    Dst.Free;
    Src.Free;
  end;
end;

佔位符尺寸方面,設了 EstimatedSignatureBytes 時 EstimateSignatureSize 回傳它,否則回傳由 credentials/info 金鑰長度導出的 RSA 模數大小。ECDSA 金鑰會得到 spsUnsupported、佔位符估算器退回到憑證大小,所以自己設 EstimatedSignatureBytes 才有可預期的結果。HPDFCMSSignPDFStreamWithProviderAutoSize 在佔位符太小時重建並重簽,而且只對宣告了 spcSafeSignRetry 的提供者這麼做——THPDFCSCSignatureProvider 在 EnableIdempotency 開啟時就宣告它,這是別去動那個預設的又一個理由

PAdES-B-T 方面,TimestampDigest 經 signatures/timestamp 向同一個服務要 RFC 3161 時戳權杖,可帶 nonce,大小上限 MaxTimestampBytes(1 MB)

留在您程式碼裡的政策決策

提供者只簽摘要。純模式的 Ed25519 與 Ed448 交給簽章提供者的是整份已簽屬性訊息(sikMessage),批次驗證器把它當畸形拒收——signHash 按定義就是以雜湊為輸入。Free Pascal 之下,提供者單元以普通函式型別取代匿名方法編譯,SignHashBatch 與 TimestampDigest 在那裡照樣可呼叫;把結果嵌進 PDF 的 HPDFCMSSignPDFStreamWithProvider 則是 Delphi 路徑

提供者也把政策留給您。CredentialInfo 回報 KeyStatus、CertificateStatus、AuthorizationMode、SCAL 與 MultiSignatures,但停用的金鑰或 SCAL1 憑證要不要拒收是您的決定。請在 RefreshCredentialInfo 之後、簽署人看到 OTP 提示之前就檢查這些欄位;讓簽署人打完驗證碼才收到服務的拒絕,比一開始就給出明確錯誤難受得多

一個提供者實例一次只跑一個批次:SignHashBatch、RefreshCredentialInfo 與 TimestampDigest 共用一把鎖,兩個執行緒搶不到同一個 SAD。吞吐量靠更大的批次換,上限是憑證的 multisign 值。產出的簽章算不算合格,由信任服務與它簽發的憑證說了算

CSC 提供者與 CMS、PAdES 建構器以及本機與 PKCS#11 簽章提供者一起,隨 HotPDF Delphi PDF component 出貨:遠端、token 與檔案式簽章全部接進同一組 PDF 簽章呼叫