💡 完整可运行示例已在 GitHub 上提供:
pdf-signing-certificate-checks-python

介绍

一个服务每晚对上传的 PDF 进行签名。某天早上,它使用的证书已超过有效期,但看起来并未改变:任务仍在运行,文件仍被写入,日志看起来正常。几周后,有人用 Acrobat 打开其中一个文档,看到警告横幅,因为使用已过期证书签名的签名并不是“弱签名”,而是验证器报告为无效的签名。看似已批准的文档价值甚至不如未签名的文档,因为人们相信了它们。

这种拒绝行为是有名称的。Certificate validity checking 是 GroupDocs.Signature 在 Python 中的行为,它在证书的有效期已结束或尚未开始时拒绝签名。该功能在 26.9 版中引入,同时伴随两项同样形式的更改:SHA‑256 成为 PDF 签名的默认摘要算法,SignatureSettings.log_level 开始进行过滤而不是悄然被忽略。每一项都把原本悄然发生的结果显现出来。

本文比较这三项控制在通过 .NET 的 Python 调用中的表现——它们各自如何影响输出、何时使用以及绑定层面的两个细节为何让人耗费了一个下午。文中所有结果均来源于对单页 PDF 运行示例得到的输出。

为什么这比单纯的版本说明更重要

这三项更改共享一个值得命名的特性:它们都把本应在事后才发现的失败,提前变为即时可见的失败。

  • 已过期的证书:签名调用会失败,允许有人及时更新证书,而不是生成在分发后验证失败的文档
  • 摘要默认值:新签名默认使用 SHA‑256,无需人为记得去指定,弱选项必须主动决定而不是因疏忽而使用
  • 日志级别:配置为仅警告的服务现在只会收到警告,这使得警告可读,从而被阅读

最后一点并非表面看起来的装饰性改动。过期证书警告的全部价值在于有人能看到它,而十条追踪信息中埋藏的警告往往无人关注。

前置条件

开始之前,请确保您具备以下条件:

  • Python 3.9 或更高版本的 64 位解释器——该包自带 .NET 运行时,不提供 32 位 wheel
  • 通过 .NET 的 GroupDocs.Signature for Python,版本 26.10.0,若想去除评估限制,请使用免费临时许可证
  • 待签名的 PDF 文件,以及 cryptography 包(如果您想像示例那样生成一次性测试证书)

安装

pip install groupdocs-signature-net cryptography

控制 1 —— 写入签名的摘要算法

DigitalSignOptions 的 hash_algorithm 用来选择摘要算法。自 26.9 起默认是 SHA‑256,符合当前验证器期望的 adbe.pkcs7.detached 格式;在此之前,新签名使用的是 SHA‑1。

with signature.Signature(source_path) as sign:
    options = DigitalSignOptions()
    options.certificate_stream = io.BytesIO(pfx)
    options.password = PASSWORD
    options.hash_algorithm = HashAlgorithm.SHA512
    options.reason = "Approved"

    result = sign.sign(output_path, options)
    return len(result.succeeded)

有两点值得说明。证书通过 certificate_stream 以 io.BytesIO 形式传入,而不是文件路径,这样在内存中构建的 PKCS#12 可以直接送入库而无需写入磁盘——示例正是依赖此方式,以免携带任何私钥。HashAlgorithm 提供 AUTO、SHA1、SHA256、SHA384、SHA512,若您添加时间戳,则使用签名时所选的摘要算法。

在实际使用中,这通常是您最少触碰的控制。默认已经是正确答案,SHA384 与 SHA512 供签名策略需要时使用,SHA1 则是为兼容无法更改的验证器而保留的选项。

控制 2 —— 过期证书是否阻止签名

如果不做任何覆盖,使用已过期或尚未生效的证书进行签名会抛出 GroupDocsSignatureException,且不会写入任何文件。

try:
    sign.sign(output_path, options)
    return True
except signature.GroupDocsSignatureException as error:
    print(f"Rejected: {str(error).splitlines()[0]}")
    return False

异常信息会列出证书名称、过期日期、指纹以及导致拒绝的属性,这足以让应用程序提示操作员进行续订。仅取第一行在 Python 中尤为重要:异常文本后面会跟随来自绑定层的 .NET 堆栈跟踪,这不应直接展示给用户。

当您确实需要强行签名——例如对已归档的证书进行测试,或在证书续订期间必须在当晚完成批处理——可以在调用时进行覆盖:

settings = signature.SignatureSettings(ConsoleLogger())
settings.log_level = LogLevel.WARNING | LogLevel.ERROR

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    result = sign.sign(output_path, options)

allow_not_yet_valid 用于证书尚未生效的情况,两者互相独立:允许已过期证书并不意味着也允许提前使用的证书。提前使用的证书通常意味着机器时钟不正确,而错误的时钟会让该机器产生的所有签名都值得怀疑,因此在覆盖前请先检查时钟。

这两种覆盖都会发出警告,而不是悄然通过,这正是与第三个控制相连的部分。

控制 3 —— 是否让任何人发现问题

SignatureSettings.log_level 是一个标志位。示例对同一文档进行三次签名,分别使用 LogLevel.NONE、LogLevel.WARNING | LogLevel.ERROR 和 LogLevel.ALL,统计收到的日志条目:

logger = CollectingLogger()
settings = signature.SignatureSettings(logger)
settings.log_level = level

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    sign.sign(output_path, options)

统计结果分别是:完全没有、仅一条警告、以及警告加十条追踪信息。26.9 之前,这三行的结果会完全相同,因为当时日志级别被接受后直接被忽略——如果您曾设置过却未见变化,这一点值得了解。

绑定层有两个细节曾让我耗费了一个下午,值得明确说明。SignatureSettings.logger 为只读属性,必须在构造函数中传入,后续赋值会抛出 AttributeError;log_level 则可以在构造后正常设置。另外,自定义日志器不能继承自 groupdocs.signature.logging.ILogger——该基类包装了一个本机对象,其构造函数需要库内部持有的句柄,直接继承会导致 TypeError。绑定层会接受任何实现了以下三个方法的普通对象:

class StdlibLogger:
    def error(self, message, exception=None):
        logging.getLogger("groupdocs").error(message, exc_info=exception)

    def warning(self, message, exception=None):
        logging.getLogger("groupdocs").warning(message)

    def trace(self, message):
        logging.getLogger("groupdocs").debug(message)

error 与 warning 方法的 exception 参数是可选的。库并不总是会传递异常对象,若日志器强制要求该参数则会在缺少异常的消息上出错。

三者比较:何时使用哪一个

控制 最适用场景 关键优势 限制
hash_algorithm 符合指定摘要策略的情况 一次设置;输出大小保持不变 若证书本身不可信,则意义不大
有效期检查与覆盖 为他人签名的任何场景 失败在可修复的阶段出现 覆盖后仍会生成文件,只是可信度降低
log_level 日志已非常繁忙的服务 十一条信息可浓缩为一条 仅过滤日志,不影响异常抛出

它们并非相互替代的选项——一次签名调用会同时使用这三项。思考顺序应按影响先后:有效期检查决定文件是否生成,摘要算法决定文件内部结构,日志级别决定谁能看到结果。

日志级别会改变我收到的异常吗?

不会。它只决定哪些消息会送达您的日志记录器,其他行为保持不变。即使在 LogLevel.NONE 下,使用已过期证书仍会抛出 GroupDocsSignatureException;在 LogLevel.ALL 下,allow_expired 仍然可以完成签名;返回值和异常在所有级别下完全相同。唯一变化的是解释可疑签名的警告是否会被人看到。

验证方向同样前进

值得一提的是,这也是同一版本的另一半改动。使用空的 DigitalVerifyOptions 调用 verify 现在会对每个 PDF 数字签名进行密码学检查,因此文档在签名后被篡改会直接返回无效,而不是仅仅显示“未解释”的状态:

with signature.Signature(signed_path) as sign:
    result = sign.verify(DigitalVerifyOptions())
    return result.is_valid

仅两行代码,值得加入任何先签名后存储的流水线。请注意 True 并不保证信任:它仅说明签名与文档匹配,而不代表颁发者被信任。示例中使用的自签名证书在这里能够通过验证,但仍会被 PDF 阅读器拒绝,这就单独回答了信任问题。

最佳实践与技巧

  • 将拒绝设为默认行为,在代表用户签名的任何场景中如此,并在单次调用时进行覆盖,而非全局设置。异常成本低,批量无效签名的代价高。
  • 记录警告文本,而不仅是计数。警告会列出证书名称和日期,这是操作员唯一可以采取行动的依据。
  • 在允许“尚未生效”证书前先检查机器时钟。证书通常是正确的,机器时钟往往出错,这会影响多个签名调用。
  • 生产环境中关闭追踪日志。每次签名约十条追踪信息会迅速累积;在诊断时打开,完成后关闭。
  • 在任何流水线中签名后进行验证,因为现在的检查是密码学级别的,能够在接收方发现问题前捕获损坏的输出。

结论

三项控制、一次签名调用,背后共享同一设计理念:风险结果现在需要决策,安全结果则无需额外操作。保留有效期检查,将 allow_expired 视为每次调用的异常并记录日志,除非策略另有要求否则保持默认摘要算法,设置合适的日志级别以让警告易于阅读。

在您自己的 PDF 上运行示例只需一分钟,即可看到每项控制的实际影响——六个已签名文件、一次明确的拒绝以及三行不再相同的消息计数。

其他资源