PDFlibPas 的 PLCreateSelfSignedCertificate 函数会创建自签名 RSA/SHA-256 证书并将其导出,其中包含私钥,直接写入受密码保护的 PFX 文件,全程只使用每台 Windows 计算机上已经安装的 Win32 CryptoAPI。无需外部工具、无需证书颁发机构,也无需手动执行 makecert 或 OpenSSL 步骤:一次函数调用即可得到一张足以驱动签名测试的证书
这个函数最常见的使用场景几乎总是 CI 流水线。签名冒烟测试需要一个包含真实私钥的真实 PFX,而将其提交到仓库本身就是安全问题,因为提交落地的那一刻,提交进去的私钥就已经泄露。也可以在构建脚本中调用 makecert.exe 或 OpenSSL,但这样流水线就依赖一个必须安装、必须位于 PATH 中,并且必须在每个构建代理上保持版本一致的工具。在运行测试的同一进程中,使用 Windows 已经提供的相同 Win32 CryptoAPI 调用生成证书,可以完全移除这项依赖
PLCreateSelfSignedCertificate 实际会生成什么
PLCreateSelfSignedCertificate 会生成一个受密码保护的 PFX 文件,其中保存自签名 RSA 证书及其私钥,并使用 sha256RSA 签名。该函数由五个参数驱动:SubjectName、PFXFileName、PFXPassword、ValidDays 和 KeyBits,并返回一个普通的 Boolean 成功标志。SubjectName 接受完整的 X.500 字符串,例如 'CN=Alice, O=Example';如果名称中没有 = 符号,则会自动添加前缀 CN=。小于 1 的 ValidDays 会回退到 365,超出 1024 到 16384 范围的 KeyBits 会回退到 2048。PDFlibPas 从 v3.224.0 起就提供此函数,它不仅可以从 Delphi 单元访问,也可以通过 DLL 和 ActiveX 接口访问。函数自身的文档注释明确说明了它的适用边界:除非有人明确安装自签名证书,否则所有主流查看器都会将其标记为不受信任,因此应将其生成的证书视为用于执行代码路径的证书,而不是要求团队之外的任何人信赖的签名
var
Success: Boolean;
begin
Success := PLCreateSelfSignedCertificate(
'CN=PDFlibPas 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 密钥,必须先将数字移入高位字
// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as 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 完全相同的错误,而不是报告上游三层位置遗漏了一个标志位。仅从签名端调试的人可能会花费整个下午检查错误的文件,最后才发现真正的问题是密钥生成时缺少一个位,而且位于完全不同的函数调用中,甚至可能位于完全不同的构建脚本中
为什么 CryptAcquireContextW 与证书之间的 ProvType 必须匹配
ProvType 必须匹配,因为 CertCreateSelfSignCertificate 会通过 CRYPT_KEY_PROV_INFO 记录解析新证书的私钥,而该记录中的 ProvType 字段必须指定与打开密钥容器时传给 CryptAcquireContextW 的完全相同的 CSP 类型值。在 PDFlibPas 的实现中,该值是 PROV_RSA_AES,数值为 24。将 ProvType 设为零,或设为实际容器所属类型之外的任何提供程序常量,证书仍然可能创建成功,但其中记录的私钥链接将无法再解析到保存私钥的容器,之后会表现为签名或导出失败,而这与证书实际的加密内容无关
// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES; // same constant, both call sites
整合起来:从 GUID 容器到受密码保护的 PFX
PLCreateSelfSignedCertificate 内部的调用链沿着一条直线执行:打开一个以新生成 GUID 命名的新密钥容器,因此并发 CI 运行不会因容器名称冲突;使用上文介绍的两个标志在其中生成 RSA 密钥对;通过 CertStrToNameW 将 SubjectName 编码为 X.500 名称 Blob;然后调用 CertCreateSelfSignCertificate,有效期窗口根据 ValidDays 计算,并以普通 SYSTEMTIME 形状的结构传入。生成的证书上下文会进入通过 CertOpenStore 和 CERT_STORE_PROV_MEMORY 打开的内存证书存储,这么做只是为了让 PFXExportCertStoreEx 有可供导出的存储,因为该 API 针对的是存储句柄,而不是单独的证书上下文
// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_DELETEKEYSET);
PFXExportCertStoreEx 本身遵循普通的 Win32 两遍调用约定:第一次使用零长度缓冲区调用,以获知 PFX 所需的字节数;分配相应大小的空间;然后再次调用以填充缓冲区。字节写入磁盘后,PDFlibPas 会使用 CRYPT_DELETEKEYSET 删除临时密钥容器,而不会将其遗留,因为 PFX 已经保存了容器中每个密钥材料字节的副本。跳过清理后,每次调用 PLCreateSelfSignedCertificate 都会在调用用户的配置文件中留下一个孤立的 GUID 命名密钥容器,而在每次构建中运行该函数的 CI 代理上,这种泄漏会持续累积数月才被发现
自签名证书可以安全地用于生产签名吗
不可以:自签名证书适合执行签名代码路径,却不适合任何团队之外的预期信任者信赖的签名,因为它没有链接回依赖方软件已经信任的根证书。对于这样的 PFX,自然的下一步是实际执行签名调用,具体内容见在 Delphi 中使用 PDFlibPas 构建合规性与签名工作台,其中以这种方式构建的 PFX 会驱动流水线的签名部分,同时运行 PDF/A 预检和 ByteRange 审计。不过,签名只是证书相关工作的一半,另一半正是自签名叶证书应当失败的地方:使用 PDFlibPas 在 Delphi 中执行 PAdES 签名和验证介绍了合规性验证器执行的信任链检查,而沿链回溯到受信任根的验证器没有理由信任这个函数在五分钟前凭空创建的证书
PLCreateSelfSignedCertificate 是适用于 Delphi 和 C++Builder 的 PDFlibPas PDF 库中证书与签名 API 之一,它正是为本文所述的场景存在:签名测试需要真实密钥对作为基础,同时不依赖外部工具来生成密钥对