HotPDF 经由 THPDFCSCSignatureProvider 用远程 Cloud Signature Consortium(CSC)服务托管的私钥签署 PDF 文档:这个签名 provider 驱动 CSC API——credential info、authorization、signatures/signHash 和轮询——而你的 Delphi 应用提供 HTTP 传输和 OAuth 访问令牌。密钥从不离开服务的 HSM
这越来越是拿到合格签名密钥的唯一方式。信任服务提供商给你的是一个 CSC 端点和一个 OAuth client,而不是 PFX 文件或 USB token,所以没有任何东西可以像经 CNG 与 CAPI 的 Windows 证书库签名那样装进本地证书库。天真的集成会以可预见的方式失败:一次 signHash 调用超时、重试把同一份合同签了两遍;或者一批四十张发票触发了四十次一次性密码,因为每个哈希都被单独授权。这个 provider 做的大部分事情就是防住这两种失败
为什么 HotPDF 把 HTTP 留给你的应用?
因为传输恰恰是每个部署都不一样的地方。代理、TLS pinning、客户端证书、企业 OAuth 保管库和日志策略全都住在 HTTP 层,所以 THPDFCSCSignatureProvider 只编排协议状态,每个请求调用一次 THPDFCSCTransport 函数。provider 递给你一个 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。provider 自己给状态码分类,把 429 变成 ctsPermanentFailure 的传输会悄悄废掉下面要讲的重试逻辑。构造函数在另一个方向上很严——transport 缺失、CredentialID 为空、既没有 AccessToken 也没有令牌回调、预算越界、或 ServiceBaseURL 不是 HTTPS 时,抛 EHPDFCSCSignatureProviderError。裸 http:// 只有在 AllowInsecureHTTP 打开时才被接受,那个开关属于测试台架,别处都不要用
SAD 是什么,HotPDF 为什么用完一次就扔掉它?
THPDFCSCSignatureProvider 把 Signature Activation Data(SAD)当一次性用品:signatures/signHash 被接受的那一刻就从 provider 状态里清掉,即使签名本身要晚些才经异步轮询到达。SAD 是服务关于「签名者批准了这几个特定哈希」的凭证,一份滞留在内存里的 SAD 就是一笔等着被花在错误文档上的授权
在 THPDFCSCOptions.Default 的默认值下——RequireSAD 和 AutoAuthorize 都为 True——provider 加载一次 credentials/info,向你的 THPDFCSCAuthenticationCallback 要 authData 值(OTP、PIN、该凭证的 auth 块要求的任何东西),然后 POST credentials/authorize。200 直接带回 SAD;202 带回一个句柄,经 credentials/authorizeCheck 轮询,最多 MaxPollAttempts(60)次、间隔 PollIntervalMS(250 ms)。回调最多返回 32 个值,每个的 ID 不超过 256 字节且非空、值不超过 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 client;服务答过 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 // 你的 UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
你自己经 Options.SAD 传入的 SAD 行为不同,而且是刻意的。HotPDF 无从知道它是为哪些哈希签发的,所以 provider 只把预置 SAD 用于单哈希请求。批量请求在 AutoAuthorize 关闭时,provider 以「CSC SAD is not pinned to the requested hash batch」失败,而不是瞎猜
SignHashBatch 怎么用一次授权签很多文档?
SignHashBatch 对最多 MaxBatchSignatures(64)个摘要只发一次 credentials/authorize 和一次 signatures/signHash,而且两个 body 都从同一个数组构建,让 numSignatures、hashes 的顺序和 hashAlgorithmOID 在两次调用里完全一致。这个一致性正是 CSC 多重签名模型的要求。把单哈希的 Sign 方法循环四十次,你得到四十次授权;发一个 authorize 和一个互相矛盾的 signHash,服务可能把 SAD 消耗在错误的批次上
在任何网络流量之前,provider 先校验批次。每个请求必须是一个 1 到 1,024 字节的摘要(sikDigest)并带摘要 OID,所有请求必须共享同一个签名算法 OID、同一个摘要 OID,RSASSA-PSS 还要同一个盐长。多哈希批次还会加载 credentials/info,凭证的 multisign 值小于批次数时返回 spsUnsupported。SAD 随后被钉在一个批次指纹上——对一个版本标签、数量、以及每个请求的算法 OID、摘要 OID、算法、盐长和摘要字节(都带长度前缀)算的 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 时 provider 还会发 signAlgoParams——一个 base64 DER 的 RSASSA-PSS-params 结构,内含哈希算法、MGF1 和盐长。构建它意味着编码 OID,而 v2.748.5 修掉了其中一个角落:X.690 §8.19.4 把前两个 arc 折成一个值(40 × 第一 arc + 第二 arc),在 2 根下第二个 arc 超过 39 会把该值推过 127,那里需要 base-128 多字节形式,而早先的构建没有应用它。没有任何 SHA-2 OID 受影响——2.16 折成 96——但畸形 OID 现在抛 provider 自己的错误,而不是 EConvertError
为什么重试的请求不会产生第二个签名?
THPDFCSCSignatureProvider 让每个可重试调用都带一个确定性的幂等键并缓存已完成的结果,所以丢失应答后的重试返回的是原来的签名,而不是向 HSM 再要新的。键是 csc- 后接操作标识与阶段的十六进制 SHA-256,阶段里为 authorization 和 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)限制,返回的是深拷贝。这个缓存活在 provider 实例里,进程重启就没了。幂等键却能活下来,因为它是推导出来的而不是随机的,重启后复用同一操作标识的进程发出同一个键——服务是否据此去重是服务的承诺,不是 HotPDF 的
怎么把 CSC 签名放进 PDF?
把 provider 连同 GetCertificateChain 给出的终端实体证书一起传给 HPDFCMSSignPDFStreamWithProvider;HotPDF 构建 CMS SignedData,provider 签署 signed attributes 的摘要。输入 PDF 需要 /ByteRange 和 /Contents 占位——THPDFPage.AddSignedSignatureField 会写,与 HotPDF 的 PAdES 签名工作流里完全一样;provider 模型则与 HotPDF 面向 ML-DSA 与 EdDSA 的可插拔签名 provider讲的是同一套
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 模数尺寸。ECDSA 要自己设 EstimatedSignatureBytes,否则估算报告 spsUnsupported。自动尺寸的签名变体在占位太小时会重签,而且只对宣称 spcSafeSignRetry 的 provider 这么做——THPDFCSCSignatureProvider 只在 EnableIdempotency 打开时才宣称。PAdES-B-T 工作流里,TimestampDigest 经 signatures/timestamp 向同一服务要时间戳令牌,封顶 MaxTimestampBytes(1 MB)
CSC provider 不做什么?
它不签消息,只签摘要。纯模式的 Ed25519 和 Ed448 会把整份 signed-attributes 消息(sikMessage)递给 provider,批次校验器把它当畸形拒绝,因为 signHash 按定义就是基于哈希的。provider 单元能在 Free Pascal 下编译(以普通函数类型代替匿名方法),但 provider 驱动的 CMS 构建器目前在 FPC 下会抛异常,所以把 CSC 签名嵌进 PDF 是一条 Delphi 路径
它也不替你做政策决定。CredentialInfo 报告密钥状态、证书状态、授权模式、SCAL 级别和多重签名上限,但 provider 自己不会拒绝被禁用的密钥或 SCAL1 凭证——在把 OTP 提示亮给签名者之前先查这些。而且一个 provider 实例一次只签一批:SignHashBatch 内部串行化,两个线程抢不到同一个 SAD,这意味着吞吐来自批量,而不是在多个工作线程间共享一个 provider。最终签名是否合格取决于信任服务及其凭证,而不是把哈希送过去的那个库
CSC provider、CMS 与 PAdES 构建器以及本地与 PKCS#11 provider 都随 HotPDF Delphi PDF component 发布