💡 完整可執行範例可在 GitHub 取得: pdf-signing-certificate-checks-python

介紹

一項服務每晚會簽署上傳的 PDF。某天早上,它使用的憑證已超過有效期限,卻看起來沒有任何變化:工作仍在執行、檔案寫入、日誌看起來正常。數週後,有人用 Acrobat 開啟其中一份文件,看到警告橫幅,因為使用過期憑證簽署的簽章並不是較弱的簽章——它會被驗證器報告為無效。看似已批准的文件其價值甚至不如未簽署的文件,因為人們相信了它。

這種拒絕行為是有名稱的。Certificate validity checking 是 GroupDocs.Signature for Python 的一項行為,當憑證的有效期間已過或尚未開始時,就會拒絕簽署。它在 26.9 版中與兩項同樣形態的變更一起推出:SHA‑256 成為 PDF 簽章的預設摘要演算法,且 SignatureSettings.log_level 開始過濾而不再悄悄被忽略。每一項都把原本靜默發生的結果顯示在使用者面前。

本文比較這三個控制項在 Python 透過 .NET 時的行為——它們各自會改變什麼輸出、何時使用,以及綁定的兩個細節如何讓人耗掉一個下午。所有引用的結果皆來自對單頁 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 為唯讀屬性,必須在建構子傳入 logger,之後再賦值會拋出 AttributeError;log_level 則是正常在之後設定。另外,自訂 logger 不能 繼承自 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 參數。函式庫並不總是會傳遞例外物件,若 logger 必須要它則會在缺少參數的訊息上崩潰。

比較三者:何時使用哪一個

控制項 最適用情境 主要優勢 限制
hash_algorithm 符合政策中指定的摘要演算法 只需一次設定;輸出大小相同 若憑證本身不被信任,則毫無意義
有效性檢查與覆寫 為他人簽署任何文件 失敗會在可修正的地方發生 覆寫會產生檔案,但不具可信度
log_level 日誌已經很繁雜的服務 十一條訊息可濃縮為一條 只過濾日誌,永不影響例外拋出

它們不是互斥的選項——一次簽署呼叫會同時使用三者。思考的順序應該依照影響的先後:有效性檢查決定檔案是否會產生,摘要決定檔案內容是什麼,日誌等級決定誰會知道。

日誌等級會改變我收到的例外嗎?

不會。它只決定哪些訊息會送到您的 logger,除此之外不會有其他影響。即使在 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 上執行範例只需約一分鐘,會精確列出每個控制項改變了什麼——六個已簽檔案、一個刻意的拒絕,以及三列訊息計數,已不再相同。

其他資源