💡 完整的工作示例可在 GitHub 上获取: vault-transit-pdf-signing-python

为什么自动签名会在密钥托管审查中失败

合规和财务运营团队每天要签署成千上万的文件:贷款协议、对账单、政策包、审计函件。几乎没有哪份文件是由人手动点击按钮签署的。是由服务在计划任务中完成的,而这些文件附带的证书上写的是组织的名称。这使得签名密钥成为公司最敏感的资产之一,且它通常与使用它的应用程序一起存放在 PFX 文件中。

外部签名是 GroupDocs.Signature 在 Python 通过 .NET 提供的一项功能,它将文档摘要传递给你提供的代码,而不是使用应用程序持有的私钥进行签名。之所以有此功能,是因为 PFX 的做法经不起审查。文件是副本,副本会传播:进入容器镜像、部署流水线、备份、以及去年春天调试生产问题的开发者笔记本。任何持有这些副本以及密码的人都可以冒你的名义签名,直到有人注意并吊销证书。

这些团队真正需要的不是不同的文件格式,而是让密钥只存在于唯一位置,并对每一次使用都有审计记录,同时提供一种签名服务,能够在不直接信任密钥的情况下请求签名。这正是 HashiCorp Vault 等密钥管理器的作用,但前提是文档库能够被指示去委托加密操作。

为什么受密码保护的证书文件仍不足够

本能的做法是锁定 PFX:限制文件权限、在密钥管理器中存储密码、加密磁盘。每一步都有帮助,但都无法改变核心事实。在签名的那一刻,私钥已经被加载到进程中,因此内存转储、容器逃逸或不慎的日志语句仍可能泄露它。你真正想要的控制是根本无法读取的密钥,而不是更难读取的密钥。

Vault 的 Transit 引擎正是为此而生。它以服务的形式提供加密:你发送一个值,它使用内部持有的密钥进行签名或加密,并返回结果。内部创建的密钥可以标记为不可导出,并且它永远不存储收到的原始数据。GroupDocs.Signature 负责另一半,因为 DigitalSignOptions.custom_sign_hash 允许你将自定义签名器接入 PDF 签名流水线。

设置:使用 Vault Transit 的 GroupDocs.Signature for Python

GroupDocs.Signature 完成除加密之外的所有工作。它打开 PDF,确定签名覆盖的字节范围,对该范围进行哈希,然后将摘要传给你的签名器。你的签名器将摘要交给 Vault,Vault 返回生成的字节。库将这些字节与证书一起嵌入,并写出普通的已签名 PDF。

你需要 Python 3.11 或 3.13、groupdocs-signature-net 与 asn1crypto 包,以及用于开发模式的 Vault Docker 容器。GroupDocs 许可证是可选的;如果没有许可证,评估模式下最多支持 2 页文档。示例仓库只需两条命令即可完成端到端运行,同样的代码只需更改两个环境变量即可指向生产环境的 Vault。

步骤构建

步骤 1 - 从 Vault 读取签名密钥

应用程序向 Vault 请求其被授权使用的密钥,并得到版本号和公钥。私钥部分永远不会出现在响应中。

key = vault.read_key(KEY_NAME)
if key is None:
    # First run: enable Transit and create an RSA-2048 key.
    vault.enable_transit()
    vault.create_key(KEY_NAME, "rsa-2048")
    key = vault.read_key(KEY_NAME)
print(f"Signing key: '{KEY_NAME}', version {key.version}")

读取一次版本号正是此步骤的意义所在。一个证书只包含一个公钥,因此证书和每个签名必须来自同一版本。

步骤 2 - 为不可导出的密钥构建证书

PDF 签名携带 X.509 证书,以便验证者能够找到公钥。对于不可导出的密钥,证书围绕公钥部分组装,并由 Vault 签名。

tbs_certificate = x509.TbsCertificate({...})   # built around the Vault public key

# Hash locally, then have Vault sign the certificate's own digest.
digest = hashlib.sha256(tbs_certificate.dump()).digest()
signature = vault.sign_hash(key_name, key.version, digest, "sha2-256")

certificate = x509.Certificate({
    "tbs_certificate": tbs_certificate,
    "signature_algorithm": algorithm,
    "signature_value": signature,
})

证书特意设置为比当前时间早一天。PDF 阅读器会将签名时间与该起始日期比较,在 UTC 东部两小时的机器上,Adobe Acrobat 曾将签名前几秒钟生成的证书报告为已过期或尚未生效。

步骤 3 - 通过外部签名器签署 PDF

certificate_object = DigitalVerifyOptions(
    certificate_stream=io.BytesIO(certificate)).certificate
digital_signature = DigitalSignature(certificate=certificate_object)
digital_signature.sign_time = datetime.now().astimezone()

options = DigitalSignOptions()
options.signature = digital_signature
options.custom_sign_hash = VaultSigner(vault, KEY_NAME, key.version)
options.hash_algorithm = HashAlgorithm.SHA256
options.reason = "Document signed with HashiCorp Vault Transit"

with gs.Signature(str(input_pdf)) as signature:
    signature.sign(str(output_pdf), options)

有两行是 Python 特有的。绑定库没有 X.509 类型,因此必须先通过 DigitalVerifyOptions 获取证书对象;如果直接把路径传给 DigitalSignOptions 会抛出 “Specified certificate doesn’t contain private key”。sign_time 故意设为带时区的时间,因为带偏移的值会随同 PDF 写入。

在签名器内部,摘要以 prehashed = true 和 pkcs1v15 方式发送给 Vault。这两个字段决定签名是否有效,若缺少第一个字段则会悄然失败。

步骤 4 - 在服务返回前进行验证

options = DigitalVerifyOptions(certificate_stream=io.BytesIO(certificate))
with gs.Signature(str(signed_pdf)) as signature:
    is_valid = signature.verify(options).is_valid

在同一次运行中先签名后验证,可将一次静默失败转化为即时错误。对于文档流水线来说,这就是关卡:未通过验证的文件永远不会进入归档、对方或客户门户。

业务影响:审计会看到哪些变化

关注点 应用主机上的 PFX 通过 Vault Transit 签名
私钥的副本 每台主机、镜像、备份和笔记本各一份 唯一,存放在 Vault 中
签名应用需要的权限 完全访问密钥文件 读取密钥,调用 transit/sign
文档已签名的证据 应用日志(如果有人记录) 每个签名对应一条 Vault 审计日志
对疑似泄漏的响应 吊销、重新发行、在所有地方重新部署 在 Vault 中轮换密钥,重新发行一次
谁可以以你的名义签名 任何拥有文件和密码的人 任何持有有效 Vault 令牌的人

最后一行是真实写照。这种设计是把秘密迁移而非消除:被盗的 Vault 令牌仍可请求签名。不同之处在于令牌是短期的、可在单点吊销、仅限两项操作,并且每次使用都会被记录。这比起无法枚举所有副本的文件更容易在审查中进行防御。

实际工作流:服务签署的贷款协议

贷款平台在审批通过后生成协议 PDF,签署后放入客户的文档库。更改前,签名服务持有公司证书,部署手册中还有一步是将 PFX 复制到新主机。更改后,服务仅持有具备两项权限的 AppRole 令牌,证书的私钥从未在任何地方出现过,只有 Vault 中存在。

审计人员询问上季度签署了多少份文件以及由谁签署时,答案直接来自 Vault 的审计设备,而不是从应用日志中重建。当团队轮换密钥时,只需在 Vault 中一次性完成,并为新版本颁发新证书。

首先必须做好什么?

摘要处理。错误的算法名称会导致 Vault 报错 500 crypto/rsa: input must be hashed message,耗时约一分钟。缺少 prehashed 标志则不会报错:Vault 会再次对摘要进行哈希,签名覆盖错误的值,PDF 看似已完成。务必对每个签署的文件进行验证,否则错误会悄然隐藏。

其他适用场景

该钩子并不关心背后使用的是什么技术,因此相同的四个步骤同样适用于硬件安全模块、云 KMS 或智能卡。我从其 .NET 兄弟项目移植此实现时,只需要调整证书交接方式;Vault 请求体和版本锁定完全相同。医疗、政府和保险行业的团队也因同样的原因采用此方案:密钥必须位于能够证明其用途的地方。

入门指南

资源