TPM を使って秘密鍵をディスクに置かずに GitHub に SSH 認証してみた
はじめに
製造ビジネステクノロジー部のふじい(大)です。
SSH の秘密鍵、どこに置いてますか?
~/.ssh にファイルとして置いているなら、マルウェアに読み取られてしまうと、その鍵を登録したサーバーやリポジトリに不正アクセスされてしまうことになります。
クラスメソッドでは全社員に 1Password が配られているので、普段は秘密鍵もそこに入れ、ディスク上に置かないようにしています。
今回セットアップしたのは手元から SSH で入って使う Ubuntu マシンで、GitHub のプライベートリポジトリにアクセスする必要がありました。
GitHub のためだけに 1Password を入れるほどでもありません。GitHub CLI で HTTPS 認証にする手もありますが、それだと GitHub の分しか片付きません。
このマシンからは他のサーバーにも SSH で入る予定があるので、秘密鍵をどうするか問題はつきまといます。
というのを AI に相談したら「TPM を使うと、秘密鍵を TPM の中で生成して外に出さず、署名だけをやらせることができる」と教えてもらいました。
この方法なら鍵ファイルがディスク上に存在しません。さらに鍵はこのマシンから持ち出せません。電源を落としてあれば、盗まれたマシンで署名させるにも PIN が要ります。
YubiKey のような追加のデバイスも要りません。TPM 2.0 は最近のマシンなら大抵載っています。
仕組みとしては、tpm2-pkcs11 が TPM の中の鍵を PKCS#11 トークンとして見せ、OpenSSH がそれを PKCS11Provider として読みます。
OpenSSH --PKCS#11--> libtpm2_pkcs11.so.1 --> /dev/tpmrm0 --> TPM 内の鍵
OpenSSH から見れば普通の公開鍵認証で、署名の計算だけが TPM の中で行われます。
TL;DR
tpm2-pkcs11で TPM の中に鍵を作り、OpenSSH からPKCS11Providerとして使います- 秘密鍵ファイルはディスク上に存在せず、鍵はこのマシンから持ち出せません
- ssh が自分で TPM にログインする構成では、PIN の入力に対話端末か askpass ヘルパーが要ります。どちらもない Claude Code のような場所から呼ぶと失敗します
- ssh-agent に鍵を読み込ませておけば、そのときの一回だけ PIN を入れれば済みます
- うまくいかないときは TPM のロックアウトカウンタを見ると、PIN が原因かどうかを切り分けられます
やってみた
環境構築
今回試した環境は次のとおりです。
- Ubuntu 24.04(x86_64)
- OpenSSH 9.6p1
- tpm2-tools 5.6-1build4
- libtpm2-pkcs11 1.9.0-0.2build4
以降に出てくる /usr/lib/x86_64-linux-gnu/ は、アーキテクチャによって変わります。ldconfig -p | grep libtpm2_pkcs11 で確認できます。
まず TPM のデバイスファイルを確認します。
ls -l /dev/tpm*
# crw-rw---- 1 tss root 10, 224 /dev/tpm0
# crw-rw---- 1 tss tss 252, 65536 /dev/tpmrm0
見つからない場合は、ファームウェア設定で TPM が無効になっているか、仮想マシンに vTPM が割り当てられていない可能性があります。
パッケージを入れます。
sudo apt install tpm2-tools libtpm2-pkcs11-1 libtpm2-pkcs11-tools
このあと使う tpm2_ptool は python3-tpm2-pkcs11-tools のコマンドで、上のコマンドで一緒に入りました。
/dev/tpmrm0 は tss グループのものなので、自分を追加します。反映には再ログインが必要です。
sudo usermod -aG tss $USER
鍵作成
TPM の中に鍵を作ります。
tpm2_ptool init
tpm2_ptool addtoken --pid=<init が出した id> --label=github --userpin='<PIN>' --sopin='<SO-PIN>'
tpm2_ptool addkey --algorithm=ecc256 --label=github --userpin='<PIN>'
init は ~/.tpm2_pkcs11/tpm2_pkcs11.sqlite3 と primary object を作り、その id を出力します。--pid に渡すのはこの値です。
今回は 1 でしたが、常に 1 とは限りません。
addtoken の --pid --sopin --userpin --label はすべて必須です。
<PIN> と <SO-PIN> は自分で決めます。
名前は PIN ですが数字である必要はなく、公式ドキュメントの例も myuserpin のような文字列です。パスワードマネージャで生成したパスフレーズをそのまま入れられます。ただしコマンドではシングルクォートで囲んでいるので、PIN 自体にシングルクォートを含めるのは避けてください。
ここで決めたユーザー PIN は、以降 ssh するたびに聞かれます。
SO-PIN はそのリセット用なので、別の値にします。両方ともパスワードマネージャに入れておいてください。ユーザー PIN を忘れると SO-PIN でリセットすることになり、SO-PIN まで忘れると鍵ごと作り直しです。
PIN がコマンドライン引数に載る点には気をつけてください。ps とシェル履歴に残ります。
できたものを確認します。
tpm2_ptool listtokens --pid=1 # 今回の環境では 1
# - id: 1
# label: github
tpm2_ptool listobjects --label=github
# - CKA_CLASS: CKO_PRIVATE_KEY / CKA_KEY_TYPE: CKK_EC
# - CKA_CLASS: CKO_PUBLIC_KEY / CKA_KEY_TYPE: CKK_EC
公開鍵を取り出します。ssh-keygen -D の出力にはコメントが付かないので、sed で足しています。
ssh-keygen -D /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 \
| sed 's/$/ tpm-github@dev-machine/' > ~/.ssh/id_tpm_github.pub
chmod 644 ~/.ssh/id_tpm_github.pub
GitHub 登録
https://github.com/settings/ssh/new に公開鍵を貼ります。
貼った鍵が手元のものと同じかは、ssh-keygen -lf ~/.ssh/id_tpm_github.pub のフィンガープリントで照合できます。
接続確認
~/.ssh/config を書きます。
Host github.com
PKCS11Provider /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
IdentityFile ~/.ssh/id_tpm_github.pub
IdentitiesOnly yes
IdentityFile に公開鍵ファイルを指しているのは意図的で、外すと動きません。IdentitiesOnly yes を維持したまま PKCS#11 の鍵を候補に残すには、この指定が必要です。
次に接続を確認します。普通のターミナルウィンドウで実行してください。
初回はホスト鍵を信頼するか聞かれるので、フィンガープリントが GitHub 公式ドキュメントの値と一致することを確認して yes と答えます。
ssh -T git@github.com
# Enter PIN for 'github':
# Hi <ユーザー名>! You've successfully authenticated, but GitHub does not provide shell access.
ディスク上に秘密鍵ファイルがない状態で認証できました。
なお ssh -T は、認証に成功しても終了コード 1 を返します。GitHub がシェルを提供しないためです。スクリプトで成否を見るときは、終了コードだけで判断しないでください。
PIN を入力する先がないと失敗する
上の接続確認、実は最初は通りませんでした。
Claude Code のセッション内で !ssh -T git@github.com として実行していて、三回失敗しています。
AI コーディングツールやエディタの Git 機能、CI のように、ssh に対話端末が割り当てられない環境から呼ぶと、ssh は PIN を聞く先として askpass ヘルパーを探します。
ヘルパーがないと、こうなります。
ssh_askpass: exec(): No such file or directory
debug1: pkcs11_login_slot: no pin specified
login failed
pkcs11_get_key failed
sign_and_send_pubkey: signing failed for ECDSA "": error in libcrypto
error in libcrypto まで出るので暗号まわりの問題に見えますが、PIN を渡せていないだけです。
普通のターミナルなら /dev/tty が使えるので Enter PIN for 'github': が出ます。
ssh-askpass-gnome を入れれば GUI ダイアログでも入力できるらしいですが、今回は入れていません。
毎回の PIN 入力をやめる
接続のたびに PIN を打つのはさすがに面倒なので、OpenSSH の ssh-agent を常駐させて、鍵を一度だけ読ませます。
Ubuntu のデスクトップセッションでは別の agent も起動しているので、専用のソケットを指定して分けます。
~/.config/systemd/user/ssh-agent.service を作ります。
[Unit]
Description=OpenSSH ssh-agent (PKCS#11 / TPM 鍵対応)
Documentation=man:ssh-agent(1)
[Service]
Type=simple
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
SSH_AUTH_SOCK をこの agent に向けます。ログインセッションと対話シェルの両方から見えるように、二箇所で設定しました。
~/.config/environment.d/10-ssh-agent.confにSSH_AUTH_SOCK=${XDG_RUNTIME_DIR}/ssh-agent.socket~/.bashrcで同じ値をexport
systemctl --user daemon-reload
systemctl --user enable --now ssh-agent.service
ssh-add -s /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 # ここで PIN を 1 回
# Card added: ...
これで、agent が再起動するか鍵が消えるまでは、PIN を再入力せずに使えます。
agent が鍵を持っているので、~/.ssh/config の PKCS11Provider はもう要りません。
残したままだと ssh が自分で TPM にログインしようとして pin required という行が出ます。そのあと agent の鍵で認証は通るのでエラーではありませんが、紛らわしいので外します。
Host github.com
IdentityFile ~/.ssh/id_tpm_github.pub
IdentitiesOnly yes
ssh-add -l
# 256 SHA256:Wid1C+... /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1.9.0 (ECDSA)
ssh -o BatchMode=yes -T git@github.com
# Hi <ユーザー名>! You've successfully authenticated, but GitHub does not provide shell access.
BatchMode=yes でも通ったので、端末のない環境からも認証できます。
ただし PKCS11Provider を外すと、agent に鍵が入っていない状態では認証できなくなります。
残してあれば、agent が空でも ssh が自分で TPM にログインしてくれるので、PIN を打てば通っていました。agent は systemd で自動起動しますが鍵は自動で読まれないので、ログイン後に ssh-add -s を忘れるといきなり認証に失敗します。ssh-add -l で鍵があるか見てから作業するか、PKCS11Provider を残して pin required を無視するか、好みで選ぶところだと思います。
-P にシンボリックリンク名を書くと拒否される
上の unit に -P は書いていません。実は最初は書いていて、そこで詰まりました。
-P は agent が読み込んでよい PKCS#11 provider のパスのパターンです。provider を明示的に絞ろうとして ssh-add に渡すのと同じパスを書いたところ、拒否されました。agent は渡されたパスを realpath() してから照合するためです。
ssh-add に渡すパス : /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1 ← シンボリックリンク
agent が照合する実体: /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1.9.0
リンク名をそのまま -P に書くと実体名と一致しないので、ssh-add -s がこうなります。
Enter passphrase for PKCS#11:
Could not add card "...libtpm2_pkcs11.so.1": agent refused operation
PIN のプロンプトが出たあとに拒否されるので PIN を疑いましたが、PIN は TPM まで届いていません。
この拒否は verbose レベルのログなので、journal を見ても出てきません。
末尾に * を付けて実体名にも当たるようにすれば通ります。systemd は ExecStart の * を展開しないので、そのまま ssh-agent に渡ります。
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket -P /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so*
成功後に ssh-add -l が出すパスが実体名になっているのが、realpath() されている裏付けです。
ところが、そもそも -P は要りませんでした。
man ssh-agent は既定値を usr/lib*/*,/usr/local/lib*/* と書いています。1 つめは先頭にスラッシュがないので /usr/lib/x86_64-linux-gnu/... には当たらないように読めますが、-P を外すと通りました。
grep ExecStart ~/.config/systemd/user/ssh-agent.service
# ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket
ssh-add -s /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
# Enter passphrase for PKCS#11:
# Card added: /usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so.1
これは man 側の誤記でした。同じ 9.6p1 のソースを見ると、実装の既定値には先頭のスラッシュが付いています。
# define DEFAULT_ALLOWED_PROVIDERS "/usr/lib*/*,/usr/local/lib*/*"
現在の upstream では man 側も直っています。既定パターンで通るので -P は書かなくてよく、明示的に絞りたいときだけ、実体名に当たる形で指定することになります。
うまくいかないとき
上の二つは、どちらも PIN 絡みのエラーが出ますが、PIN は原因ではありません。
これを見分けるには、TPM のロックアウトカウンタを見ます。試す前後で値を比べます。
tpm2_getcap properties-variable | grep LOCKOUT_COUNTER
| カウンタの変化 | 疑うところ |
|---|---|
| 増えていない | TPM の認証処理まで届いていない。agent の -P、SSH_AUTH_SOCK の向き先、askpass の有無 |
| 増えた | TPM が認証要求を拒否した。ユーザー PIN と SO-PIN の取り違えを含めて PIN を疑う |
カウンタは TPM を使う他のソフトウェアの操作でも動きます。これだけで原因を決めず、前後の差を見る材料として使ってください。
実際、askpass で三回失敗したあともカウンタは 0 のままでした。
tpm2_getcap properties-variable | grep -iE "lockout|maxAuthFail"
# TPM2_PT_LOCKOUT_COUNTER: 0x0
# TPM2_PT_MAX_AUTH_FAIL: 0x20 ← ロックアウト閾値は 32
カウンタが増えていない場合は、順にこのあたりを見ます。
echo $SSH_AUTH_SOCK # 常駐させた agent を指しているか
systemctl --user show ssh-agent.service -p MainPID # 正しいプロセスを見ているか
grep ^Groups /proc/<上の PID>/status # 105(tss)を含むか
pgrep ssh-agent でプロセスを探すときは気をつけてください。gnome-keyring が別の ssh-agent を起動しているので、先頭に当たったものを見ると違うプロセスを調べることになります。
それでも分からなければ、ExecStart に -d を足して起動すると、拒否の理由が journal に出ます。
守れるもの、守れないもの
この構成で防げるのは、鍵ファイルの持ち出しです。~/.ssh やバックアップから鍵を抜かれること、~/.tpm2_pkcs11 のストアだけを別のマシンにコピーして使われることは防げます。
一方、ログイン中のマシンを乗っ取られた場合は話が別です。秘密鍵を取り出すことはできませんが、agent に鍵を読ませたあとは PIN なしで署名を依頼できます。man ssh-agent にも、agent のソケットは同じユーザーや root から悪用されうると書かれています。TPM が守るのは鍵の抽出であって、そのマシン上で署名に使われることではありません。
署名できる時間を区切りたいなら、ssh-add -t 8h -s ... のように期限を付けられます。乗っ取られた時間帯の悪用は防げませんが、放置される時間は短くできます。今回は試していません。
運用上の制約もあります。
- 鍵はこのマシンでしか使えず、バックアップも取れません。TPM の故障、マザーボード交換、TPM のクリアが起きれば鍵は失われます。GitHub に入り直せる別の手段を用意しておかないと締め出されます
usermod -aG tssとenvironment.dは、どちらも再ログインが必要です。入れた直後に動かないのは大抵これですSSH_AUTH_SOCKが gnome-keyring を指していると、常駐させた agent は使われません~/.tpm2_pkcs11/tpm2_pkcs11.sqlite3を消すと、TPM 内の鍵を参照できなくなります。鍵そのものではなくメタデータですが、消したら作り直しです。別のマシンや、TPM をクリアした環境にコピーしても鍵は使えません。同じ TPM が残っている場合の備えとして、~/.tpm2_pkcs11をバックアップ対象にしておくとよさそうです- PIN の総当たりは TPM の Dictionary Attack 保護が止めます。ロックしたら回復時間を待つか、
tpm2_dictionarylockout --clear-lockoutを lockout 認証で実行して解除します。鍵作成のときに決めた SO-PIN はユーザー PIN のリセット用で、TPM のロックアウト解除には使えません
さいごに
ディスク上に秘密鍵ファイルがない状態で、GitHub に SSH 認証できるようになりました。git push する側から見れば普通の SSH なので、同じやり方は他のサーバーへの接続にも使えます。
ただし鍵まで共用にすると、失効や監査の単位がまとめて動いてしまいます。tpm2_ptool addkey は何本でも作れるので、用途ごとに分けるほうがいいと思います。
ハマった二箇所は、どちらも PIN が TPM まで届いていない失敗でした。
PIN のプロンプトが出ることと、PIN が検証されることは別です。症状だけでは区別が付かないので、ロックアウトカウンタで切り分けます。
秘密鍵の置き場所に悩んだときの選択肢として、頭の片隅にでも残しておいていただけると幸いです。






