💡 完全に動作するサンプルは GitHub にあります:
qr-sign-password-protected-pdf-python

はじめに

文書に署名が必要なのに暗号化されている場合、ほとんどのチームが取る三段階パターンがあります。

  1. 復号する
  2. 平文に署名する
  3. 再暗号化する

この手順は機能しますが、数百ミリ秒の間、意図的に保護された文書の読み取り可能なコピーが一時ディレクトリに存在することになります。監査されたパイプラインでは、そのウィンドウが「発見」になるわけです。

保護された PDF に署名することは、.NET 経由で提供される Python 用 GroupDocs.Signature の機能で、上記の三段階をすべて省略できます。パスワードでソースをその場で開き、署名を適用し、出力を再び保護された状態で書き戻します。本稿では、意図的に成功する 2 つと失敗する 2 つのパスワードパスを比較し、このバインディング固有の失敗契約について説明します。

なぜ重要か

パスワード処理は文書パイプラインが情報漏えいするポイントです。署名ライブラリ自体ではなく、周辺の仕組みが原因になることが多いです。たとえば、削除されるはずの一時ファイル、間違ったパスワードエラーを捕捉して永遠にリトライする例外ハンドラ、受取人に伝えていないパスワードで渡された署名済みコピーなどです。

これらはすべて「パスワードは操作の途中で取り除くべきもの」という誤った考え方が根本原因です。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 の 2 行は必須です。覚えておくべきポイントは、password を設定しただけで use_original_password をデフォルトのままにすると、観測できる変化が起きないということです。フラグが優先され、出力は古いパスワードのままになります。受取人が「送ったパスワードが機能しない」と報告したときに初めて気付くでしょう。

方法 3 と 4 – 失敗ケース 2 つ

暗号化された文書は「パスワードが無い」場合と「間違ったパスワード」の場合で挙動が異なります。この違いを適切に扱う必要があります。

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 行自体が問題箇所として指摘されます。最初に書いたハンドラはこのエラーで 20 分も費やしたため、本節が存在します。

実際に返ってくるのはメッセージが Proxy error(<Name>): で始まる RuntimeError です。このプレフィックスを解析すれば原因名が取得できます。

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]

取得した名前で分岐し、メッセージ本文(ファイルパスが含まれ実行ごとに変わる)に依存しないようにします。

署名前に情報を取得する

知っておくと便利な第 5 のパスがあります。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

利用シーンは 2 つ。

  1. パスワードがユーザーフォームから来た場合、バッチ処理の途中ではなく安価な呼び出しで認証情報を検証できる。
  2. パイプラインが平文の保存を一切許可されていない場合でも、暗号化されたままページ数やサイズといったメタ情報を監査ログやクォータ計算に利用できる。

メソッド比較: いつどれを使うか

メソッド 主な利用シーン 主な利点 制限事項
元のパスワードを保持 その場で署名するパイプライン SaveOptions が不要、平文で書き出されない 受取人は元のパスワードが必要
保存時に再キー 別の相手に引き渡す場合 元文書は認証情報を保持、コピーは新しい認証情報 SaveOptions の 2 行が必要で、1 行だけ設定しがち
パスワードなし(失敗) テストで契約を検証したいとき 開く段階で失敗し何も書き込まない 署名パスではない
誤ったパスワード(失敗) 古い認証情報を区別したいとき 例外名が異なる 署名パスではない

再取得呼び出しは価値があるか?

はい、2 つの理由があります。QrCodeVerifyOptions で署名済みファイルを再度開くことで、保存後も署名が残っていることを確認でき、同時にパスワードを供給する必要があるため、出力が本当に暗号化されていることも検証できます。カウントが 0 の場合はほとんどがライセンス問題であり、署名自体が失敗したわけではありません。署名呼び出しは実際に失敗したときに例外を投げるので、無音かつ 0 件は非ライセンスビルドを示唆します。

変更にかかるコスト

構造的な変更はありません。既に一時ファイルへ復号しているコードがある場合は、そのステップを削除し、パスワードを LoadOptions に移し、最後の再暗号化呼び出しを除去すれば済みます。行数はむしろ減ります。署名呼び出し自体の形は変わらず、出力はバイト単位で入力と同じ保護が施された署名済み PDF になります。

唯一注意が必要なのはクリーンアップコードです。復号‑署名‑再暗号化のパイプラインは通常 finally ブロックで一時ファイルを削除しますが、ファイルが不要になった時点でそのブロックが存在しないパスを削除しようとしてエラーになることがあります。

ベストプラクティス

  • use_original_password は特に回転させる意図がない限り触らないでください。デフォルトが安全です。
  • プロキシ名の解析はヘルパー関数にまとめ、以降はその結果で分岐する。
  • バッチ開始前に get_document_info でユーザー提供パスワードを検証すれば、失敗コストは安価な 1 回の呼び出しに抑えられます。
  • 署名出力を元のパスに上書きしないようにし、万が一のミスで元ファイルが回復可能な状態にしておく。

結論

パスワードは署名前に回避すべき障害ではなく、操作の引数です。LoadOptions で開き、SaveOptions で出力保護を決定し、失敗時はプロキシ名を解析し、最後にパスワードで再検証します。サンプルは 4 つのパスすべてを一度に実行し、違いをコマンド一つで確認できるようにしています。

追加リソース