技术文章

使用 CryptoAPI 在 Delphi 中创建自签名测试证书

PDF Library for Delphi 的 PLCreateSelfSignedCertificate 函数会创建自签名 RSA/SHA-256 证书并将其导出,其中包含私钥,直接写入受密码保护的 PFX 文件,全程只使用每台 Windows 计算机上已经安装的 Win32 CryptoAPI。无需外部工具、无需证书颁发机构,也无需手动执行 makecert 或 OpenSSL 步骤:一次函数调用即可得到一张足以驱动签名测试的证书

这个函数最常见的使用场景几乎总是 CI 流水线。签名冒烟测试需要一个包含真实私钥的真实 PFX,而将其提交到仓库本身就是安全问题,因为提交落地的那一刻,提交进去的私钥就已经泄露。也可以在构建脚本中调用 makecert.exe 或 OpenSSL,但这样流水线就依赖一个必须安装、必须位于 PATH 中,并且必须在每个构建代理上保持版本一致的工具。在运行测试的同一进程中,使用 Windows 已经提供的相同 Win32 CryptoAPI 调用生成证书,可以完全移除这项依赖

PLCreateSelfSignedCertificate 实际会生成什么

PLCreateSelfSignedCertificate 会生成一个受密码保护的 PFX 文件,其中保存自签名 RSA 证书及其私钥,并使用 sha256RSA 签名。该函数由五个参数驱动:SubjectNamePFXFileNamePFXPasswordValidDaysKeyBits,并返回一个普通的 Boolean 成功标志。SubjectName 接受完整的 X.500 字符串,例如 'CN=Alice, O=Example';如果名称中没有 = 符号,则会自动添加前缀 CN=。小于 1 的 ValidDays 会回退到 365,超出 1024 到 16384 范围的 KeyBits 会回退到 2048。PDF Library for Delphi 从 v3.224.0 起就提供此函数,它不仅可以从 Delphi 单元访问,也可以通过 DLL 和 ActiveX 接口访问。函数自身的文档注释明确说明了它的适用边界:除非有人明确安装自签名证书,否则所有主流查看器都会将其标记为不受信任,因此应将其生成的证书视为用于执行代码路径的证书,而不是要求团队之外的任何人信赖的签名

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDF Library for Delphi CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

为什么 CryptGenKey 会在标志参数中编码密钥长度

CryptGenKey 会将两个互不相关的设置打包到一个 dwFlags 参数中。低位字包含行为标志,其中包括 CRYPT_EXPORTABLE;对于 RSA 密钥交换密钥,高位字则包含以位为单位的请求密钥长度。如果将 2048 当作普通标志传入,它会落在低位字中,而该值与 CryptoAPI 定义的任何行为标志都不匹配,因此调用会生成提供程序回退到的默认长度密钥,而不是调用者以为请求的长度。要获得实际的 2048 位 RSA 密钥,必须先将数字移入高位字

PDF Library for Delphi 示意图:CryptGenKey 的 dwFlags 参数拆分为高位字(承载 KeyBits shl 16 作为 RSA 密钥长度)和低位字(承载 CRYPT_EXPORTABLE 等行为标志)
裸写的 2048 会落到行为标志半边,提供者于是悄悄回退到默认密钥长度
// 密钥长度存放在 CryptGenKey 标志位的高 16 位;
// 低位字携带行为标志,比如 CRYPT_EXPORTABLE。
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

忘记 CRYPT_EXPORTABLE 会发生什么

从同一个标志值中删除 CRYPT_EXPORTABLE 后,CryptGenKey 仍然会成功,但它会在 CSP 层将生成的私钥标记为不可导出。后续所有步骤也会继续报告成功:CertCreateSelfSignCertificate 返回有效的证书上下文,PFXExportCertStoreEx 即使使用 EXPORT_PRIVATE_KEYS 调用,也会成功并写入一个可以打开、解析且看起来完全普通的 PFX 文件。它没有包含的是私钥,因为 CSP 拒绝将私钥从密钥容器中导出,而 PFXExportCertStoreEx 从不会将这一拒绝视为整个导出失败的理由

失败只会在之后、完全不同的地方出现:签名调用打开该 PFX,发现证书没有关联私钥,并报告与损坏或错误 PFX 完全相同的错误,而不是报告上游三层位置遗漏了一个标志位。仅从签名端调试的人可能会花费整个下午检查错误的文件,最后才发现真正的问题是密钥生成时缺少一个位,而且位于完全不同的函数调用中,甚至可能位于完全不同的构建脚本中

PDF Library for Delphi 示意图:省略 CRYPT_EXPORTABLE 触发的静默失败级联——密钥生成、证书创建和 PFX 导出全部报告成功,直到稍后的签名调用发现 PFX 内没有私钥
省略 CRYPT_EXPORTABLE 会生成一个看似正常的 PFX,私钥缺失的问题要到签名时才暴露

为什么 CryptAcquireContextW 与证书之间的 ProvType 必须匹配

ProvType 必须匹配,因为 CertCreateSelfSignCertificate 会通过 CRYPT_KEY_PROV_INFO 记录解析新证书的私钥,而该记录中的 ProvType 字段必须指定与打开密钥容器时传给 CryptAcquireContextW 的完全相同的 CSP 类型值。在 PDF Library for Delphi 的实现中,该值是 PROV_RSA_AES,数值为 24。将 ProvType 设为零,或设为实际容器所属类型之外的任何提供程序常量,证书仍然可能创建成功,但其中记录的私钥链接将无法再解析到保存私钥的容器,之后会表现为签名或导出失败,而这与证书实际的加密内容无关

// 打开密钥容器时使用的提供程序类型,必须与
// 证书密钥提供程序信息中记录的提供程序类型一致。
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ……生成密钥、构建 subject name blob,然后:
KeyProvInfo.ProvType := PROV_RSA_AES;   // 同一个常量,两处调用点保持一致

整合起来:从 GUID 容器到受密码保护的 PFX

PLCreateSelfSignedCertificate 内部的调用链沿着一条直线执行:打开一个以新生成 GUID 命名的新密钥容器,因此并发 CI 运行不会因容器名称冲突;使用上文介绍的两个标志在其中生成 RSA 密钥对;通过 CertStrToNameWSubjectName 编码为 X.500 名称 Blob;然后调用 CertCreateSelfSignCertificate,有效期窗口根据 ValidDays 计算,并以普通 SYSTEMTIME 形状的结构传入。生成的证书上下文会进入通过 CertOpenStoreCERT_STORE_PROV_MEMORY 打开的内存证书存储,这么做只是为了让 PFXExportCertStoreEx 有可供导出的存储,因为该 API 针对的是存储句柄,而不是单独的证书上下文

// 每次调用都会打开一个以新 GUID 命名的一次性容器:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ……生成密钥、自签名证书、导出 PFX……
// 待 PFX 已保存密钥的自有副本后,再删除该容器:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx 本身遵循普通的 Win32 两遍调用约定:第一次使用零长度缓冲区调用,以获知 PFX 所需的字节数;分配相应大小的空间;然后再次调用以填充缓冲区。字节写入磁盘后,PDF Library for Delphi 会使用 CRYPT_DELETEKEYSET 删除临时密钥容器,而不会将其遗留,因为 PFX 已经保存了容器中每个密钥材料字节的副本。跳过清理后,每次调用 PLCreateSelfSignedCertificate 都会在调用用户的配置文件中留下一个孤立的 GUID 命名密钥容器,而在每次构建中运行该函数的 CI 代理上,这种泄漏会持续累积数月才被发现

PDF Library for Delphi 示意图:PLCreateSelfSignedCertificate 调用链——从 GUID 命名的密钥容器出发,经 CryptGenKey、CertStrToNameW 与配套 ProvType 的 CertCreateSelfSignCertificate 到两遍 PFX 导出,随后删除容器
一次性 GUID 容器驱动一条直连的 CryptoAPI 链,终点是 PFX,用完立即删除

自签名证书可以安全地用于生产签名吗

不可以:自签名证书适合执行签名代码路径,却不适合任何团队之外的预期信任者信赖的签名,因为它没有链接回依赖方软件已经信任的根证书。对于这样的 PFX,自然的下一步是实际执行签名调用,具体内容见在 Delphi 中使用 PDF Library for Delphi 构建合规性与签名工作台,其中以这种方式构建的 PFX 会驱动流水线的签名部分,同时运行 PDF/A 预检和 ByteRange 审计。不过,签名只是证书相关工作的一半,另一半正是自签名叶证书应当失败的地方:使用 PDF Library for Delphi 在 Delphi 中执行 PAdES 签名和验证介绍了合规性验证器执行的信任链检查,而沿链回溯到受信任根的验证器没有理由信任这个函数在五分钟前凭空创建的证书

PLCreateSelfSignedCertificate适用于 Delphi 和 C++Builder 的 PDF Library for Delphi PDF 库中证书与签名 API 之一,它正是为本文所述的场景存在:签名测试需要真实密钥对作为基础,同时不依赖外部工具来生成密钥对