💡 完整可运行示例已在 GitHub 上提供: 使用 ML-DSA 证书签署 DOCX 的 Python 示例
介绍
今天下午使用 RSA-2048 签署一份合同,即意味着你作出了一个必须在合同仍然有效期间一直成立的承诺。如果这段时间是二三十年——对于契约、同意书和工程签字来说,这种情况很常见——那么这个承诺必须超越算法本身的寿命。攻击不必在今天就存在,只要在文档不再重要之前出现即可,届时任何持有公钥的人都可以推导出私钥并冒用你的名义签名。
后量子文档签名是 GroupDocs.Signature 为 Python 提供的功能,它用 ML-DSA(2024 年作为 FIPS 204 标准化的 NIST 签名算法)取代了上述承诺。Word 格式支持已在 GroupDocs.Signature 26.9 中加入,并且复用了你已经熟悉的 API:ML-DSA 密钥存放在 PFX 中,使用方式与 RSA 密钥完全相同,放入 DigitalSignOptions 即可。
本指南通过四个步骤对 DOCX 进行签名,比较三种安全级别的实际输出,使用仅公钥证书进行验证,并在结束时说明两个在决定前必须了解的限制。
为什么这比普通迁移更重要
签名迁移与加密迁移不同之处在于,它更容易被推迟,却更尴尬地需要修复。
在加密场景中,“先收集‑后解密”问题是立刻显现的:今天被拦截的任何数据都可以被存储并在以后打开。对于签名来说,已经签署的内容不会在事后被伪造——但一旦密钥可以从所有人都有的证书中推导出来,已签署的内容也不再能被证明是你的。对十年归档文档使用新密钥重新签名是可能的,但没有人愿意去策划这件事。
因此,实际建议更为细致而非笼统:迁移保留期限长的文档,其他的保持原样。一些规范已经设定了门槛——CNSA 2.0 要求国家安全系统使用 ML-DSA-87——对其他情况而言,决定因素是文件需要保持可辩护的时长。
前置条件
- Python 3.9 或更高版本的 64 位解释器——该包自带 .NET 运行时,不提供 32 位 wheel
- 通过 .NET 26.10.0 的 GroupDocs.Signature for Python,使用免费临时许可证以去除评估限制
- 一个受密码保护的 PFX 格式的 ML-DSA 证书,以及一个待签署的 Word 文档
安装
pip install groupdocs-signature-net
步骤 1 - 使用 ML-DSA 证书签名
证书完成所有工作。调用方式与 RSA 完全相同:
with signature.Signature(source_path) as sign:
options = DigitalSignOptions(pfx_path)
options.password = CERTIFICATE_PASSWORD
result = sign.sign(output_path, options)
这就是已经会签名的代码的全部改动:把 DigitalSignOptions 指向不同的 PFX。没有新选项,没有单独的算法参数,也没有后量子分支。
读取签名者信息需要再执行一步,并且包含了本练习中唯一的 Python 特有陷阱:
for created in result.succeeded:
certificate = getattr(created, "certificate", None)
subject = getattr(certificate, "subject", None)
if subject:
return str(subject)
DigitalSignature 上的证书是一个桥接对象,属性是动态解析的。certificate.subject 返回 CN=GroupDocs.Signature MLDSA65 test,而对同一对象调用 dir() 却什么也不显示。我最初先用 dir() 检查,误以为 subject 没有暴露,结果导致代码错误——因此在读取之前不要先做 introspection,否则会错过已经存在的值。
步骤 2 - 比较三种安全级别
ML-DSA 有三套参数组合,可通过使用不同的证书来选择:
levels = (
("ML-DSA-44", MLDSA44_PFX),
("ML-DSA-65", MLDSA65_PFX),
("ML-DSA-87", MLDSA87_PFX),
)
for level, pfx_path in levels:
with signature.Signature(source_path) as sign:
options = DigitalSignOptions(pfx_path)
options.password = CERTIFICATE_PASSWORD
sign.sign(output_path, options)
sizes[f"{level} -> {file_name}"] = os.path.getsize(output_path)
这一步值得实际运行,因为权衡通常只在文档中描述,却很少有实际测量。以下是对一个 132 KB 源合同的测量结果:
| 级别 | NIST 安全类别 | 签名文件 | 相对于最小文件的增量 |
|---|---|---|---|
| ML-DSA-44 | 2 | 138,202 字节 | - |
| ML-DSA-65 | 3 | 140,650 字节 | +2,448 字节 |
| ML-DSA-87 | 5 | 143,971 字节 | +5,769 字节 |
不足 6 KB 的差距就把最弱级别和最强级别区分开来。对合同而言,这几乎可以忽略不计,从而简化决策:默认使用 ML-DSA-65;在配置要求第 5 类或文件大小不重要时使用 ML-DSA-87;只有在需要签署大量文件、千字节累计成显著体积时才考虑使用 ML-DSA-44。
步骤 3 - 使用公钥证书进行验证
接收方只需要签名者的公钥证书且不涉及任何机密信息:
with signature.Signature(signed_path) as sign:
options = DigitalVerifyOptions(certificate_path)
if password is not None:
options.password = password
return sign.verify(options).is_valid
示例对同一文件调用了两次:一次使用 mldsa65.cer(签名密钥的公钥部分),一次使用另一个签名者的 PFX。第一次返回 True,第二次返回 False。请注意,错误的证书会返回 False 而不是抛异常——“由他人签名”是代码应当处理的结果,而不是异常。该检查同时验证文档内容、证书序列号和指纹,因此签名后被编辑的文件也会验证失败。
步骤 4 - 从文档中读取签名
当收到一个已签名的文档且不知道会使用哪个证书时:
with signature.Signature(signed_path) as sign:
found = sign.search(SignatureType.DIGITAL)
for item in found:
print(item.sign_time, item.is_valid)
使用 SignatureType.DIGITAL 的 search 会返回携带证书、签名时间和有效性标记的 DigitalSignature 对象。Word 文档可以包含多个签名,甚至是 RSA 与 ML-DSA 的混合,每个签名都会单独报告其证书和有效性。
这会改变接收方的验证方式吗?
不会,对接收方而言没有任何可感知的变化。接收方仍然只需要签名者的公钥证书,仍然把它传给同样的 DigitalVerifyOptions,并仍然得到一个布尔值。验证路径本身并不针对 ML-DSA。唯一会显现算法差异的地方是 Microsoft Word 自身的签名指示器,它可能尚未识别 ML-DSA,因为该格式目前没有标准标识符。
实际应用场景
长期保留的合同
最典型的案例。需要在数十年内保持可验证性的文档,只需一次使用 ML-DSA-65 或 ML-DSA-87 签名,之后无需因算法老化而重新签名。
受特定规范约束的受监管环境
当 CNSA 2.0 或类似规范生效时,级别不再是判断题——必须使用 ML-DSA-87,唯一的技术问题是格式是否受支持。
迁移期间的混合流水线
在迁移期间对新文档使用后量子签名,而对归档文档保持原样,是完全合理的中间状态。search 分别报告每个签名,使得管理变得可行。
最佳实践与提示
- 按保留期限而非文档数量迁移。 需要此类签名的往往是长期保存的文件;而仅在 90 天内有效的收据则不必迁移。
- 默认使用 ML-DSA-65,除非规范明确指定其他级别;并且不要因为签名大小差异而犹豫——每个签名的体积差不足 6 KB。
- 在 Word 中仍需使用 RSA 的场景下保留 RSA。 当阅读器在 Word 中标记签名为错误时,这比迁移慢但保持兼容要好得多。
- 替换示例中的测试证书。 示例的 PFX 文件是自签名且密码已公开,使用它们签名的文档并不具备任何可信度。
- 在任何流水线中签名后都进行验证,使用接收方将拥有的公钥证书。
常见问题排查
Microsoft Word 未将签名显示为有效。 目前属于预期行为:ML-DSA 尚无标准的 XML‑DSig 标识符,Word 可能无法识别它,即使签名本身是正确的且 GroupDocs.Signature 已通过验证。请在自己的流水线中验证,并对依赖 Word 指示器的文档继续使用 RSA。
签名调用拒绝 PDF 或电子表格。 ML-DSA 签名目前仅覆盖 Word 格式——DOCX、DOC、ODT 等。PDF、电子表格和演示文稿尚未受支持,仍需使用 RSA 或 ECDSA。
证书主题返回为空。 几乎总是步骤 1 中的 dir() 陷阱:属性是动态解析的,应该直接读取而不是先检查是否存在。
结论
代码层面的唯一改动是更换证书,这正是让你在需求紧迫之前就可以实施的原因。对长期保存的 Word 文档使用 ML-DSA-65,若规范要求则使用 ML-DSA-87,使用公钥证书进行验证,并在格式或阅读器要求时保留 RSA。
将示例运行在你自己的合同上,三种大小会以字节为单位明确告诉你最强级别的成本。我的测试文件增量为 5,769 字节。