💡 完整的工作示例可在 GitHub 上获取:
qr-sign-password-protected-pdf-python

介绍

大多数团队在遇到需要签名的文档被加密时,会采用三步模式:先解密、对明文签名、再重新加密。此方法可行,但也意味着在几百毫秒的时间窗口内,一个可读的受保护文档副本会出现在临时目录中,而在审计流水线中,这段时间窗口才是被发现的风险点,而不是签名本身。

对受保护 PDF 的签名是 GroupDocs.Signature 在 Python 通过 .NET 提供的功能,它完全跳过上述三步:密码直接在原文件上打开,签名被应用,输出再次以受保护形式写回。本文比较了四种密码路径——两种可用、两种故意失败——并阐述了该绑定特有的失败约定。

为什么这很重要

密码处理是文档流水线泄漏的关键点。泄漏通常不是来自签名库本身,而是来自其周边的支撑代码:本应被删除的临时文件、吞掉错误密码异常并无限重试的异常处理器、以及带有收件人未被告知的密码的已签名副本。

这三者的根本原因相同:密码被当作需要“摆脱”的东西,而不是操作的一部分。LoadOptions 和 SaveOptions 将密码重新放回操作中。

前置条件

Python 3 和 groupdocs-signature-net==26.1,以及一个带有用户密码的 PDF。没有许可证时,库以评估模式运行,仍可签名,但会在页面上添加自己的文字。

安装

pip install groupdocs-signature-net==26.1

方法 1 - 保持原始密码

默认方式,也是代码量最少的方式。密码通过 LoadOptions 传入,且根本不传入 SaveOptions:

load_options = LoadOptions()
load_options.password = password
options = _build_qr_options(qr_text)
with signature.Signature(source_path, load_options) as sign:
    result = sign.sign(output_path, options)
    return len(result.succeeded)

这里不使用 SaveOptions 正是关键。use_original_password 默认 True,因此 GroupDocs 会把源文件的密码重新应用到已签名的输出上。整个过程中不存在未受保护的版本,无论是磁盘上还是其他地方,len(result.succeeded) 返回写入的签名数量。

方法 2 - 为签名副本重新设置密码

当签名文档要交给其他方时,合理的做法是给副本单独设置凭证,而保持源文件不变:

save_options = SaveOptions()
save_options.password = new_password
save_options.use_original_password = False
with signature.Signature(source_path, load_options) as sign:
    result = sign.sign(output_path, options, save_options)
    return len(result.succeeded)

这两行 SaveOptions 必不可少,值得记住的细节是:仅设置 password 而不修改 use_original_password(保持默认)不会产生可观察的效果。标志位起决定作用,输出仍保留旧密码,收件人报告密码无效时,你才会发现问题。

方法 3 和 4 - 两个失败情况

加密文档对缺失密码和错误密码的响应不同,这一点值得专门处理。

如果根本不提供 LoadOptions,打开会失败且不会写入任何内容:

try:
    with signature.Signature(source_path) as sign:
        sign.sign(output_path, options)
    return ""
except RuntimeError as error:
    return proxy_error_name(error)

此时会返回 PasswordRequiredException。如果提供了错误的密码,则同样的代码会返回 IncorrectPasswordException。前者意味着需要向用户请求凭证,后者则表示已有的凭证已失效。无法区分两者的处理器会一直重试一个永远不会成功的密码。

失败约定,以及为何显而易见的代码会出错

这里是如果没人提醒你会花掉一个下午的坑。该绑定将 PasswordRequiredException、IncorrectPasswordException 和 GroupDocsSignatureException 以裸名称暴露,而这些名称并未继承自 BaseException。写下直觉式的捕获代码:

except IncorrectPasswordException:
    ...

Python 会抛出 TypeError: catching classes that do not inherit from BaseException is not allowed。原始错误信息消失,取而代之的是指向你的 except 行的错误,而不是指向密码本身。我第一次写的就是这种处理器,花了二十分钟阅读 TypeError,正是本节存在的原因。

实际到达的异常是 RuntimeError,其消息以 Proxy error(<Name>): 开头。解析该前缀即可恢复真实原因:

message = str(error)
marker = "Proxy error("
if not message.startswith(marker):
    return ""
start = len(marker)
end = message.find(")", start)
if end < 0:
    return ""
return message[start:end]

根据返回的名称进行分支,而不是根据包含文件路径且每次运行可能不同的完整消息文本。

在签名之前进行检查

还有一种值得了解的路径,它根本不写入任何内容。使用 LoadOptions 打开文档并调用 get_document_info,即可在文件仍保持加密状态的情况下获取格式、页数和大小:

with signature.Signature(source_path, load_options) as sign:
    info = sign.get_document_info()
    return info.file_type.file_format, info.page_count, info.size

此功能有两种用途。第一,当密码来源于用户表单时,可在批量处理两百个文档之前先用一次廉价调用验证凭证。第二,当流水线根本不允许存储明文时,它仍然可以让流水线报告所持有的内容——审计日志的页数、配额的大小——而无需解密任何东西。

方法比较:何时使用每种方法

方法 最适用场景 关键优势 限制
保持原始密码 在原地签名的流水线 不需要 SaveOptions,未以明文写入 收件人需要源文件密码
保存时重新设置密码 移交给其他方 源文件保持原凭证,副本使用新密码 需要两行 SaveOptions,容易只写一行而出错
无密码(失败) 在测试中验证约定 打开即失败,未写入任何内容 不是签名路径
错误密码(失败) 区分凭证是否过期 异常名称不同 不是签名路径

读取回执是否值得额外的调用?

答案是肯定的,原因有二。使用 QrCodeVerifyOptions 重新打开已签名文件可以验证签名在保存后仍然有效;因为重新打开时必须提供密码,这也证明输出仍然是加密的。计数为零几乎总是许可证问题,而非签名失败——真正的签名失败会抛异常,返回零且无异常通常意味着使用了未授权的构建。

切换的成本

结构上没有任何改变。如果你的代码已经先解密到临时文件,只需删除这一步,将密码移入 LoadOptions,并去掉最后的重新加密调用——通常还能减少代码行数。签名调用本身的形态不变,输出仍是字节完全相同、且保持原有保护的 PDF。

唯一需要仔细检查的地方是清理代码。基于“解密‑签名‑重新加密”构建的流水线通常在 finally 块中删除临时文件,而当临时文件不再生成时,这段代码会尝试删除一个不存在的路径。

最佳实践

  • 除非有意进行密码轮换,否则保持 use_original_password 默认不动;默认设置是最安全的。
  • 将代理名称的解析封装到辅助函数中,随后在所有地方基于该名称进行分支。
  • 在批量处理前使用 get_document_info 验证用户提供的密码,这样错误凭证只会消耗一次廉价调用,而不是导致半途而废的批处理。
  • 切勿将已签名的输出直接写回源路径,这样即使出现错误也能保留原始文件以供恢复。

结论

密码不是签名前需要规避的障碍,而是操作本身的一个参数。使用 LoadOptions 打开文档,使用 SaveOptions 决定输出的保护方式,失败时解析代理名称,并在事后通过密码进行验证。示例一次性演示了四条路径,区别只需一条命令即可看到,而不必阅读冗长的段落来相信它们的行为。

附加资源