💡 完整的工作示例可在 GitHub 上获取:
digital-signing-certificate-validity-dotnet
审计员出现前看不见的合规问题
一个签名服务运行了三年没有出现错误。文档被发送,收件人接受它们,日志中没有任何提示问题的迹象。随后,交易对手的验证器将一批文件标记为无效,调查发现了两个原因:签名使用了 SHA‑1,且在过去四个月里证书已经过期。
这两种失败在签名时都是静默的。这正是 GroupDocs.Signature 26.9 所改变的地方。
证书有效期强制执行成为 .NET 数字签名的新默认行为:超出有效期的证书将被拒绝而不是使用。它随同两个伴随特性一起出现——默认使用 SHA‑256 作为 PDF 摘要算法,以及一个最终能够过滤的 LogLevel——它们共同将三类失败从收件人转移回发送方,从而仍有机会进行修复。
为什么“静默成功”是代价高昂的结果
签名的特殊之处在于,犯错的一方并不是发现错误的一方。格式错误的发票会在自己的系统中失败;无效的签名则会在他人的系统中几周后才失败,而且没有可供阅读的诊断信息。
正是这种不对称导致“API 返回成功”在这里并不是一个有用的保证。旧的默认设置旨在不打断调用方,而代价则落在收件人身上,最终落到需要重新签名并重新发送数百份文档的人员身上。
变更 1:过期证书将被拒绝
这是最显著的变化。Sign 现在会在证书已过期或尚未生效时抛出 GroupDocsSignatureException,且不会向磁盘写入任何内容。
try
{
signature.Sign(outputPath, options);
return true;
}
catch (GroupDocsSignatureException ex)
{
Console.WriteLine($" Rejected: {ex.Message}");
return false;
}
异常信息会指明是哪张证书以及导致问题的属性,操作员只需阅读日志行即可采取行动,无需打开文档。对于升级到 26.9 并开始出现失败的流水线,这几乎总是原因所在——正确的响应是续订证书,而不是压制错误。
当你确实需要旧行为时,只需设置一个属性:
var options = new DigitalSignOptions(certificate)
{
Password = certificatePassword,
AllowExpired = true
};
文档仍会被签名,且会向日志记录器发送警告。验证器仍会拒绝结果,因为 AllowExpired 控制的是库允许的行为,而不是证书本身的价值。伴随的标志 AllowNotYetValid 处理窗口的另一端,并且是独立的:允许使用已过期的证书并不意味着会悄悄允许使用未来生效的证书。
变更 2:默认使用 SHA‑256
PDF 数字签名现在使用 adbe.pkcs7.detached 格式并采用 SHA‑256,符合当前验证器的期望。早期版本使用的是 SHA‑1。
var options = new DigitalSignOptions(certificate)
{
Password = certificatePassword,
HashAlgorithm = HashAlgorithm.Sha256,
Reason = "Approved",
Location = "Head office"
};
显式设置该属性仅在需要进一步提升——例如策略要求 Sha384 或 Sha512——或在必须保持 Sha1(因为验证器不支持其他算法)时才必要。时间戳添加到签名时也使用相同的摘要算法。
同一版本中,验证也朝相同方向改变:未设置任何条件的 DigitalVerifyOptions 过去几乎不做任何检查,而现在会执行完整的密码学校验,因此签名后被篡改的文档会被报告为无效。
变更 3:LogLevel 真正起到过滤作用
SignatureSettings 长期以来已经接受日志记录器。在 26.9 之前,日志级别被忽略,所有信息都会被发送,导致大多数服务关闭日志以免被大量跟踪信息淹没。
示例通过使用计数日志器对同一文档进行三次签名,使差异可度量:
var levels = new Dictionary<string, LogLevel>
{
["None"] = LogLevel.None,
["Warning | Error"] = LogLevel.Warning | LogLevel.Error,
["All"] = LogLevel.All
};
None 不产生任何消息,Warning | Error 只保留因允许使用已过期证书而产生的单条警告,All 则在每一步添加一条跟踪。计数日志器本身就是与你的日志体系集成的入口点:
public void Warning(string message)
{
Warnings++;
WarningMessages.Add(message);
}
将这三个方法实现为 Serilog、NLog 或 Application Insights,即可让库的诊断信息落在你的服务日志的任意位置。
日志级别会影响我收到的异常吗?
不会,这一点值得明确说明,因为两者看起来好像有关联。LogLevel 只过滤到达 ILogger 的内容。异常始终会抛给你的代码:即使在 LogLevel.None 下,未设置 AllowExpired 的过期证书仍会抛异常,你的 catch 块行为完全相同。诊断信息和控制流是独立的通道,这正是能够在生产环境下安全使用 Warning | Error 的原因。
拒绝的成本其实并不高
对硬性停止的反对主要是运营层面的:原本在深夜完成的批处理现在在 02:00 失败,导致有人被呼叫。的确这是一笔真实的成本,但仍然是较小的那一笔。一次被拒绝的批处理只会产生一次警报、一次续订和一次重新运行,全部发生在你自己的系统内部。使用过期证书的批处理则会被收件人发现,这意味着要开启支持工单、重新签发所有受影响的文档,并进行一场尴尬的对话,讨论这种情况已经持续了多久。
示例通过故意使用过期证书进行签名、捕获异常并打印信息,使失败具体可见,而不是抽象的理论。这样你在升级到生产环境之前就能看到日志中会出现的内容。建议在升级前先在自己的证书库上运行该方法,以确认行为。
升级前需要做的事情
三项检查,按可能出现问题的概率排序。
- 检查所有签名路径上的证书到期情况,包括每月或每季运行的路径——这些地方是过期证书潜伏最久的。
- 搜索
HashAlgorithm:如果没有任何地方显式设置,它将在升级时从 SHA‑1 自动切换为 SHA‑256,这是一项改进,也应写入发行说明。 - 有意识地决定日志级别。服务的合理默认是
Warning | Error;All适用于复现特定问题,None则放弃了唯一能告诉你签名是基于豁免进行的信号。
验证也朝相同方向改变
这点容易被忽视,因为调用代码本身无需修改。未设置任何条件的 DigitalVerifyOptions 过去几乎不做任何事:它只比较提供的条件,而没有条件时几乎没有输出。从 26.9 开始,同样的调用会对每个 PDF 数字签名执行完整的密码学检查。
对于验证入站文档的服务来说,这相当于一次“静默升级”:从“这里有签名”变为“此签名与此内容匹配”。在看到上个月通过的文档开始验证失败之前,了解这一点很重要:文档很可能已经被篡改,而旧的检查根本没有检测到。
示例中的证书
这里有一个值得复制的细节,而不是代码本身:示例不包含私钥。TestCertificates.cs 在运行时内存中生成三个自签名 PFX——有效的、去年已过期的、明年才生效的——因此演示可以在任何日期运行,且仓库中不含任何敏感信息。
这种模式值得在自己的测试套件中采用。提交到仓库的测试证书终有一天会过期,届时的失败正好会表现为本次发布旨在揭示的错误。
结论
三项变更,同一方向:原本在收件人侧出现的失败现在转移到发送方。续订证书而不是使用 AllowExpired,让 SHA‑256 成为默认,使用密码学方式验证入站文档,并在需要之前就确定好日志级别。示例一次性演示了全部六种行为,包括拒绝情况,因而可以在几分钟内完成升级演练。