HotPDF 透過 THPDFCSCSignatureProvider 用遠端 Cloud Signature Consortium(CSC)服務持有的私鑰簽署 PDF 文件,這個簽章提供者驅動 CSC API——credential info、授權、signatures/signHash 與輪詢——而 HTTP 傳輸與 OAuth 存取權杖由您的 Delphi 應用供應。金鑰永遠不出服務的 HSM
這越來越是拿到合格簽章金鑰的唯一途徑。信任服務提供者發給你的是 CSC 端點與 OAuth 用戶端,不是 PFX 檔、也不是 USB token,所以沒有東西可以像透過 CNG 與 CAPI 用 Windows 憑證儲存區簽章那樣載進本機憑證庫。天真的整合會以可預期的方式失敗:signHash 呼叫逾時,重試把同一份合約簽了兩次;或一批四十張發票觸發四十個一次性密碼,因為每個雜湊都各自授權。這個提供者做的事,大部分就是防著這兩種失敗
HotPDF 為什麼把 HTTP 留給您的應用?
因為傳輸層正是每個部署都不一樣的地方。代理、TLS pinning、用戶端憑證、企業 OAuth 金庫與日誌政策全住在 HTTP 層,所以 THPDFCSCSignatureProvider 只編排協定狀態,每個請求呼叫一次 THPDFCSCTransport 函式。提供者遞給您一個 THPDFCSCTransportRequest:Method(永遠 POST)、由 ServiceBaseURL 加端點路徑拼出的完整 URL、現成的 Authorization bearer 標頭、ContentType、JSON Body、IdempotencyKey、Attempt 次數與 MaxResponseBytes。您填一個 THPDFCSCTransportResponse:StatusCode、Body 與 RetryAfterMS,並回傳 ctsSuccess、ctsTemporaryFailure、ctsPermanentFailure 或 ctsCancelled 之一
uses
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: TStringStream;
Reply: TMemoryStream;
Headers: TNetHeaders;
HttpResp: IHTTPResponse;
begin
Response := Default(THPDFCSCTransportResponse);
Body := TStringStream.Create(string(Request.Body), TEncoding.UTF8);
Reply := TMemoryStream.Create;
try
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); // socket 或 DNS 出事:可重試
end;
Response.StatusCode := HttpResp.StatusCode; // 503 原樣回報,不做分類
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
值得記住的一條規則:伺服器真的應答了,就回傳 ctsSuccess,哪怕是 503。狀態碼由提供者分類,把 429 變成 ctsPermanentFailure 的傳輸層,會悄悄廢掉下面要講的重試邏輯。建構子在另一個方向上很嚴:傳輸函式缺席、CredentialID 為空、AccessToken 與權杖回呼都沒給、預算超出範圍、或 ServiceBaseURL 不是 HTTPS,都丟 EHPDFCSCSignatureProviderError。裸 http:// 只在 AllowInsecureHTTP 下接受,那個開關屬於測試台,別處都別放
SAD 是什麼,HotPDF 為什麼用一次就丟?
THPDFCSCSignatureProvider 把 Signature Activation Data(SAD)當一次性物:signatures/signHash 被接受的那一刻就從提供者狀態清掉,就算簽章本身稍後才經非同步輪詢抵達也一樣。SAD 是服務手中「簽署者核可了這一批雜湊」的證明,一個賴在記憶體裡的 SAD,是一筆等著花在錯誤文件上的授權
用 THPDFCSCOptions.Default 的預設值——RequireSAD 與 AutoAuthorize 都是 True——提供者載入一次 credentials/info,向您的 THPDFCSCAuthenticationCallback 要 authData 值(OTP、PIN,憑證的 auth 區塊要什麼就給什麼),然後發 credentials/authorize。200 直接帶回 SAD;202 帶回一個句柄,透過 credentials/authorizeCheck 輪詢,最多 MaxPollAttempts(60)次、間隔 PollIntervalMS(250 ms)。回呼最多回傳 32 個值,每個帶最長 256 位元組的非空 ID 與最長 4,096 位元組的值。若網路在 signHash 被接受之前斷線,自動取得的 SAD 會留著,同一批可以重試、不必再麻煩簽署者
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD、AutoAuthorize、非同步模式、重試 2 次
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
// 您的 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);
您自己透過 Options.SAD 塞進去的 SAD 行為不同,而且是刻意的。HotPDF 無從知道它是為哪些雜湊簽發的,所以預設 SAD 只用於單雜湊請求。批次場景若關掉了 AutoAuthorize,提供者以「CSC SAD is not pinned to the requested hash batch」失敗,而不是瞎猜
SignHashBatch 怎麼用一次授權簽多份文件?
SignHashBatch 為最多 MaxBatchSignatures(64)個摘要發一次 credentials/authorize 與一次 signatures/signHash,兩個請求體由同一個陣列建出,所以 numSignatures、hashes 順序與 hashAlgorithmOID 在兩次呼叫中完全一致。這個一致性正是 CSC 多重簽章模型要求的。把單雜湊的 Sign 迴圈跑四十次,就拿到四十次授權;送出互相矛盾的 authorize 與 signHash,服務可能把 SAD 花在錯的批次上
任何網路流量之前,提供者先驗批次。每個請求必須是 1 到 1,024 位元組、帶摘要 OID 的摘要(sikDigest),所有請求必須共用同一個簽章演算法 OID、同一個摘要 OID,RSASSA-PSS 還要同一個 salt 長度。多雜湊批次還會載 credentials/info,憑證的 multisign 值小於批次時回傳 spsUnsupported。SAD 隨後釘在批次指紋上——對版本標籤、計數,以及每個請求的演算法 OID、摘要 OID、演算法、salt 長度與摘要位元組逐一加長度前綴後做的 SHA-256。換掉兩個雜湊,就是另一批,需要新的授權
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
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;
Status := Provider.SignHashBatch(Requests, 'invoices-2026-09-25-a', 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,一個 base64 DER 的 RSASSA-PSS-params 結構,帶雜湊演算法、MGF1 與 salt 長度。建它就得編 OID,而 v2.748.5 修了其中一個角落:X.690 §8.19.4 把前兩個弧折成一個值(40 × 第一弧 + 第二弧),在根 2 底下,第二弧超過 39 會把那個值推過 127,需要 base-128 多位元組形式,較早的建置沒有套用。SHA-2 的 OID 都不受影響——2.16 折成 96——但畸形的 OID 現在丟提供者自己的錯誤,而不是 EConvertError
重試的請求為什麼不會多產出一個簽章?
THPDFCSCSignatureProvider 讓每個可重試呼叫帶一個確定性的等冪鍵,並快取已完成的結果,所以回應丟失後的重試,拿回的是原本的簽章,而不是向 HSM 要新的。鍵是 csc- 接操作識別碼與階段的 hex SHA-256,而階段裡嵌著批次指紋,authorize 與 signHash 都是。用雜湊而不是截斷要緊:兩個共用前綴的長操作識別碼,在截斷下會相撞,固定長度、按內容定址的鍵則跨嘗試保持唯一與穩定
共用請求路徑裡的重試政策刻意收得很窄:
- HTTP 401 強制經存取權杖回呼刷新權杖恰好一次,已指派權杖回呼時請求重發一次;第二個 401 就是終局
- 其他 4xx 回應與
ctsPermanentFailure以spsProviderError結束呼叫,服務的error_description落進LastError - 408、429、5xx 與
ctsTemporaryFailure最多重試RetryLimit(預設 2)次,等Retry-After或RetryBaseDelayMS× 2attempt(基數 100 ms),上限MaxRetryAfterMS(5,000 ms) - 等待以 25 ms 為切片進行、每片檢查
Cancel,中斷的使用者不必熬完五秒的退避 signHash只在EnableIdempotency開啟時重試;關掉之後,送出後的逾時就是終局,因為沒有人說得準金鑰是否已經用掉
非同步簽章(operationMode "A",預設)多一道防線:responseID 在輪詢 signatures/signPolling 之前就存下,所以帶相同操作識別碼的重複呼叫接續輪詢、而不是重送。完成的批次待在以操作識別碼、憑證與指紋為鍵的快取裡,受 MaxOperationCacheEntries(128)限制,回傳的是深拷貝。快取住在提供者實例裡,重啟就沒了。等冪鍵則活得過重啟,因為它是導出的、不是隨機的,重啟的行程重用操作識別碼就送出同一把鍵,服務要不要據此去重,是服務的承諾,不是 HotPDF 的
怎麼把 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;
/Contents 佔位的大小走 EstimateSignatureSize,設了 EstimatedSignatureBytes 就用它,否則用憑證金鑰長度推出的 RSA modulus 大小。ECDSA 得自己設 EstimatedSignatureBytes,否則估算回報 spsUnsupported。自動尺寸的簽章變體在佔位太小時會重簽,而且只對宣稱 spcSafeSignRetry 的提供者這麼做——THPDFCSCSignatureProvider 只在 EnableIdempotency 開啟時宣稱。PAdES-B-T 工作流程方面,TimestampDigest 透過 signatures/timestamp 向同一個服務要時間戳權杖,上限 MaxTimestampBytes(1 MB)
CSC 提供者不做什麼?
它不簽訊息,只簽摘要。純模式的 Ed25519 與 Ed448 會把整份 signed-attributes 訊息交給提供者(sikMessage),批次驗證器把它當畸形拒收,因為 signHash 定義上就是以雜湊為基礎。提供者單元在 Free Pascal 下以普通函式型別取代匿名方法編譯,但提供者驅動的 CMS 建構器今天在 FPC 下會丟例外,所以把 CSC 簽章嵌進 PDF 目前是 Delphi 路徑
它也不替您決定政策。CredentialInfo 回報金鑰狀態、憑證狀態、授權模式、SCAL 等級與多重簽章上限,但提供者不會自己拒絕被停用的金鑰或 SCAL1 憑證——把簽署者叫到 OTP 提示之前,先自己查。而且一個提供者實例一次只簽一批:SignHashBatch 內部序列化,兩個執行緒搶不到同一個 SAD,所以吞吐量來自批次,不是來自多執行緒共用一個提供者。最終簽章合不合格,取決於信任服務與它的憑證,不是取決於把雜湊送過去的那個函式庫
CSC 提供者、CMS 與 PAdES 建構器,以及本機與 PKCS#11 提供者,都隨 HotPDF Delphi PDF component 出貨