💡 完整可运行示例已在 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 字节。

其他资源