PDFium Component 依靠内容长度来定位嵌套 CMS 结构的起点,绝不倒着走过那些长度字节——因为内容前面那个字节是最后一个长度字节,它对前面还有几个字节一无所知。取而代之,FPdfCms.pas 里的 CmsHeaderStart 从 ContentLen 推导头部长度,而 DER 让这个推导是精确的;正是这一点让 AddSignatureTimestampToCms 不至于破坏每一份证书集长于 127 字节的 CMS
这里说的是 PAdES B-T 升级。签名时间戳属性——ETSI EN 319 122-1 第 5.3 条用 OID 1.2.840.113549.1.9.16.2.14 定义的那个——必须落进 RFC 5652 第 5.3 条描述的 SignerInfo 的 unsignedAttrs 里;而按定义,它只能在签名值存在之后才能加上,因为时间戳令牌是对着那个值算出来的。所以令牌到达时,CMS 早就构建好、也早就签好了。加一个属性会改变 SignerInfo 的长度,进而改变 signerInfos SET 的长度,再改变 SignedData、[0] EXPLICIT 包装,最后改变外层的 ContentInfo。每一层包裹的头部都得重新输出,而不在这条路径上的一切都必须逐字节搬运过去。B-LT 与 B-LTA 走查讲的是这个令牌能换来什么;本文要讲的是证书集前面那四个字节,重建时一直把它们弄错
为什么加一个时间戳需要拿到兄弟元素的标签偏移?
因为重建会逐字复用 signerInfos SET 的四个兄弟元素,而 reader 报告的是它们的内容在哪,不是它们的标签在哪。TDerReader.ReadTlv 交回标签字节、内容偏移、内容长度和下一个 TLV 的偏移。对向下钻进一个结构来说这是正确的接口,但要完整复制一个元素,你需要它标签所在的那个字节,而调用方手里只有 ContentOffs。CmsSliceTlv 就是来补这个缺口的:给定内容偏移和长度,它把标签、长度字节和内容作为一个缓冲区返回;AddSignatureTimestampToCms 会对 contentType OID、version INTEGER、digestAlgorithms SET、encapContentInfo SEQUENCE 以及存在时的 certificates [0] 集分别调用它
// 在 AddSignatureTimestampToCms 内部:下钻,逐字切出各兄弟元素
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// 可选的 certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
raise Exception.Create('CMS: certificates [0] malformed');
SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL); // 标签 + 长度字节 + 内容
R.Position:= CN;
end;
这五个切片里有四个都很小:11 字节的 OID、3 字节的 INTEGER、17 字节的摘要算法集、13 字节的 detached encapContentInfo。证书集才是那个装着签名者证书及其证书链的,而一张真实的 X.509 证书至少也要几百字节。所以证书集是唯一一个长度字节会进入长形式的切片,也正是旧 helper 定位不到的那个切段
为什么 DER 的长度字节不能倒着走?
因为长度字节的个数存在它们中的第一个里,而从内容往回读,你先遇到的是最后一个。X.690 第 8.1.3.4 条规定短形式:一个字节,第 8 位清零,第 7 到第 1 位承载 0 到 127 的长度。第 8.1.3.5 条规定长形式:首字节第 8 位置一,其第 7 到第 1 位给出后续字节的个数,后面那些字节以无符号大端整数承载长度。规则里没有任何东西把某个后续字节标记成「后续」。它的第 8 位和别的位一样是大小位,所以倒着走、去测 Buf[ContentOffs- 1] 的最高位,测的其实是一个数据位,然后又把它的低七位当成计数来读
// 旧的 helper,只给了内容偏移
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // 落在最后一个长度字节上
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // 只对第一个字节才有意义
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// 1500 字节证书集的头部:A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> 第 8 位置一,$DC and $7F= 92
// Result= ContentOffs- 94 (标签其实在 ContentOffs- 4)
拿一个装着 1500 字节证书的证书集头部来说,A0 82 05 DC。倒着走落在 DC 上,看到最高位置一,从低七位里取出 92,于是报告标签在内容之前 94 字节处——而它其实只在前面 4 个字节。BuildSignedData 构建出来的 SignedData 里,证书集内容距 CMS 开头只有几十个字节,所以算出来的偏移不只是偏早,而是负数;而旧代码只防了 ContentOffs- 1 不越下界,没防它的最终结果。CmsSliceTlv 于是切出一个比元素长了九十多字节、还从缓冲区之前开始的切片,重建出来的 SignedData 就把这个切片放在了本该是证书集的位置。三个字节的长度如果最后一个字节恰好小于 $80,比如 A0 82 05 10,则往另一个方向失败:倒着走把它当成短形式字节,从 05 开始切,晚了两个字节、切进了长度字节里面,连标签都没有。两个方向的结局都是错的,变的只是方向
DER 保证了什么,让正向推导是精确的?
DER 保证长度编码是长度的纯函数。X.690 第 10.1 条把 DER 限制在确定形式,并要求字节数最少,这就去掉了 BER 允许的两种自由:不定形式,以及用前导零字节给长形式长度做填充。在这条规则下,小于 128 的内容长度有且只有一个长度字节,其他长度则有一个首字节,加上恰好等于该长度所需有效字节数的后续字节。CmsHeaderStart 的调用方本来就握着 ContentLen,因为 ReadTlv 刚刚把它返回;所以头部长度不用看缓冲区里的任何一个字节就能算出来
// 实际交付的 helper:从内容长度推导头部。
// 按 X.690 10.1,长度字节是 ContentLen 的函数
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // 短形式,X.690 8.1.3.4
else
begin
LengthOctets:= 1; // 首字节,X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // 每个有效字节一个
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
两个细节让这件事是安全的,而不只是听起来合理。第一,「输入是 DER」这个假设在上游就被强制了:TDerReader.TryReadTlvAt(ReadTlv 就建在它上面)拒绝不定形式、拒绝后续首个字节为零的长形式长度、也拒绝只有一个后续字节且其值小于 $80 的情况。能到达 CmsSliceTlv 的 TLV 早已通过这些检查,所以 BER 风格的非最小长度不可能走到推导里把它骗倒。第二,负结果的兜底现在防的是真正的答案,而不是某个中间值。值得一提的是,reader 一直都知道标签偏移:TDerTlv 同时带着 Offset 和 HeaderLength,只是那个四出参的 ReadTlv 接口把它们丢了。把它们返回出来会是更干净的长期接口;交付的修复保持那个接口不动,并让 helper 按自己的条件变正确
bug 还在的时候,那些时间戳测试为什么是绿的?
因为每个夹具证书都短到用短形式,而倒着走恰好就在这种情况下是对的。Tests.PadesTimestamp.pas 在一个测试里用 SetLength(SignerCertDer, 32) 构建签名者证书,另一个测试里用 64,内容填的是一段递增字节。32 字节的证书集编码成 A0 20,64 字节的编码成 A0 40,各自只有一个长度字节。从内容倒着走就落在那一个字节上,它的最高位是零——因为它是第一个也是唯一的长度字节——于是 helper 因为错误的原因给出了正确的答案。1414 个用例的套件全绿,带时间戳的 CMS 解析通过,第一阶段的校验器报出 B-T,而这一条条检查跑的都是一个真实文档里从来不会出现的证书集
有用的部分是这条通用规则。只要某条代码路径依赖长度是怎么编码的,夹具就必须跨过编码边界;对 DER 来说,那就是内容长于 127 字节(会强制进入长形式),最好还长于 255 字节(会强制出现第二个后续字节)。同一批审查里另一处「自校验看不出 DER 偏差」的案例适用同样的纪律:signedAttrs 里没有排序的 SET OF 对同源自往返是隐形的,原因在结构上完全相同——测试只覆盖了错误代码与正确代码意见一致的输入。下面这段草稿直接调用切片 helper,这意味着要在测试构建里把它从 FPdfCms.pas 导出;同样的边界也可以通过公开接口摸到:给 BuildSignedData 喂各种大小的链上证书,再重新解析带时间戳的结果
// 钉住边界:穿过长形式头部的切片必须从标签处开始
const
Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
W: TDerWriter;
Content, Tlv: TBytes;
I, Len: Integer;
begin
W:= TDerWriter.Create;
try
for I:= Low(Lens) to High(Lens) do
begin
Len:= Lens[I];
SetLength(Content, Len);
Tlv:= W.Wrap($A0, Content); // A0 7F / A0 81 80 / A0 82 05 DC ...
W.Clear;
// 内容紧跟在头部之后开始;切片必须是整个 TLV
Assert.AreEqual(Length(Tlv),
Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
'slice through header of a '+ IntToStr(Len)+ '-byte content');
end;
finally
W.Free;
end;
end;
重建的边界画在哪
AddSignatureTimestampToCms 是为 BuildSignedData 输出的那种 CMS 写的,它的限制也由此而来。遍历只预期一个 SignerInfo,也只重新输出那一个,所以外来的多签名者 CMS 回来时会只剩一个签名者;它认得可选的 certificates [0] 集,但不认 crls [1] 集,带着后者的 CMS 会大声失败——抛 signerInfos SET expected 异常——而不是安静地切错。新的 unsignedAttrs 只装一个属性,所以 X.690 第 11.6 条的 SET OF 排序规则被平凡地满足,不需要排序。而已签名的那部分从构造上就没被碰过:从 SignerInfo 前缀一直到签名 OCTET STRING 都是逐字复制的,这就是为什么重新对 signedAttrs 做摘要的校验器,在时间戳加上前后看到的是同样的字节。如果对方仍然把文档判为不合格,原因通常在别处,值得单独列一份清单
DER reader、writer、CMS 构建器和这个时间戳注入都以 Pascal 源码交付,随 PDFium Delphi 组件一起;这个形状的 bug 本身就是理由:当重建出来的 SignedData 长出九十多个字节时,你想读的是切出那个切片的 helper 和它读错的 X.690 条款,而不是一个黑盒给出的调用栈