技术文章

别倒着走 DER 长度字节:PDFium Component

四个字节 A0 82 05 DC 打开一个携带 1500 字节内容的上下文相关 [0] 元素。从左往右读毫无歧义:标签 A0,首长度字节 82 宣告后面还有两个,然后 05 DC。从右往左读、从第一个内容字节起步,碰到的第一个字节是 DC。它最高位置 1、低 7 位说是 92,于是信任它的辅助函数会报告标签在内容之前 94 字节处。真实距离是 4

没有人故意倒着走。它是这么发生的:多数 DER 阅读器交回每个元素的内容偏移和长度——往下钻正需要这个——而原样拷贝一个兄弟元素要的是它标签的偏移。从内容偏移往回减看着是两行代码的事,只要每个长度都装得进单个字节就一直正确。真实证书集的长度从来装不进一个

签名时间戳对已签名 CMS 做了什么

加 PAdES B-T 时间戳,CMS 的每层外覆头部都得重编码,而五个兄弟元素必须逐字节原样带过来。ETSI EN 319 122-1 第 5.3 条在 OID 1.2.840.113549.1.9.16.2.14 下定义的 signature-time-stamp 属性,住在 RFC 5652 第 5.3 条 SignerInfo 的 unsignedAttrs 里,其中的 RFC 3161 令牌是对签名值算的。所以令牌从 TSA 到手时,CMS 已经构建完、也签完了。多出来的一个属性撑长 SignerInfo,进而撑长 signerInfos SET,然后 SignedData、[0] EXPLICIT 包装,最后是外层 ContentInfo

在 PDFium Component 里,这条序列是 FPdfCms.pas 和 FPdfTsa.pas 里的三个导出调用。TPadesSignOptions.Level 为 plB_T 或更高且设了 TsaUrl 时,SignPadesBytes 替你跑完;要求 B-T 等级却给了空 TsaUrl 时,它抛 EPadesCrypto,而不是退而写一个普通 B-B 签名。自己用 BuildSignedData 构建 CMS 的话,链条长这样

// CmsDer 装 BuildSignedData 返回的 ContentInfo。
// Options.Level >= plB_T 时 SignPadesBytes 执行同样三步
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // TSA 的 messageImprint 对签名值字节计算
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // 重编码 SignerInfo 到 ContentInfo,兄弟元素原样拷贝
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms 原样带过五个元素:contentType OID、SignedData 版本 INTEGER、digestAlgorithms SET、encapContentInfo SEQUENCE,以及(存在时)certificates [0] 集。在 BuildSignedData 以 SHA-256 产出的 CMS 里,其中四个很小:OID 11 字节、版本 3 字节、摘要算法集 17 字节、分离式 encapContentInfo 13 字节。证书集装着签名者证书加上你传入的每张链证书。真实的 X.509 证书有几百字节。这是唯一一段长度字节必然用长形式的切片

加 PAdES B-T 时间戳在 CMS 中重建什么:AddSignatureTimestampToCms 逐字节拷贝 contentType、version、digestAlgorithms、encapContentInfo 和证书集,证书集是唯一长到必须离开短长度形式的切片,从 SignerInfo 到 ContentInfo 的每层外覆头部都重新发出
签名部分按构造原封不动,因为从 SignerInfo 前缀到签名 OCTET STRING 整段逐字拷贝,重算 signedAttrs 摘要的验证器在时间戳落地前后看到完全相同的字节

为什么 DER 长度字节不能倒着走?

长度字节的个数存在第一个长度字节里,倒着走先遇到最后一个。X.690 第 8.1.3.4 条定义短形式:一个字节,第 8 位清零,第 7 到 1 位装 0 到 127 的长度。第 8.1.3.5 条定义长形式:首字节第 8 位置 1,第 7 到 1 位数出后续有几个字节、以无符号大端整数装长度。没有任何东西标记某个字节是“后续”字节。它的第 8 位和其他 7 位一样是数值位。所以检测 Buf[ContentOffs- 1] 最高位的辅助函数,是在检测数据、然后把数据当计数读

// 只拿到内容偏移时的诱人写法。别把它发上线
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // 落在最后一个长度字节上
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 只对第一个字节有意义
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC(1500 字节):Buf[ContentOffs- 1]= $DC,$DC and $7F= 92
//   Result= ContentOffs- 94,而标签在 ContentOffs- 4
// A0 82 05 10(1296 字节):$10 第 8 位为 0,被当短形式读
//   Result= ContentOffs- 2,指向长度字节里的 05

两个失败方向都会在寻常尺寸上出现。1500 字节的证书集头部以 DC 结尾,辅助函数把切片起点提前了 90 字节。1296 字节的集以 10 结尾,辅助函数把它当成短形式长度,切片晚两个字节才开始、压根没有标签。某条链落进哪一种,只取决于总长度的低字节

提前的情况还可能冲出缓冲区头部。按 BuildSignedData 的排布方式、每层外覆长度占两个字节时,证书集的内容从 60 字节处开始:ContentInfo 头 4 字节、OID 11 字节、[0] 和 SignedData 头各 4 字节,然后小兄弟 3、17、13 字节,再加证书集头 4 字节。60 减 94,辅助函数返回 -34。它唯一的守卫设在 ContentOffs- 1 上,负值直接进了拷贝

为什么 DER 长度字节不能倒着走:从尾部读 A0 82 05 DC 落在最后一个字节 DC 上,其置位最高位给出 92 的假计数、把标签提前了 94 字节;A0 82 05 10 则朝另一头失败,切片晚两个字节、起在长度字节里
守卫设在中间减法而不是最终结果上的倒着走,甚至可能把切片起点放到缓冲区之前,让重建的 SignedData 把那段切片摆在证书集该在的位置

X.690 第 10.1 条钉死了什么

DER 把长度编码变成关于长度的纯函数。X.690 第 10.1 条把 DER 限定为定长形式、并要求最少字节数,砍掉了 BER 允许的两处自由:不定长形式,以及用前导零字节填充的长形式长度。在这条规则下,低于 128 的内容长度必然恰好一个长度字节。其他任何长度用一个首字节,外加与数值有效字节数恰好相等的后续字节。128 得 81 80;256 时头部长成 82 01 00,65536 需要 83 01 00 00。手里已有内容长度的代码,不读缓冲区一个字节就能算出头部尺寸

DER 保证的正向推导:内容长度 127 编码为 A0 7F,128 为 A0 81 80,255 为 A0 81 FF,256 为 A0 82 01 00,1500 为 A0 82 05 DC,所以头部长度仅由 ContentLen 决定,而 TryReadTlvAt 已拒绝一切非最短 BER 形式
短于 128 字节的内容留在短形式里——倒着走在那里答对纯属运气——所以边界测试必须跨过 127、128、255 和 256 字节

推导再好,也得输入真是 DER 才成立。TDerReader.TryReadTlvAt 在任何切片发生之前强制这一点。它拒绝 $80 的首长度字节(不定长形式)、首个后续字节为零的长形式长度、低于 $80 本该用短形式的单个后续字节,以及超过四个的后续字节。ReadTlv 建在 TryReadTlvAt 之上,所以 AddSignatureTimestampToCms 走到的每个元素都已过检。BER 式填充长度永远到不了算术那一步

AddSignatureTimestampToCms 内部,每个兄弟元素的标签偏移就是这样从内容偏移和内容长度推导的。推导出的起点落到缓冲区开头之前——意味着这些偏移根本不配套——过程就抛出消息为 CMS: TLV header precedes the buffer start 的异常,一个 CMS 都不产。仍能解析的畸形重建,比偏移失去一致那一刻的异常难查得多

推导还带一个假设:标识符只占一个字节。X.690 允许标签号 31 及以上用多字节标识符,TryReadTlvAt 也解析它们。CMS SignedData 从不用多字节标识符——它的标签是通用类 SEQUENCE、SET、INTEGER、OID,加上上下文相关的 [0] 和 [1]——所以时间戳重建碰到的每个元素都满足这个假设

在自己的 DER 代码里拷贝兄弟元素

直接对 TDerReader 写代码的话,推导可以整个跳过。PeekTlv 和 ReadNextTlv 填一个 TDerTlv 记录,其 Offset 已指向标签、HeaderLength 是实测的标签加长度字节尺寸,EncodedBytes 返回从 Offset 到 NextOffset 的元素。这条路还能应付多字节标识符——多少长度算术都救不回的那种。ReadTlv 的四出参形式往下钻方便。但它只报内容侧,所以要内容用它,要整个元素用记录形式

// SignedData 的每个子元素都是完整 TLV:标签、长度字节、内容。
// TDerTlv.Offset 指向标签,不涉及任何头部算术
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // 例如 A0 82 05 DC……
  end;
end;

别的 API 只给你内容偏移和长度时,正向推导很短。下一节连着边界检查一起展示的 DerHeaderStart 是你自己的代码;它复刻第 10.1 条的规则,对不可能配套的偏移返回 -1,调用方因此拿到一个可以拒绝的值,而不是一个看着挺像样的起始字节

为什么短测试证书把错误藏起来?

小占位证书让证书集留在短形式里,倒着走恰恰在这种情形下正确。32 字节的集编码为 A0 20,64 字节的为 A0 40。从内容往回走落在那唯一一个长度字节上,它最高位是 0——因为它是第一个也是唯一一个。答案对,理由错。用这么小的证书搭的时间戳测试,可以重新解析结果、找到属性、报出 B-T,却从头到尾没让一个真实尺寸的证书集过过这条切片

任何依赖长度如何编码的代码路径,都需要跨过编码边界的测试输入。对 DER 来说就是 128 字节及以上的内容——逼出长形式——和 256 字节及以上的内容——逼出第二个后续字节。下面的检查在两个边界上、以及开头那个 1500 字节的案例上,把 DerHeaderStart 与阅读器解析时实测的头部对照。signedAttrs 里未排序的 SET OF能溜过同源往返,也是同一个结构性原因:测试只喂错误代码和正确代码恰好给出一致结果的输入

// 给只报内容偏移和长度的 API 用的你自己的辅助函数。
// 假设标识符占一个字节,CMS SignedData 里每个标签都成立
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // 短形式,X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // 长形式,X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 每个有效字节一个
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // 偏移不配套:让调用方去抛
end;

// 边界检查,写成你自己的测试代码:推导出的标签偏移
// 必须等于 TDerReader 解析头部时实测的偏移
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC……
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

同一条边界经导出函数也能端到端够到。给 BuildSignedData 递一组链证书,让合并尺寸把证书集分别压到 128 和 256 字节的上下两侧,对每个结果跑 AddSignatureTimestampToCms,再用 SignedDataChildren 走一遍输出,把证书集元素与加时间戳之前 CMS 里的那个逐字节比对

AddSignatureTimestampToCms 接受什么

AddSignatureTimestampToCms 是为 BuildSignedData 产出的 CMS 写的:一个 SignerInfo、可选的 certificates [0] 集、没有 crls [1]、也还没有 unsignedAttrs。喂它一个装着多个 SignerInfo 的 signerInfos SET,它会抛异常,而不是留下第一个签名者、扔掉其余。带 crls [1] 集的 CMS 停在 CMS: signerInfos SET expected 异常上,因为证书之后的元素不是这条走查要找的 SET。两种拒绝都发生在任何东西写回 CmsDer 之前

新的 unsignedAttrs 恰好装一个属性,所以 X.690 第 11.6 条的 SET OF 排序规则不用排序即满足。SignerInfo 的签名部分——从版本到签名 OCTET STRING——整块拷贝,意味着重算 signedAttrs 摘要的验证器在令牌加入前后看到相同字节。验证器仍然拒收文档时,原因通常在别处,这份 PAdES 拒收原因清单有覆盖。令牌随时间带来的好处、以及 B-LT 和 B-LTA 如何在它之上构建,见RFC 3161 时间戳与 DSS 走查

FPdfAsn1.pas 里的 DER 读写器、FPdfCms.pas 里的 CMS 构建器和时间戳步骤,都是随 PDFium Component for Delphi 交付的 Pascal 源码。重建的 SignedData 在验证器里解析失败时,你可以单步走进切出每个元素的那段代码,对着它引用的 X.690 条款核对