Diffie-Hellman鍵交換プロトコル:理論とPython実装

Diffie-Hellman鍵交換プロトコルの理論(離散対数問題・計算量的DH問題・前方秘匿性)とPython実装を解説。中間者攻撃・部分群攻撃・Pohlig-Hellman攻撃を実際にコードで再現し、Safe Primeが必須である理由を数値実験で示します。

概要

Diffie-Hellman(ディフィー・ヘルマン)鍵交換は、1976年にWhitfield DiffieとMartin Hellmanによって発表された、公開鍵暗号の概念を初めて具体化したプロトコルです。このプロトコルを用いることで、事前に秘密の情報を共有していなくても、盗聴の危険がある公開された通信路を通じて、安全に共通の秘密鍵を確立することができます。

このプロトコルは、**前方秘匿性(Forward Secrecy)**を確保する上で重要です。前方秘匿性とは、たとえ将来的に秘密鍵が漏洩したとしても、過去の通信内容が解読されないようにする性質を指します。DH鍵交換では、セッションごとに新しい共通鍵を生成するため、この性質が実現されます。

本記事は DH の入門編として、プロトコルの導出・安全性の根拠・代表的な攻撃手法(中間者攻撃・部分群攻撃・Pohlig-Hellman攻撃)を Python で実際に再現しながら解説します。Safe Prime・ECDH・X25519・Signal の X3DH まで踏み込んだ発展的な内容は、 Diffie-Hellman 鍵交換の理論と実装:詳解版 にまとめています。同じ公開鍵暗号の枠組みとしては、素因数分解の困難性に依拠する RSA 暗号 が代表的です。暗号技術全体の見取り図は 暗号技術ロードマップ を参照してください。

アルゴリズムの原理

Diffie-Hellman鍵交換は、離散対数問題の困難性を安全性の根拠としています。

  1. 公開パラメータの合意: 通信を行うアリスとボブは、まず以下の2つの公開パラメータに合意します。

    • 大きな素数 \(p\)
    • \(p\) を法とする原始根(または生成元)\(g\)
  2. 秘密鍵の生成:

    • アリスは秘密の整数 \(a\) をランダムに選びます。
    • ボブは秘密の整数 \(b\) をランダムに選びます。 これらの \(a\) と \(b\) は、それぞれアリスとボブの秘密鍵となります。
  3. 公開鍵の計算と交換:

    • アリスは自身の公開鍵 \(A = g^a \pmod{p}\) を計算し、ボブに送信します。
    • ボブは自身の公開鍵 \(B = g^b \pmod{p}\) を計算し、アリスに送信します。 この \(A\) と \(B\) は公開された通信路を流れるため、盗聴者も知ることができます。
  4. 共通鍵の計算:

    • アリスは、ボブから受け取った公開鍵 \(B\) と自身の秘密鍵 \(a\) を使って、共通鍵 \(S_A = B^a \pmod{p}\) を計算します。
    • ボブは、アリスから受け取った公開鍵 \(A\) と自身の秘密鍵 \(b\) を使って、共通鍵 \(S_B = A^b \pmod{p}\) を計算します。

ここで、\(S_A = (g^b)^a \pmod{p} = g^{ba} \pmod{p}\) であり、\(S_B = (g^a)^b \pmod{p} = g^{ab} \pmod{p}\) となるため、\(S_A = S_B\) となり、アリスとボブは同じ秘密の共通鍵を共有できます。この一致は冪指数の交換則 \(ba = ab\) だけから成り立つ、非常にシンプルな仕組みです。

安全性の根拠:離散対数問題と計算量的Diffie-Hellman問題

盗聴者イブは、公開された \(g, p, A, B\) をすべて観測できます。イブが最終的に知りたいのは共通鍵 \(K = g^{ab} \bmod p\) ですが、これは次の2つの、似ているが異なる問題に分解できます。

離散対数問題(Discrete Logarithm Problem; DLP): \(A = g^a \bmod p\) から \(a\) を求める問題。

\[ A \equiv g^{a} \pmod{p} \quad \text{が既知のとき} \; a \; \text{を求めよ} \]

計算量的Diffie-Hellman問題(Computational Diffie-Hellman; CDH): \(g, p, A=g^a \bmod p, B=g^b \bmod p\) から、\(a\) や \(b\) を経由せずに直接 \(g^{ab} \bmod p\) を求める問題。

\[ g^{a} \bmod p, \; g^{b} \bmod p \; \text{が既知のとき} \; g^{ab} \bmod p \; \text{を求めよ} \]

DLP が解ければ(\(A\) から \(a\) が求まれば)\(K = B^a \bmod p\) を計算するだけなので CDH も解けます。つまり DLP が解ける ⟹ CDH が解ける という関係は明らかです。しかし逆方向、すなわち「DLP を解かずに CDH だけを解く近道が存在するか」は未解決の問題で、現在のところ知られている最良の攻撃はすべて事実上 DLP を解く(あるいはそれと同程度の計算量を要する)ものです注1。DH の安全性は、正確には「CDH が困難である」という仮定(CDH仮定)に基づいており、これは DLP の困難性よりわずかに弱い(=壊しやすいかもしれない)仮定であることに注意してください。

注1: 一般の群では CDH が DLP より真に易しくなり得ることが理論的に示されていますが、DH で実際に使われる有限体の乗法群 \(\mathbb{Z}_p^*\) ではそのような分離は知られておらず、実務上は「CDH ≈ DLP の困難性」として扱われます。

数値例で確認する

教科書的に小さい \(p=23, g=5\) を使い、プロトコル全体と、イブが取りうる最も直接的な攻撃(離散対数の総当たり)を実行して確認します。乱数シードは再現性のため固定しています。

import random
import time

random.seed(20230907)  # 再現性のため乱数シードを固定

p = 23  # 大きな素数(教科書的な例のため小さい値を使用)
g = 5   # p を法とする原始根

alice_private_key = random.randint(1, p - 2)
bob_private_key = random.randint(1, p - 2)

alice_public_key = pow(g, alice_private_key, p)  # A = g^a mod p
bob_public_key = pow(g, bob_private_key, p)      # B = g^b mod p

shared_secret_alice = pow(bob_public_key, alice_private_key, p)  # S_A = B^a mod p
shared_secret_bob = pow(alice_public_key, bob_private_key, p)    # S_B = A^b mod p

print(f"公開パラメータ: p = {p}, g = {g}")
print(f"アリスの秘密鍵 a = {alice_private_key}, 公開鍵 A = {alice_public_key}")
print(f"ボブの秘密鍵   b = {bob_private_key}, 公開鍵 B = {bob_public_key}")
print(f"アリス側の共通鍵 S_A = B^a mod p = {shared_secret_alice}")
print(f"ボブ側の共通鍵   S_B = A^b mod p = {shared_secret_bob}")
assert shared_secret_alice == shared_secret_bob
print(f"一致確認: S_A == S_B -> {shared_secret_alice}")


def brute_force_dlog(g, A, p):
    """g, A, p から離散対数 a を総当たりで探す(教育目的。小さい p でのみ現実的)"""
    for x in range(1, p - 1):
        if pow(g, x, p) == A:
            return x
    return None


t0 = time.perf_counter()
recovered_a = brute_force_dlog(g, alice_public_key, p)
t1 = time.perf_counter()
print(f"\nEve が総当たりで復元した a = {recovered_a}(正解: {alice_private_key})")
print(f"総当たりに要した時間: {(t1 - t0) * 1e6:.2f} us(探索空間: p-2 = {p - 2} 通り)")

# a さえ割り出せば、CDH問題(g^{ab} mod p の計算)も自明に解ける
eve_shared_secret = pow(bob_public_key, recovered_a, p)
print(f"Eve が計算した共通鍵: {eve_shared_secret}(アリス・ボブの鍵と一致: {eve_shared_secret == shared_secret_alice})")

実行結果:

公開パラメータ: p = 23, g = 5
アリスの秘密鍵 a = 4, 公開鍵 A = 4
ボブの秘密鍵   b = 8, 公開鍵 B = 16
アリス側の共通鍵 S_A = B^a mod p = 9
ボブ側の共通鍵   S_B = A^b mod p = 9
一致確認: S_A == S_B -> 9

Eve が総当たりで復元した a = 4(正解: 4)
総当たりに要した時間: 1.83 us(探索空間: p-2 = 21 通り)
Eve が計算した共通鍵: 9(アリス・ボブの鍵と一致: True)

\(p=23\) という小さな探索空間(\(p-2=21\) 通り)では、総当たりがマイクロ秒で終わってしまい、イブは \(a\) を即座に割り出して共通鍵まで計算できています(実行時間は実行環境により変動しますが、いずれにせよマイクロ秒オーダーです)。探索空間は \(p\) にほぼ比例して増える一方、実運用の \(p\) は2048〜3072ビット、つまり探索候補が \(2^{2048}\) 通り以上になるため、総当たりは宇宙年齢を費やしても終わりません。この「候補数が指数的に増える」性質こそが、DLP・CDH の困難性の直感的な根拠です。

中間者攻撃(MITM):認証なしでは2つの別々の鍵ができる

DH 単体には、受け取った公開鍵が本当に「相手のもの」であるかを確認する手段がありません。これを悪用すると、通信路上に割り込んだ攻撃者イブは、アリスとは1つの共通鍵、ボブとは別の共通鍵をそれぞれ独立に確立し、両者の通信をすべて平文で中継・盗聴できてしまいます。

具体的には、イブはアリス宛の公開鍵とボブ宛の公開鍵をそれぞれ自分の値にすり替えます。

  • アリスには「ボブの公開鍵」と称して \(B' = g^{e_B} \bmod p\) (\(e_B\) はイブの秘密指数)を渡す。
  • ボブには「アリスの公開鍵」と称して \(A' = g^{e_A} \bmod p\) (\(e_A\) はイブの秘密指数)を渡す。

アリスは \(B'\) を本物のボブの公開鍵だと信じて \(K_{AE} = (B')^{a} \bmod p = g^{e_B a} \bmod p\) を計算します。イブは、アリスの本物の公開鍵 \(A\) と自分の \(e_B\) から \(A^{e_B} \bmod p = g^{a e_B} \bmod p\) を計算すれば同じ値が得られるため、イブはアリスと同じ鍵 \(K_{AE}\) を握っています。ボブ側についても同様に、イブとボブは \(K_{EB} = g^{e_A b} \bmod p\) を共有します。\(K_{AE}\) と \(K_{EB}\) は一般に異なる値であり、アリスとボブは「相手と直接鍵を共有した」と誤信したまま、実際にはそれぞれイブと別々の鍵を共有してしまいます。

import random

random.seed(20230907)

p = 23
g = 5

# 正規の当事者
alice_private = random.randint(1, p - 2)
bob_private = random.randint(1, p - 2)
alice_public = pow(g, alice_private, p)  # A = g^a mod p
bob_public = pow(g, bob_private, p)      # B = g^b mod p

# Eve は Alice-Eve 間、Eve-Bob 間で別々の秘密指数を用意する
eve_secret_vs_alice = random.randint(1, p - 2)  # Bob になりすます際に使う e_B
eve_secret_vs_bob = random.randint(1, p - 2)    # Alice になりすます際に使う e_A
fake_B = pow(g, eve_secret_vs_alice, p)  # Alice に「Bobの公開鍵」として送る
fake_A = pow(g, eve_secret_vs_bob, p)    # Bob に「Aliceの公開鍵」として送る

print(f"公開パラメータ: p = {p}, g = {g}")
print(f"Alice: a = {alice_private}, A = {alice_public}")
print(f"Bob  : b = {bob_private}, B = {bob_public}")
print(f"Eve  : e_B(対Alice用) = {eve_secret_vs_alice} -> 偽B' = {fake_B}")
print(f"Eve  : e_A(対Bob用)   = {eve_secret_vs_bob} -> 偽A' = {fake_A}")

# Alice は fake_B を「Bobの公開鍵」だと思って共通鍵を計算する
K_Alice_Eve = pow(fake_B, alice_private, p)                   # (g^{e_B})^a mod p
K_Eve_with_Alice = pow(alice_public, eve_secret_vs_alice, p)  # (g^a)^{e_B} mod p(Eve側の計算)

# Bob は fake_A を「Aliceの公開鍵」だと思って共通鍵を計算する
K_Bob_Eve = pow(fake_A, bob_private, p)                # (g^{e_A})^b mod p
K_Eve_with_Bob = pow(bob_public, eve_secret_vs_bob, p)  # (g^b)^{e_A} mod p(Eve側の計算)

print(f"\nAlice が確立したと思っている共通鍵 K_AE = {K_Alice_Eve}")
print(f"Eve が Alice との間で計算した共通鍵        = {K_Eve_with_Alice}(一致: {K_Alice_Eve == K_Eve_with_Alice})")
print(f"Bob が確立したと思っている共通鍵   K_EB = {K_Bob_Eve}")
print(f"Eve が Bob との間で計算した共通鍵          = {K_Eve_with_Bob}(一致: {K_Bob_Eve == K_Eve_with_Bob})")
print(f"\nK_AE == K_EB か? {K_Alice_Eve == K_Bob_Eve}")

実行結果:

公開パラメータ: p = 23, g = 5
Alice: a = 4, A = 4
Bob  : b = 8, B = 16
Eve  : e_B(対Alice用) = 17 -> 偽B' = 15
Eve  : e_A(対Bob用)   = 1 -> 偽A' = 5

Alice が確立したと思っている共通鍵 K_AE = 2
Eve が Alice との間で計算した共通鍵        = 2(一致: True)
Bob が確立したと思っている共通鍵   K_EB = 16
Eve が Bob との間で計算した共通鍵          = 16(一致: True)

K_AE == K_EB か? False

\(K_{AE}=2\) 、\(K_{EB}=16\) と、アリス・ボブそれぞれの「共通鍵」が実際には別々の値になっており、しかもイブはその両方を正確に把握していることが確認できます。これが MITM の本質で、DH は鍵の交換はできても、相手の身元は一切保証しないことを示しています。対策は公開鍵の真正性を別途認証することで、実務では署名付きDH(TLSのECDHE-RSA/ECDHE-ECDSAなど)、PAKE、STSプロトコルなどが使われます。認証つき鍵交換の詳細は 詳解版 を参照してください。

部分群攻撃:公開鍵の妥当性検証を怠ると何が起きるか

もう一つの実務的な落とし穴は、受け取った公開鍵が「期待する群(位数 \(p-1\) の巡回群、あるいはその中の適切な部分群)に属しているか」を検証しないことです。攻撃者が、位数 \(t\) が小さい元 \(h\) (\(t\) は \(p-1\) の約数)を「自分の公開鍵」として送りつけると、相手がどんな秘密指数 \(b\) を使おうと、計算される共通鍵 \(S_B = h^b \bmod p\) は \(h\) が生成する位数 \(t\) の部分群の中にしか存在できません。つまり、本来 \(p-1\) 通りあるはずの探索空間が、たった \(t\) 通りに縮退してしまいます。

\(p=2003\) (\(p-1 = 2 \times 7 \times 11 \times 13 = 2002\) )を例に、位数 \(t=7\) の元を送りつけた場合の共通鍵の分布を確認します。

import random
from sympy import isprime, factorint, primitive_root

random.seed(20230907)

# p-1 が複数の小さな素因数を持つ素数を用意する
p = 2003
assert isprime(p)
print(f"p = {p}, p-1 の素因数分解: {factorint(p - 1)}")

g = primitive_root(p)
print(f"原始根 g = {g}")

# 攻撃者は「公開鍵」と称して、位数 t=7 の元を送りつける
t = 7
assert (p - 1) % t == 0
h = pow(g, (p - 1) // t, p)  # 位数ちょうど t の元
print(f"位数 {t} の元 h = g^((p-1)/{t}) mod p = {h}")
order_check = [pow(h, i, p) for i in range(1, t + 1)]
print(f"h, h^2, ..., h^{t} mod p = {order_check}(h^{t} = 1 のはず)")

# Bob は h を「相手の公開鍵」として受け取り、部分群チェックをせずに共通鍵を計算する
distinct_secrets = set()
trials = 2000
for _ in range(trials):
    b = random.randint(1, p - 2)  # Bob の本物の秘密鍵(毎回ランダム)
    S_B = pow(h, b, p)
    distinct_secrets.add(S_B)

print(f"\nBob の秘密鍵 b を {trials} 回変えて共通鍵 S_B = h^b mod p を計算")
print(f"実際に出現した S_B の相異なる値: {sorted(distinct_secrets)}")
print(f"個数: {len(distinct_secrets)}(高々 t={t} 通りに収まる)")

実行結果:

p = 2003, p-1 の素因数分解: {2: 1, 7: 1, 11: 1, 13: 1}
原始根 g = 5
位数 7 の元 h = g^((p-1)/7) mod p = 874
h, h^2, ..., h^7 mod p = [874, 733, 1685, 485, 1257, 974, 1](h^7 = 1 のはず)

Bob の秘密鍵 b を 2000 回変えて共通鍵 S_B = h^b mod p を計算
実際に出現した S_B の相異なる値: [1, 485, 733, 874, 974, 1257, 1685]
個数: 7(高々 t=7 通りに収まる)

Bobの秘密鍵 \(b\) を2000回変えても、計算される共通鍵は常にこの7個のいずれかにしかならないことが分かります。攻撃者はあらかじめこの7個の候補を計算しておき、何らかのオラクル(例えば、鍵導出後の暗号文の復号成功・失敗といったサイドチャネル)を使って正しい値を特定できれば、\(b \bmod t\) の情報を得られます。異なる小さな部分群を何度も送りつけてこれを繰り返せば(中国剰余定理と組み合わせて)秘密鍵全体を復元できる可能性があり、これは楕円曲線暗号における invalid curve attack の乗法群版に相当します。

異なる位数 \(t\) を選んだ場合に、共通鍵の候補数がどう変化するかを可視化したのが次の図です。

攻撃者が位数tの元を送りつける部分群攻撃において、Bobの秘密鍵を2000回変えて計算した共通鍵の相異なる値の個数を、t=2,7,11,13,14,22の各場合についてプロットした対数スケールの棒グラフ。すべてのtでちょうどt個の値に収まっており、本来の探索空間p-1=2002よりも大幅に小さいことを示す

\(t\) を大きくするほど攻撃の効果は薄れますが、いずれの \(t\) でも観測された候補数はちょうど \(t\) 個であり、本来の探索空間 \(p-1=2002\) から大幅に縮退しています。対策は、受け取った公開鍵が正しい位数(多くの場合、Safe Primeの下での大きな素数位数の部分群)に属していることを検証することです。cryptography ライブラリなど実運用のDH実装は、この検証を自動的に行います。

Safe Prime と Pohlig-Hellman攻撃

部分群攻撃をそもそも成立させないための標準的な対策が Safe Prime の使用です。素数 \(p\) が

\[ p = 2q + 1 \quad (q \text{ も素数}) \]

の形を取るとき、\(p\) をSafe Prime、\(q\) をSophie Germain素数と呼びます。このとき \(p - 1 = 2q\) の素因数は \(2\) と \(q\) のみなので、攻撃可能な小さい部分群が(位数2の部分群を除いて)存在しなくなります。

逆に、\(p-1\) が小さな素因数だけで構成される(smooth である)場合、Pohlig-Hellmanアルゴリズムによって離散対数問題全体を効率的に解けてしまいます。考え方は次の通りです。\(p - 1 = q_1^{e_1} q_2^{e_2} \cdots q_k^{e_k}\) と素因数分解できるとき、

  1. 各素因数冪 \(q_i^{e_i}\) ごとに、元 \(g, h\) を位数 \(q_i^{e_i}\) の部分群に落とし込む(\(g^{(p-1)/q_i^{e_i}}\) , \(h^{(p-1)/q_i^{e_i}}\) を計算する)。
  2. 落とし込んだ小さな部分群の中で離散対数 \(x \bmod q_i^{e_i}\) を(総当たりやBSGSで)解く。
  3. 中国剰余定理(CRT)で各 \(x \bmod q_i^{e_i}\) を組み合わせ、\(x \bmod (p-1)\) を復元する。

全体の計算量は最大の素因数 \(\max_i q_i^{e_i}\) に対する部分問題の困難さで決まるため、\(p-1\) の素因数がすべて小さければ、\(p\) 自体がどれだけ大きくても離散対数は簡単に解けてしまいます

手計算できるトイ例

\(p=41\) (\(p-1 = 40 = 2^3 \times 5\) )で、実際に Pohlig-Hellman を手順どおりに実行し、秘密の指数 \(x=15\) を復元します。

from sympy import factorint
from sympy.ntheory.modular import crt

# p-1 = 40 = 2^3 * 5 (smooth: 小さな素因数のみ)
p = 41
g = 6  # 原始根
x_true = 15  # 秘密の離散対数(Pohlig-Hellmanで復元する)
h = pow(g, x_true, p)
print(f"p = {p}, g = {g}, p-1 = {p - 1} の素因数分解 = {factorint(p - 1)}")
print(f"求めたい x: g^x = h mod p, h = {h}")

factors = factorint(p - 1)
remainders = []
moduli = []
for q, e in factors.items():
    qe = q**e
    g_sub = pow(g, (p - 1) // qe, p)  # 位数 qe の部分群に落とし込んだ g
    h_sub = pow(h, (p - 1) // qe, p)  # 位数 qe の部分群に落とし込んだ h
    x_i = None
    for candidate in range(qe):  # 小さな群なので総当たりで解ける
        if pow(g_sub, candidate, p) == h_sub:
            x_i = candidate
            break
    remainders.append(x_i)
    moduli.append(qe)
    print(f"  素因数冪 {qe:>3d}: 部分群での離散対数 x ≡ {x_i} (mod {qe})")

x_recovered, mod_total = crt(moduli, remainders)
print(f"\nCRT で復元した x = {x_recovered} (mod {mod_total})")
print(f"検算: g^x_recovered mod p = {pow(g, int(x_recovered), p)}(h = {h})")
assert pow(g, int(x_recovered), p) == h
print(f"真の秘密指数と一致: {int(x_recovered) == x_true}")

実行結果:

p = 41, g = 6, p-1 = 40 の素因数分解 = {2: 3, 5: 1}
求めたい x: g^x = h mod p, h = 3

  素因数冪   8: 部分群での離散対数 x ≡ 7 (mod 8)
  素因数冪   5: 部分群での離散対数 x ≡ 0 (mod 5)

CRT で復元した x = 15 (mod 40)
検算: g^x_recovered mod p = 3(h = 3)
真の秘密指数と一致: True

位数8の部分群では \(x \equiv 7 \pmod 8\) 、位数5の部分群では \(x \equiv 0 \pmod 5\) という2つの小さな部分問題に分解され、CRTで組み合わせるだけで真の \(x=15\) が正確に復元できています。\(p-1=40\) を丸ごと総当たりする必要はなく、部分群の大きさ(最大でも8)だけの探索で済んでいる点がPohlig-Hellmanの核心です。

smooth な \(p-1\) と Safe Prime の実測比較

同程度のビット長の素数を、smooth な \(p-1\) を持つものと Safe Prime のそれぞれで用意し、sympy.discrete_log(内部でPohlig-Hellman + Baby-step Giant-stepを実行)を使って実際に離散対数を解く時間を比較します。

import time
import random
from sympy import isprime, factorint, primitive_root, discrete_log, primerange

random.seed(20230907)

small_primes = list(primerange(2, 100))  # smooth性を保つための小さな素因数プール


def find_smooth_prime(target_bits, tries=20000):
    """p-1 の素因数がすべて100未満になるsmoothな素数を探す"""
    for _ in range(tries):
        random.shuffle(small_primes)
        prod = 1
        for q in small_primes:
            if prod.bit_length() >= target_bits:
                break
            prod *= q
        p = prod + 1
        if prod.bit_length() >= target_bits - 2 and isprime(p):
            return p
    raise RuntimeError("not found")


def find_safe_prime(bits):
    while True:
        q = random.getrandbits(bits - 1) | 1 | (1 << (bits - 2))
        if not isprime(q):
            continue
        p = 2 * q + 1
        if isprime(p):
            return p


for bits in [24, 32, 40, 48]:
    p_smooth = find_smooth_prime(bits)
    p_safe = find_safe_prime(bits)

    g_smooth = primitive_root(p_smooth)
    g_safe = primitive_root(p_safe)

    x_smooth = random.randint(2, p_smooth - 2)
    h_smooth = pow(g_smooth, x_smooth, p_smooth)
    x_safe = random.randint(2, p_safe - 2)
    h_safe = pow(g_safe, x_safe, p_safe)

    t0 = time.perf_counter()
    xr_smooth = discrete_log(p_smooth, h_smooth, g_smooth)
    t_smooth = time.perf_counter() - t0
    assert xr_smooth == x_smooth

    t0 = time.perf_counter()
    xr_safe = discrete_log(p_safe, h_safe, g_safe)
    t_safe = time.perf_counter() - t0
    assert xr_safe == x_safe

    max_factor_smooth = max(factorint(p_smooth - 1).keys())
    max_factor_safe_bits = max(factorint(p_safe - 1).keys()).bit_length()

    print(
        f"bit~{bits}: smooth p={p_smooth} (最大素因数 {max_factor_smooth}) -> {t_smooth * 1000:.3f} ms | "
        f"safe p={p_safe} (最大素因数 {max_factor_safe_bits}bit) -> {t_safe * 1000:.3f} ms | "
        f"倍率 {t_safe / t_smooth:.1f}x"
    )

実行結果:

bit~24: smooth p=16297859 (最大素因数 89) -> 0.107 ms | safe p=11600867 (最大素因数 23bit) -> 0.365 ms | 倍率 3.4x
bit~32: smooth p=10747150679 (最大素因数 89) -> 0.194 ms | safe p=3978555587 (最大素因数 31bit) -> 9.649 ms | 倍率 49.8x
bit~40: smooth p=1401201149063 (最大素因数 67) -> 0.262 ms | safe p=837601172687 (最大素因数 39bit) -> 173.684 ms | 倍率 664.1x
bit~48: smooth p=212996770326583 (最大素因数 71) -> 0.353 ms | safe p=253901762228183 (最大素因数 47bit) -> 16301.000 ms | 倍率 46200.3x

smoothなp-1を持つ素数とSafe Primeについて、ビット長24/32/40/48で離散対数の求解時間(ミリ秒、対数軸)を比較した棒グラフ。smooth側は常に1ミリ秒未満で解けるのに対し、Safe Prime側はビット長が増えるごとに指数的に時間が増加し、48ビットでは16秒以上かかっている

smooth側(最大素因数が100未満)は \(p\) のビット長を増やしても常に1ミリ秒未満で解けているのに対し、Safe Prime側は最大素因数(ほぼ \(q\) 自身)のビット長が増えるにつれて求解時間が指数的に増加し、48ビットでは16.3秒、smooth側との倍率は約46,200倍に達しています。これは Baby-step Giant-step の計算量 \(O(\sqrt{q})\) が、\(q\) のビット長に対して指数的であることの直接的な帰結です。実運用の2048〜3072ビットのSafe Primeでは \(q\) も2000ビット超になり、\(\sqrt{q}\) ですら天文学的な数になるため、この意味で安全性が保たれます。\(p-1\) に小さな素因数しか含まれない場合、\(p\) が何ビットであってもPohlig-Hellmanで実質的に無力化されるという結論は、上記の実測から明確に読み取れます。

まとめ

  • DH の共通鍵 \(K=g^{ab} \bmod p\) は冪指数の交換則だけで成立するが、その安全性はCDH問題(DLPよりわずかに弱い可能性がある仮定)に依拠する。
  • 小さな \(p=23\) の数値例では、離散対数の総当たりがマイクロ秒で終わることを実際に確認した。実運用では \(p\) を2048ビット以上にすることで探索空間を指数的に増大させる。
  • 中間者攻撃は認証を欠くDHの本質的な弱点で、攻撃者はアリス・ボブそれぞれと別々の共通鍵を確立できる。対策には署名付きDH・PAKE・STSなどの認証機構が必要。
  • 部分群攻撃は、公開鍵の位数を検証しないと、小さい位数の元を送りつけられて共通鍵の候補が数個に縮退してしまう攻撃。
  • Pohlig-Hellman攻撃は \(p-1\) がsmoothな場合に離散対数問題全体を効率的に解く手法で、実測でも48ビットのSafe Primeとsmooth素数の間で約46,200倍もの求解時間の差が確認できた。
  • 上記すべての対策として、実務ではSafe Prime(\(p=2q+1\) )の使用と、受信した公開鍵の位数検証が必須となる。詳細な実装(cryptography.hazmat、ECDH、X25519)は 詳解版 を参照してください。

参考文献

  • Diffie, W., & Hellman, M. (1976). “New Directions in Cryptography.” IEEE Transactions on Information Theory, 22(6), 644–654.
  • Pohlig, S., & Hellman, M. (1978). “An Improved Algorithm for Computing Logarithms over GF(p) and Its Cryptographic Significance.” IEEE Transactions on Information Theory, 24(1), 106–110.
  • Pfeiffer, S., & Tihanyi, N. (2024). “D(HE)at: A Practical Denial-of-Service Attack on the Finite Field Diffie–Hellman Key Exchange.” IEEE Access, 12, 957–980. (公開鍵の位数検証の欠如がDoS攻撃"D(HE)at"/CVE-2024-41996につながることを示した2024年の研究)
  • Tang, K. F., Wu, K. L., & Chau, S. Y. (2024). “Investigating TLS Version Downgrade in Enterprise Software.” Proceedings of the 14th ACM Conference on Data and Application Security and Privacy (CODASPY 2024). (Logjamに続く、エンタープライズソフトウェアにおけるTLSダウングレード攻撃の実態調査)
  • Adrian, D., et al. (2015). “Imperfect Forward Secrecy: How Diffie-Hellman Fails in Practice” (Logjam attack). CCS 2015.

関連記事

関連ツール