← タイムラインに戻るPDFに電子署名する:PGPを起点にしたミニ認証局をTailsで運用

エンジニアリング

PDFに電子署名する:PGPを起点にしたミニ認証局をTailsで運用

Adobeも有料の認証局も使わずに、見積書・請求書・履歴書に署名する方法。起点となるPGP鍵、Tails上の暗号化USBメモリにだけ存在するX.509ルート認証局、毎年更新する署名用証明書、失効リスト、pyHankoによるタイムスタンプ付き署名、そしてこのサイト上の検証ツール。Tailsでの全手順を、失敗も含めて紹介します。

  • engineering

なぜPDFに署名するのか?

メールで送ったPDFに、どれだけの価値があるでしょうか?正直、あまりありません。 フリーランスを始めてから、見積書、請求書、履歴書、ときには契約書と、たくさんの PDFを送っています。でも誰でもエディタで開いて、金額や日付を書き換えて転送できて しまいます。手書きサインの画像を貼っても何も変わりません。画像もコピーできるからです。

本物の電子署名があれば、この問題は解決します。受け取った人は、ファイルが私から 来たこと、そして署名してから1バイトも変わっていないことを確認できます。問題は、 どうやって手に入れるかです。

有料の証明書(Adobeの信頼リスト)

  • メリット: Acrobatですぐに緑のチェックが出る。
  • デメリット: 年間契約で、ハードウェアトークンが郵送されてくることも多く、信頼を まるごと企業から借りることになる。

自己署名証明書

  • メリット: 無料で、openssl なら5分。
  • デメリット: 本当に私のものか誰にも分からないし、パソコンを盗まれても無効にする 手段がない。

PDFの横にPGP署名(cv.pdf.asc)

  • メリット: 強固で、私のPGP鍵はすでに公開されている。
  • デメリット: PDFビューアには何も表示されないし、一部のギーク以外は誰も .asc ファイルを確認しない。

そこで、それぞれの良いところを取りました。PGP鍵がルートを保証する自分専用の小さな 認証局(「ミニ認証局」)、どのビューアでも読める本物のPDF署名、そしてチェーン全体を ブラウザ上で確認できるこのサイトのページです。費用は0円。 USBメモリ数本を除けば、ですが。

免責事項: 私はセキュリティの専門家ではありませんし、これは適格電子署名 (eIDASなど)でもありません。証明できるのは、ファイルが私から来たもので、改ざん されていないということだけです。また、私のルートはAdobeのリストに入っていないので、 PDFビューアは署名者の身元を「不明」と表示します。そのための検証ページです。

信頼のチェーン

手順に入る前に、各部品がどうつながっているかを確認しておきます。日本に住んでいる 身としては、いちばん分かりやすいのはハンコのたとえです。

  • 実印は登録したハンコで、引き出しにしまっておき、車を買うときのような大事な 場面でしか出しません。これが私のルート認証局です。オフラインに置いたまま、 年に一度だけ出てきます。
  • 認印は宅配便の受け取りに使う普段使いのハンコです。これが私の 署名用証明書です。パソコンの中にあってすべてのPDFに署名しますが、毎年 交換され、無効にすることもできます。
  • 実印が本人のものだと証明する印鑑登録にあたるのが、私のPGP鍵です。複数の 場所で公開しているので、私のサイト以外でもフィンガープリントを確認できます。

(ちなみに、署名をするPythonツールの名前は pyHanko です。偶然ではないと思います ^^)

技術的に書くと、こうなります。

PGP鍵 7526 0AC4 B55D 0A91 A079  760C BE7E 6798 D983 5EA6
  │  主鍵はオフライン、[S] 副鍵はノートパソコン上
  └─ 証明書のSHA-512にクリア署名 → /gpg/x509.cert.sha512.asc

ルート認証局  (RSA-4096、10年、LUKSのUSBメモリ上のみ、Tailsでのみ使用)
  ├─ 発行 ──▶ 署名用証明書  (RSA-4096、400日、ノートパソコン上)
  │               └─ PDFに署名  (PAdES + RFC 3161 タイムスタンプ)
  └─ 署名 ──▶ root.crl  (失効リスト、400日、/gpg/root.crl で公開)

あと2つの部品があります。

  • CRL(証明書失効リスト)は、ルートが署名した「無効になったもの」のリストです。 すべての署名用証明書にその場所が書かれているので、検証する側はどこを見ればいいか 分かります。
  • タイムスタンプは、DigiCertの無料公開タイムスタンプサーバー(RFC 3161)から 取得します。PDFがいつ署名されたかを証明するものです。これがとても重要で、 検証ツールは今日ではなく、タイムスタンプの時点で証明書を確認します。だから2026年に 署名したPDFは証明書の期限が切れた後も有効なままで、2027年に私の鍵を盗んだ人が 「2026年に署名した」と偽ることもできません。

必要なもの

  • TailsのUSBメモリ。Tailsは、USBから起動し、シャットダウンするとすべてを忘れる Linuxです。今回はネットワークに一切つなぎません。
  • 認証局用USBメモリ。LUKSで暗号化し、Tails以外には挿しません。ルートの秘密鍵が 入っています。
  • 転送用USBメモリ。暗号化なしで、ルートに pdf-ca-transfer というフォルダを 作っておきます。ノートパソコンとTailsの間で公開データだけを運びます。
  • バックアップ用USBメモリ(こちらもLUKS)。ノートパソコンから離れた安全な場所に 保管します。
  • ノートパソコン側には openssl、gpg、pyHanko。Tails側にはインストール不要で、 bash、openssl、gpg が最初から入っています。

スクリプトはすべてこのサイトのリポジトリの scripts/pki/ にあり、Tails用は scripts/pki/offline/ にあります。

なぜUSBメモリを2本に?

最初のバージョンでは、USBメモリは1本だけでした。ノートパソコンが署名リクエストを 認証局フォルダに直接書き込み、結果もそこから読み戻していました。動いてはいましたが、 これが何を意味するか考えてみてください。毎年、ルート鍵(暗号化されているので まだいい)、認証局のデータベース、そしてTailsがルートのパスフレーズ付きで実行する スクリプトが、インターネットにつながったパソコンにマウントされていたのです。 ノートパソコンにマルウェアがいれば、session.sh を書き換えて、復号した鍵をUSBメモリに コピーさせ、翌年を待つだけです。これではTailsを使う意味がありません。

なので今のルールはシンプルです。認証局用USBメモリは絶対にノートパソコンに挿さない。 やり取りはすべて転送用USBメモリの3つのフォルダで行います。

  • requests/:ノートパソコン → Tails、署名リクエスト
  • results/:Tails → ノートパソコン、ルート証明書、CRL、発行済みの証明書
  • update/:ノートパソコン → Tails、PGP鍵で署名したスクリプトの新バージョン

しかも、スクリプトがこれをチェックしてくれます。ノートパソコン側のスクリプトは 認証局フォルダがマウントされていると動きません。Tails側のスクリプトは、ネットワークに つながっているとき、または転送フォルダが認証局用USBメモリ自体にあるときは動きません。

ステップ・バイ・ステップ:Tailsでの作業

ステップ0:ノートパソコンで

転送用USBメモリだけを挿して、2つのコマンドを実行します。

scripts/pki/pack-offline.sh      # PGP署名付きスクリプト → update/
scripts/pki/new-signing-key.sh   # 署名鍵 + リクエスト → requests/

new-signing-key.sh は署名鍵のパスフレーズ(PDFに署名するたびに入力するもの)を 尋ね、公開鍵のsha256を表示します。メモするか写真を撮っておいてください。Tailsでも もう一度表示されるので、2つが一致する必要があります。

ステップ1:Tailsを起動

  • ウェルカム画面で、パスフレーズを入力するキーボード配列を選びます。違う配列で 入力したパスフレーズは、ただの間違ったパスフレーズです。
  • ネットワークには一切つながないこと。 一度でもダメです。
  • 2本のUSBメモリを挿します。初回は「ディスク」で認証局用USBメモリをフォーマットし、 パスワード保護(LUKS)にチェックを入れます。その後「ファイル」でロックを解除します。

ステップ2:実行前にスクリプトを確認

スクリプトはオンラインのノートパソコンから来ています。なので、ルートのパスフレーズで 何かを実行する前に、Tails側で本当に私のPGP鍵で署名されているかを確認します。 初回だけは認証局用USBメモリに信頼できるコピーがまだないので、手作業で確認します。

cd /media/amnesia/<transfer label>/pdf-ca-transfer/update
gpg --import pgp.asc
gpg --decrypt SHA512SUMS.asc > /tmp/SUMS   # "Good signature" と表示されること
sha512sum -c /tmp/SUMS                     # すべての行が OK
mkdir -p /media/amnesia/<CA label>/pdf-ca
INSTALL_TO=/media/amnesia/<CA label>/pdf-ca bash update.sh

このステップは時間をかけてください。 gpgが表示するフィンガープリントは 7526 0AC4 B55D 0A91 A079 760C BE7E 6798 D983 5EA6 でなければならず、信頼できる コピー(紙、スマホ、別の端末で見たkeys.openpgp.org)と照合します。もちろんUSBメモリの 中身と比べても意味がありません。2回目以降は update.sh が認証局用USBメモリに入っていて、 _lib.sh に固定したフィンガープリントで自動的に確認してくれます。

ステップ3:ルート認証局を作る(10年に一度)

bash /media/amnesia/<CA label>/pdf-ca/init-root.sh

ネットワークがないことを確認し、転送用USBメモリを見つけ、時計を表示してから、新しい ルートのパスフレーズを尋ねます(12文字以上。私はKeePassXCに保存しています)。 そして次のものを作成します。

  • AES-256で暗号化した RSA-4096 鍵
  • 有効期間10年の自己署名証明書。証明書とCRLへの署名しかできず、下に1階層だけ (pathlen:0)
  • 最初のCRLと、results/ への公開データ一式のコピー

最後にルートのSHA-256フィンガープリントが表示されます。これもメモしてください。 ノートパソコン側の publish.sh でも表示されるので、一致する必要があります。

ステップ4:毎年のセッション

bash /media/amnesia/<CA label>/pdf-ca/session.sh

毎年Tailsで実行するコマンドはこれだけです。

  1. ルートのパスフレーズを一度だけ尋ねる
  2. 転送用USBメモリ上の各リクエストについて、サブジェクトと公開鍵のsha256を表示する。 ステップ0でノートパソコンが表示した値と比べて、一致したときだけ y と答える
  3. 有効期間400日の署名用証明書を発行する。リクエストに何が書かれていても、名前と メールアドレスはスクリプトが固定し、正しい用途(digitalSignature、 nonRepudiation、文書署名)とCRLの場所を証明書に入れる
  4. CRLをさらに400日分署名し直し、results/ を書き直す

なぜ400日なのか?年に一度のセッションで証明書とCRLの両方を更新でき、遅れても約5週間の 余裕があるからです。

ステップ5:バックアップ(「石」)

init-root.sh の直後に、pdf-ca/ をバックアップ用USBメモリにコピーし、PGPの主鍵と その失効証明書も一緒に入れます。ルートのパスフレーズは入れず、別の場所に保管します。 あとはUSBメモリを安全な場所にしまい、10年間触りません。

なぜ一度だけでいいのか?かけがえのないものはルート鍵だけで、それは決して変わらない からです。毎年変わるもの(発行した証明書、失効情報)はすべて公開データなので、転送用 USBメモリとこのサイトのgit履歴に残っています。最初のバージョンでは毎回セッション後に バックアップを更新する必要がありましたが、現実には誰もやりません。今は次のように なっています。

  • シリアル番号はランダムなので、古いコピーがすでに使われた番号を再発行することはない
  • CRL番号は日付から作るので、逆戻りしない
  • session.sh はまず results/ を読み、認証局フォルダに足りない証明書や失効情報を 追加する

なので、3年前のコピーから復元しても普通に動きます。

ステップ6:ノートパソコンに戻る

Tailsをシャットダウンし、転送用USBメモリだけをノートパソコンに挿します。

scripts/pki/publish.sh
git add public/gpg && git commit && git push

publish.sh は、ルートがすでに公開しているものと同じか(違うなら、USBメモリを 間違えたか改ざんです)、CRLと証明書がルートにつながっているかを確認し、public/gpg/ に 書き出します。中身は私のPGP公開鍵、ルート証明書と署名用証明書、CRL、そして x509.cert.sha512.asc、つまり証明書のSHA-512にPGP鍵でクリア署名したものです。 この最後のファイルがPGPとX.509の橋渡しです。Cloudflareがサイトを再ビルドすれば公開完了です。

PDFに署名する

ようやく日常の作業です!pyHankoはPDF署名用のオープンソースのPythonツールです。 コマンドラインは別パッケージで、画像サポートは手動で追加するエクストラです (これでコミットを2つ費やしました)。

pipx install pyhanko-cli
pipx runpip pyhanko-cli install 'pyhanko[image-support]'
scripts/pki/set-signature-image.sh sig.png   # 一度だけ
scripts/pki/sign-pdf.sh cv.pdf               # → cv.signed.pdf
  • set-signature-image.sh は手書きサインの余白を切り取り、私のPGP鍵で暗号化して (signature.png.asc)保存します。なのでリポジトリに置いておけます。復号されるのは 署名中だけ、非公開の一時フォルダの中です。
  • sign-pdf.sh はPDFの空の署名欄(フォームの「ここに署名」の枠)を一覧にして、どれを 使うか尋ねます。枠には手書きサインと「Signed by… / 日付 / Verify at eymeric.me/gpg」が 表示されます。最後の行はクリックできるリンクで、署名の前に追加するので、署名の 対象にも含まれます。
  • パスフレーズは2回聞かれます。画像を復号するためのPGPのもの、次に署名鍵のものです。
  • 署名はPAdES形式で、DigiCertのサーバーのタイムスタンプ付きです。

空の署名欄はどこから来るのでしょう?私の見積書や請求書は自作のTypstテンプレートで 作っていますが、TypstはPDFのフォームフィールドを書き出せません。そこでテンプレートの sign-field() という枠は、ページ上のどこに置かれたかを記録するだけにして、小さな スクリプトが pyhanko sign addfields でその場所に空の署名欄を追加します。あとは sign-pdf.sh が自分で見つけてくれます。

ひとつだけ覚えておいてください。画像はただの画像です。証拠になるのは、ファイル全体に 対する暗号学的な署名のほうです。署名した後にPDFを編集しないこと。

PDFを検証する

/gpgページにPDFをドロップすると、ブラウザの中ですべて確認できます。

  • 署名がファイルを覆っていて、ファイルが改ざんされていないこと
  • 署名用証明書が私のルートにつながっていること
  • タイムスタンプの時点で失効していなかったこと(CRL)
  • タイムスタンプ自体が有効であること
  • ルートのフィンガープリントが私のPGP鍵で署名されていること
  • オプションで、ページ上のPGP鍵がkeys.openpgp.orgとGitHubのものと一致すること。 サイトが乗っ取られていても騙されません

自分で試してみてください! 私の見積書を待つ必要はありません。同じTypstテンプレートで 作って sign-pdf.sh で署名した小さなサンプルPDF(英語)を用意しました。 ダウンロードして/gpgページにドロップすると、すべて緑になるはずです。次は ズルをしてみましょう。偽の見積の合計 ¥0 を、PDFエディタで ¥1,000,000 に書き換えて (PDFリーダーでコメントを足すだけでも構いません)保存し、もう一度ドロップします。今度は、 署名の後にファイルが変更されたと検証ツールが教えてくれます。

鍵を組み込んだオフライン用の verify.html も1ファイルで用意しています。そして何も 信用しない人(それが正解です!)のために、同じ確認を手作業で行う方法です。

gpg --import eymeric.asc   # フィンガープリントを確認
gpg --verify x509.cert.sha512.asc
gpg --decrypt x509.cert.sha512.asc | sha512sum -c
openssl crl -inform der -in root.crl -out root.crl.pem
openssl verify -CAfile root.cert.pem -CRLfile root.crl.pem -crl_check signing.cert.pem
pyhanko sign validate --pretty-print --trust root.cert.pem document.pdf

問題が起きたとき

この部分を使う日が来ないことを願っていますが、チェーンにこれだけの段階があるのは このためです。

  • ノートパソコンの盗難・署名鍵の漏洩: Tailsで bash revoke.sh <serial> keyCompromise を実行し(漏洩がもっと前なら COMPROMISED_AT=... を付ける)、すぐに publish.sh とpush。それ以前にタイムスタンプ が付いたPDFは有効なままです。
  • ルート鍵の漏洩: X.509ではルートを失効できないので、PGP鍵が代わりに行います。 revoke-root.sh がPGP署名付きの通知を公開し、検証ツールがそれを読みます。その後、 認証局をゼロから作り直します。
  • PGP鍵の漏洩: すべてがこれに依存しています。鍵と一緒に作った失効証明書を使い、 新しい鍵と新しい認証局を作ります。

まとめ

結局、コミット3日分(9月27日〜29日)、USBメモリ数本、サブスクリプションなしで、 私が送る見積書や請求書には誰でも確認できる署名が付くようになりました。毎年の手間は、 Tailsのセッション1回と publish.sh 1回だけです。

正直な限界は残ります。Acrobatは「身元不明」と表示し続けます。私のルート証明書を ビューアの信頼リストに追加することもできますが、そこまでする人はいないと思うので、 検証ページを用意しました。

次は?PGPの副鍵は2028年9月24日に期限が切れるので、2年後には主鍵を隠し場所から 出す必要があります。それから、署名鍵をYubiKey(PIVアプレットはファームウェア5.7から RSA-4096に対応)に移して、コピーすらできないようにしたいと考えています。お楽しみに!