HotPDF 通过将摘要交给 Windows 本身,为已经存放在 Windows 证书存储区中的证书签署 PDF,而 Windows 会通过两个私钥后端之一完成请求:返回 RSA 大端序签名的 CNG,或返回小端序签名的传统 CryptoAPI CSP。如果将两者混淆,HotPDF 嵌入的 CMS 签名就会按照实际响应的后端被反转字节序,因此符合规范的验证器会报告签名无效,尽管文档字节从未被改动
这句话背后隐藏着两个互不相关的问题,HotPDF 的系统证书签名器必须在真正签名之前同时解决它们。字节序不匹配是无声的:签名调用仍然返回 True,PDF 仍然可以打开,只有查看器遍历 CMS 结构并拒绝它时才会暴露失败。第二个问题声音更大,并且是 C++Builder 特有的:有六个左右的 crypt32 函数拒绝链接,因为 RAD Studio 随附的导入库没有导出它们。如果你始终只使用 PFX 文件签名,这两个问题都不会出现,因此从基于 PFX 的单调用签名转向 IT 部门已经安装在用户配置文件中的证书时,开发者往往才会遇到它们
从存储区选择证书
HotPDF 通过 HPDFSignPDFStreamWithSystemCertificate 和 HPDFSignPDFFileWithSystemCertificate 暴露此路径,两者都由 THPDFCertificateStoreSelector 记录驱动:Location(cslCurrentUser 或 cslLocalMachine)、StoreName(个人存储区,默认为 'MY')、SHA-1 Thumbprint 以及 AllowUI 标志。指纹会在内部规范化,因此直接从证书管理器用户界面复制的连字符或空格会在比较前被去除
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
AllowUI = False 的意义比看起来更大,因为它直接映射到 CRYPT_ACQUIRE_SILENT_FLAG,而 Windows 会严格遵守这一点:如果匹配证书的私钥位于智能卡或令牌上,并且需要输入 PIN,而 Windows 尚未缓存该 PIN,CryptAcquireCertificatePrivateKey 会失败,而不是从可能是服务进程的环境中弹出对话框。这个失败很明确,会立即出现一个 EHPDFCMSError,但很容易被误读为“未找到证书”,实际原因却是令牌正在等待没人会输入的 PIN
为什么 CNG 和 CAPI 的字节序不同
哪个后端响应并不是猜测:CryptAcquireCertificatePrivateKey 会通过 KeySpec 输出参数直接报告,而这个单一值正是 HotPDF 签名器据此分支的依据。CNG 密钥存储提供程序的密钥返回时,KeySpec 会被设置为哨兵值 CERT_NCRYPT_KEY_SPEC ($FFFFFFFF);其他任何值都表示传统 CryptoAPI CSP 密钥。当前 Windows 安装中签发或导入的大多数个人证书都会解析为 CNG,尽管为了兼容性仍然存在传统 CSP 兼容层,这正是 HotPDF 在检查返回值之前同时请求 CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG 和 CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG 的原因
两个后端不仅调用不同的函数,即对 CNG 密钥调用 NCryptSignHash,对 CSP 密钥调用 CryptSignHashA;它们还以相反的字节序返回原始 RSA 签名。CNG 的输出已经符合 PKCS#1 的要求:一个大端序八位字节字符串,最高有效字节在前,正是 RFC 8017 的 I2OSP 转换产生的结果,也是 CMS SignerInfo(RFC 5652)在 ISO 32000-1 §12.8.3 的签名字段中所需的形式。相比之下,CryptoAPI 的 CryptSignHash 返回小端序签名,这是从经典 CSP 内部表示大数的方式延续而来的已记录特性。如果在 CAPI 路径上跳过反转,签名中的每个字节都会位于错误位置;RSA 数学计算仍然正确,但验证器读取到的八位字节字符串并不是 PKCS#1 定义的那一个
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
自定义签名器回调怎么办
任何绕过 HotPDF 内置证书存储区签名器的实现都要遵守同样的字节序规则。HPDFCMSSignPDFStreamWithExternalSigner 接收一个 THPDFCMSSignDigestCallback,这是一个类型为 reference to function(const SignedAttributesSHA256: TBytes): TBytes 的闭包,用于通过 HSM、智能卡中间件栈或其他无法由 Windows 存储区提供密钥句柄的方式进行签名。无论回调背后使用哪个后端,它返回的字节都必须先落入大端序,然后 HotPDF 才会将其折叠进 CMS 结构
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
这里有一个边界需要明确:HotPDF 的两个内置签名路径,即使用带 PKCS#1 填充的 NCryptSignHash 的 CNG 路径和使用 CryptSignHashA 的 CAPI 路径,都针对 RSA 密钥签署 32 字节的 SHA-256 摘要。两者都不会协商 ECDSA 签名格式。私钥基于 EC 的证书需要你针对 HPDFCMSSignPDFStreamWithExternalSigner 自行编写签名器,按照 CMS 的要求编码 ECDSA 签名,而不是假定 RSA 字节字符串具有固定长度,因此不要期待内置证书存储区签名器能为配置了 EC 证书的令牌完成正确处理
为什么 C++Builder 无法链接 CertOpenStore
因为 RAD Studio 的默认 C++Builder 导入库 import32.lib 没有导出 CertOpenStore,也没有导出它的五个邻近函数:CertEnumCertificatesInStore、CertGetCertificateContextProperty、CertFreeCertificateContext、CertCloseStore 和 CryptAcquireCertificatePrivateKey。Delphi 构建不会遇到这个问题,因为 dcc32/dcc64 会将静态 external 'crypt32.dll' 导入直接解析到 PE 导入表中。C++Builder 则不同:Delphi 编译器为软件包构建生成 OMF .obj,ilink32 对其进行链接,而此时相同的 external 声明只是等待命令行提供导入库的未解析符号。将链接器指向 Windows SDK 的 psdk 目录(完整的 crypt32.lib 确实在那里导出了全部六个符号)也无法解决问题:ilink32 只会链接命令行中实际命名的导入库,默认是 import32.lib cp32mt.lib,向该目录添加搜索路径并不会让它从中额外提取任何内容。使用 tdump 检查 import32.lib 可以直接确认这一缺口:找不到 CertOpenStore,而 SDK 的 crypt32.lib 中则能干净地找到六个匹配项
HotPDF 采用与库中其他位置处理证书枚举相同的方式解决此问题:不要求链接器提供这些符号,而是在运行时加载它们。内部的 THPDFCryptoProcs 记录携带一个 crypt32.dll 句柄、一个 advapi32.dll 句柄以及十一个函数指针字段;LoadCryptoProcs 加载这两个 DLL,并通过 GetProcAddress 恰好解析每个入口点一次,解析发生在 HPDFSignPDFStreamWithSystemCertificate 开始时。如果缺少任何入口点,就立即引发 EHPDFCMSError,而不是等到签名流程深处发生访问冲突
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
每次调用都会加载一次,而不是在每个辅助函数内部延迟加载,因为负责在 CNG 和 CAPI 之间选择的闭包会按值捕获已加载的函数表,并且必须在整个签名流程(包括回调到 HPDFCMSSignPDFStreamWithExternalSigner)期间保持有效;签名完成或引发异常后,两个 DLL 句柄都会在最外层的 finally 块中释放。这些改动不会触及公共表面:HPDFSignPDFStreamWithSystemCertificate、HPDFSignPDFFileWithSystemCertificate 和 THPDFCertificateStoreSelector 保持原有的精确签名,因此现有调用方只需重新构建,无需修改代码
本文不涉及什么
修正字节序并解决 C++Builder 链接问题后,会产生验证器可以解析且能够进行数学检查的 CMS SignerInfo 和签名;但这并不说明验证器是否应信任其背后的证书,因为链构建、吊销检查和时间戳策略是通过 CMS 选项叠加的独立关注点,并不是字节序正确性自动带来的结果。两个与密码学同样重要的维护细节是:证书查找返回的 PCCERT_CONTEXT 必须在存储区关闭前用 CertFreeCertificateContext 释放;如果 API 报告调用方拥有已获取的 CNG 或 CSP 密钥句柄,则必须通过对应后端自己的调用释放,绝不能使用另一个后端的调用。如果在完成这些操作后得到的 svValid 结果仍然比预期更窄,验证 PDF 数字签名的文章准确说明了该标志能保证和不能保证什么。由于证书在整个过程中始终由 Windows 保管,证书存储区签名避开了一整类攻击面:无需解析 PKCS#12 文件,也无需自行遍历 ASN.1,而这正是 HotPDF 的 PKCS#12 与 ASN.1 加固文章针对 PFX 文件签名路径解决的问题
证书存储区签名、PFX 签名和外部签名器回调,是 适用于 Delphi 和 C++Builder 的 HotPDF PDF 组件 内部同一 CMS/PKCS#7 流程的三扇入口,而选择哪一种,主要取决于谁可以持有私钥:你的进程、PFX 文件,还是 Windows 本身