なぜ SSH の鍵交換を学ぶか
このブログではこれまで Diffie-Hellman 鍵交換、楕円曲線暗号(ECC)と ECDH、TLS 1.3 ハンドシェイク と、鍵交換の数学とプロトコル実装を積み上げてきました。しかし、多くのエンジニアが TLS よりも先に、そして毎日のように使っているプロトコルがあります。SSH です。
ssh user@host を打つたびに、裏側では DH または ECDH の鍵交換が行われ、セッション鍵が導出されています。SSH のアルゴリズム名がまさにそれを物語っています。curve25519-sha256 は X25519 (ECDH) そのものであり、diffie-hellman-group-exchange-sha256 は Safe Prime ベースの古典 DH そのものです。本記事は「これまで学んだ数学が、実際にどのプロトコルでどう使われているか」を、SSH の接続確立フェーズを解剖しながら Python で検証します。
SSH トランスポート層と鍵交換フェーズの全体像
SSH プロトコル(RFC 4253, “The Secure Shell (SSH) Transport Layer Protocol”)は、TCP 接続確立の直後に次の順序で処理を進めます。
- バージョン文字列交換: クライアント・サーバーはそれぞれ
SSH-2.0-...の識別文字列を送り合う(\(V_C\) , \(V_S\) )。 - SSH_MSG_KEXINIT 交換: 双方が対応する鍵交換・ホスト鍵・暗号・MAC・圧縮アルゴリズムの優先順位付きリストを送る(\(I_C\) , \(I_S\) )。
- アルゴリズムネゴシエーション: 双方のリストから実際に使うアルゴリズムを1つずつ決定する。
- 鍵交換 (KEX): 選ばれた鍵交換アルゴリズムで一時鍵ペアを生成し、公開鍵を交換して共有秘密 \(K\) と交換ハッシュ \(H\) を計算する。
- セッション鍵導出: \(K\) と \(H\) から、暗号鍵・IV・MAC 鍵を導出する。
- SSH_MSG_NEWKEYS: 双方が導出した鍵での暗号化通信に切り替える。
この記事で扱うのは 2〜5 のステップです。
アルゴリズムネゴシエーション(RFC 4253 §7.1)
SSH_MSG_KEXINIT には kex_algorithms, server_host_key_algorithms, encryption_algorithms_client_to_server など複数のname-list(カンマ区切りの優先順位付きアルゴリズム名リスト)が含まれます。RFC 4253 Section 7.1 はネゴシエーションのルールを次のように定めています。
The chosen encryption algorithm to each direction MUST be the first algorithm on the client’s name-list that is also on the server’s name-list.
つまりクライアントの優先順位が最優先され、「クライアントのリストを先頭から見て、サーバーもサポートしている最初のアルゴリズム」が採用されます。これは鍵交換アルゴリズム自体(kex_algorithms)にも同じ規則が適用されます。
さらに RFC 4253 は「楽観的な鍵交換パケット(guessed packet)」という最適化を定義しています。
Each side MAY guess which algorithm the other side is using, and MAY send an initial key exchange packet according to the algorithm, if appropriate.
双方が「相手も自分と同じ第一候補を使うはずだ」と予想し、KEXINIT の直後に予想したアルゴリズムでの鍵交換パケットを送ってしまうことで、往復回数を減らせます。予想が外れた場合(鍵交換アルゴリズムかホスト鍵アルゴリズムのどちらかが不一致)は、送ってしまったパケットは破棄され、正式なネゴシエーション結果で鍵交換をやり直します。
OpenSSH の kex_algorithms は代表的には次のような優先順位です(バージョンにより変動)。
curve25519-sha256,curve25519-sha256@libssh.org,
ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,
diffie-hellman-group-exchange-sha256,
diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
先頭に ECDH 系(curve25519-sha256, ecdh-sha2-*)が並び、古典 DH(diffie-hellman-group-*)は後方に配置されるのが現代の標準的な構成です。これは後述するベンチマークで示す通り、curve25519 が古典 DH より高速なためです。
鍵交換アルゴリズムと既存記事の対応
SSH の主要な鍵交換アルゴリズムは、実はこのブログで既に解説した数学そのものです。
| SSH アルゴリズム名 | 定義 RFC | 数学的基盤 |
|---|---|---|
diffie-hellman-group14-sha256 等 | RFC 8268 | 有限体上の DH(Safe Prime, 固定パラメータ) |
diffie-hellman-group-exchange-sha256 | RFC 4419 | 有限体上の DH(サーバーが p, g を都度提示) |
ecdh-sha2-nistp256 / nistp384/nistp521 | RFC 5656 | 楕円曲線 ECDH(P-256 等 Weierstrass 曲線) |
curve25519-sha256 | RFC 8731 | X25519(Curve25519 上の ECDH) |
diffie-hellman-group-exchange-sha256 (RFC 4419) は、Diffie-Hellman 記事 で解説した「固定の Safe Prime を使わず、クライアントが希望する鍵長 (min/preferred/max bit) を提示し、サーバーが対応する \((p, g)\)
を都度選んで返す」という DH の一変種です。一方 curve25519-sha256 (RFC 8731) は、ECC 記事の X25519 節 で扱った ECDH をそのまま SSH の鍵交換メッセージ形式に載せたものです。
以降、本記事は最も現代的でデファクトとなっている curve25519-sha256 に絞り、Python でトイ実装を行います。
curve25519-sha256 のプロトコルフロー(RFC 8731)
RFC 8731 は SSH_MSG_KEX_ECDH_INIT / SSH_MSG_KEX_ECDH_REPLY のメッセージ形式とプロトコルフローについて「[RFC 5656] の Section 4 と同一」であると定めています。流れは次の通りです。
- クライアントは X25519 の一時鍵ペア \((x, Q_C)\)
を生成し、\(Q_C\)
(32 byte の公開鍵)を
SSH_MSG_KEX_ECDH_INITでサーバーに送る。 - サーバーは自身の一時鍵ペア \((y, Q_S)\) を生成し、\(Q_C\) から共有秘密 \(K\) を計算する。
- サーバーはホスト鍵 \(K_S\)
、公開鍵 \(Q_S\)
、\(K\)
を使って計算した交換ハッシュ \(H\)
、そして \(H\)
に対する署名を
SSH_MSG_KEX_ECDH_REPLYで返す。 - クライアントはホスト鍵の署名を検証し、自身の秘密鍵 \(x\) とサーバー公開鍵 \(Q_S\) から同じ \(K\) を計算する。
交換ハッシュ \(H\) は次の値を連結してハッシュ化したものです(RFC 8731 が参照する RFC 5656 §4)。
\[ H = \mathrm{hash}(V_C \,\|\, V_S \,\|\, I_C \,\|\, I_S \,\|\, K_S \,\|\, Q_C \,\|\, Q_S \,\|\, K) \tag{1} \]curve25519-sha256 では、この \(\mathrm{hash}\)
は SHA-256 です(RFC 8731 §3: “The hash used is SHA-256 for ‘curve25519-sha256’")。ここで \(Q_C, Q_S\)
は式 (1) 中では string 型としてエンコードされ、\(K\)
は RFC 8731 §3.1 の規定に従い、X25519 の生の出力バイト列(本来は little-endian で扱われる \(x\)
座標)を改めて big-endian の符号なし整数として再解釈し、RFC 4251 §5 の mpint 形式にエンコードしたものです。
Python 実装: curve25519-sha256 のトイ鍵交換
楕円曲線暗号の記事 で使った cryptography.hazmat.primitives.asymmetric.x25519 をそのまま使い、SSH のメッセージ形式に沿ってエンコードします。
import hashlib
import struct
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
def mpint(n: int) -> bytes:
"""RFC 4251 Section 5 の mpint エンコード"""
if n == 0:
return struct.pack(">I", 0)
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
if b[0] & 0x80:
b = b"\x00" + b # 符号ビット衝突を避けるため先頭に 0x00 を付与
return struct.pack(">I", len(b)) + b
def ssh_string(b: bytes) -> bytes:
"""RFC 4251 Section 5 の string エンコード(長さ4byte + 本体)"""
return struct.pack(">I", len(b)) + b
# --- クライアント/サーバーの一時鍵ペア (SSH_MSG_KEX_ECDH_INIT/REPLY) ---
client_priv = X25519PrivateKey.generate()
server_priv = X25519PrivateKey.generate()
Q_C = client_priv.public_key().public_bytes_raw() # 32 byte
Q_S = server_priv.public_key().public_bytes_raw() # 32 byte
shared_client = client_priv.exchange(server_priv.public_key())
shared_server = server_priv.exchange(client_priv.public_key())
assert shared_client == shared_server
# RFC 8731 §3.1: raw な X を big-endian unsigned integer K として再解釈し mpint 化
K_mpint = mpint(int.from_bytes(shared_client, "big"))
実行結果(実際に走らせた出力):
Q_C (client 公開鍵, 32 byte): 58d54c455205c7c27c36b4ff018c0d00a6ec7bfefcce0389e9266e76e45b2f7b
Q_S (server 公開鍵, 32 byte): a02fc32cf1764c1b8d37c529e9822b8ccbd138704221894cfbddd928b1317852
共有秘密 X (raw, 32 byte): 664e4fb17ccbd06cb5c907b5b39cffcddf9d45d9403d83e908cddd766ffe1c08
client/server 一致: True
K (mpint, 36 byte): 00000020664e4fb17ccbd06cb5c907b5b39cffcddf9d45d9403d83e908cddd766ffe1c08
X25519 のスカラー倍算の可換性(ECC 記事の ECDH 節 式 (8) と同じ構造)により、クライアント・サーバーは独立に同じ共有秘密に到達しています。
交換ハッシュ H とセッション鍵導出(RFC 4253 §7.2)
次に、式 (1) の交換ハッシュ \(H\) を計算します。実際のプロトコルでは \(V_C, V_S\) はバージョン文字列、\(I_C, I_S\) は KEXINIT ペイロード全体、\(K_S\) はサーバーのホスト鍵ですが、本記事はネットワーク越しの実通信は行わないため、これらは固定のモック値で代用します(\(H\) の計算式そのものの正しさの検証が目的です)。
V_C = b"SSH-2.0-ToyClient_1.0"
V_S = b"SSH-2.0-ToyServer_1.0"
I_C = b"\x14" + b"\x00" * 16 + b"mock-kexinit-client-payload"
I_S = b"\x14" + b"\x00" * 16 + b"mock-kexinit-server-payload"
K_S = ssh_string(b"ssh-ed25519") + ssh_string(b"\x00" * 32) # モックホスト鍵
hash_input = (
ssh_string(V_C) + ssh_string(V_S) + ssh_string(I_C) + ssh_string(I_S)
+ ssh_string(K_S) + ssh_string(Q_C) + ssh_string(Q_S) + K_mpint
)
H = hashlib.sha256(hash_input).digest() # RFC 8731: curve25519-sha256 は SHA-256
session_id = H # RFC 4253 §7.2: 初回鍵交換では H がそのまま session_id になる
実行結果:
交換ハッシュ H (SHA-256, 32 byte): 81349c15fe800a34c8992ce7184fad4afc2c8241f009bdcbbc13b73fd2407fe6
RFC 4253 §7.2 “Output from Key Exchange” は、共有秘密 \(K\)
(mpint)と交換ハッシュ \(H\)
、そして session_id(初回鍵交換では \(H\)
そのもの)から、6 種類の鍵材料を導出する式を定義しています。
必要な鍵長がハッシュ出力長を超える場合は、RFC 4253 §7.2 が次の鍵拡張手順も定義しています。
\[ K_1 = \mathrm{HASH}(K\|H\|X\|\mathrm{session\_id}), \quad K_2 = \mathrm{HASH}(K\|H\|K_1), \quad K_3 = \mathrm{HASH}(K\|H\|K_1\|K_2), \ \ldots \tag{3} \]必要なバイト数になるまで \(K_1 \| K_2 \| K_3 \| \cdots\)
を連結し、先頭から切り出します。これは TLS 1.3 記事 で扱った HKDF-Expand の反復ハッシュ構造と本質的に同じ発想です(ただし SSH は HMAC ベースの HKDF ではなく、単純な HASH の連結を使う点が異なります)。
式 (2) を curve25519-sha256 なので HASH = SHA-256 として Python で実装します。
def derive(letter: bytes) -> bytes:
return hashlib.sha256(K_mpint + H + letter + session_id).digest()
iv_c2s = derive(b"A")
iv_s2c = derive(b"B")
enc_c2s = derive(b"C")
enc_s2c = derive(b"D")
mac_c2s = derive(b"E")
mac_s2c = derive(b"F")
実行結果:
Initial IV (client->server) : fdd055ef7c5cfc89afd1a1ff08e436489136ae03d6c64a166c5c7d8f5b13d897
Initial IV (server->client) : 1e10f9fa7428f316f8746c98febb0d0bca035bcfccb16dfc308527ad59745f3f
Encryption key (client->server) : 17c292253737c9caa6f29000c397ea4a30156ab7706b8191ae719e13adf50ecb
Encryption key (server->client) : 5b3f39417d643b8062594f2fdb8ec86f1d1451038f679c26d7a1a96ae1a72db9
Integrity key (client->server) : c1d9231d590e790799c1d4e0762d14881135c8cf6f0666d879fa8b91a7e63c18
Integrity key (server->client) : cea5a0c4b6d43937e5c1787aa36fb78338a7cd7a9afe1104cc9e250d504a6d8b
クライアント/サーバー独立導出の一致: True
サーバー側で shared_server から独立に同じ手順を実行しても、暗号鍵 (ENC_{C→S}) が完全に一致することを確認しました。共有秘密 \(K\)
と交換ハッシュ \(H\)
さえ一致すれば、6 種類の鍵はどちらの当事者も追加の通信なしに導出できる——これが DH/ECDH による鍵共有の実用的な価値です。
ベンチマーク: X25519 vs DH group exchange (2048/3072 bit)
SSH のデフォルトのアルゴリズム優先順位で curve25519-sha256 が diffie-hellman-group-exchange-sha256 より先に並ぶのには、実測できる性能上の理由があります。cryptography.hazmat を使い、X25519 と FFDHE(RFC 7919 の 2048/3072 bit 標準パラメータ相当)それぞれについて、鍵生成 (generate_private_key) と鍵交換 (exchange) の時間を実測しました。
import time
from cryptography.hazmat.primitives.asymmetric import dh
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
N = 200
t0 = time.perf_counter()
keys = [X25519PrivateKey.generate() for _ in range(N)]
t1 = time.perf_counter()
x25519_keygen_ms = (t1 - t0) / N * 1000
# ... 鍵交換も同様に time.perf_counter() で計測
for bits in (2048, 3072):
params = dh.generate_parameters(generator=2, key_size=bits)
N_dh = 20
t0 = time.perf_counter()
dh_keys = [params.generate_private_key() for _ in range(N_dh)]
t1 = time.perf_counter()
dh_keygen_ms = (t1 - t0) / N_dh * 1000
# ... 鍵交換も同様に計測
実測結果(Python 3.x, cryptography 49.0.0, X25519 は N=200 回、DH は N=20 回の平均):
| アルゴリズム | 鍵生成 [ms/回] | 鍵交換 [ms/回] | 合計 [ms/回] |
|---|---|---|---|
| X25519 | 0.090 | 0.160 | 0.250 |
| DH 2048 bit | 2.630 | 2.639 | 5.268 |
| DH 3072 bit | 7.964 | 7.905 | 15.869 |
X25519 は DH 2048 bit の 約 21.0 倍、DH 3072 bit の 約 63.4 倍高速という結果になりました。

DH group exchange は \(g^a \bmod p\) という大きな法での冪乗剰余を計算する必要があり、鍵長を伸ばすほど計算コストが増大します(DH 記事 で解説した繰り返し二乗法を使っても、2048 bit・3072 bit の整数演算は本質的に重い)。一方 X25519 は 255 bit 程度の固定長スカラー倍算で完結し、楕円曲線暗号の記事 で述べた「短い鍵長で同等の安全性」という ECC の利点が、そのまま処理速度の利点として表れています。ハンドシェイクのたびに一時鍵を生成する SSH・TLS のようなエフェメラル鍵交換では、この速度差が接続確立時間に直結するため、curve25519 が優先されるのは合理的です。
まとめ
- SSH の鍵交換は KEXINIT によるアルゴリズムネゴシエーション(RFC 4253 §7.1、クライアント優先・first-match ルール)から始まり、鍵交換・セッション鍵導出へと進む。
curve25519-sha256(RFC 8731)は X25519 (ECDH) そのもので、diffie-hellman-group-exchange-sha256(RFC 4419)は Safe Prime ベースの古典 DH の変種。- 交換ハッシュ \(H = \mathrm{hash}(V_C \| V_S \| I_C \| I_S \| K_S \| Q_C \| Q_S \| K)\) と、セッション鍵 \(\mathrm{HASH}(K \| H \| X \| \mathrm{session\_id})\) (RFC 4253 §7.2)を Python で実装し、クライアント・サーバー双方が独立に同じ鍵に到達することを確認した。
- 実測ベンチマークでは、X25519 は DH 2048 bit の約 21 倍、DH 3072 bit の約 63 倍高速という具体的な性能差を確認し、SSH がデフォルトで curve25519 系を優先する理由を裏付けた。
SSH は「新しい暗号」ではなく、これまで学んだ DH・ECDH という枯れた数学の実装形態の1つに過ぎません。次は TLS 1.3 ハンドシェイク と読み比べ、同じ ECDHE がどう異なるプロトコルフォーマットに落とし込まれているかを確認してみてください。
関連記事
- Diffie-Hellman鍵交換の理論と実装:離散対数問題・Safe Prime・MITM対策・X25519までPythonで解説 - 本記事の
diffie-hellman-group-exchange-sha256の数学的基盤。Safe Prime・MITM対策・X25519の導入まで。 - 楕円曲線暗号(ECC)の数学と Python 実装 - 本記事の
curve25519-sha256の理論的基盤。ECDH の可換性・X25519 のクランプ処理を詳解。 - TLS 1.3ハンドシェイク解剖 - 同じ X25519 ECDHE を使う別プロトコル。HKDF鍵スケジュールとの違いを比較できる。
- 暗号技術ロードマップ:DLP・素因数分解・楕円曲線から PQC まで - 暗号領域全体の学習マップ。
関連ツール
- ハッシュ生成ツール (DevToolBox) - セッション鍵導出に使う SHA-256 の計算に。
- 進数変換ツール (CalcBox) - mpint エンコードのバイト列を16進表記で確認するのに。
参考文献
- Ylonen, T., & Lonvick, C. (2006). “The Secure Shell (SSH) Transport Layer Protocol”. RFC 4253.
- Friedl, M., Provos, N., & Simpson, W. (2006). “Diffie-Hellman Group Exchange for the Secure Shell (SSH) Transport Layer Protocol”. RFC 4419.
- Stebila, D., & Green, J. (2009). “Elliptic Curve Algorithm Integration in the Secure Shell Transport Layer”. RFC 5656.
- Adamantiadis, A., Josefsson, S., & Baushke, M. (2020). “Secure Shell (SSH) Key Exchange Method Using Curve25519 and Curve448”. RFC 8731.
- Baushke, M. (2017). “More Modular Exponentiation (MODP) Diffie-Hellman (DH) Key Exchange (KEX) Groups for Secure Shell (SSH)”. RFC 8268.