技术文章

HotPDF CSC 远程签名:Delphi 云端 PDF 签名

发给远程签名服务的 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 里返回一个

HotPDF CSC 传输边界图解:THPDFCSCSignatureProvider 编排协议并递给你的代码一个 THPDFCSCTransportRequest——POST 方法、完整 URL、现成的 Authorization bearer 头、JSON body、IdempotencyKey 和尝试次数;你返回 StatusCode、Body、RetryAfterMS 加四个 cts 状态之一,而密钥从不离开 HSM
provider 自己给状态码分类,所以把应答过的 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 日期形式的值此处回落为 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 把这次批准花一次

HotPDF SAD 生命周期图解:RequireSAD 与 AutoAuthorize 开启时,provider 加载一次 credentials/info,向认证回调要 OTP 或 PIN 值,POST 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、异步模式、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 打开时重试;关掉它,提交之后的超时就是终局,因为没人说得清那个哈希是否已经被签过
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 在 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 签名调用