一张已签名的发票逐字节验证通过,可它的签名者上个月被吊销了,而你取回的 CRL 里怎么都找不到那个序列号:它代表的是另一个分区。HotPDF 的路径验证正是围绕这类情况设计的:RFC 5280 的范围规则与锚点规则、按 UTC 走的评估时钟,以及只有 TSA 链被信任后其时间才算数的 RFC 3161 时间戳
评估时钟跑在 UTC 上
路径验证器比较的每一个时间都以 UTC 存储。RFC 5280 §4.1.2.5 要求证书有效期按格林尼治时间编码为 UTCTime 或 GeneralizedTime,CRL 的 thisUpdate 与 nextUpdate 用同样的编码,RFC 6960 的 OCSP 响应则把 producedAt、thisUpdate 和 nextUpdate 作为 UTC 的 GeneralizedTime 带上。Delphi 的 TDateTime 没有时区,Now 返回本地墙上时间,所以写成 if Now > NotAfter 的检查把两个时钟混在一起,编译器还不会吭声。错误按地理位置分岔。格林尼治以西,它提前生效。纽约服务器本地时间 09:00、世界时钟 14:00Z 时,一份 13:59Z 生成的 OCSP 响应落在天真时钟的未来五小时处,远远超出 HotPDF 默认 OCSPAllowedClockSkewMinutes 这种五分钟容差,于是被判为尚未生效而拒掉。到上海符号反过来:本地 22:00 即 14:00Z,一张 notAfter 为 20:00Z 的签名证书看着已经过期,早了八小时。这两种故障在设成 UTC 的构建代理上都看不见——那也是大多数托管 CI runner 的默认值
HotPDF 把 THPDFCertificateValidationOptions.ValidationTime 当作 UTC 处理。留默认值零时,评估时间取当前 UTC 时间,除非有可信时间戳提供了签署时刻(下面细说),而真正用到的那个瞬间会通过 Info.CertificateValidation.EvaluationTime 返回。VerifyLoadedSignatureWithTrust 在做这些之前先跑 svValid 背后的 byte-range 验证,然后才搭路径。你自己要钉一个历史时刻时——比如审计员问某张发票的签名在季末那一刻是否有效——转换是你的事,System.DateUtils 里的 TTimeZone.Local.ToUniversalTime 就干这个。请把转换放在时间戳进入系统的位置,而不是验证调用处:有夏令时的时区里,每年秋天有一个本地小时会重复,春天又有一个小时根本不存在,所以一列本地时间戳的数据库存不住每一个瞬间
uses
System.SysUtils, System.DateUtils, System.IOUtils,
HPDFDoc, HPDFSignVerify;
var
Pdf: THotPDF;
Options: THPDFCertificateValidationOptions;
Info: THPDFSignatureInfo;
Status: THPDFCertificateValidationStatus;
ClosingLocal: TDateTime;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('invoices-2026-q1.pdf');
Options := THPDFCertificateValidationOptions.Default;
SetLength(Options.TrustAnchors, 1);
Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
Options.RevocationMode := rvmRequire;
// 季末时刻,按 ERP 记录的样子,取服务器本地时区
ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
// 证书、CRL 与 OCSP 响应带的都是 UTC 时间
Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
Writeln('Evaluated at (UTC): ',
DateTimeToStr(Info.CertificateValidation.EvaluationTime));
case Status of
cvsTrusted:
Writeln('Trusted via ',
string(Info.CertificateValidation.TrustAnchorSubjectDN));
cvsRevoked:
Writeln('Revoked at (UTC): ',
DateTimeToStr(Info.CertificateValidation.RevokedAt));
else
Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
end;
finally
Pdf.Free;
end;
end;
一张 CRL 到底覆盖哪些证书
一张 CRL 只代表它范围内的证书说话,范围由 RFC 5280 §5.2.5 的 Issuing Distribution Point 扩展声明。把吊销数据分开的 CA 会发布好几张 CRL,都用同一把密钥签名、都很新鲜,某张里没有某张证书什么也说明不了——除非那张 CRL 声称覆盖它。收窄范围的字段是这些:
distributionPoint:CRL 只覆盖那些 CRL Distribution Points 扩展点明了该点的证书onlyContainsUserCerts与onlyContainsCACerts:只含终端实体证书,或只含 CA 证书onlySomeReasons:只含部分原因码的吊销,比如 keyCompromiseonlyContainsAttributeCerts:这张 CRL 讲的是属性证书,对公钥证书一言不发indirectCRL:条目可以属于 CRL 签名者之外的签发者
天真的查法是:验 CRL 签名、看 thisUpdate 与 nextUpdate、找序列号,找不到就说证书没问题。最后这一步只有在通过 RFC 5280 §6.3.3 (b)(2) 的范围检查之后才成立。7 号分区的 CRL 里永远不会列出 3 号分区的证书。限定只含 CA 证书的 CRL 永远不会列出终端实体签名者。两者对一张已吊销的证书都回答“没找到”。HotPDF 只在 CRL 既不限定属性证书也不限定部分原因、其 user 或 CA 限制与证书相符、且它点名的分发点与证书自己 CRL Distribution Points 扩展里某个 URI 匹配(比较时忽略大小写)时,才拿这张 base CRL 去查该证书——写成相对于 CRL 签发者的名字的分发点什么都匹配不上。限定原因码的 CRL 根本不参与查询,它列的吊销也就不算数。§6.3.3 确实允许用原因分区拼出完整覆盖,用一个原因掩码一直记到所有原因都有交代,但对文档签名来说,一份 OCSP 响应或一张全范围的 CRL 是更简单的证据:挑一种收着。范围内证据一张都拿不出来时,吊销状态就是未知,在 rvmRequire 下调用返回 cvsRevocationUnknown
收束路径的信任锚必须是 CA
RFC 5280 §6.1.1 交给路径验证算法的信任锚只是信息——一个签发者名字和一把公钥,算法从不问锚点自己的证书有没有签发资格;§6.1.4 的 basicConstraints 检查跑在路径的中间证书上。当锚点来自精心维护的存储区里的根 CA 时,这没问题。文档工作流打破了这个前提,因为人们会钉终端实体证书:一个从三家固定供应商收合同的采购系统,常常直接信任每家供应商的签名证书。钉在一证书路径上工作良好,签名者和锚点就是同一张证书。危险的是两证书路径。供应商的私钥可以签出一张主题随便写的新证书——比如写你 CFO 的名字——而一个把所有锚点密钥都当签发密钥的验证器,会从这份伪造一路搭出一条干净的路,直通那张被钉死的叶子证书
只要路径超过一张证书,HotPDF 就对锚点施加 RFC 5280 的签发者要求。此时锚点需要有 basicConstraints 且 cA=TRUE(§4.2.1.9),若它带 keyUsage 扩展,keyCertSign 位必须置上(§4.2.1.3)。没有 basicConstraints 扩展的根,只有在自签发、subject 与 issuer 名字相同时才算签发者,这让扩展出现之前的老版本 1 根继续可用。钉住的做法照样能用。被钉死的终端实体证书仍然能验证它自己的签名,而由它签出的证书回来时是 cvsPathValidationFailed,Issue 字符串说明签发该路径的锚点不是证书颁发机构
哪些时间戳有权决定评估时间
只有可信的才行。CMS 里嵌的 RFC 3161 token 声称签名值在 genTime 就已存在,验证器便按那一刻去判断签名者证书——签名之所以能活得比证书久,靠的就是这个。但 token 本身也只是另一个 CMS 签名。拿它自带的证书去验它,只证明了内部自洽,对它是谁产出的毫无说明:任何手里握着过期或已吊销签名密钥的人都能跑一套自己的时间戳服务,用去年的 genTime 给一份新签名打戳,把它拽回证书的有效期内。RFC 3161 §2.3 要求 TSA 证书把 id-kp-timeStamping 扩展密钥用法作为唯一用途,并标为 critical。HotPDF 只有在 token 证书按当前 UTC 时间、按时间戳用途、对你的锚点验证通过之后,才采纳 Info.Timestamp.GenerationTime 作为评估时间。文档时间戳 /SubFilter ETSI.RFC3161 是另一回事:那里 token 本身就是被验证的签名,它的 genTime 只管它自己那条路径
token 是用你自己的选项去验的,所以 TSA 的根必须和签名者的根放在同一个 TrustAnchors 列表里,并且在 rvmRequire 下 TSA 链也需要自己的吊销证据。任一条没做到,评估就退回当前时间。TSA 证书的寿命是另一道限制:token 按当前时间判定,那张证书一过期,签署时刻的评估也跟着没了,所以归档要在此刻之前补一个文档时间戳,也就是 PAdES LTV 证据与 B-LTA 时间戳续期里讲到的那一步。手里握着原始 CMS 字节的代码,可以用 HPDFCMSTimestampTimeIsTrusted(CMSDER, Options) 问同一个问题,ValidateLoadedSignatureBatch 则把它用到一批里的每个签名上。下面这个函数报告判定是在哪个时钟上做出的
// 预期结果:只给 SignerRoot 时,一张证书已在 2025 年过期的签名会按
// 当前 UTC 时间评估,不会返回 cvsTrusted,尽管其时间戳 token 自身
// 能验证通过;把 TsaRoot 加进去后,同一个调用改为按 genTime 评估,
// 而不是当前时间
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
const SignerRoot, TsaRoot: TBytes): string;
var
Options: THPDFCertificateValidationOptions;
Info: THPDFSignatureInfo;
Status: THPDFCertificateValidationStatus;
Clock: string;
begin
Options := THPDFCertificateValidationOptions.Default;
// TSA 链与签名者用同一份锚点列表来判定
SetLength(Options.TrustAnchors, 2);
Options.TrustAnchors[0] := SignerRoot;
Options.TrustAnchors[1] := TsaRoot;
Options.RevocationMode := rvmBestEffort;
Options.ValidationTime := 0; // 可信时间戳可以自带时间
Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
if Info.Timestamp.Kind <> tskSignature then
Clock := 'no signature timestamp, current UTC time'
else if (Info.Timestamp.Status = svValid) and
(Info.CertificateValidation.EvaluationTime =
Info.Timestamp.GenerationTime) then
Clock := 'trusted timestamp genTime'
else
Clock := 'timestamp not trusted, current UTC time';
Result := Format('%s at %s (UTC), status %d', [Clock,
DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;
第一个信任决定之前的恶意输入
有些验证工作跑在攻击者完全掌控的数据上,而且还发生在任何东西被信任之前。缺失的中间证书通过 RFC 5280 §4.2.2.1 的 Authority Information Access caIssuers URL 去取,而每取回一张中间证书,它又能反过来公布自己签发者的 URL,所以工作列表在你遍历的过程中不断变长。在 Delphi 里这直接排除了 for 循环。for 语句的终值在循环开始前只求一次——遍历途中追加的 URL 永远不会被访问,缺两级的链就一直不全。请改用 while 循环,每一轮都重读 Count。在 HotPDF 里,HPDFFetchAIAIntermediates 上的 MaxFetch 给一次遍历能下载多少张证书封顶;FetchMissingIntermediates 让路径验证自行抓取时,MaxFetchedIntermediates 设的是同一个上限
Retry-After 这条提示也是陌生人挑的值。它的服务器是由一张未经验证的证书点出来的,而它可以要求每个请求都停一小时。把它夹住。THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS 为 HotPDF 的证据抓取与 AIA 抓取设这个上限,默认 30,000 ms。下面这段例程把两条规则都用上,给那些自己爬 AIA 的队伍——比如想预热离线签名层的中间证书缓存
uses
System.SysUtils, System.Generics.Collections;
type
// 你的传输层实现:下载一个 caIssuers 目标并返回它收到的
// 那张证书所公布的 caIssuers URL
TFetchIssuer = reference to function(const URL: string;
out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;
function WalkCAIssuers(const SeedURLs: TArray<string>;
const Fetch: TFetchIssuer; MaxFetch: Integer;
MaxRetryAfterMS: Cardinal): Integer;
var
Queue: TList<string>;
Seen: TDictionary<string, Boolean>;
NextURLs: TArray<string>;
URL: string;
I, Attempt: Integer;
RetryAfter: Cardinal;
Ok: Boolean;
procedure Enqueue(const AURL: string);
begin
if not Seen.ContainsKey(LowerCase(AURL)) then
begin
Seen.Add(LowerCase(AURL), True);
Queue.Add(AURL);
end;
end;
begin
Result := 0;
Queue := TList<string>.Create;
Seen := TDictionary<string, Boolean>.Create;
try
for URL in SeedURLs do
Enqueue(URL);
I := 0;
// for 循环在进入时就把 Queue.Count 定死,下面抓回证书追加的
// URL 永远访问不到
while (I < Queue.Count) and (Result < MaxFetch) do
begin
Ok := False;
for Attempt := 1 to 3 do
begin
NextURLs := nil;
RetryAfter := 0;
Ok := Fetch(Queue[I], NextURLs, RetryAfter);
if Ok or (Attempt = 3) then
Break;
// 这个延迟只是服务器的一厢情愿,而服务器是一张未经验证
// 的证书选的
if RetryAfter > MaxRetryAfterMS then
RetryAfter := MaxRetryAfterMS;
Sleep(RetryAfter);
end;
Inc(I);
Inc(Result);
if Ok then
for URL in NextURLs do
Enqueue(URL);
end;
finally
Seen.Free;
Queue.Free;
end;
end;
CMS 签名检查本身跑在路径验证之前,因为给一个验不过的签名搭路径纯属白干,而它用的是签名里嵌的那张证书的公钥。这个顺序意味着,最贵的算术开跑的那一刻,密钥尺寸是攻击者说了算的,什么都还没被信任。RSA 验证的开销随模长和指数长度一起涨,所以一张模长巨大、指数也同样长的证书,足以在验证上传文件的服务器上把一个线程钉在模幂运算里。HotPDF 拒绝 16,384 位以上的 RSA 模,也拒绝比模还长的指数,超过 3,072 位之后只接受最多 64 位的指数。真实的签名密钥在 2,048 到 4,096 位之间,e = 65537,指数 17 位,所以上限永远碰不到它们;下限是另一条可配置的策略 AlgorithmPolicy.MinimumRSAKeyBits,默认 2,048
同一个签名,在时间戳的 genTime 上可以是 cvsTrusted,按今天的日期却过不了。把 Info.CertificateValidation.EvaluationTime 和 TrustAnchorSubjectDN 记在HotPDF Delphi PDF 组件每个返回判定旁边,日后读记录的审计员就能看出这个答案靠的是哪个时钟、哪个锚点