发给远程签名服务的 signatures/signHash 请求,可能在 HSM 已经产出签名之后才超时,而客户端说不清自己落在那条线的哪一侧。盲目重试,一次批准可能在同一份合同上留下两个签名。拒绝重试,夜间批处理死在一个 503 上。一次一个哈希地签四十张发票,签名者就得输四十次一次性密码
任何 Cloud Signature Consortium(CSC)集成都得同时解决这两件事。发放合格密钥的信任服务提供商,越来越多地给出的是 CSC API 端点加 OAuth client,而不是 PFX 文件或 USB token——没有任何东西可以像经 CNG 与 CAPI 的 Windows 证书库签名那样装进本地证书库。密钥留在提供商的 HSM 里,每个签名都是一趟 HTTPS 往返
为什么 HotPDF 把 HTTP 留给你的应用?
HotPDF 经 THPDFCSCSignatureProvider 走远程 CSC 服务签名:这个签名 provider 跑 CSC v2 协议(credential info、authorization、signatures/signHash、轮询),而你的 Delphi 代码握着 HTTP 传输和 OAuth 令牌。代理、TLS pinning、客户端证书、令牌保管库和请求日志都住在 HTTP 层,而每个部署的这些东西都不一样
每个请求,provider 构造一个 THPDFCSCTransportRequest 并调用你的 THPDFCSCTransport 函数。记录里带完整 URL、现成的 Authorization bearer 头、JSON Body、IdempotencyKey、OperationIdentifier 和 Attempt 次数。你的函数填一个 THPDFCSCTransportResponse——StatusCode、Body 和 RetryAfterMS——然后从 ctsSuccess、ctsTemporaryFailure、ctsPermanentFailure 或 ctsCancelled 里返回一个
基于 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 日期形式的值此处回落为 0
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
值得记住的一条规则:服务器只要应答了就返回 ctsSuccess,哪怕是 503。provider 自己给状态码分类,把 429 映射成 ctsPermanentFailure 的传输会悄悄关掉下文的重试逻辑。超过 MaxResponseBytes(默认 2 MB)的 body,无论传输返回什么,都以 spsMalformed 拒收
构造函数在另一个方向上很严。transport 缺失、ServiceBaseURL 或 CredentialID 为空、既无 AccessToken 也无令牌回调、预算越界、或 URL 不是 HTTPS 时,抛 EHPDFCSCSignatureProviderError。裸 http:// 只有在 AllowInsecureHTTP 置位时才通过,那个开关属于指向本地 mock 服务的测试台架
SAD 是什么,为什么它只能用一次?
THPDFCSCSignatureProvider 把 Signature Activation Data(SAD)当一次性用品:signatures/signHash 被接受的那一刻就从 provider 状态里清掉,哪怕签名本身要晚些才经异步轮询到达。SAD 是服务关于「签名者批准了这几个特定哈希」的凭证。一份滞留内存的 SAD,是一笔等着被花在错误文档上的授权
在 THPDFCSCOptions.Default 的默认值下(RequireSAD 与 AutoAuthorize 均为 True),provider 加载一次 credentials/info,向你的 THPDFCSCAuthenticationCallback 要凭证 auth 块要求的 authData 值——通常是一个 OTP 或 PIN——然后 POST credentials/authorize。200 直接带回 SAD;202 带回一个句柄,provider 经 credentials/authorizeCheck 轮询,最多 MaxPollAttempts(60)次、间隔 PollIntervalMS(250 ms),服务发 Retry-After 时除外。回调至多返回 32 个值,每个的 ID 非空、至多 256 字节,值至多 4,096 字节
清除发生在接受之后。网络在 signHash 被接受之前断掉时,自动获取的 SAD 保持原位,仍系于同一批次,重试因而不需要再问签名者一遍。签名者批准了一次;provider 把这次批准花一次
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
// TokenVault 是你自己的 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);
令牌回调在缓存令牌缺失、或距你上报的过期时间不足 TokenRefreshSkewSeconds(30 秒)时触发;ExpiresAtUTC 为零表示令牌一直复用,直到某个 401 强制刷新
你自己经 Options.SAD 传入的 SAD 行为不同,而且是刻意的。没有任何东西告诉 provider 这份 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 时 provider 还会发 signAlgoParams——一个 base64 DER 的 RSASSA-PSS-params 结构,带哈希算法、MGF1 和盐长。X.690 §8.19.4 把 OID 前两个 arc 折成一个值(40 × 第一 arc + 第二 arc),在 2 根下第二个 arc 超过 39 会把该值推过 127,那里需要 base-128 多字节形式。没有任何 SHA-2 OID 走到那条路径——2.16 折成 96——所以一个编码器可以通过全部 SHA-2 测试却仍然是错的。provider 写多字节形式,畸形 OID 报自己的 EHPDFCSCSignatureProviderError 而不是 EConvertError
为什么重试的请求不会产生第二个签名?
THPDFCSCSignatureProvider 给每个可重试调用一个确定性的幂等键,并缓存已完成的结果,所以丢失应答后的重试拿回的是原签名,而不是向 HSM 再要新的。键是 csc- 后接操作标识与协议阶段的十六进制 SHA-256,authorization 与 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打开时重试;关掉它,提交之后的超时就是终局,因为没人说得清那个哈希是否已经被签过
异步签名(operationMode 为 "A",默认值,validity_period 取 AsyncValidityPeriodMS 即 60,000 ms)另有一道护栏。responseID 在 provider 开始轮询 signatures/signPolling 之前就被存下,于是相同操作标识的重复调用接上未完成的作业,而不是重新提交哈希。已完成的批次按 provider 实例缓存,至多 MaxOperationCacheEntries(128)条,重启之后缓存即失。幂等键却能幸存,因为它是推导出来的而非随机:重启后复用同一操作标识的进程发出同一个键,服务是否据此去重,属于服务自己的契约
这一切都系在操作标识上,而这个参数很容易留空。给 SignHashBatch 传 '',provider 回退到请求上的 OperationIdentifier,再回退到新 GUID。每次调用一个新 GUID,意味着你自己的重试循环每次都开一个新操作:新缓存槽、新幂等键,可能还有第二次 OTP 提示;也意味着 Provider.Cancel 无的放矢,因为你的代码从没见过那个 GUID。从你已经持久化的东西推导标识——批次号或文档 ID——并让 EnableIdempotency 保持默认 True。服务拒收该头时,在传输层丢掉它,而不是关选项
把 CSC 签名嵌进 PDF
把 provider 连同 GetCertificateChain 给出的终端实体证书一起交给 HPDFCMSSignPDFStreamWithProvider;HotPDF 构建 CMS SignedData,provider 签署 signed attributes 的摘要。输入 PDF 需要 THPDFPage.AddSignedSignatureField 写出的 /ByteRange 与 /Contents 占位——与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;
占位尺寸方面,设了 EstimatedSignatureBytes 时 EstimateSignatureSize 返回它,否则返回按 credentials/info 里密钥长度推导的 RSA 模数尺寸。ECDSA 密钥它答 spsUnsupported,占位估算器回退到证书,所以自己设 EstimatedSignatureBytes 结果可预期。HPDFCMSSignPDFStreamWithProviderAutoSize 在占位太小时重建并重签,且只对宣称 spcSafeSignRetry 的 provider 这么做——THPDFCSCSignatureProvider 在 EnableIdempotency 打开时宣称它,这是别动那个默认值的又一个理由
PAdES-B-T 方面,TimestampDigest 经 signatures/timestamp 向同一服务要 RFC 3161 令牌,可带 nonce,尺寸封顶 MaxTimestampBytes(1 MB)
留在你代码里的政策决定
provider 签的是摘要。纯模式的 Ed25519 与 Ed448 会把整份 signed-attributes 消息(sikMessage)递给签名 provider,批次校验器把它当畸形拒绝——signHash 按定义就是基于哈希的。Free Pascal 下 provider 单元可以编译(以普通函数类型代替匿名方法),SignHashBatch 与 TimestampDigest 在那里可调用;经 HPDFCMSSignPDFStreamWithProvider 把结果嵌进 PDF 是 Delphi 路径
provider 也把政策留给你。CredentialInfo 报告 KeyStatus、CertificateStatus、AuthorizationMode、SCAL 和 MultiSignatures,但被禁用的密钥或 SCAL1 凭证要你亲自拒绝。在 RefreshCredentialInfo 之后、签名者看到 OTP 提示之前就查这些字段;签名者输了验证码之后服务才拒绝,比一开始就给出明确错误难受得多
一个 provider 实例一次跑一批,因为 SignHashBatch、RefreshCredentialInfo 和 TimestampDigest 共享一把锁,两个线程抢不到同一个 SAD。吞吐来自更大的批次,上限是凭证的 multisign 限制。最终签名算不算合格,由信任服务及其签发的凭证决定
CSC provider 与 CMS 及 PAdES 构建器、本地与 PKCS#11 签名 provider 一起随 HotPDF Delphi PDF component 发布,远程、令牌与文件签名于是都喂进同一组 PDF 签名调用