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 开头的输入一律返回空。尾部有多余字节合法的地方只有最外层,而这个区别对下一节很关键:「元素必须吃光整个缓冲区」的严格规则会拒绝现实中所有签名,而不分深浅处处放宽的规则又让嵌套字段读到不属于它们的字节
DER 读取器为什么需要上一级的结束偏移?
嵌套元素只有在自己结尾落在上一级内部时才算合法,而拿缓冲区结尾来检查证明不了这一点。PDFlibASN1 里的底层函数 DERReadTLV 拿整个字符串给每个元素定界,这对最外层对象是正确的检查,对再往下的每一层都是错的。设想一个 SignerInfo,它的 issuerAndSerialNumber 声明 40 字节,里面的 issuer Name 却声称 60 字节。每个字节都还在缓冲区里,于是按缓冲区定界的读取器接受这个 Name,把紧随其后的摘要算法当成序号读出来,再拿这一对去比对内嵌证书。v3.539.10 之前 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.
合并后的值特意用 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