CMS容器内的一个ECDSAsignatureValue是一个DER编码的SEQUENCE { INTEGER r, INTEGER s }。而Windows CNG函数BCryptVerifySignature两种格式都不接受:它要的是IEEE P1363的固定宽度r || s,没有标签,也没有长度字段。HotPDF是面向Delphi和C++Builder的原生VCL PDF组件,它在导入密钥之前,按照严格的DER规则在这两种格式之间做转换
它所防范的失败情形既具体又让人沮丧。Acrobat打开文档、显示一个绿色对勾,而你自己写的校验器遍历同样的字节却返回无效,或者CNG直接返回STATUS_INVALID_SIGNATURE而没有任何进一步说明。签名本身没有任何问题,问题在于大约七十字节的ASN.1数据被传给了一个期待六十四字节原始整数的API,而这种不匹配除非你知道该往哪里找,否则完全隐形
为什么BCryptVerifySignature会拒绝一个有效的ECDSA签名?
因为调用的两端使用的是不同的签名编码,而且谁都不会主动声明自己用的是哪一种。ISO 32000-1 §12.8规定签名字典在/Contents中携带一个CMS数据块;RFC 5652 §5.3规定每个SignerInfo中的signatureValue是一个OCTET STRING,其内容由签名算法自行定义。对ECDSA来说,这个内容就是SEC 1 DER结构:一个持有两个INTEGER的SEQUENCE。它按设计是变长的,因为r和s是整数,而DER会去掉整数前导的零字节
IEEE P1363采取的是相反的做法。它把签名定义为两个坐标的拼接,每一个都在左侧补零,补到恰好等于曲线域的字节宽度。一个P-256签名永远是64字节。同一个签名的DER编码通常是70或71字节,实际上可能落在大约8到72字节之间的任何长度。如果把DER格式直接交给BCryptVerifySignature,光是长度检查这一关就会让调用注定失败,这正是HotPDF在校验之前先做归一化、而不是校验之后再处理的原因
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
签名解析器绝不能放宽的DER规则
这里列出的每一项拒绝,都是HotPDF刻意执行的拒绝,每一项都堵住了一条宽容型解析器会放任敞开的路径。写转换器时的诱惑在于,只要找到那两个INTEGER节点、复制它们的内容就完事了。这种做法在格式良好的输入上没问题,但面对恶意输入时会悄悄接受一整类可延展的重新编码。所以HPDFECDSANormalizeSignature会拒绝负整数,也就是任何r或s的第一个内容字节高位被置位的情况,因为一个合法的ECDSA标量必须是正数。它会拒绝值全为零的情况,因为r = 0或s = 0从来都不是合法签名。它会拒绝多余的前导零字节:X.690 §8.3恰好只允许一个前导零,而且仅在下一个字节若不加这个零就会被读成负数的情况下才允许,所以一个00后面跟着一个小于0x80的字节,就是一次重新编码,而不是一个签名。它会拒绝非最简形式的长度头,因为X.690 §10.1要求确定形式必须用尽可能少的字节数编码,一个本可以用短格式却用了长格式的长度字段,是携带相同含义的另一个字节串。它会拒绝宽度超过曲线坐标大小的整数,因为这样的值不可能是一个域元素。它还会拒绝s之后出现的任何尾随节点,以及外层SEQUENCE的总长度与整个数据块长度不一致的情况
最后这两项比看起来更重要。SEQUENCE之后的尾随字节正是经典的签名可延展性手法:追加一些垃圾字节,一个宽松的校验器依然会说有效,但它所验证的那个字节串已经不是被签名时的那个字节串了。PKCS#12解析笔记中所述的ASN.1长度加固遵循的是同一种直觉,这里也是一样。在校验路径中,接受一个合规签名者从未生成过的结构,是一个缺陷,而不是一种通融
坐标宽度属于曲线,不属于签名本身
HotPDF从具名曲线的OID推导输出宽度,绝不会从它刚解析出的DER长度来推导。这是转换过程的后半段,也是最容易在细微之处出错的一半。RFC 5480 §2.1.1在证书的SubjectPublicKeyInfo参数中标识曲线,HPDFECDSACurveFromOID负责映射HotPDF支持的三个OID:P-256对应1.2.840.10045.3.1.7,P-384对应1.3.132.0.34,P-521对应1.3.132.0.35。HPDFECDSACoordinateSize随后返回32、48或66字节,而P1363缓冲区是它的两倍:64、96或132字节。每个解码出来的整数都在自己那一半中靠右对齐,所以一个较短的r会在左侧补零,而不是发生偏移。P-521是最容易让人栽跟头的那一个,因为521比特等于65.125字节,向上取整到66,得到一个132字节的签名,这是任何按二的幂来猜测宽度的直觉都预测不到的。公钥则按照RFC 5480 §2.2以未压缩的EC点形式随行传递,也就是0x04后面跟着X和Y,所以HotPDF在接触CNG之前,会先检查它的长度恰好是1 + 2 * CoordinateSize字节,且以0x04开头
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
请留意最后一个参数。HPDFECDSAVerifyDigest也接受eseP1363,供那些已经持有固定宽度签名的调用方使用,比如来自硬件令牌或远程签名服务、直接返回原始r || s的场景。这条路径依然会对两半分别执行长度检查和非零检查,所以一段大小正确但全是零的缓冲区会被拒绝,而不会被直接传给底层提供程序
为什么通用的ECDSA算法名在较老的Windows上会失败?
因为这个通用名称出现的时间比你要部署的目标环境基线更新。CNG暴露了一个算法标识符ECDSA,它会从导入的密钥推断曲线,这本是写这段代码最干净的方式,但BCryptOpenAlgorithmProvider只在较新的Windows版本上才保证能解析它。在较旧的机器上,打开调用会失败,提供程序句柄始终为nil,你应用程序里的每一次ECDSA校验都会对一个完全没问题的签名报告"不支持"。HotPDF通过改为按曲线分别打开标识符来避开这个陷阱。它只解析一次ECDSA_P256、ECDSA_P384和ECDSA_P521,为每条曲线缓存一个提供程序句柄,并在unit finalization阶段关闭它们。此后每一次校验只需要做便宜的工作:从一个ECCPUBLICBLOB导入一个临时公钥,调用BCryptVerifySignature,再销毁这个密钥。不会有重复的LoadLibrary,不会有重复的GetProcAddress,也不会每签一次名就打开关闭一次提供程序。批量校验几百份文档时能明显感觉到这个差别,一个在负载下运行的服务进程更是如此,否则它会不断折腾提供程序句柄
返回结果码在这一点上保持了诚实的区分。evrProviderUnavailable意味着这台机器没能给HotPDF提供一个提供程序;evrInvalid意味着CNG确实回答了STATUS_INVALID_SIGNATURE。把这两者混为一谈,就是部署问题被误报成伪造文档的根源。同样对环境失败与密码学失败加以区分的做法,也贯穿在签名侧的CNG与CAPI处理中,详见证书存储签名与字节序一文
是哪张证书签的名?SignerIdentifier其实是两种不同的东西
RFC 5652 §5.3把SignerIdentifier定义为一个CHOICE,一个只处理其中一个分支的校验器会悄无声息地拿错误的密钥去校验。第一个分支是issuerAndSerialNumber,一个持有原始DER形式的颁发者Name和序列号INTEGER的SEQUENCE,匹配它就是对CMS certificates集合中的每张证书做字节比较。第二个分支是[0] subjectKeyIdentifier,一个隐式打标签的OCTET STRING,匹配它需要深入证书内部去挖,而不是比较证书的头部字段
这次深挖藏着一层容易让人吃惊的结构。密钥标识符存放在一个X.509v3扩展中,所以HotPDF会遍历tbsCertificate的[3]扩展字段,找到OID为2.5.29.14的那个扩展,跳过可选的critical布尔值,取出extnValue这个OCTET STRING。这个字节串本身并不是标识符。按照RFC 5280 §4.2.1.2,它的内容本身又是一段DER,而KeyIdentifier类型是另一个OCTET STRING,所以你需要再解析一层才能到达真正的字节内容。少解一层,你就会拿一个22字节的外壳去和一个20字节的标识符比较,结果永远匹配不上任何证书,校验器于是退回到你接下来写的某种启发式逻辑上——而这才是真正的隐患所在。取集合中的第一张证书是个很诱人的捷径,但只要CMS携带的是一条证书链——大多数情况下确实如此——这个捷径就是错的,因为规范并不要求叶子证书排在第一位。HotPDF只有在容器里恰好只有一张证书时,才会接受一次未匹配的证书;一旦存在多张证书,就必须做到SignerIdentifier的精确匹配。用一个中间CA的公钥去校验摘要不会产生一个友好的错误提示,它只会对一份本来完全没问题的文档给出一个信心十足的"无效"结果
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName会报告P-256、P-384或P-521,这样审计日志记录的就是实际使用的具体曲线,而不只是"ECDSA"这个笼统的词。围绕这次调用的文档级配套机制,尤其是/ByteRange各段是如何被哈希的、为什么摘要必须基于文件字节而不是已解析的对象树来计算,是校验PDF数字签名这篇配套文章的主题
它没有替你回答的问题
HPDFECDSAVerifyDigest给出的绿色结果只回答了一个问题:这些字节是由与该公钥匹配的私钥所签署的。它并没有说明这个密钥是否属于任何你应该信任的人。构建到信任锚点的证书链、通过CRL或OCSP做吊销检查,以及策略检查,都是另外的工作,任何一个不做这些检查就报告"签名有效"的产品,实际报告的信息都比用户以为的要少。正因如此,证书的有效期会在THPDFSignatureInfo中单独暴露出来:一个签名可以在密码学意义上完全通过校验,同时它所使用的证书两年前就已经过期了。曲线支持范围也是刻意收窄的。目前只处理三条NIST素数曲线,任何其他曲线上的签名都会返回"不支持",而不是给出一个猜测结果。CNG这条路径仅限Windows,这对一个VCL组件来说是正确的取舍,但如果你打算围绕它规划一个跨平台服务,这一点值得提前说清楚。严格程度也不是可配置的:没有什么宽松模式会因为某个遗留签名者生成了非最简的DER长度就予以接受。如果你在生产环境中遇到这样的文件,诚实的做法是记录下来、去追问生成方,而不是不断放宽解析器直到这份文件能通过
这里介绍的ECDSA校验路径,随同RSA PKCS#1 v1.5与RSA-PSS路径以及完整的签名信息记录,一起作为标准版HotPDF Component的一部分,提供给Delphi和C++Builder使用;产品页收录了完整的数字签名参考