AES / ChaCha20 対称鍵暗号の理論と Python 実装:ラウンド構成・GF(2^8)・暗号モード・AEAD・nonce まで解説

AES / ChaCha20 対称鍵暗号の理論 + 設計比較 + Python実装 (AESGCM / ChaCha20Poly1305 / Cipher / cryptography.hazmat) を体系解説。SubBytes・ShiftRows・MixColumns・AddRoundKey のラウンド構成、GF(2^8) 演算、ECB/CBC/CTR/GCM モード、nonce 再利用の危険、AES-NI と ARX 構造の比較、フルスクラッチ AES-128 まで一気通貫でカバー。

なぜ対称鍵暗号を学ぶか

対称鍵暗号 (symmetric-key cryptography) は、暗号化と復号に同じ鍵を使う方式です。 RSA楕円曲線暗号 (ECC) のような公開鍵暗号が「鍵をどう共有するか」を解決するのに対し、対称鍵暗号は「共有した鍵で大量のデータを高速に暗号化する」役割を担います。AES はハードウェア支援 (AES-NI) 込みで公開鍵暗号の数百〜数千倍高速であり、TLS・ディスク暗号化・VPN で実際にデータを守っているのはほぼすべて対称鍵暗号です。

現代の主役は 2 つです。AES (Advanced Encryption Standard) は 2001 年に NIST が標準化したブロック暗号で、AES-NI 命令を持つ CPU では 1 バイトあたり 1 サイクル未満で動作します。ChaCha20 は Daniel J. Bernstein が設計したストリーム暗号で、AES-NI のないモバイル / 組み込み環境でも定数時間・高速に動作するため、TLS 1.3 では AES-256-GCM / AES-128-GCM / ChaCha20-Poly1305 の 3 つが標準暗号スイートに並んでいます。

一方で対称鍵暗号は「アルゴリズムを正しく選ぶ」だけでは安全になりません。モード (ECB/CBC/CTR/GCM) の選択ミス、IV/nonce の再利用、認証なし暗号化という 3 大事故パターンが現場では繰り返されています。本記事では、AES のラウンド構成と GF(2^8) の数学、ブロック暗号モードの設計、ChaCha20-Poly1305 との比較を整理し、Python で (1) cryptography ライブラリによる実用 AEAD コード、(2) 教育用のフルスクラッチ AES-128 を実装します。暗号領域全体の俯瞰は 暗号技術ロードマップ を参照してください。

対称鍵 vs 公開鍵:ハイブリッド暗号という役割分担

まず全体像です。実際のシステムは対称鍵と公開鍵のハイブリッド構成で動きます。

項目対称鍵暗号 (AES / ChaCha20)公開鍵暗号 ( RSA / ECC )
送受信者が同じ鍵を共有公開鍵と秘密鍵のペア
速度非常に高速 (GB/s 級)低速 (対称鍵の数百〜数千倍遅い)
鍵長128〜256 bitRSA 2048〜4096 bit / ECC 256〜512 bit
課題鍵配送問題速度・大量データに不向き
主な用途データ本体の暗号化鍵交換・署名・認証

対称鍵暗号の弱点は鍵配送問題です。安全にデータを送るには鍵の共有が必要ですが、その鍵を送る安全な経路がない、という循環に陥ります。これを解決するのが Diffie-Hellman 鍵交換 や RSA / ElGamal による鍵カプセル化です。

TLS 1.3 の実際の流れはこうです。

  1. 鍵交換: ECDHE (X25519 / P-256) で一時的な共有秘密を確立( 前方秘匿性 のため毎回使い捨て)
  2. 鍵導出: 共有秘密を HKDF に通してセッション鍵(対称鍵)を導出
  3. 本体暗号化: セッション鍵を使い AES-GCM または ChaCha20-Poly1305 でアプリケーションデータを暗号化

つまり公開鍵暗号は「最初の数百バイト」だけを担当し、残りの全データは対称鍵暗号が守ります。本記事はこの第 3 段を深掘りします。

AES のラウンド構成

AES は 128 bit (16 byte) ブロックを単位に、SPN (Substitution-Permutation Network) 構造で暗号化するブロック暗号です。16 byte の内部状態を 4×4 バイト行列(state、列優先)とみなし、4 種類の変換を ラウンドとして繰り返します。ラウンド数は鍵長で決まります。

鍵長ラウンド数 \(N_r\)ラウンド鍵の数
128 bit1011
192 bit1213
256 bit1415

1 ラウンドは次の 4 変換の合成です(最終ラウンドのみ MixColumns を省略)。

SubBytes — 非線形置換

各バイトを S-box で置換します。S-box は「GF(2^8) 上の逆元 \(x^{-1}\) 」と「GF(2) 上のアフィン変換」の合成で、

\[ S(x) = A \cdot x^{-1} \oplus c \quad (c = \mathtt{0x63}) \tag{1} \]

と定義されます(\(x = 0\) のときは \(x^{-1} = 0\) と約束)。逆元写像は唯一の非線形要素であり、差分解読法・線形解読法への耐性はここから生まれます。アフィン変換は代数的な単純さ(補間攻撃への弱さ)を隠すためのものです。

ShiftRows — 行の巡回シフト

state の第 \(r\) 行を左に \(r\) バイト巡回シフトします(\(r = 0, 1, 2, 3\) )。これにより 1 つの列にあった 4 バイトが 4 つの異なる列へ拡散します。

MixColumns — 列の線形変換

各列を GF(2^8) 上の 4 次元ベクトルとみなし、固定行列を乗算します。

\[ \begin{pmatrix} b_0 \\ b_1 \\ b_2 \\ b_3 \end{pmatrix} = \begin{pmatrix} 02 & 03 & 01 & 01 \\ 01 & 02 & 03 & 01 \\ 01 & 01 & 02 & 03 \\ 03 & 01 & 01 & 02 \end{pmatrix} \begin{pmatrix} a_0 \\ a_1 \\ a_2 \\ a_3 \end{pmatrix} \tag{2} \]

この行列は MDS (Maximum Distance Separable) 行列で、「入力 1 バイトの変化が出力 4 バイトすべてに波及する」最大拡散を保証します。ShiftRows(行方向)と MixColumns(列方向)を 2 ラウンド重ねると、1 バイトの変化が state 全体 16 バイトに波及します(完全拡散)。

AddRoundKey — 鍵の混入

state とラウンド鍵のバイトごとの XOR です。唯一鍵が関与する変換であり、XOR なので暗号化と復号で同じ演算になります。

鍵スケジュール (Key Expansion)

元の鍵から \(N_r + 1\) 個のラウンド鍵を生成します。AES-128 では 16 byte の鍵を 4 ワードとして、ワード \(w_i\) (\(i \ge 4\) )を

\[ w_i = w_{i-4} \oplus \begin{cases} \mathrm{SubWord}(\mathrm{RotWord}(w_{i-1})) \oplus \mathrm{Rcon}_{i/4} & (i \equiv 0 \bmod 4) \\ w_{i-1} & (\text{otherwise}) \end{cases} \tag{3} \]

で拡張します。RotWord は 1 バイト巡回シフト、SubWord は S-box 適用、Rcon はラウンド定数 \(x^{j-1} \in \mathrm{GF}(2^8)\) です。

GF(2^8) 上の演算

AES の数学的基盤は位数 256 の有限体 (ガロア体) \(\mathrm{GF}(2^8)\) です。1 バイト \(b_7 b_6 \cdots b_0\) を GF(2) 係数の多項式

\[ b(x) = b_7 x^7 + b_6 x^6 + \cdots + b_1 x + b_0 \tag{4} \]

と同一視します。加算は XOR そのものです(係数ごとの mod 2 加算)。乗算は多項式の積を既約多項式

\[ m(x) = x^8 + x^4 + x^3 + x + 1 \quad (= \mathtt{0x11B}) \tag{5} \]

で割った余りとして定義します。\(m(x)\) が既約であることにより、0 以外のすべての元が逆元を持つ「体」になります。実装上は「\(x\) 倍 (xtime) = 左シフトして、あふれたら 0x1B と XOR」を基本演算とし、任意の乗算はシフトと XOR の組み合わせに分解できます。逆元は拡張ユークリッド互除法か、位数の性質 \(a^{255} = 1\) から

\[ a^{-1} = a^{254} \tag{6} \]

として 繰り返し二乗法 で計算できます。SubBytes の S-box (式 1) も MixColumns の行列乗算 (式 2) もすべてこの体の上の演算であり、後述のフルスクラッチ実装では S-box をテーブル定数ではなく式 (1), (6) から構成して、この構造を確かめます。

ブロック暗号モード:ECB の危険性から GCM まで

AES 単体は「16 byte を 16 byte に写す関数」にすぎません。実際のメッセージは 16 byte を超えるので、ブロックをどう連結するかを決める暗号利用モード (mode of operation) が必要です。モードの選択こそが実務上の安全性を左右します。

ECB — 使ってはいけないモード

ECB (Electronic CodeBook) は各ブロックを独立に暗号化します。

\[ C_i = E_K(P_i) \tag{7} \]

同じ平文ブロックが常に同じ暗号文ブロックになるため、データのパターンが暗号文に透けます。ペンギンの画像を ECB で暗号化するとシルエットが残る「ECB ペンギン」が有名な失敗例です。ECB は事実上いかなる用途にも推奨されません。

CBC — 連鎖による撹拌とパディング

CBC (Cipher Block Chaining) は前の暗号文ブロックと XOR してから暗号化します。

\[ C_i = E_K(P_i \oplus C_{i-1}), \quad C_0 = IV \tag{8} \]

初回は初期化ベクトル (IV) を使います。IV は秘密でなくてよいものの、予測不能(ランダム)でなければなりません。CBC はブロック単位でしか暗号化できないため、平文を 16 byte の倍数に揃える PKCS#7 パディング(不足 \(n\) byte を値 \(n\) で埋める)が必要で、このパディング検証の挙動差を突くパディングオラクル攻撃 (Vaudenay, 2002) が長年 TLS を苦しめました。TLS 1.3 で CBC 系スイートが全廃されたのはこのためです。

CTR — ブロック暗号をストリーム暗号化する

CTR (Counter) モードはカウンタ値を暗号化して鍵ストリームを作り、平文と XOR します。

\[ C_i = P_i \oplus E_K(\mathrm{nonce} \,\|\, i) \tag{9} \]

パディング不要・並列化可能・復号も同じ演算、と利点が多く、現代モードの基礎です。ただし式 (9) から明らかなように、同じ nonce とカウンタの組を 2 度使うと鍵ストリームが一致し、\(C_1 \oplus C_2 = P_1 \oplus P_2\) が漏れます(two-time pad 問題)。

GCM — 認証付き暗号 (AEAD)

CTR までのモードは機密性しか提供せず、暗号文を改竄されても検出できません。CBC/CTR の暗号文はビット反転攻撃で平文を狙って書き換えられます。そこで現代の標準は認証付き暗号 (AEAD: Authenticated Encryption with Associated Data) です。

GCM (Galois/Counter Mode) は CTR モードの暗号化に、\(\mathrm{GF}(2^{128})\) 上の多項式評価 GHASH による認証タグを組み合わせます。

\[ T = \mathrm{GHASH}_H(A, C) \oplus E_K(\mathrm{nonce} \,\|\, 0) \tag{10} \]

ここで \(H = E_K(0^{128})\) は認証用サブキー、\(A\) は暗号化しないが改竄検知したい関連データ (AAD)(ヘッダなど)です。復号側はタグを再計算して一致しなければ平文を一切返さずにエラーにします。

nonce の扱いが GCM 最大の急所です。GCM の nonce は 96 bit で、同じ鍵で同じ nonce を 2 度使うと、(a) CTR の鍵ストリーム再利用で平文の XOR が漏れ、さらに (b) GHASH のサブキー \(H\) が代数的に復元されて以後すべての認証タグが偽造可能になります (Joux の “forbidden attack”)。nonce はカウンタ管理(メッセージ番号)にするか、ランダム生成なら同一鍵での暗号化回数を \(2^{32}\) 回以下に抑えます。誤用耐性が必要なら AES-GCM-SIV という選択肢もあります。

nonce の再利用以外にも、GCM には誕生日限界 (birthday bound) に起因する構造的な弱さがあります。GHASH は \(\mathrm{GF}(2^{128})\) 上のブロック数に応じた次数を持つ多項式の評価なので、同一鍵で暗号化する総ブロック数が増えるほど偽造成功確率が二乗的に増加します。NIST SP 800-38D が 1 回の呼び出しあたりの暗号化データ長を \(2^{39} - 256\) bit(約 64 GB)に制限しているのはこのためです。この構造的な誕生日限界そのものを取り除く研究も進んでおり、2025 年の EUROCRYPT で発表された Chung ら (2025) の eGCM は、2n bit カウンタを用いた eCTR モードにより beyond-birthday-bound(誕生日限界を超えた)安全性を達成しつつ GCM と同等の効率を保つ拡張方式を提案しています。

nonce を 2 回使うと何が起きるか:forbidden attack を再現する

「\(H\) が代数的に復元される」という主張を、実際に手を動かして確認します。GHASH は次の漸化式で定義されます(\(X_1, \ldots, X_m\) は AAD ブロック・暗号文ブロック・長さブロックをこの順に並べたもの)。

\[ Y_0 = 0, \quad Y_i = (Y_{i-1} \oplus X_i) \cdot H \quad (i = 1, \ldots, m) \tag{11} \]

AAD なし・暗号文がちょうど 1 ブロック(16 byte)のケースは \(m = 2\) (暗号文ブロック \(C\) と長さブロック \(L\) )なので、式 (11) を展開すると

\[ \mathrm{GHASH}_H(\emptyset, C) = C \cdot H^2 \oplus L \cdot H \tag{12} \]

となります。同じ鍵・同じ nonceで 2 つの 1 ブロック平文 \(P_1, P_2\) (暗号文 \(C_1, C_2\) 、タグ \(T_1, T_2\) )を暗号化すると、両方とも同じマスク \(E_K(\mathrm{nonce} \,\|\, 0)\) と同じ長さブロック \(L\) (暗号文長がどちらも 128 bit のため)を使うので、2 つのタグの XOR を取るとこの 2 項が両方とも消え去ります。

\[ T_1 \oplus T_2 = (C_1 \oplus C_2) \cdot H^2 \tag{13} \]

\(C_1, C_2, T_1, T_2\) はすべて攻撃者が観測できる値なので、式 (13) から \(H^2 = (T_1 \oplus T_2) \cdot (C_1 \oplus C_2)^{-1}\) が計算できます(\(\mathrm{GF}(2^{128})\) の逆元はフェルマーの小定理 \(a^{-1} = a^{2^{128}-2}\) で計算)。あとは \(H^2\) から \(H\) を復元するだけです。\(\mathrm{GF}(2^{128})\) は標数 2 なので 2 乗写像 \(x \mapsto x^2\) はフロベニウス自己同型であり全単射、その逆写像は \(x \mapsto x^{2^{127}}\) です(乗法群の位数が \(2^{128}-1\) であることから \((x^2)^{2^{127}} = x^{2^{128}} = x\) が従います)。したがって

\[ H = (H^2)^{2^{127}} \tag{14} \]

で一意に復元できます。以下、GHASH をフルスクラッチ実装し、この手順を実際に検証します。まず cryptographyAESGCM が出す暗号文・タグと完全一致することを確認し(GF(2^128) の演算がビット反転表現であるため、乗法単位元は整数の 1 ではなく 1 << 127 である点に注意が必要です)、そのうえで nonce を使い回した 2 通のタグから式 (13), (14) だけで \(H\) を復元します。

import random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

random.seed(20260703)

R = 0xE1000000000000000000000000000000  # GCM の被約多項式 x^128+x^7+x^2+x+1 に対応する定数
ONE = 1 << 127  # 乗法単位元(GCM のビット反転表現では整数 1 ではない)


def aes_ecb_block(key, block16):
    """E_K(block): 生の AES 1 ブロック暗号化 (GCM 内部演算の構成要素として使用)"""
    enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
    return enc.update(block16) + enc.finalize()


def gf128_mul(x, y):
    """GF(2^128) 乗算 (GCM のビット反転基底)"""
    z, v = 0, y
    for i in range(128):
        if (x >> (127 - i)) & 1:
            z ^= v
        if v & 1:
            v = (v >> 1) ^ R
        else:
            v >>= 1
    return z


def gf128_pow(a, e):
    result, base = ONE, a
    while e:
        if e & 1:
            result = gf128_mul(result, base)
        base = gf128_mul(base, base)
        e >>= 1
    return result


def gf128_inv(a):
    return gf128_pow(a, (1 << 128) - 2)  # フェルマーの小定理: a^(2^128-2) == a^-1


def ghash(h, aad, ct):
    """式 (11) の漸化式をそのまま実装"""
    def blocks(b):
        return [b[i : i + 16].ljust(16, b"\x00") for i in range(0, len(b), 16)] if b else []

    y = 0
    for blk in blocks(aad) + blocks(ct):
        y = gf128_mul(y ^ int.from_bytes(blk, "big"), h)
    length_block = (len(aad) * 8).to_bytes(8, "big") + (len(ct) * 8).to_bytes(8, "big")
    return gf128_mul(y ^ int.from_bytes(length_block, "big"), h)


def gctr(key, icb, data):
    out, counter = bytearray(), icb
    for i in range(0, len(data), 16):
        ks = aes_ecb_block(key, (counter & ((1 << 128) - 1)).to_bytes(16, "big"))
        chunk = data[i : i + 16]
        out += bytes(a ^ b for a, b in zip(chunk, ks[: len(chunk)]))
        counter += 1
    return bytes(out)


def gcm_encrypt(key, nonce12, aad, plaintext):
    h = int.from_bytes(aes_ecb_block(key, b"\x00" * 16), "big")
    j0 = int.from_bytes(nonce12 + b"\x00\x00\x00\x01", "big")
    ct = gctr(key, j0 + 1, plaintext)
    s = ghash(h, aad, ct)
    mask = int.from_bytes(aes_ecb_block(key, j0.to_bytes(16, "big")), "big")
    return ct, (s ^ mask).to_bytes(16, "big")


# --- 検証1: フルスクラッチ実装が cryptography の AESGCM と一致するか ---
key, nonce, aad, pt = random.randbytes(32), random.randbytes(12), b"header:42", random.randbytes(37)
ct_scratch, tag_scratch = gcm_encrypt(key, nonce, aad, pt)
ct_lib = AESGCM(key).encrypt(nonce, pt, aad)
print("scratch ciphertext == library:", ct_scratch == ct_lib[:-16])
print("scratch tag        == library:", tag_scratch == ct_lib[-16:])
print(f"tag = {tag_scratch.hex()}")

# --- 検証2: forbidden attack (Joux, 2006) ---
# 同じ (鍵, nonce) を使い回して2つの異なる1ブロック平文を暗号化する
key2, reused_nonce = random.randbytes(16), random.randbytes(12)
p1, p2 = random.randbytes(16), random.randbytes(16)
c1, t1 = gcm_encrypt(key2, reused_nonce, b"", p1)
c2, t2 = gcm_encrypt(key2, reused_nonce, b"", p2)

C1, C2 = int.from_bytes(c1, "big"), int.from_bytes(c2, "big")
T1, T2 = int.from_bytes(t1, "big"), int.from_bytes(t2, "big")

# 式 (13): T1 xor T2 = (C1 xor C2) * H^2  -- 長さブロックとマスクが両方とも消える
h_sq = gf128_mul(T1 ^ T2, gf128_inv(C1 ^ C2))
# 式 (14): フロベニウス写像の逆写像 x -> x^(2^127) で平方根を取る
h_recovered = gf128_pow(h_sq, 1 << 127)
h_true = int.from_bytes(aes_ecb_block(key2, b"\x00" * 16), "big")

print()
print(f"nonce再利用から復元したH: {h_recovered:032x}")
print(f"真のH = AES_K(0^128)   : {h_true:032x}")
print("H の復元に成功:", h_recovered == h_true)

実行結果:

scratch ciphertext == library: True
scratch tag        == library: True
tag = 1e3e6f7ceab5595682da39a628b1bd97

nonce再利用から復元したH: c5a52976029faf95603b2f9b58e50994
真のH = AES_K(0^128)   : c5a52976029faf95603b2f9b58e50994
H の復元に成功: True

デバッグの過程で実際に踏んだ落とし穴も共有しておきます。当初 gf128_pow の単位元を(他の有限体の実装でよくあるように)整数の 1 としたところ、\(H\) の復元に失敗しました。原因は GCM の GHASH が使う \(\mathrm{GF}(2^{128})\) の表現がビット反転(reflected)基底であることで、この基底では定数多項式 \(1\) (\(x^0\) の係数のみ 1)は整数としては最上位ビットが立った 1 << 127 に対応します。単位元を正しく 1 << 127 に直したところ、h_true * h_true の平方根計算 (gf128_pow(h_squared, 1 << 127)) が真の \(H\) と一致するようになりました。GF(2^8) の実装(本記事前半の AES スクラッチ実装)とは異なる基底を GCM が採用していることは、GCM 実装のよくあるバグの温床です。

\(H\) さえ判明すれば、攻撃者はその鍵で暗号化された任意の過去・未来の暗号文に対して正しい認証タグを計算できてしまいます(式 12, 13 は \(H\) さえ分かれば誰でも計算可能な式だからです)。「nonce の再利用は一度きりの情報漏洩」ではなく「その鍵の認証機能そのものの永久な喪失」であることが、実装レベルで裏付けられました。

モード機密性完全性パディング並列化備考
ECB××必要使用禁止
CBC×必要復号のみパディングオラクルの温床
CTR×不要nonce 再利用で即崩壊
GCM不要現代の標準 (AEAD)

ChaCha20-Poly1305:ARX 構造のストリーム暗号

ChaCha20 は Bernstein の Salsa20 を改良したストリーム暗号で、Poly1305 MAC と組み合わせた ChaCha20-Poly1305 が RFC 8439 で標準化されています。TLS 1.3・SSH・WireGuard が採用し、AES-GCM と並ぶもう 1 つのデファクト AEAD です。

ARX 構造

ChaCha20 の内部状態は 32 bit ワード 16 個(512 bit)の 4×4 行列で、定数 ("expand 32-byte k")・256 bit 鍵・カウンタ・96 bit nonce で初期化されます。基本演算は ARX (Addition-Rotation-XOR) の 3 種類のみです。

\[ a \mathrel{+}= b;\quad d \mathrel{\oplus}= a;\quad d \lll 16 \tag{15} \]

これを 4 ワードに対して 4 回繰り返す quarter round を、列方向と対角方向に交互に計 20 ラウンド適用し、初期状態を加算したものが 64 byte の鍵ストリームブロックになります。AES の S-box のようなテーブル参照が存在しないため、キャッシュタイミング攻撃に対して構造的に定数時間です。

AES-GCM との使い分け

項目AES-256-GCMChaCha20-Poly1305
種別ブロック暗号 + GCM モードストリーム暗号 + Poly1305
基本演算S-box・GF(2^8) / GF(2^128)ARX (加算・回転・XOR)
鍵長 / nonce256 bit / 96 bit256 bit / 96 bit
AES-NI あり極めて高速 (~1 GB/s 超)高速
AES-NI なし低速 + テーブル参照でタイミング攻撃の懸念高速かつ定数時間
ハードウェアサーバ・PC 向きモバイル・組み込み・IoT 向き
標準化NIST SP 800-38DRFC 8439
採用例TLS, IPsec, ディスク暗号化TLS, SSH, WireGuard, Signal

要点は「AES-NI があれば AES-GCM、なければ ChaCha20-Poly1305」です。ソフトウェア実装の AES はテーブル参照のキャッシュ挙動が秘密情報に依存し得るのに対し、ChaCha20 は CPU の基本命令だけで完結します。Google が Android 向けに ChaCha20 系を推進した理由もここにあります。安全性の面では、どちらも 2026 年現在有効な攻撃は知られておらず、成熟度で並んでいます。

実測: モード別スループットベンチマーク

この指針を実測で確認します。cryptography 49.0.0 を使い、1 MB のデータを AES-256-GCM・ChaCha20-Poly1305・AES-256-CTR・AES-256-CBC・AES-256-ECB(認証なし、比較用の下限値として)でそれぞれ暗号化し、time.perf_counter() で 30 回平均を計測しました。

import random, time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

random.seed(20260703)
DATA_SIZE = 1_000_000  # 1 MB
N = 30

data = random.randbytes(DATA_SIZE)
key = random.randbytes(32)
nonce = random.randbytes(12)
iv16 = random.randbytes(16)


def pkcs7_pad(b, block=16):
    n = block - (len(b) % block)
    return b + bytes([n]) * n


def bench(fn, n=N):
    fn()  # ウォームアップ
    t0 = time.perf_counter()
    for _ in range(n):
        fn()
    dt_ms = (time.perf_counter() - t0) / n * 1000
    mb_per_s = (DATA_SIZE / (1024 * 1024)) / (dt_ms / 1000)
    return dt_ms, mb_per_s


aesgcm = AESGCM(key)
chacha = ChaCha20Poly1305(key)
padded = pkcs7_pad(data)

targets = {
    "AES-256-GCM": lambda: aesgcm.encrypt(nonce, data, None),
    "ChaCha20-Poly1305": lambda: chacha.encrypt(nonce, data, None),
    "AES-256-CTR": lambda: Cipher(algorithms.AES(key), modes.CTR(iv16)).encryptor().update(data),
    "AES-256-CBC": lambda: Cipher(algorithms.AES(key), modes.CBC(iv16)).encryptor().update(padded),
    "AES-256-ECB": lambda: Cipher(algorithms.AES(key), modes.ECB()).encryptor().update(padded),
}

for name, fn in targets.items():
    ms, mbs = bench(fn)
    print(f"{name:20s}: {ms:7.4f} ms/MB, {mbs:8.1f} MB/s")

実測結果(ハードウェア暗号支援 (ARMv8 Cryptography Extensions) 搭載環境、cryptography 49.0.0、30 回平均):

モードスループット [MB/s]AEAD (認証)
AES-256-ECB7317.3× (使用禁止)
AES-256-CTR6609.1×
AES-256-GCM6235.7
ChaCha20-Poly13051662.0
AES-256-CBC998.1×

対称鍵暗号 暗号化モードのスループット比較(1MB, cryptography 49.0.0, 30回平均)

読み取れることは 3 点です。

  1. このマシンはハードウェア暗号支援を持つため、AES-256-GCM は ChaCha20-Poly1305 の約 3.75 倍高速でした。「AES-NI (相当) があれば AES-GCM」という指針が実測で裏付けられます。逆にハードウェア支援のない環境(古い組み込みマイコンなど)では、ソフトウェア AES のテーブル参照コストのためこの差は縮む、あるいは逆転しえます。
  2. AES-256-CBC は同じ鍵長の AES-256-CTR の約 1/6.6 の速度でした。CBC は 1 ブロックごとに前段の暗号文に依存する逐次連鎖のため命令レベル並列性を活かせないのに対し、CTR は各ブロックのカウンタ値が独立に決まるため並列・パイプライン化できるという構造上の違いが、実測の速度差として裏付けられました。
  3. AES-256-ECB / CTR は生のスループットこそ最速ですが、認証を持たないため実運用では使えません(ECB はさらにパターン漏洩のため使用禁止)。「速いから」という理由だけでモードを選んではならないという本記事の主張を、数字が裏付けています。

Python 実装

1. 実用: cryptography ライブラリの AESGCM / ChaCha20Poly1305

本番コードでは cryptography.hazmat.primitives.ciphers.aead の高水準 AEAD API を使います。モード選択やパディングの余地がなく、誤用しにくい設計です。

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305

# --- AES-256-GCM ---
key = AESGCM.generate_key(bit_length=256)  # 内部は os.urandom
aesgcm = AESGCM(key)

nonce = os.urandom(12)  # 96 bit。同じ鍵で絶対に再利用しない
aad = b"header: message-id=42"  # 暗号化しないが改竄検知する関連データ

ciphertext = aesgcm.encrypt(nonce, b"secret message", aad)
plaintext = aesgcm.decrypt(nonce, ciphertext, aad)  # タグ不一致なら InvalidTag
assert plaintext == b"secret message"

# 暗号文はタグ 16 byte を含むため平文より 16 byte 長い
print(f"暗号文長: {len(ciphertext)} byte (平文 14 + タグ 16)")

# --- ChaCha20-Poly1305: API は完全に同型 ---
key2 = ChaCha20Poly1305.generate_key()  # 常に 256 bit
chacha = ChaCha20Poly1305(key2)
nonce2 = os.urandom(12)

ct2 = chacha.encrypt(nonce2, b"secret message", aad)
assert chacha.decrypt(nonce2, ct2, aad) == b"secret message"

# --- 改竄検知のデモ ---
from cryptography.exceptions import InvalidTag

tampered = bytes([ciphertext[0] ^ 0x01]) + ciphertext[1:]
try:
    aesgcm.decrypt(nonce, tampered, aad)
except InvalidTag:
    print("改竄を検知: 平文は一切返らない")

実務での約束事は 3 つです。

  1. 鍵は AESGCM.generate_key()os.urandom(32): random モジュールは疑似乱数なので鍵生成に使ってはいけません。
  2. nonce は 12 byte を毎回新規生成(またはカウンタ管理)。保存・送信は平文のままで構いませんが、同じ鍵での再利用だけは絶対に避けます
  3. 低水準 API (Cipher + modes.CBC など) は原則使わない: cryptography.hazmat.primitives.ciphersCipher で CBC/CTR を直接組むのは、パディングやタグ管理を自前で背負うことを意味します。AEAD API で足りるなら常にそちらを選びます。

なお nonce 再利用の危険は実感しておく価値があります。CTR 系である GCM / ChaCha20 は、同じ (鍵, nonce) で 2 つの平文を暗号化すると ct1 XOR ct2 == pt1 XOR pt2 が成立し、平文の構造が漏れます。

2. 教育用: AES-128 ラウンド関数のフルスクラッチ実装

学習用に、S-box をテーブル定数からではなく式 (1), (6) の定義通りに構成し、AES-128 の 1 ブロック暗号化を標準ライブラリのみで実装します。FIPS-197 Appendix C.1 の公式テストベクトルで検証済みです(このコードは教育用であり、サイドチャネル対策が皆無のため本番使用は厳禁)。

# 教育用: AES-128 のラウンド関数をフルスクラッチ実装 (FIPS-197 準拠)

# --- GF(2^8) 上の乗算: 既約多項式 x^8 + x^4 + x^3 + x + 1 (0x11B) ---
def gf_mul(a, b):
    """GF(2^8) の乗算: シフトと XOR の繰り返し (carry-less)"""
    r = 0
    for _ in range(8):
        if b & 1:
            r ^= a          # 係数が 1 なら足す (XOR)
        b >>= 1
        carry = a & 0x80
        a = (a << 1) & 0xFF
        if carry:
            a ^= 0x1B       # x^8 を x^4 + x^3 + x + 1 で置換
    return r


def gf_inv(a):
    """GF(2^8) の逆元: a^254 (式 6)。繰り返し二乗法で計算"""
    if a == 0:
        return 0
    result, base, exp = 1, a, 254
    while exp:
        if exp & 1:
            result = gf_mul(result, base)
        base = gf_mul(base, base)
        exp >>= 1
    return result


# --- S-box の構成: 逆元 + アフィン変換 (式 1) ---
def make_sbox():
    sbox = []
    for x in range(256):
        b, c = gf_inv(x), 0x63
        y = 0
        for i in range(8):
            bit = ((b >> i) ^ (b >> ((i + 4) % 8)) ^ (b >> ((i + 5) % 8))
                   ^ (b >> ((i + 6) % 8)) ^ (b >> ((i + 7) % 8)) ^ (c >> i)) & 1
            y |= bit << i
        sbox.append(y)
    return sbox


SBOX = make_sbox()
assert SBOX[0x00] == 0x63 and SBOX[0x53] == 0xED  # FIPS-197 の既知値


# --- 4 つのラウンド関数 (state は 16 byte のリスト、column-major) ---
def sub_bytes(state):
    """SubBytes: 各バイトを S-box で置換 (非線形層)"""
    return [SBOX[b] for b in state]


def shift_rows(state):
    """ShiftRows: 第 r 行を左に r バイト巡回シフト (行間の拡散)"""
    return [state[(i + 4 * (i % 4)) % 16] for i in range(16)]


def mix_columns(state):
    """MixColumns: 各列に式 (2) の MDS 行列を GF(2^8) 上で乗算"""
    out = []
    for c in range(4):
        col = state[4 * c:4 * c + 4]
        out += [
            gf_mul(col[0], 2) ^ gf_mul(col[1], 3) ^ col[2] ^ col[3],
            col[0] ^ gf_mul(col[1], 2) ^ gf_mul(col[2], 3) ^ col[3],
            col[0] ^ col[1] ^ gf_mul(col[2], 2) ^ gf_mul(col[3], 3),
            gf_mul(col[0], 3) ^ col[1] ^ col[2] ^ gf_mul(col[3], 2),
        ]
    return out


def add_round_key(state, rk):
    """AddRoundKey: ラウンド鍵と XOR (鍵の混入)"""
    return [s ^ k for s, k in zip(state, rk)]


# --- 鍵スケジュール (式 3): AES-128 は 10 ラウンド、11 個のラウンド鍵 ---
RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]


def key_expansion(key):
    words = [list(key[4 * i:4 * i + 4]) for i in range(4)]
    for i in range(4, 44):
        temp = list(words[i - 1])
        if i % 4 == 0:
            temp = temp[1:] + temp[:1]              # RotWord
            temp = [SBOX[b] for b in temp]          # SubWord
            temp[0] ^= RCON[i // 4 - 1]             # Rcon
        words.append([t ^ w for t, w in zip(temp, words[i - 4])])
    return [sum(words[4 * r:4 * r + 4], []) for r in range(11)]


def aes128_encrypt_block(plaintext, key):
    """AES-128 で 1 ブロック (16 byte) を暗号化"""
    round_keys = key_expansion(key)
    state = add_round_key(list(plaintext), round_keys[0])
    for r in range(1, 10):
        state = sub_bytes(state)
        state = shift_rows(state)
        state = mix_columns(state)
        state = add_round_key(state, round_keys[r])
    # 最終ラウンドは MixColumns なし
    state = sub_bytes(state)
    state = shift_rows(state)
    state = add_round_key(state, round_keys[10])
    return bytes(state)


# --- FIPS-197 Appendix C.1 のテストベクトルで検証 ---
key = bytes(range(16))                     # 000102...0e0f
pt = bytes.fromhex("00112233445566778899aabbccddeeff")
ct = aes128_encrypt_block(pt, key)
print(f"暗号文: {ct.hex()}")
assert ct.hex() == "69c4e0d86a7b0430d8cdb78070b4c55a"  # FIPS-197 の期待値
print("FIPS-197 テストベクトルと一致: 実装は正しい")

実行結果:

暗号文: 69c4e0d86a7b0430d8cdb78070b4c55a
FIPS-197 テストベクトルと一致: 実装は正しい

読みどころは 3 点です。

  1. S-box が定義から導出できる: make_sbox() は式 (1) のアフィン変換と式 (6) の逆元計算だけで、FIPS-197 掲載のテーブルと完全一致するテーブルを生成します。S-box は「魔法の定数」ではなく GF(2^8) の構造そのものです。
  2. gf_inv繰り返し二乗法 : \(a^{254}\) の計算は RSA のべき乗剰余や ECC のスカラー倍算 と同じ「指数の 2 進展開」で動きます。暗号全域を貫く共通アルゴリズムです。
  3. この実装がなぜ本番不可か: SBOX[b] のテーブル参照はアクセス位置が秘密情報に依存し、キャッシュタイミング攻撃 (Bernstein, 2005) の標的になります。AES-NI やビットスライス実装が使われるのは、まさにこの問題を回避するためです。

鍵長とセキュリティレベル比較

対称鍵の鍵長 \(k\) bit に対する総当たり攻撃は \(2^k\) 回の試行を要し、この \(k\) がセキュリティレベルの基準になります。公開鍵暗号は数学的攻撃(数体篩・Pollard rho)があるため、同レベルに達するにはずっと長い鍵が必要です( ECC 記事の鍵長比較 も参照)。

セキュリティレベル対称鍵RSA / DHECC位置づけ
112 bit3DES (廃止)2048 bit224 bit現行最低ライン
128 bitAES-1283072 bit256 bit現代の標準
192 bitAES-1927680 bit384 bit高セキュリティ
256 bitAES-25615360 bit512 bit長期・政府機密

量子計算機の影響も対称鍵と公開鍵で非対称です。RSA / ECC は Shor のアルゴリズムで多項式時間で破られるのに対し、対称鍵への量子攻撃は Grover のアルゴリズムによる平方根加速のみで、実効セキュリティが半分になるにとどまります(AES-128 → 実効 64 bit、AES-256 → 実効 128 bit)。**「量子時代に備えるなら AES-256」**と言われるのはこのためで、対称鍵暗号は耐量子暗号 (PQC) 移行後もそのまま使われ続けます。RSA / ECC が置き換えられる先である格子ベースのLearning With Errors(LWE)問題がなぜShorのアルゴリズムに耐えると考えられているかは、 耐量子暗号入門の記事 でRegev暗号のフルスクラッチ実装とともに詳しく解説しています。

AES-192 が実務でほぼ見られないのは、多くのプロトコル (TLS など) が 128 / 256 の 2 択しか定義していないためです。また AES-256 の関連鍵攻撃 (2009) は理論的興味に留まり、実用上の脅威にはなっていません。

まとめ

  • 対称鍵暗号はハイブリッド構成の本体側: DH / RSA / ECC が確立した共有鍵で、AES / ChaCha20 が大量データを高速に暗号化する。
  • AES は SubBytes / ShiftRows / MixColumns / AddRoundKey の 4 変換を 10〜14 ラウンド繰り返す SPN 構造で、すべての演算が GF(2^8)(既約多項式 \(x^8 + x^4 + x^3 + x + 1\) )の上で定義される。S-box は逆元 \(a^{254}\) とアフィン変換から構成できる。
  • モードの選択が実務の安全性を決める: ECB は使用禁止、CBC はパディングオラクル、CTR / GCM は nonce 再利用が即致命傷。現代の標準は AEAD の AES-GCMChaCha20-Poly1305。GCM の nonce 再利用が「サブキー \(H\) の代数的復元」に直結することを、GHASH をフルスクラッチ実装して実際に検証した(forbidden attack の再現)。GCM にはさらに誕生日限界に起因する構造的制約もあり、これを取り除く eGCM (Chung et al., 2025) のような研究も進んでいる。
  • ChaCha20 は ARX 構造でテーブル参照がなく、AES-NI のない環境では AES より速くかつ定数時間。「AES-NI があれば AES-GCM、なければ ChaCha20-Poly1305」という指針は、実測ベンチマーク(AES-256-GCM が ChaCha20-Poly1305 の約 3.75 倍高速)でも裏付けられた。
  • Python では cryptography.hazmat.primitives.ciphers.aeadAESGCM / ChaCha20Poly1305 を使う。鍵は generate_key()、nonce は 12 byte を毎回新規生成。低水準 Cipher API の自前組み立ては避ける。
  • セキュリティレベルは AES-128 ≈ RSA-3072 ≈ ECC-256。量子攻撃 (Grover) に対しては鍵長効果が半減するため、長期用途は AES-256 を選ぶ。

関連記事

関連ツール

参考文献

  • NIST (2001). “Advanced Encryption Standard (AES)”. FIPS PUB 197.
  • Daemen, J., & Rijmen, V. (2002). The Design of Rijndael: AES — The Advanced Encryption Standard. Springer.
  • Dworkin, M. (2007). “Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC”. NIST SP 800-38D.
  • Nir, Y., & Langley, A. (2018). “ChaCha20 and Poly1305 for IETF Protocols”. RFC 8439.
  • Bernstein, D. J. (2005). “Cache-timing attacks on AES”.
  • Joux, A. (2006). “Authentication Failures in NIST version of GCM”. Comments on NIST SP 800-38D draft.
  • Vaudenay, S. (2002). “Security Flaws Induced by CBC Padding — Applications to SSL, IPSEC, WTLS…”. EUROCRYPT 2002.
  • Chung, W., Hwang, S., Kim, S., Lee, B., & Lee, J. (2025). “Making GCM Great Again: Toward Full Security and Longer Nonces”. EUROCRYPT 2025 (IACR ePrint 2025/577).