技术文章

Delphi 中的 CMS 签名解析:DER 边界与 OID 段

PDF Library for Delphi(PDFlibPas)从 PDF 签名的 /Contents 里存储的 CMS SignedData 中提取证书,走的是一条纯 DER 解析路径,完全不经过 CryptoAPI。从 v3.539.10 起,每次嵌套读取都以上一级元素为界,CMS 之后的零填充在 CMS 自己声明的长度处截断,对象标识符把合并后的首段子标识符按 base-128 编码。边界规则和 OID 修复替换掉的旧代码都会给出错误结果却不报错,填充规则则让更严格的读取端不会拒绝真实签名

读取端比看上去更重要。长期验证工具得先从既有签名里取出签名者证书及其各级签发者,才能去取吊销数据;审计报告得说清楚是谁签的;Linux 上的 Lazarus 构建没有 Windows 消息函数可依。处在那个位置的解析器很少在坏输入上直接崩溃。真正疼的失败模式是:证书计数把邻居的字节算了进去、签名者对错了字段、或者一个 OID 悄悄变成了另一个 OID。架在这种地基上的签名流水线,输出的全是自信满满的胡话

从已签名的 PDF 里读出签名者证书

读取端由五个 TPDFlib 方法覆盖,参数都是 InputFile, Password, FieldName:每次调用以只读方式打开文件、给出答案、再把文件关上。GetSignatureEmbeddedCertificateCount 和 GetSignatureEmbeddedCertificateDER 按编码顺序枚举证书集合,GetSignatureSignerCertificateDER 返回产出某个 SignerInfo 的那张证书,GetSignatureCertificateChainLength / GetSignatureCertificateChainDER 则从这个签名者一路走向签名自身携带的最远签发者。索引从 0 开始。结果要保存在 AnsiString 里,库之所以就按这个类型返回也是同一个原因:DER 数据块一旦绕道 string 或 TStrings,就要过一道字符集转换,回来时已经坏了

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

这份输出有两处需要留心。计数为 0 不是诊断结论:字段不存在、密码不对、数据块不是 DER、以及 SignedData 干脆省略了可选的证书集合,这些情况统统返回 0 或空字符串,所以要在数字旁边把字段名记进日志。链在遇到自签发证书之前就结束也不是错误。链的构建只用嵌在签名里的证书,剩余的签发者得通过 GetCertificateIssuerURLs 报告的地址去取

/Contents 里到底哪一段才是 CMS?

只有外层 SEQUENCE 声明的那段前缀属于 CMS,PLTrimCMSPadding 把其后的一切裁掉。签名工具得在 CMS 存在之前就预留 /Contents 十六进制串,因为 ISO 32000-1 §12.8.1 描述的 /ByteRange 必须先定下来,所以槽位开得宽裕,没用到的尾部全是零。PLTrimCMSPadding 读第一个 TLV,要求标签是 $30,返回到该元素结尾为止的字节;凡不是以合法 SEQUENCE 开头的输入一律返回空。尾部有多余字节合法的地方只有最外层,而这个区别对下一节很关键:「元素必须吃光整个缓冲区」的严格规则会拒绝现实中所有签名,而不分深浅处处放宽的规则又让嵌套字段读到不属于它们的字节

PDFlibPas 的 PLTrimCMSPadding 读取预留 /Contents 十六进制串的第一个 TLV,要求标签为 $30,在外层 SEQUENCE 声明的长度处截断零填充,缓冲区不是以合法 SEQUENCE 开头时返回空结果
尾部字节只在最外层合法,因为预留槽位必须为 /ByteRange 保持定长——更深的读取改用以上一级元素为界的规则

DER 读取器为什么需要上一级的结束偏移?

嵌套元素只有在自己结尾落在上一级内部时才算合法,而拿缓冲区结尾来检查证明不了这一点。PDFlibASN1 里的底层函数 DERReadTLV 拿整个字符串给每个元素定界,这对最外层对象是正确的检查,对再往下的每一层都是错的。设想一个 SignerInfo,它的 issuerAndSerialNumber 声明 40 字节,里面的 issuer Name 却声称 60 字节。每个字节都还在缓冲区里,于是按缓冲区定界的读取器接受这个 Name,把紧随其后的摘要算法当成序号读出来,再拿这一对去比对内嵌证书。v3.539.10 之前 CMS 遍历正是这么读的。修法是一个小小的包装函数,把上一级的结束位置带进每一次读取

PDFlibPas 给每次嵌套 DER 读取都加上上一级元素的边界:40 字节 issuerAndSerialNumber 里的 60 字节 issuer Name 会被旧的按缓冲区定界的 DERReadTLV 接受,进而把 digestAlgorithm 当序号读出,而 ReadTLVWithin 拒绝任何结束位置越过 ParentEnd 的元素
在缓冲区内是内存安全,在上一级内是正确性——PDFlibPas 把上一级结束偏移贯穿到 CMS 的每一层,恶意构造的长度借不走邻居的字节
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // 上一级内部已经没有剩余字节:拒绝开始读取
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // 此时 Offset 落在元素后一个字节;它不得越过上一级
  Result := Offset <= ParentEnd;
end;

// each level records its own end and hands it down:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

PDFlibCMSRead 单元现在把这些结束位置贯穿到 ContentInfo、[0] EXPLICIT 包装层、SignedData 直到 signerInfos 的各个字段、以 issuerAndSerialNumber 和 [0] subjectKeyIdentifier 两种形式出现的 SignerIdentifier(RFC 5652 §5.3),以及匹配签名者时从每张内嵌证书读出的 tbsCertificate 字段。在证书集合和 signerInfos 集合内部,越过集合结尾的元素会让循环停下:PLExtractCMSCertificates 返回已经接受的那些证书,绝不会把后面的 crls 或 signerInfos 字节粘到最后一张上。签发者与序号的匹配同时要求两半都齐,因为序号只在同一个签发者之内唯一

2.999.3 为什么会变成 1.15.3?

OID 的前两段合成的是一个子标识符,不是一个字节,而且这个子标识符像其他所有段一样按 base-128 编码。X.690 §8.19.4 把它定义为 40 * arc1 + arc2;早先的 DER_OID 用 Byte(...) 写这个值,只在 127 以内——也就是 2.47——才正确。对 2.999 来说和是 1079,字节强转留下 55,55 解码成 1.15,标识符于是无声地指向了树上另一个分支。128 到 255 的值坏法不同:发出一个置了继续位的字节,把下一段吞进肚里。大多数 PKI 标识符(1.2.840…、2.5.29…、0.4.0…)到不了这个边界,bug 因此一直活着;2.48 以上的 joint-iso-itu-t 段会踩中。DER_OID 同时服务于签名属性的编码器、DERFindExtensionByOID 里的匹配器以及 SignedData 内容类型检查,所以一个编码错误让写入和查找一起坏掉

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
PDFlibPas 的 DER_OID 把 OID 前两段按 40 * arc1 + arc2 合并并在 UInt64 里做 base-128 编码,2.999.3 得到 06 03 88 37 03,而旧的 Byte 强转只留下 55,标识符被无声解码成 1.15.3
大多数 PKI 段到不了这个边界,bug 因此活了下来——2.48 以上的 joint-iso-itu-t 段需要两个字节,测试让 2.47 与 2.48 分居边界两侧

合并后的值特意用 UInt64 存放。DER_OID 把各段解析进 Int64,所以合法的第二段可以大到 Int64.MaxValue,arc1 为 2 时再加 80 就会溢出有符号 64 位整数。UInt64 装得下 Int64.MaxValue + 80 不会回绕,十字节的暂存缓冲也装得下 64 位值需要的十个 7 位组。值得留住的测试向量是边界两侧的那两个:2.47 必须保持一个字节,2.48 必须变成两个

读取端 CMS 遍历保证什么?

PDFlibCMSRead 只保证结构,别无其他:它返回的字节都落在 RFC 5652 规定的位置,但不验证任何签名、摘要或有效期。遍历只接受 DER,DERReadTLV 会拒绝不定长编码和多字节标签号,不合规签名者产出的 BER 编码 CMS 会报零张证书而不是给出部分猜测。属性证书和 CertificateChoices 的其他备选形式会被跳过,因为下游没有东西用得上它们。密码学验证留给负责它的代码:从 Delphi 中的 PAdES 签名与 ByteRange 校验讲的字节覆盖检查开始,到 给 PDF 签名后发生的变化分类继续

更一般的一条经验适用于任何二进制格式:「在缓冲区内」是内存安全属性,「在上一级内」是正确性属性,解析器两者都要。对恶意长度的同一套思考贯穿在 加固 Pascal PDF 解析器以抵御恶意文件一文里。这里讨论的证书提取、链构建和长期验证 API 随 losLab PDF Library for Delphi 发布,支持 Delphi、C++Builder 和 Lazarus