コンテンツへスキップ

「not a valid OPENSSH private key file」を救う — SSH 秘密鍵の 7 形式に対応する互換ローダー

WordPress 保守自動化に SSH 接続を組み込むと、たいてい一度はこのエラーに会います。

SSHException: not a valid OPENSSH private key file

「鍵ファイルは指定してあるのに、なぜ?」と困るパターン。実際に SSH 接続テストが失敗するケースをいくつか追っていくと、paramiko ライブラリ単体だと OpenSSH 形式以外の秘密鍵を読めないという事情が見えてきます。

実運用では、ホスティング会社や鍵生成ツールによって鍵形式が分かれているため、paramiko の標準ロードだけでは弾かれる場面が出てくる。今回は、その対応として組んだ「7 形式互換ローダー」の設計を書き残しておきます。

SSH 秘密鍵には、想像以上に多くの形式がある

SSH と聞くと「OpenSSH 形式 = -----BEGIN OPENSSH PRIVATE KEY-----」を思い浮かべる人が多いはずですが、実運用で受け取る鍵は次のように分かれています。

  1. OpenSSH 新形式 (-----BEGIN OPENSSH PRIVATE KEY-----)
  2. PKCS#1 RSA (-----BEGIN RSA PRIVATE KEY-----)
  3. SEC 1 EC (-----BEGIN EC PRIVATE KEY-----)
  4. PKCS#8 平文 (-----BEGIN PRIVATE KEY-----)
  5. PKCS#8 暗号化 (-----BEGIN ENCRYPTED PRIVATE KEY-----)
  6. 旧 PEM 暗号化 (-----BEGIN RSA PRIVATE KEY----- + Proc-Type: 4,ENCRYPTED)
  7. PuTTY .ppk (v2 / v3 — Windows ユーザーが渡してくることが多い)

paramiko の from_private_key_file() は OpenSSH 形式と PKCS#1 系を扱えますが、PKCS#8 と .ppk は扱えません。たとえばさくらインターネットのコントロールパネル「鍵ペアを生成して登録」では、現在 ECDSA + PKCS#8 形式で発行されるので、paramiko の正規表現がそのまま弾いてしまう構造になっていました。

解決方針 — cryptography で先読みして OpenSSH 形式に再シリアライズ

paramiko を直接拡張するのは難しいので、手前で形式を判別 → 共通形式に正規化 → paramiko に渡す、という 3 段階の互換ローダーを core/ssh_key_loader.py として新設しました。

def load_any_ssh_key(path: str, passphrase: str | None = None) -> paramiko.PKey:
    """
    7 形式の SSH 秘密鍵を読み込み、paramiko.PKey に正規化して返す。
    対応形式: OpenSSH / PKCS#1 RSA / SEC 1 EC / PKCS#8 平文 /
              PKCS#8 暗号化 / 旧 PEM 暗号化 / PuTTY .ppk (v2 / v3)
    """
    raw = open(path, "rb").read()
    fmt = _detect_format(raw)
    pem_openssh = _to_openssh_pem(raw, fmt, passphrase)
    return paramiko.RSAKey.from_private_key(io.BytesIO(pem_openssh))

_detect_format() は鍵の先頭バイト・PEM ヘッダ・PuTTY 固有の PuTTY-User-Key-File-2/3: 行を見て 7 形式を判別します。判別後、cryptography ライブラリで秘密鍵オブジェクトを読み込み、OpenSSH 互換 PEM に再シリアライズしてから paramiko に渡す。これで paramiko 側は「いつもの OpenSSH 形式」しか見ない状態を保てます。

PuTTY .ppk は外部依存なしの自前パーサ

PuTTY 形式(.ppk)は paramiko も cryptography も非対応なので、追加依存を増やさず自前パーサで対応しました:

  • v2: SHA1 + HMAC-SHA1 認証・base64 公開鍵 + 暗号化された秘密鍵セクションを復号
  • v3: Argon2id + HMAC-SHA256(より新しい KDF)・パスフレーズハンドリングが異なる

.ppk パーサだけで約 200 行ですが、pyppk などの追加ライブラリを持ち込むより、配布バイナリのサイズと互換性検証の手間を考えると自前実装の方が運用は楽でした。

「想定外形式」エラーの設計

_detect_format() でどの形式にも該当しなかった場合、従来は「形式が不明です」のような曖昧なメッセージしか出していませんでした。これだとユーザーは「だから何をすればいいの?」と止まってしまう。

7 形式互換化と同時に、エラーメッセージも書き直しました:

鍵ファイルの形式を判別できませんでした。
受け入れ可能な形式: OpenSSH / PKCS#1 RSA / SEC 1 EC /
                  PKCS#8 / PuTTY .ppk
判別された先頭バイト: <hex dump>
推奨アクション: ssh-keygen -t rsa -f new_key で OpenSSH 形式の鍵を再生成し、
              サーバーに公開鍵を再登録してください。

何が来たか・何を受け入れられるか・次に何をすべきかを 3 点で示すパターンに統一しました。エラーメッセージが「何かが起きた」しか伝えていない状態は、サポート問い合わせの最大の発生源です。

まとめ — ライブラリの制約は「手前で正規化する」で吸収できる

paramiko の対応形式の狭さに対して、手前で形式判別 + 共通形式への正規化という王道のパターンを挟むことで、7 形式すべてをアプリ側からは「いつも通りの OpenSSH 鍵」として扱えるようになりました。paramiko を直接いじるよりも、変更範囲は小さく、回帰テストも書きやすい。

エラーメッセージは「何が来たか・何を受け入れるか・次に何をすべきか」の 3 点を必ず含む、というパターンも、SSH に限らず全部のエラーに広げたい設計でした。ライブラリの制約は、その手前で吸収するレイヤーを 1 枚挟めば、ユーザーから見れば消える。今回の改修で改めて実感したところです。