💡 完整的工作範例可在 GitHub 上取得: digital-signing-certificate-validity-dotnet

合規問題:在審計員發現之前無人察覺

簽署服務運行了三年且未出現錯誤。文件發出,收件人接受,日誌中沒有任何問題的跡象。然後,對方的驗證器將一批文件標記為無效,調查發現兩個原因:簽名使用了 SHA‑1,且在過去四個月內憑證已過期。

這兩個失敗在簽署時都是沉默的。這正是 GroupDocs.Signature 26.9 所改變的地方。

為何沉默的成功代價高昂

簽署的特點在於,犯錯的一方並非發現錯誤的一方。格式錯誤的發票會在自己的系統中失敗;無效的簽名則在別人的系統中幾週後失效,且沒有可讀的診斷資訊。

正是這種不對稱,使得「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 數位簽章現在使用 SHA‑256 以 adbe.pkcs7.detached 格式寫入,這是目前驗證器所期望的。早期版本使用的是 SHA‑1。

var options = new DigitalSignOptions(certificate)
{
    Password = certificatePassword,
    HashAlgorithm = HashAlgorithm.Sha256,
    Reason = "Approved",
    Location = "Head office"
};

僅在需要更高等級時才必須明確設定屬性——例如政策要求 Sha384 或 Sha512,或為了兼容只能接受 Sha1 的驗證器而保持使用 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 失敗,導致有人被叫醒。這確實是一筆成本,但仍是較小的那筆。一次被拒絕的批次只會產生一次警報、一次續期與一次重新執行,全部發生在自己的系統內。相較之下,使用過期憑證簽署的批次被收件人發現,則會產生支援工單、所有受影響文件的重新發行,以及關於問題持續時間的尷尬對話。

範例將失敗具體化而非抽象化:它故意使用過期憑證簽署,捕獲例外並印出訊息,讓你在升級到正式環境前就能看到日誌會包含什麼內容。建議在排程版本升級前,先對自己的憑證庫執行此方法。

升級前的準備工作

三項檢查,依可能造成問題的概率排序。

  1. 檢查所有簽署路徑的憑證到期情況,包括每月或每季執行的路徑——這些路徑最容易讓過期憑證長時間隱藏。
  2. 搜尋 HashAlgorithm:如果沒有任何設定,升級後你的雜湊演算法會從 SHA‑1 變為 SHA‑256,這是一項改進,仍需寫入發行說明。
  3. 有意識地決定日誌等級。服務的誠實預設應為 Warning | Error;All 用於重現特定問題,None 則放棄唯一能告訴你簽章是在豁免條件下完成的訊號。

驗證也朝相同方向變更

這點容易被忽略,因為呼叫端的程式碼不需要變更。未設定任何條件的 DigitalVerifyOptions 以前幾乎等同於不執行任何操作:它只比較給定的條件,若沒有條件則無話可說。從 26.9 起,同樣的呼叫會對每個 PDF 數位簽章執行完整的密碼學檢查。

範例中的憑證

值得複製的細節不是程式碼本身:範例不會隨附私鑰。TestCertificates.cs 在執行時於記憶體中建立三個自簽 PFX——有效、去年過期、明年起有效——因此示範能在任何日期執行,且倉庫中不含任何敏感資訊。

這種模式值得在自己的測試套件中採用。提交的測試憑證最終會過期,屆時失敗正好會呈現本次發行欲揭露的錯誤。

結論

三項變更,同一方向:原本在收件端出現的失敗現在會在寄件端顯現。應以續期取代使用 AllowExpired,讓 SHA‑256 成為預設,對進來的文件執行密碼學驗證,並在需要之前先決定日誌等級。範例一次執行全部六種行為,包括拒絕,讓升級可在數分鐘內完成演練。

其他資源