💡 完整的工作範例可在 GitHub 上取得:
qr-sign-password-protected-pdf-python
介紹
大多數團隊在需要簽署的文件被加密時,會採用三步走的模式:先解密、在明文上簽署,最後再重新加密。這個流程可行,但也意味著在短短幾百毫秒內,會在暫存目錄中產生一個可讀的受保護文件副本;在受審核的流水線中,這段時間的存在本身就成為了問題,而非簽章本身。
使用 GroupDocs.Signature(Python 透過 .NET)簽署受保護的 PDF 可以完全跳過這三個步驟:密碼直接在原檔上開啟、套用簽章,然後把輸出寫回同樣受保護的檔案。本文比較了四種密碼處理方式——兩種可行、兩種故意失敗——並說明此綁定特有的失敗合約。
為何這很重要
密碼處理是文件流水線洩漏的關鍵點。通常不是簽章函式庫本身,而是圍繞它的支援程式碼:本應被刪除的暫存檔、吞掉錯誤密碼例外並無限重試的例外處理器、以及帶有收件人未被告知的密碼的已簽署副本。
這三者的根本原因相同:密碼被視為「需要先處理」的東西,而不是操作的一部份。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;預設即為最安全的設定。 - 將解析 proxy 名稱的程式寫成輔助函式,之後只需根據名稱分支。
- 在批次處理前先用
get_document_info驗證使用者提供的密碼,這樣壞的憑證只會花一次廉價的呼叫,而不是半途而廢的批次。 - 千萬不要把已簽署的輸出直接寫回來源路徑,避免因操作失誤而覆寫原始檔案。
結論
密碼不是在簽署前必須繞過的障礙,而是操作本身的參數。使用 LoadOptions 開啟文件,使用 SaveOptions 決定輸出保護,失敗時解析 proxy 名稱,最後再以密碼驗證結果。範例一次執行四條路徑,讓差異只需要一個指令即可觀察,而不必閱讀長篇說明。