0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIに「いい感じ」のSSH鍵生成器を作ってもらった話 ー ブラックボックスの恐怖

0
Posted at

物語はフィクションです。eMudhra のニュースを除き実在の人物や出来事は関係ありません。

世界最強のSSH鍵

2026年10月。

エンジニア君は、とあるニュースを見て驚愕していた。

なんと、認証局のeMudhraが使用していた楕円曲線暗号の秘密鍵が、4 だったというのである。

384ビットの楕円曲線暗号を採用しておきながら、秘密鍵の値は 4。

4から始まる384ビットの巨大な整数ではない。整数の4である。

ほかにも 56 や 115 など、どう考えても秘密とは呼べない数字が並んでいた。

「こんなこと、本当にあるんだ……」

エンジニア君は考えた。

暗号は難しい。人間が自分で実装するから、こういう問題が起きるのだろう。

しかし、今はAIの時代である。

優秀なLLMに作らせれば、このような初歩的なミスは避けられるはずだ。

そこでエンジニア君は、いつものコーディングエージェントに依頼した。

OpenSSHで利用できる、世界最高水準の安全性を持ったSSH鍵生成ツールを作って。使い勝手もいい感じにして。

AIは早速、鍵生成ツールの実装に取りかかった。

しばらくすると、ツールが完成した。

採用された暗号はECDSA P-521。先ほどの事件で使われていたP-384よりも大きな楕円曲線である。

「521ビットか。なかなか強そうだな」

エンジニア君は、早速ツールを実行してみることにした。

$ python generate_key.py --output key1

Secure SSH Key Generator
Algorithm: ECDSA
Curve: NIST P-521

Enter an unpredictable positive integer
to enhance key security [0-9]+:

何やら英語で入力を求められている。

エンジニア君はAIが賢いことを知っていた。少々複雑な処理でも、うまく取り計らってくれるだろう。

とりあえず、画面の末尾に表示されている [0-9]+ が目に入った。

「ああ、数字を入れればいいのね」

> 0

Generating your secure SSH key...
Algorithm: ECDSA
Curve: NIST P-521
Key size: 521 bits
Key validation: PASSED

Successfully generated SSH key!

問題なく生成できた。

念のため、実際の秘密鍵を見てみよう。

$ cat key1

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS1zaGEy
LW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQAxoWOBrcEBOnNnj7LZiOVtEKcZIE5BT+1Ifgor2Br
TT26oUted+/nWSj+HcEnov+o3jNIs8GFakKb+X5+McLlvWYBGDkpaniaO8AEXIpftCx9G9mY9URJ
V5tEaBevvRcnPmYsl+5ymV70JkDFULkBP60HYTU8cIaicsJAiL6Udp/RZlAAAADA3YqZBd2KmQUA
AAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQAAAIUEAMaFjga3BATpzZ4+y2YjlbRC
nGSBOQU/tSH4KK9ga009uqFLXnf51ko/h3BJ6L/qN4zSLPBhWpCm/l+fjHC5b1mARg5KWp4mjvA
BFyKX7QsfRvZmPVESVebRGgXr70XJz5mLJfucple9CZAxVC5AT+tB2E1PHCGonLCQIi+lHaf0WZQ
AAAAAQEAAAAAAQID
-----END OPENSSH PRIVATE KEY-----

なかなか立派な秘密鍵だ。

626バイトもある。見た目も十分複雑そうである。

しかし、エンジニア君は先ほどのニュースを思い出した。

「いや、待てよ。もしかして毎回同じ鍵が生成されたりしないよな?」

AIを信頼しているとはいえ、念のため確認しておこう。

もう一度プログラムを実行した。

$ python generate_key.py --output key2

Secure SSH Key Generator
Algorithm: ECDSA
Curve: NIST P-521

Enter an unpredictable positive integer
to enhance key security [0-9]+:
> 0

Successfully generated SSH key!

今度も問題なく生成できた。

早速、先ほど作った鍵と比較してみる。

$ cmp key1 key2

key1 key2 differ: byte 332, line 5

おや、違うらしい。

念のため、SHA-256でも比較してみよう。

$ sha256sum key1 key2

06c9c2ddc303c348b5f1386e737b3d93ead7bec75adb2801e15bf0e88e1d7e7a  key1
0b1f82f4ed99a3bc1dacf1aa02338a2fd6905909fd9c7a2ec220237088ae198b  key2

完全に異なるハッシュ値だ。

「よしよし、ちゃんと乱数を使って生成されているみたいだな」

エンジニア君は安心した。

やはりAIに任せて正解だったようだ。


問題:この鍵生成システムには何か問題があるでしょうか?

ここまでに示した秘密鍵は、実際にPythonのcryptographyで生成したOpenSSH形式の秘密鍵です。

ファイル形式は正しく、ライブラリで再読み込みできます。公開鍵も有効で、ECDSAによる署名と検証も成功します。

そして、2回生成した秘密鍵ファイルが異なっていたことも、実際に確認した結果です。

では、この鍵生成システムに何か致命的な問題があることに気付けるでしょうか?

少し考えてみてください。

ヒントは、冒頭のニュースです。

答え合わせ:秘密鍵を解析してみる

まず、実際の秘密スカラーを取り出してみましょう。

from pathlib import Path
from cryptography.hazmat.primitives import serialization

key = serialization.load_ssh_private_key(
    Path("key1").read_bytes(),
    password=None,
)

d = key.private_numbers().private_value

print("Curve size:", key.key_size)
print("Private scalar:", d)
print("Scalar bit length:", d.bit_length())

実行結果です。

Curve size: 521
Private scalar: 1
Scalar bit length: 1

秘密鍵は1でした。

eMudhraの秘密鍵 4 を、さらに75%も削減することに成功したのです。

一体、どうしてこうなったのでしょうか。

AIは何を考えたのか

ここで、エンジニア君が最初にAIに依頼した内容を振り返ってみましょう。

世界最高水準の安全性を持ったSSH鍵生成ツールを作って。使い勝手もいい感じにして。

AIはこの依頼に対し、次のような設計を選択した、という設定です。

まず、暗号鍵には十分なエントロピーが必要です。

乱数生成器だけに依存するのは心配だと考えたAIは、人間による入力をエントロピー源として活用することにしました。

さらに、使い勝手も重要です。

人間は時として不正な入力をします。しかし、そのたびにエラーを表示して入力し直させるのは、あまりユーザーフレンドリーではありません。

そこでAIは、入力値を自動的に補正することにしました。

そして、次のコードが生まれました。

seed = int(input(
    "Enter an unpredictable positive integer "
    "to enhance key security [0-9]+: "
))

# Ensure the scalar is positive
d = max(1, seed)

key = ec.derive_private_key(
    d,
    ec.SECP521R1(),
)

人間が入力した値は 0。

max(1, 0) は 1。

見事に数学的に有効な秘密スカラーへと補正されました。

なお、AIはユーザーの入力を受け取る前から、この補正処理を実装していました。

バグが発生したから修正したわけではありません。

最初から仕様どおりに動いていたのです。

ちなみに、[0-9]+ は正規表現として 0 を許容します。「正の整数」という英文の説明とは微妙に矛盾しています。

エンジニア君は、目に入った正規表現に従っただけでした。

鍵生成器の実装全体

再現できるよう、先ほどの実験で使用したコード全体も掲載しておきます。

Pythonのcryptographyを使用します。

pip install cryptography
# generate_key.py
import argparse
from pathlib import Path

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec

parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()

print("Secure SSH Key Generator")
print("Algorithm: ECDSA")
print("Curve: NIST P-521")
print()
print("Enter an unpredictable positive integer")

seed = int(input(
    "to enhance key security [0-9]+: "
))

# Automatically correct invalid scalar values
scalar = max(1, seed)

key = ec.derive_private_key(
    scalar,
    ec.SECP521R1(),
)

private_pem = key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.OpenSSH,
    encryption_algorithm=serialization.NoEncryption(),
)

args.output.write_bytes(private_pem)
args.output.chmod(0o600)

public_ssh = key.public_key().public_bytes(
    encoding=serialization.Encoding.OpenSSH,
    format=serialization.PublicFormat.OpenSSH,
)

Path(f"{args.output}.pub").write_bytes(
    public_ssh + b" engineer-kun-demo-DO-NOT-USE\n"
)

print("\nGenerating your secure SSH key...")
print("Algorithm: ECDSA")
print("Curve: NIST P-521")
print("Key size: 521 bits")
print("Key validation: PASSED")
print("\nSuccessfully generated SSH key!")

※ このコードは意図的に安全性を欠いた教育用のデモです。鍵は暗号化せず保存しています。実際の認証や本番システムには絶対に使用しないでください。

技術解説:なぜ秘密鍵が1でも問題なく動くのか

ここからは少し真面目に、楕円曲線暗号と鍵生成について考えてみます。

1. 521ビットの曲線を使っていても、秘密スカラーが521ビットとは限らない

ECDSAの公開鍵は、次のように計算されます。

Q = dG

ここで、

  • d:秘密スカラー
  • G:楕円曲線上の標準基点
  • Q:公開鍵

です。

秘密スカラーの有効範囲は、基点の位数を n とすると、

1 ≤ d < n

となります。

つまり、d = 1 は数学的には完全に有効な秘密スカラーです。

今回の場合、

Q = 1G = G

となります。

公開鍵が、誰でも知っている標準基点そのものになってしまいます。

暗号ライブラリは、与えられた有効な秘密スカラーを使って、正しく公開鍵を計算しています。

その秘密スカラーが予測可能かどうかまでは、通常の数学的な妥当性チェックでは判断できません。

また、P-521という名前は521ビットの楕円曲線を表しているのであって、生成された秘密鍵が521ビットのエントロピーを持つことを保証するものではありません。

適切に生成されたP-521鍵の古典計算機に対するセキュリティ強度は、一般に約256ビットです。

2. なぜ秘密鍵のテキストは複雑に見えるのか

OpenSSHの秘密鍵ファイルは、秘密スカラーをそのまま十進数で保存したものではありません。

例えば、次の情報が格納されています。

  • 鍵の種類や楕円曲線の識別子
  • 公開鍵
  • 秘密スカラー
  • チェック値
  • コメントやパディングなど

これらがOpenSSH独自のバイナリ形式で構造化され、Base64でエンコードされています。

今回のように秘密スカラーが 1 でも、ファイル全体には多くの情報が含まれます。

さらに、公開鍵の座標は一見すると複雑な巨大整数になります。

したがって、秘密鍵ファイルの長さやBase64文字列の複雑さは、秘密スカラーのエントロピーを示しません。

ちなみに、Base64は暗号化ではありません。形式を理解してデコードすれば、保存されている情報を取り出せます。

3. なぜ2回生成した秘密鍵ファイルは異なっていたのか

さて、ここでエンジニア君の検証に戻ってみましょう。

エンジニア君は、2回生成したファイルのSHA-256が異なることを確認していました。

しかし、両方の秘密スカラーを取り出してみると、次のようになります。

from pathlib import Path
from cryptography.hazmat.primitives import serialization

def load(path):
    return serialization.load_ssh_private_key(
        Path(path).read_bytes(),
        password=None,
    )

key1 = load("key1")
key2 = load("key2")

print(
    key1.private_numbers().private_value,
    key2.private_numbers().private_value,
)
1 1

どちらも1でした。

では、なぜ秘密鍵ファイルは異なっていたのでしょうか?

実は、OpenSSHの秘密鍵ファイル形式には、checkint と呼ばれる32ビットのチェック値が2つあります。

この2つには同じランダムな値が格納され、主に復号が正しく行われたかを簡易的に確認するために使われます。

今回使用したPythonのcryptographyでは、鍵をOpenSSH形式で保存する際に、このチェック値をランダムに生成します。

つまり、暗号鍵そのものが同じでも、ファイルを保存し直すたびにチェック値が変化し得るのです。

実際に今回生成した2つのファイルをBase64デコードして比較すると、

  • デコード後のファイルサイズ:411バイト
  • 異なっていた箇所:8バイト
  • 異なっていた内容:2つのチェック値

という結果になりました。

暗号鍵を生成する際に乱数は使われていません。

しかし、鍵をファイルへ保存する際には乱数が使われていました。

エンジニア君の検証は、乱数が使われていることを確かに確認していたのです。

ただし、それは秘密鍵の生成ではなく、秘密鍵ファイルの保存処理についてでした。

参考:OpenSSH PROTOCOL.key

4. 公開鍵のフィンガープリントを比較すると?

今度は秘密鍵ファイルそのものではなく、公開鍵を比較してみましょう。

$ sha256sum key1.pub key2.pub

651e95de9566c2b1d702882bc8a20e1fae6a642a5c43b35b5dc9d98a586d9269  key1.pub
651e95de9566c2b1d702882bc8a20e1fae6a642a5c43b35b5dc9d98a586d9269  key2.pub

今度は完全に一致しました。

SSHの公開鍵フィンガープリントも同じです。

SHA256:zRVIWrTxGdqqZxpYW8IR7xT1OqCYLxz0tEN3IKizSdo

そもそも、秘密スカラーがどちらも 1 なので、公開鍵もどちらも標準基点 G になるのです。

ここで、SHA-256そのものにも注意が必要です。

SHA-256は、入力が予測可能であっても複雑に見えるハッシュ値を出力します。

したがって、フィンガープリントがランダムに見えることは、秘密鍵が十分なエントロピーを持っている証拠にはなりません。

ただし、今回のように2回生成した公開鍵のフィンガープリントが一致することは、鍵生成の問題を疑う十分なきっかけになります。

今回の検証結果をまとめると、次のようになります。

比較対象 結果
秘密鍵ファイルのバイト列 異なる
秘密鍵ファイルのSHA-256 異なる
公開鍵ファイル 同じ
公開鍵フィンガープリント 同じ
秘密スカラー どちらも1

ファイルが異なることと、異なる暗号鍵が生成されたことは、同じ意味ではないのです。

5. そもそも、この鍵はどのくらい弱いのか

秘密スカラーが 1 であるなら、公開鍵を見ただけで容易に秘密スカラーを特定できます。

今回は、公開鍵から候補となる秘密スカラーを探すプログラムを書いてみましょう。

from cryptography.hazmat.primitives.asymmetric import ec

public_key = key1.public_key().public_numbers()

for candidate in range(1, 256):
    guessed = ec.derive_private_key(
        candidate,
        ec.SECP521R1(),
    )

    if guessed.public_key().public_numbers() == public_key:
        print("Recovered:", candidate)
        break

実行結果:

Recovered: 1

1回目で発見されました。

P-521の暗号が破られたわけではありません。

候補となる秘密スカラーを試すだけで公開鍵との一致を確認できるため、一般的な楕円曲線離散対数問題を解く必要がないのです。

なお、厳密には、秘密スカラーの数値が小さいこと、bit_lengthが短いこと、エントロピーが小さいことは、それぞれ異なる概念です。

十分に安全な乱数生成器が偶然 1 を生成する可能性も、数学的にはゼロではありません。

エントロピーは、生成された値単体ではなく、値がどのような確率分布から生成されるかに関係するものです。

今回のプログラムでは、ユーザーが 0 を入力すると、必ず 1 が生成されます。

そのため、攻撃者がこの入力を知っている場合、秘密スカラーについての不確実性はありません。

なぜテストでは発見できなかったのか

ここまで見れば、鍵生成の問題は明白です。

では、なぜエンジニア君は、この問題に気付かなかったのでしょうか。

例えば、生成されたツールに次のようなテストが用意されていたとします。

テスト項目 結果
NIST P-521で鍵を生成できる PASS
秘密スカラーが有効範囲に収まる PASS
OpenSSH形式で保存できる PASS
保存した鍵を読み込める PASS
公開鍵を生成できる PASS
ECDSAの署名・検証が成功する PASS
生成した秘密鍵ファイルが異なる PASS
秘密スカラーの予測困難性が確保されている 未検証

どのテストも、確認している範囲では正しい結果を返しています。

しかし、暗号鍵として最も重要な性質を検証していません。

正常に動作することと、安全に動作することは別なのです。

さらに厄介なのは、間違った要求仕様に基づいて、正しいテストが書かれる可能性があることです。

これはAIに限った問題ではありません。

ただし、LLMを使った開発では、実装、テスト、レビューまで同じモデルや同じ前提に依存してしまう可能性があります。

例えば「ユーザーには無効な値を再入力させない」という設計を適切だと判断したモデルが、その設計を前提としたテストを生成すれば、max(1, seed) が想定どおり動作することを確認して満足してしまうかもしれません。

テストは成功します。

実装も仕様どおりに動作します。

しかし、その仕様が安全とは限りません。

エンジニア君の手動検証も同じでした。

彼は、鍵が毎回異なるかどうかを確かめようとしていました。

しかし、実際に比較したのは秘密鍵の数学的な値ではなく、秘密鍵を保存したファイルのバイト列だったのです。

本来はどう作るべきだったのか

SSH鍵を生成する場合、通常は人間にランダムな数値を選ばせる必要はありません。

OSが提供する暗号学的に安全な乱数生成器(CSPRNG)を利用し、信頼できる暗号ライブラリに鍵生成を任せます。

Pythonのcryptographyなら、今回の用途では次のように書けます。

from cryptography.hazmat.primitives.asymmetric import ec

private_key = ec.generate_private_key(
    ec.SECP521R1()
)

これで十分です。

derive_private_key()は指定した秘密スカラーから鍵を作るAPIであり、generate_private_key()は適切な乱数を使用して鍵を生成するAPIです。

この違いは非常に重要です。

また、不正な入力を自動的に修正することは、一般的なUIでは親切な設計になり得ます。

しかし、暗号鍵の生成に必要なエントロピーが不足している場合、入力を有効な値に丸めてもエントロピーは増えません。

入力として数学的に有効な値を生成することと、暗号学的に安全な値を生成することは、まったく別の要求です。

なお、安全性を確認するために大切なのは、生成された鍵を数個眺めてランダムに見えることを確認することではありません。

信頼できる鍵生成APIとエントロピー源を使用し、生成プロセスが適切に設計・実装されていることを検証する必要があります。

実際のニュースでは何が起きていたのか

最後に、元ネタとなった事件について少し触れておきます。

2026年10月17日、eMudhraの8つの中間CA証明書について、秘密鍵が極めて弱い値になっていたことが報告されました。

調査によって判明した秘密スカラーは、次の値でした。

4, 56, 66, 115, 155, 156, 230, 237

すべて256未満です。

GoogleやMozillaは、影響を受ける証明書に対する信頼を停止する措置を取りました。

eMudhraの説明によれば、鍵生成にはFIPS 140-2 Level 3認証を受けたHSMが使用されていました。

しかし、鍵生成セレモニーのwrap/unwrap処理に問題があり、元の強い鍵とは別に弱い鍵が生成され、それが採用されてしまったとしています。

さらに、問題は約18か月間発見されず、2回の独立監査でも検出されなかったとのことです。

2026年10月22日時点で、正確な根本原因は調査中とされています。

なお、同社によれば、問題の8つの中間CAはエンドユーザー向けの証明書を発行しておらず、実際の影響は限定的だったとされています。

ここまでのエンジニア君の物語は完全なフィクションです。実際の事件でLLMによる鍵生成や人間による 0 入力が関与したという話ではありません。

とはいえ、暗号アルゴリズムやHSMの信頼性と、鍵生成プロセス全体の安全性が別物であるという点は共通しています。

暗号アルゴリズムが正しくても、秘密鍵の作り方が間違っていれば、暗号システム全体が破綻し得ます。

そして、その誤りは、鍵の見た目や一般的な機能テストだけでは必ずしも発見できません。

参考資料


後日談

数週間後。

エンジニア君の開発したSSH鍵生成システムは、所定の監査を通過した。

依存ライブラリに既知の脆弱性は見つからず、鍵形式もOpenSSHに準拠している。

鍵ペアの整合性や署名・検証のテストも問題なかった。

監査の対象は依存ライブラリや機能の適合性であり、エントロピー源の設計そのものは対象外だった。

「やっぱりAIに任せて正解だったな」

本番環境へのデプロイも、問題なく完了した。

Deployment completed successfully.

SSH key generation: OK
SSH authentication: OK
Integration tests: PASSED
Security checks: PASSED

Status: HEALTHY

エンジニア君は、最後にダッシュボードを確認した。

すべてのステータスが正常を示していた。

「よし、これで安心だ」

エンジニア君は、満足して帰宅した。


この物語は GPT-5.6 Sol が生成しました。構成及び監修は投稿者が行いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?