Cover Image for Pocket IDとopkssh で宅内SSHをパスキーSSO化した

Pocket IDとopkssh で宅内SSHをパスキーSSO化した

2026/10/23
...

前回に引き続き怠惰Humanシリーズです。
さて、いっぱいVMやらLXCがある弊PVEクラスタですが、ホストごとにパスワード認証だったり公開鍵認証だったりバラバラでやってられないので、認証基盤を統一しました。

すでにk8sにpocket IDが乗っているので、これに統合して1度パスキーログインをすればその後はssh user@hostで入れるようにしました。

やりたいこと

  • どんなクライアントでも認証を1回通すだけで以降は自由につなげるようにしたい
  • わざわざパスワードログインしたくない
    • でもクライアントごとに全ホストに公開鍵を登録しにいくのもやだ
  • ラッパーコマンドや長いオプションは打ちたくない。ただのssh [email protected]で入りたいし、~/.ssh/configも汚したくない
  • 毎日・毎回のログインは絶対にやりたくない
    • どことは言いませんがお前のことやぞGCP
  • 既存の公開鍵・パスワード認証・Proxmoxコンソールは復旧経路としてそのまま残す

ボツったもの

方式判断理由
公開鍵をAnsible等で全台配布ボツ新端末を増やすたびにmanifest更新+全台へpushしないといけない
Smallstep等でSSH CA構築ボツ標準のOpenSSH証明書だけで完結しサーバー側にバイナリ不要なのは綺麗だけどCAサーバーの常駐運用・CA秘密鍵の保護が増える。自宅1人運用には重い
Tailscale SSH / NetBird SSHボツ全ゲストにメッシュVPNエージェントを入れる前提にしたくない

採用したもの

opkssh + pocket ID

pocket IDは皆さまおなじみOIDC IdPです。
opksshは、OpenPubkey SSHというらしいです。
OIDCのIDトークンに、クライアントが生成した一時SSH鍵のハッシュを埋め込んで署名します。
別途SSH CAサーバーを建てなくてよくて、Pocket IDが署名したIDトークンそのものが公開鍵の証明書として機能するのが良いところです。

  • クライアント側
    • opkssh login → ブラウザでPocket IDのパスキー認証 → ~/.ssh/id_ecdsaと IDトークンを埋め込んだ~/.ssh/id_ecdsa-cert.pub が生成される。
    • ~/.ssh/id_ecdsaに置かれるのでOpenSSHクライアントが何の設定もなしに自動で提示してくれる。
  • サーバー側:
    • sshdのAuthorizedKeysCommand /usr/bin/opkssh verify %u %k %tが呼ばれる。
    • opkssh verifyがPocket IDの.well-known/jwks.jsonでIDトークンの署名を検証し、/etc/opk/providersと/etc/opk/auth_idを照合してOKなら公開鍵を返す。

設計ポイント

トークンの有効期限ポリシー

これは期間指定をせず、neverにしました。
期間での自動失効が行われません。
あぶなくね?と思う方もいるかもしれませんが、

  1. デフォルトの24h:
    • 毎日ブラウザが開いてパスキーを要求される。ありえなさすぎる。
  2. oidc_refreshed + クライアント常駐 opkssh login --auto-refresh:
    • 一見これが本命に見えたが、opkssh側の実装を読むとリフレッシュトークンをディスクに一切保存せずプロセスのメモリ上にしか持っていない。
    • つまりPCを再起動したり、スリープ中にオフラインで更新に1回失敗してプロセスが落ちるたびに結局ブラウザが開いてパスキー認証を求められる。むりむりカタツムリ。
    • ついでにサーバー側の/etc/opk/providersに書ける有効期限もコード上12h / 24h / 48h / 1week / oidc / oidc_refreshed / neverの7種類しかなく、30日みたいな任意の日数指定もできない。
  3. never:
    • 結局認証しないなら普通のSSH公開鍵認証と何が違うの?
      • opksshの証明書だけ漏れてもクライアントの秘密鍵がなければログインできない。
      • クライアントの秘密鍵ごと抜かれる状況だったらもう何抜かれても一緒。
      • そもそも22番はインターネットに公開ないので外からはNetBirdの認証も抜かないとSSHアクセスしようがない。
      • 新しい端末をセットアップするとき、鍵のコピペではなくパスキー認証で本人確認できることこそが目的で、発行後の寿命を無期限にしてもリスクは今までと一緒じゃん。ってことで採用。
    • もし端末を紛失したらどうするの?
      • neverだとPocket ID側でログアウトしても証明書は失効しないが、Pocket IDでOIDC Clientを作り直して全ホストの/etc/opk/providersのClient IDを書き換えれば、旧端末の証明書はaud不一致で一斉に無効化できる。

パッケージ管理

最初は公式のinstall-linux.shを試したのですが、野良バイナリをバラまくと後から管理してらんなくなります。
かといってOS標準リポジトリを見ると、

  • Debian 13公式apt:opkssh 0.4.0とかいう遺産バージョンが降ってきて使い物にならない
  • EPEL 10:0.17.0があるけど全ホストにEPELを入れないといけない

てことでGitHuで配られている公式のrpm / .debを直接入れる方式にしました。
これならOSやArchが混ざっていても全部揃うし、rpm / dpkgの管理下に収まります。

パッケージの中身は/usr/bin/opksshバイナリだけなので、opksshuserの作成や/etc/opk/の生成、SELinuxモジュール導入、/etc/ssh/sshd_config.d/60-opk-ssh.confの配置、sshd -Tの差分チェックまでやるスクリプトを書いて全台に流しました。

SELinux対応

AlmaLinux 9 / 10のホストはSELinux Enforcingで動かしています。(最近は脳死で無効化をやめた、えらい)
sshdから呼ばれるopkssh verifyは検証時にPocket IDへHTTPS通信しにいくので、素のままだとSELinuxにブロックされます。

Permissiveに落とさずEnforcingのまま通るようにしました。

Device Flowは使えなかった

ブラウザがないcode-serverのコンテナでもクライアントとしてログインさせようと、opksshのdevice_flow: trueを試してみました。
Pocket ID側にもエンドポイントがあるのでブラウザの承認画面までは進んだのですが、最後のトークン検証で以下のエラーが出て弾かれました。

error verifying PK Token: missing commitment claim nonce
(the OpenID Provider https://id.fugafuga does not return the nonce in device flow, so device_flow cannot be used with it)

Pocket IDはDevice Flowのときにnonceを返さない実装になっているので原理的に使えないようです。
まぁヘッドレス環境がクライアントになることはほぼないのでヨシ。

実際の設定

Pocket ID側

OIDC Clientを1個作ります。

  • Name: hogehoge-ssh
  • Public Client: ON
  • Callback URLs:
    http://localhost:3000/login-callback
    http://localhost:10001/login-callback
    http://localhost:11110/login-callback

サーバー側

  • /etc/opk/providers
    https://id.fugafuga <Client_ID> never
  • /etc/opk/auth_id
    メールアドレスではなくPocket IDのsubで紐付けます。ホストに応じてrootと、wheel / sudo持ちの一般ユーザーを登録しました。
    root <User_Sub_UUID> https://id.fugafuga
    user <User_Sub_UUID> https://id.fugafuga
  • /etc/ssh/sshd_config.d/60-opk-ssh.conf
    AuthorizedKeysCommand /usr/bin/opkssh verify %u %k %t
    AuthorizedKeysCommandUser opksshuser
    

クライアント側

  • ~/.opk/config.yml
    ---
    default_provider: pocket-id
    providers:
      - alias: pocket-id
        issuer: https://id.fugafuga
        client_id: <Client_ID>
        scopes: openid email
        redirect_uris:
          - http://localhost:3000/login-callback
          - http://localhost:10001/login-callback
          - http://localhost:11110/login-callback

初回だけopkssh login pocket-idを叩いてブラウザでパスキー認証すれば、あとは普通にssh [email protected]で入れます。

おわりに

毎回パスワードを手打ちしていたVMも、手元のMacの鍵を入れ忘れていたホストも、全部そのままsshで入れるようになりました。
もっと怠惰になっていけ。