技术文章

面向 FPC 的 PDFium VCL libcurl 时间戳后端

PDFium VCL 在非 Windows 目标上通过 libcurl 发送 RFC 3161 时间戳请求,动态绑定八个符号,与绑定 WinHTTP 的 Windows 后端形状完全对应。有两个选项设置决定这条传输在负载下是否可靠,而整个单元的验证是在一台根本无法为它的目标平台编译的机器上完成的

时间戳是把签名变成「能在证书过期后依然站得住」的东西,而它本身是一次嵌在签名操作里的网络操作。这个组合让传输选择变得异乎寻常地重要:它跑在工作线程上,它对话的是一台你控制不了的服务器,在那里挂死卡住的是整条签名流水线,而不是一次页面加载

为什么用 libcurl 而不是 FPC 的 HTTP 客户端?

因为替代方案会把一整个 TLS 栈拖进仓库,然后让你替它维护版本检测。Free Pascal 上显而易见的路线是 fphttpclient 配 OpenSSL socket 层,它在细节上翻车:FPC 3.2.2 的 OpenSSL 绑定在眼下大多数发行版上都无法可靠检测 OpenSSL 3.x,macOS 还要再叠加 LibreSSL 的差异。一个原本很小的 HTTP 调用,变成了对别人家 TLS ABI 的长期维护

libcurl 自己解析 TLS 后端,并对照平台信任库验证证书链,Pascal 侧一样都不用管。绑定层是八个符号。这个数量本身就是论据:你的代码与一个会移动的依赖之间,表面积越小,发行版升级弄坏你的地方就越少;而且它与既有的 Windows 后端一致——那边同样以这种方式绑定少数几个 WinHTTP 入口点

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

在 Pascal 里声明 C 可变参数函数

curl_easy_setoptcurl_easy_getinfo 在 C 侧是可变参数的,而 Object Pascal 没有表达这个的方式。行得通的做法是声明几个固定原型——每个参数类别一个,全都指向同一个导出符号:吃 long 的变体、吃指针的变体等等,在调用点按你实际传的东西选择

这样做是安全的,理由值得理解而不是照抄。在上面这些平台调用约定下,那几种参数类型都经整数寄存器传递,而这正是 C 实现里 va_arg 读取它们的位置。所以这个技巧对整数、指针和句柄成立,对浮点参数则成立——浮点走的是另一组寄存器。不要抱着「这个模式可以推广」的假设去加一个吃 double 的变体

// 一个导出符号,几个固定原型。每个变体都把参数
// 传进整数寄存器,C 侧正是从那里读取。浮点变体行
// 不通,也绝不能加
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

决定请求能否完成的两个设置

第一个是显式的空 Expect: 头。libcurl 对超过约 1 KB 的请求体会开启 HTTP 100-continue 握手,而带证书请求的时间戳查询通常刚好越过这条线。有些 TSA 服务器永远不会应答这个 continue,于是客户端干等满一个超时,才发出服务器本来会立刻接受的请求体。发送一个空的 Expect: 头可以抑制握手,请求一个往返就完成

第二个是 CURLOPT_NOSIGNAL,必须设置。没有它,libcurl 用 SIGALRM 实现名字解析超时,而这个机制不是线程安全的。签名跑在工作线程上,所以默认行为是一个潜在的崩溃:在并发下出现,在单线程测试里永远不出现。设置这个标志会关掉基于信号的路径,代价只是解析器超时的粒度

两个缺陷共享同一个画像,这让它们事后查找起来特别贵。在单线程上对着一个乖巧的服务器做功能测试,两者都不出现。两者都出现在生产环境、对着某个特定的 TSA、在负载之下。绑定一个网络库的时候,先读一读它的默认值对你的进程做了什么假设,再假设它们匹配

PDFium VCL 的 libcurl 时间戳传输示意图:curl_easy_setopt 声明为按整数寄存器传参的固定 long 与指针 Pascal 原型,空 Expect 头抑制 HTTP 100-continue 握手,CURLOPT_NOSIGNAL 在工作线程上移除 SIGALRM 路径,外加传输层的响应上限
两个设置决定请求能否完成:空 Expect 头避开那些永不应答 continue 的服务器,NOSIGNAL 在签名跑在工作线程上时把名字解析超时从信号路径上摘走

怎么验证编译器永远见不到的代码?

办法是通过一份受控副本让编译器见到它。这里的开发机没有 Linux 或 macOS 交叉编译器,所以时间戳单元的非 Windows 分支在正常构建中永远到不了代码生成器。从不被编译的代码就是悄悄烂掉的代码:共享类型里一个改名、一个变化的参数表、一个新增的单元依赖,几个月没人发现

手法是机械的。把单元复制到一个临时目录,改名,把每个 Windows 条件——{$IFDEF MSWINDOWS} 形式和 {$IF DEFINED(MSWINDOWS) 形式都算——替换成一个永不定义的符号。然后编译这份副本。当全部 3,828 行都编译通过,你就证明了非 Windows 路径引用的单元都存在、调用的后端函数签名匹配、引用的类型都在作用域内。这不是传输能工作的证明,那个只有目标平台能给。它证明的是这个分支没有已经坏掉——而「已经坏掉」恰恰是会实际累积的那种失败

配套的习惯是让 libcurl 单元自身不带平台守卫,这样它即使没有任何东西引用,也参与普通的 Windows 构建。日常构建于是免费替它守着语法和类型。一个只能在你没有的平台编译的单元,就是一个完全没有编译器看管的单元;同样的推理适用于 Delphi 与 FPC 交叉编译陷阱里描述的整个跨编译器工作

给返回的东西设上限

时间戳响应是一个小的 DER 结构,而传输层没有任何东西强制这一点。一台被攻陷、配置错误、或者干脆指向了错误 URL 的服务器可以返回任意流,而一个读到连接关闭为止的客户端会欣然把它全部收下。因此两条传输路径都给响应设了上限,而且这个限制放在这里才是对的:在传输层拒绝,超大的响应体根本没有机会被分配;解析器层的检查只在内存已经交出之后才触发

同样的推理适用于 URL。后端只接受它有意义地说得出口的 scheme,于是配置错误会立刻带着清晰的报错失败,而不是被丢给 libcurl 按它的协议支持随意解读

传输在签名故事里的位置

时间戳是长期验证故事的第一步,而不是全部。令牌要附到签名上,验证材料要记录进文档安全存储(DSS),归档时间戳要在当前这一个变弱之前续期。整个弧线在 带 RFC 3161 时间戳与 DSS 的长期 PDF 签名里有完整覆盖

PDFium VCL 的 RFC 3161 时间戳请求流程图:从 DocumentDigest 经 BuildTimeStampQuery 和 PostTimeStampQuery 走 libcurl 到 TSA 服务器,DER 响应在传输层设上限,随后 AttachTimeStampToken 在长期验证中供给 DSS 与归档时间戳续期
时间戳是长期验证故事的第一步:令牌必须附上,验证材料必须记入文档安全存储,归档时间戳必须在当前这个变弱之前续期

这条传输也是更广的可移植性布局中的一块:在任意目标上加载原生库描述的原生库加载器,为 PDFium 二进制本身处理同一类问题。两种情况下模式一致:动态绑定少量符号,精确报告哪个符号绑定失败,永远不让一个缺失依赖变成阻止应用启动的链接期失败

Windows 与非 Windows 的时间戳后端都随 PDFium Delphi component 提供,按目标平台选择而不是按配置,于是 Linux 上的 Lazarus 应用和 Windows 上的 Delphi 应用,通过不同的管道产出同样带时间戳的签名