AES-GCM を正しく使う:ECB・nonce 再利用・タグ検証の落とし穴
セキュリティ基礎講座5。ECB で絵が透ける、nonce 再利用で中身が読める、PHP が1バイトのタグを通した実例、openssl enc の罠、PBKDF2・scrypt・Argon2id の鍵導出時間まで、すべて手元で実行して確認します。
シリーズ:セキュリティ基礎講座 5/6(上級)。 顧客の API キー、トークン、個人データの列、バックアップファイルなどを暗号化して保存する担当になった人向けです。「動く呼び出し」ではなく「安全な呼び出し」がどれかを知りたい人のための回です。読み終えると、ECB、nonce の再利用、認証なしの CBC/CTR を自分のマシンで実際に破った経験と、Python と PHP の両方で開ける「パスワード → 鍵 → AES-GCM」の小さな型が手に入ります。
暗号化は、セキュリティの中でも「動くのに間違っている」コードが最も多い分野です。以下の壊れた例はすべて、自分のデータで試すと正しく復号できます。問題が出るのは、誰かが1バイト書き換えたとき、暗号文を2つ見たとき、短いタグを送ってきたときだけです。
日本の現場では、個人情報保護法の「漏えい等の報告」の話と結びつけて考えるとわかりやすいでしょう。一般論として、漏えいしたデータが適切に暗号化されていたかどうかは、事故後の扱いに大きく影響すると広く知られています。ただし「暗号化した」と言えるのは、鍵が一緒に漏れておらず、方式も正しく使われている場合だけです。ECB で模様が透けるデータや、鍵がソースコードに直書きされたデータを「暗号化済み」と呼ぶのは無理があります(具体的な判断は公式ガイドラインと専門家に確認してください)。
実行環境は Apple Silicon Mac(M1 Pro)、PHP 8.5.6(OpenSSL 3.6.2・libsodium 1.0.22 とリンク)、OpenSSL 4.1.0 CLI、macOS 標準の Python 3.9.6、Java 26 です。鍵・メッセージ・「攻撃」はすべて手元で作ったデモ用データで、パスは ~/demo/ に置き換えています。
前の回を飛ばした人へ:第1回 でハッシュ・MAC・暗号化を区別し、第4回 で MAC をトークンの署名に使いました。今回はその両方、つまり秘匿性(読めない)と完全性(気づかれずに変えられない)が必要になります。
手元の道具で何が使えるか
コードを書く前に、何が実際にあるかを確認します。下の表は1行ずつ試した結果です。
| 道具(今回の版) | AES-GCM | 他の AEAD | パスワード用 KDF |
|---|---|---|---|
| Python 3.9.6 標準ライブラリ | なし(AES 自体がない) | なし | hashlib.pbkdf2_hmac のみ。hashlib.scrypt がない |
Python + cryptography 48.0.0(外部パッケージ) |
AESGCM |
ChaCha20-Poly1305 | Scrypt、PBKDF2 |
PHP 8.5.6 openssl_* |
aes-256-gcm |
aes-256-gcm-siv が一覧にある |
hash_pbkdf2 |
PHP 8.5.6 sodium_* |
sodium_crypto_aead_aes256gcm_*(利用可:true) |
XChaCha20-Poly1305 | Argon2id、scrypt |
Java 26 javax.crypto |
AES/GCM/NoPadding |
ChaCha20-Poly1305 | PBKDF2 |
OpenSSL 4.1.0 CLI enc |
拒否される | 拒否される | 既定は旧方式、-pbkdf2 は任意 |
驚いたのは2点です。macOS 標準の Python は LibreSSL 2.8.3 とリンクされていて、hashlib.scrypt がそもそも存在しません。
$ python3 -c "import hashlib; hashlib.scrypt(b'pw', salt=b's'*16, n=2**14, r=8, p=1)"
AttributeError: module 'hashlib' has no attribute 'scrypt'
python.org 版や Homebrew 版のように OpenSSL とリンクされた Python にはあるので、「自分の Mac では動いた」コードが同僚の環境で落ちることがあります。もう1点の CLI は後で扱います。この記事の Python の AES コードは cryptography パッケージを使っています。このマシンにはたまたま入っていましたが、標準ライブラリではありません。プロジェクトでは requirements に明記します。
ECB:データの「絵」が透けるモード
ECB(Electronic Codebook)は16バイトのブロックをそれぞれ同じ鍵で独立に暗号化します。同じ平文ブロックは同じ暗号文ブロックになります。これを見えるようにするため、8×8 の「アイコン」を作りました。1マスが16バイトのブロックで、0xFF(黒)か 0x00(白)です。暗号文の中で異なるブロックがいくつあるかを数えます。
<?php
// ~/demo/ecb.php — 繰り返しのあるデータで ECB / CBC / GCM を比べる
$key = hex2bin('000102030405060708090a0b0c0d0e0f'); // デモ専用の固定鍵
$icon = ["..####..", ".#....#.", "#.#..#.#", "#......#",
"#.#..#.#", "#..##..#", ".#....#.", "..####.."];
$pt = '';
foreach ($icon as $row) foreach (str_split($row) as $c)
$pt .= str_repeat($c === '#' ? "\xFF" : "\x00", 16);
function show(string $name, string $ct): void {
$blocks = str_split(substr($ct, 0, 1024), 16);
$uniq = array_values(array_unique($blocks));
echo "$name: unique ciphertext blocks = ", count($uniq), " / 64\n";
if (count($uniq) <= 4) foreach (array_chunk($blocks, 8) as $row) {
echo " ";
foreach ($row as $b) echo chr(65 + array_search($b, $uniq));
echo "\n";
}
}
$raw = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING;
show('AES-128-ECB', openssl_encrypt($pt, 'aes-128-ecb', $key, $raw));
show('AES-128-CBC', openssl_encrypt($pt, 'aes-128-cbc', $key, $raw, random_bytes(16)));
show('AES-128-GCM', openssl_encrypt($pt, 'aes-128-gcm', $key, OPENSSL_RAW_DATA, random_bytes(12), $tag));
AES-128-ECB: unique ciphertext blocks = 2 / 64
AABBBBAA
ABAAAABA
BABAABAB
BAAAAAAB
BABAABAB
BAABBAAB
ABAAAABA
AABBBBAA
AES-128-CBC: unique ciphertext blocks = 64 / 64
AES-128-GCM: unique ciphertext blocks = 64 / 64
鍵がなくても、ECB の暗号文からニコちゃんマークがそのまま出てきます。異なるブロックは2種類しかなく、その並びが絵そのものです。実データでも同じです。16バイトの「ステータス」欄、固定長レコード、パディングした会員番号を ECB で暗号化すると、どの行が同じ値か、各値がどれくらいの頻度かが透けます。
ECB はうっかりでも使ってしまいます。PHP で IV なしに openssl_encrypt($data, 'aes-256-cbc', $key) と書くと、Warning: openssl_encrypt(): Using an empty Initialization Vector (iv) is potentially insecure and not recommended と警告を出したうえで全部0の IV で暗号化します。1ブロックのメッセージでは ECB とまったく同じ暗号文 sHQyEV+uqAy8hauXYqiQGw== になりました。
nonce の再利用:暗号文2つで XOR 1回
GCM は CTR モードに認証タグを足したものです。CTR は鍵と nonce からキーストリームを作り、平文と XOR します。同じ鍵と nonce を2回使うとキーストリームが同じになり、打ち消し合います。
c1 ⊕ c2 = (m1 ⊕ k) ⊕ (m2 ⊕ k) = m1 ⊕ m2
攻撃者に鍵は要りません。推測できる平文が1つあれば十分です。ここでは1通目を社内ポータルに掲示されるメンテナンスのお知らせ、2通目を人事評価のメッセージにしました。
<?php
// ~/demo/nonce.php — GCM の nonce を使い回すと何が漏れるか
$m1 = 'お知らせ:本日22時からメンテナンスです'; // 誰でも読める公開文
$m2 = '社員番号1234の評価はA、昇給5%'; // 秘密
$key = random_bytes(32);
$nonce = random_bytes(12); // バグ:下で同じ nonce を2回使う
$c1 = openssl_encrypt($m1, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t1);
$c2 = openssl_encrypt($m2, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t2);
$n = min(strlen($c1), strlen($c2));
$xc = substr($c1, 0, $n) ^ substr($c2, 0, $n);
echo "c1 xor c2 == m1 xor m2 ? ", var_export($xc === (substr($m1, 0, $n) ^ substr($m2, 0, $n)), true), "\n";
echo "recovered m2 prefix: ", $xc ^ substr($m1, 0, $n), "\n"; // 鍵は使っていない
c1 xor c2 == m1 xor m2 ? true
recovered m2 prefix: 社員番号1234の評価はA、昇給5%
お知らせ文は UTF-8 で56バイト、秘密のメッセージは40バイトなので、秘密側は全部復元できました。XOR は文字単位ではなくバイト単位で効きます(文字がバイトになる仕組み)。実行はしていませんが、もっと悪い帰結もあります。同じ nonce のメッセージが2つあると、攻撃者は GCM 内部の認証用の鍵を求められ、好きなメッセージに正しいタグを付けて偽造できるようになります。
実際のコードで nonce が再利用されるのは、「とりあえず」の定数 nonce、再起動で0に戻るメモリ上のカウンタ、2台のサーバーがそれぞれ1から数えるカウンタ、時刻から作った nonce などです。Java はこのうち1つのケースだけを防ぎます。
first: 19 bytes
java.security.InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption
new Cipher, same key+iv: accepted, 19 bytes
同じ Cipher オブジェクトは同じ IV での再暗号化を拒否しましたが、新しい Cipher オブジェクトは受け付けました。オブジェクト・プロセス・サーバーをまたいだ再利用はライブラリには防げません。ルールは「メッセージごとにランダムな12バイトの nonce を作り、暗号文の隣に保存する」です。ランダムな96ビット nonce では、1つの鍵で暗号化するメッセージ数を 2^32 より十分少なく保ちます(NIST の GCM の指針)。それより多いか、乱数の質に自信がなければ、24バイト nonce の XChaCha20-Poly1305 を使います。
認証のない暗号化:100 が 900 になる
暗号化はデータを隠しますが、書き換えは止めません。CTR モードでは暗号文の1ビットを反転すると平文の同じ位置の1ビットが反転します。フォーマットが決まっていれば amount=100 がどこにあるかは攻撃者にもわかります。
<?php
// ~/demo/tamper.php — 同じ1バイトの書き換えを CTR と GCM に
$m = 'to=tanaka&amount=100&cur=JPY';
$key = random_bytes(32);
$pos = strpos($m, 'amount=') + 7; // 100 の '1'
$flip = ord('1') ^ ord('9');
$iv = random_bytes(16);
$c = openssl_encrypt($m, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv);
$c[$pos] = chr(ord($c[$pos]) ^ $flip); // 鍵なしで1バイト書き換え
echo "CTR decrypts to: ", openssl_decrypt($c, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv), "\n";
$n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$c[$pos] = chr(ord($c[$pos]) ^ $flip);
echo "GCM decrypt: "; var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag));
CTR decrypts to: to=tanaka&amount=900&cur=JPY
GCM decrypt: bool(false)
CTR は見た目に何の問題もない「900円の振込」を返しました。GCM は16バイトのタグが合わなくなったので拒否しました。これが「認証付き暗号(AEAD)」の意味です。復号は元の平文を返すか、失敗するかのどちらかです。
CBC も安全ではありません。33バイトのファイル memo: thanks!!!!to=alice;pay=100 を OpenSSL CLI で暗号化し、最初の暗号文ブロックの1バイトを反転してから復号しました。
$ openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass pass:demo -in t2.bin | xxd
00000000: 7fbf 389b 36ff 0355 7316 78ee 6aaf d17d ..8.6..Us.x.j..}
00000010: 746f 3d61 6c69 6365 3b70 6179 3d39 3030 to=alice;pay=900
00000020: 0a .
exit=0
1ブロック目は壊れ、2ブロック目は pay=900 になり、終了コードは0です。終了コードしか見ないプログラムはこれを受け入れます。パディングも助けになりません。CBC のファイルに間違ったパスワードを2,000個試したところ、8個 が bad decrypt を出さずに「復号」できてしまいました(期待値は 2000/256 ≈ 7.8)。中身はパディングがたまたま正しく見えるゴミです。
PHP が1バイトのタグを通した
GCM のタグは通常16バイトです。改ざんのデモ中に復号側へ短いタグを渡してみたところ、PHP 8.5.6 は1バイトまですべて受け付けました。
tag len 16: ACCEPTED
tag len 12: ACCEPTED
tag len 8: ACCEPTED
tag len 4: ACCEPTED
tag len 1: ACCEPTED
リクエストや保存データからタグを取り出してそのまま openssl_decrypt に渡すコードだと、攻撃者は1バイトのタグを送れます。すると偽造は最大256回の試行で済みます。
<?php
// ~/demo/shorttag.php — タグ長を信用する受信側に対して GCM メッセージを偽造する
$m = 'to=tanaka&amount=100&cur=JPY';
$key = random_bytes(32); $n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$pos = strpos($m, 'amount=') + 7;
$c[$pos] = chr(ord($c[$pos]) ^ (ord('1') ^ ord('9'))); // 改ざん済み、本物のタグは不明
for ($b = 0; $b < 256; $b++) {
$r = openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, chr($b));
if ($r !== false) { echo "forged after ", $b + 1, " tries: $r\n"; break; }
}
function gcm_open(string $c, string $key, string $n, string $tag): string|false {
if (strlen($key) !== 32 || strlen($n) !== 12 || strlen($tag) !== 16) return false; // 対策
return openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
}
forged after 51 tries: to=tanaka&amount=900&cur=JPY
forged after 248 tries: to=tanaka&amount=900&cur=JPY
forged after 178 tries: to=tanaka&amount=900&cur=JPY
鍵と nonce は毎回ランダムなので回数は変わります(平均は約128回)。長さの確認を入れると、同じ1バイトのタグは false になります。空のタグは確認なしでも拒否されました。Java はもっと厳格で、new GCMParameterSpec(32, iv) は Unsupported TLen value. Must be one of {128, 120, 112, 104, 96} で失敗しました。libsodium にはタグ長の引数自体がありません。
PHP の鍵の扱いも同じように黙っています。aes-256-cbc で鍵 "k" を渡すと、"k" の後ろに NUL を31バイト付けた鍵と同じ暗号文になり、40バイトの鍵は先頭32バイトと同じ結果でした。どちらも警告なしです。つまり openssl_encrypt($data, 'aes-256-cbc', 'password123', …) は、パスワードを0で埋めてそのまま AES の鍵として使っています。鍵導出はまったく行われていません。
完成形:パスワード → 鍵 → AES-GCM
鍵をパスワードから作る場合、暗号文の他に3つを保存値に入れます。KDF 用のランダムなソルト、ランダムな nonce、後でパラメータを変えられるようにする版の接頭辞です。版は関連データ(AAD)としても渡します。AAD は暗号化されませんが、改ざんは検出されます。
# ~/demo/seal.py — PBKDF2 は標準ライブラリ、AES-256-GCM は "cryptography" パッケージ
import base64, hashlib, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
def seal(password: str, plaintext: str, aad: bytes = b"v1") -> str:
salt, nonce = os.urandom(16), os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), aad) # 暗号文 || 16バイトのタグ
return base64.b64encode(b"v1" + salt + nonce + ct).decode()
def open_(password: str, token: str, aad: bytes = b"v1") -> str:
raw = base64.b64decode(token)
if raw[:2] != b"v1" or len(raw) < 2 + 16 + 12 + 16:
raise ValueError("bad format")
salt, nonce, ct = raw[2:18], raw[18:30], raw[30:]
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
return AESGCM(key).decrypt(nonce, ct, aad).decode()
tok = seal("correct horse", "ステージングのAPIキー: demo-123")
print(len(tok), tok[:24] + "...")
print(open_("correct horse", tok))
try:
open_("wrong horse", tok)
except InvalidTag:
print("wrong password -> InvalidTag")
116 djHXZd2lwbAipouKedGSixdO...
ステージングのAPIキー: demo-123
wrong password -> InvalidTag
間違ったパスワードは毎回 InvalidTag になります。CBC の「2,000回に8回は通ってしまう」とは違います。この形式は言語をまたげます。Python で封をした値を、PHP は hash_pbkdf2 と openssl_decrypt だけで開けました。タグは末尾16バイトを切り出して渡します。
<?php
$raw = base64_decode(trim(file_get_contents($argv[1])), true);
$salt = substr($raw, 2, 16); $nonce = substr($raw, 18, 12);
$tag = substr($raw, -16); $ct = substr($raw, 30, -16);
$key = hash_pbkdf2('sha256', 'correct horse', $salt, 600000, 32, true);
var_dump(openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, 'v1'));
// string(12) "interop test"
KDF はどのくらい重くすべきでしょうか。役割は パスワードのハッシュ化(第2回) と同じで、パスワードの推測1回ごとに確かな時間を払わせることです。固定のパスワードとソルトで、各設定を5回ずつ測った中央値です。
| KDF と設定 | 中央値 |
|---|---|
| PBKDF2-SHA256、1万回(Python) | 3.0 ms |
| PBKDF2-SHA256、10万回(Python) | 30.5 ms |
| PBKDF2-SHA256、60万回(Python) | 181.6 ms |
PBKDF2-SHA256、60万回(PHP hash_pbkdf2) |
793.6 ms |
| scrypt、libsodium の interactive(16 MiB) | 33.9 ms |
scrypt N=2^17、r=8(OpenSSL CLI の kdf、起動時間込み) |
0.30 s |
| Argon2id、libsodium の interactive(2パス、64 MiB) | 62.9 ms |
| Argon2id、libsodium の moderate(3パス、256 MiB) | 425.7 ms |
Python と PHP の PBKDF2 は同じ鍵(96a5904c2e08c8da…)を出しましたが、PHP は約4.4倍の時間がかかりました。片方の言語で反復回数を決め、もう片方で検証する構成では効いてきます。Argon2id と scrypt はメモリも消費するので GPU での総当たりがずっと高くつき、使えるなら PBKDF2 より良い選択です。PBKDF2 の60万回は、それしか使えない場合(この Mac の Python 標準ライブラリのように)の SHA-256 向けの OWASP の推奨値です。
そもそもパスワードが要らないなら(サーバー側のデータでは普通こちら)、KDF は不要です。random_bytes(32) か secrets.token_bytes(32) で32バイトの鍵を一度だけ作り、データベースの外(環境変数やシークレット管理)に置いてそのまま使います。ランダムな鍵に引き延ばしは要りません。
コマンドラインの openssl enc
バックアップの暗号化には OpenSSL CLI がよく使われるので、4.1.0 が実際に何をするか確認しておきます。
$ openssl enc -aes-256-gcm -pass pass:demo -in msg.txt -out /dev/null
enc: AEAD ciphers not supported
enc: Use -help for summary.
exit=1
$ openssl enc -aes-256-cbc -pass pass:demo -in msg.txt -out c1.bin
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
exit=0
encは GCM を拒否します。CLI で使えるのは認証のないモードだけで、上の改ざんはこのコマンドが作るすべてのファイルに当てはまります。-pbkdf2も-iterも付けないと、既定は今でも古い1回きりの鍵導出(EVP_BytesToKey)です。警告が出るだけで終了コードは0です。今も既定なのか自信がなかったので 4.1.0 で確認しましたが、既定のままでした。-pbkdf2を付けた場合、openssl enc -helpによると反復回数の既定は10000です。上の実測では3.0 ms でした。-iter 600000以上を指定します。-pbkdf2で暗号化したファイルを付けずに復号するとbad decryptで失敗します。オプションはファイルに記録されず、入っているのはSalted__と8バイトのソルトだけです。バックアップの隣に設定をメモしておきます。-pass pass:…はパスワードがシェル履歴とプロセス一覧に残ります。-pass file:か-pass env:を使います。
暗号化バックアップには、認証付き暗号を使うツールの方が向いています(age や gpg がよく使われますが、今回は実行していません)。どうしても openssl enc を使うなら、ファイル全体に別途 HMAC を付け、復号の前に検証します。
よくある間違いと直し方
| 間違い | 実際に起きたこと | 直し方 |
|---|---|---|
| AES-ECB、または IV なしの CBC | アイコンが残った。64ブロック中2種類 | ランダム nonce の AEAD モード |
| 固定やカウンタの nonce を再利用 | 公開文1つから秘密40バイトを全部復元 | メッセージごとにランダムな12バイト nonce、または XChaCha20-Poly1305 |
| MAC なしの CTR/CBC | amount=900、pay=900 が通り終了コード0 |
GCM か ChaCha20-Poly1305。復号結果を必ず確認 |
| タグ長を入力から取る | 1バイトのタグが通り、256回以内に偽造 | タグ16バイト・nonce 12バイトを必須に |
| パスワードをそのまま鍵に | PHP が "k" を黙って NUL で埋めた |
ソルト付き KDF、またはランダムな32バイト鍵 |
openssl enc の既定値 |
旧 KDF、PBKDF2 は1万回、完全性なし | -pbkdf2 -iter 600000 と MAC、または AEAD のツール |
復号の false を無視 |
false や空文字列のまま処理が続く |
失敗はエラーとして扱い、元データに「戻さない」 |
どの方式を選ぶか
| 状況 | 使うもの | 理由 |
|---|---|---|
| PHP で libsodium が使える | sodium_crypto_aead_xchacha20poly1305_ietf_* |
サイズ固定、24バイトのランダム nonce、モードを選ばなくてよい |
| AES が必須(規程、相互運用) | AES-256-GCM、ランダム12バイト nonce、16バイトのタグを確認 | 標準の AEAD、ハードウェア支援あり |
| 1つの鍵で非常に多くのメッセージ、乱数が弱い | XChaCha20-Poly1305 または AES-GCM-SIV | ランダム nonce で安全、または誤用に強い |
| 人間のパスワードから鍵を作る | Argon2id で鍵を作り、AEAD | 推測1回ごとに時間とメモリがかかる |
| サーバー側の秘密鍵 | random_bytes(32) を DB の外に保管 |
KDF は不要 |
| CLI でファイルを暗号化 | AEAD のツール、または enc と別の HMAC |
enc は AEAD ができない |
libsodium なら PHP 側は2つの呼び出しで済み、関連データも GCM と同じように使えます。
<?php
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen(); // ランダムな32バイト
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
$c = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('to=tanaka&amount=100&cur=JPY', 'order:42', $nonce, $key);
var_dump(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($c, 'order:43', $nonce, $key)); // bool(false)
28バイトのメッセージは44バイト(28 + タグ16バイト)になり、AAD を order:42 ではなく order:43 にして復号すると false が返りました。
正直な限界・バージョンについて
- 版:PHP 8.5.6(OpenSSL 3.6.2、libsodium 1.0.22)、OpenSSL CLI 4.1.0、Python 3.9.6(LibreSSL 2.8.3)と
cryptography48.0.0、Java 26。1バイトのタグが通ったのはこの PHP 8.5.6 での結果で、他の版は試していません。自分の環境では上のループで確認してください。 - nonce 再利用後の GCM 認証鍵の復元は説明のみで、実行していません。XOR による復元は実行しました。
- KDF の時間は1台の M1 Pro での5回の中央値で、CLI の scrypt は起動時間込みです。本番サーバーでは値が変わるので、そこで測り、ログイン処理が払えるコストを狙います。
- この Mac では
sodium_crypto_aead_aes256gcm_is_available()がtrueでしたが、CPU によってはfalseになります。XChaCha20-Poly1305 にはこの依存がありません。 - Swift の CryptoKit と .NET の
AesGcmは今回実行していません。 - これは鍵管理の設計全体ではありません。鍵のローテーション、鍵自体のバックアップ、アクセス制御は別の作業です。
演習
1. 同僚が顧客メモを openssl_encrypt($note, 'aes-256-ctr', $key, 0, $iv) で保存しています。$iv は設定ファイルにある16バイトの定数です。問題を2つ挙げ、データベースを読み書きできる攻撃者がそれぞれで何をできるか答えてください。
2. 上の Python の seal() が作った値を PHP で受け取ります。openssl_decrypt を呼ぶ前に行う確認を順番に書いてください。
解答
1. (a) IV が固定なので、すべてのメモが同じキーストリームで暗号化されます。2件を読めばその XOR が得られ、既知または推測しやすいメモ(「お客様より電話:…」のような定型文)が1件あれば他のメモが読めます。nonce のデモと同じです。(b) CTR には完全性がありません。書き込みできる攻撃者は既知の位置のバイトを反転し、100 → 900 のように変えられ、復号は成功します。直し方:AES-256-GCM か XChaCha20-Poly1305 を使い、メモごとに新しいランダム nonce を作って一緒に保存し、復号の false はエラーとして扱います。
2. 厳格モードで Base64 デコードし、false なら失敗。v1 の接頭辞を確認。全体の長さが 2 + 16 + 12 + 16 以上か確認。ソルト(16)、nonce(12)、タグ(末尾16)、暗号文に分割。タグがちょうど16バイト、nonce が12バイトであることを確認。同じ KDF とパラメータで鍵を導出。AAD に v1 を渡す。結果が false でないときだけ平文を使う。
次へ
- 前回:セキュリティ基礎講座 4/6:HS256 JWT の署名の落とし穴
- 次回:セキュリティ基礎講座 6/6:タイミング攻撃と定数時間比較
- シリーズの前半:ハッシュ・暗号化・エンコードの違い、bcrypt と Argon2 によるパスワードハッシュ、安全な乱数・トークン・シークレット
- 保存時ではなく通信の暗号化:HTTPS・TLS・証明書
- PHP の Web セキュリティのもう半分:PHP の XSS・CSRF・セッション
- すべての講座:プログラミング学習ロードマップ
よくある質問
鍵が秘密なら AES-GCM の nonce を使い回してもいいですか?
いけません。同じ鍵と nonce ではキーストリームが同じになり、2つの暗号文の XOR が2つの平文の XOR と一致します。今回の実行では、公開済みのお知らせ文1つを知っているだけで、鍵なしでもう一方の社内メッセージが全部読めました。さらにタグの偽造も可能になります。メッセージごとにランダムな12バイトの nonce を使うか、24バイト nonce の XChaCha20-Poly1305 を使います。
openssl_decrypt が改ざんされた AES-GCM のデータを通したのはなぜですか?
PHP 8.5.6 の openssl_decrypt は復号時にどんな長さのタグでも受け付け、1バイトでも通りました。amount=100 を amount=900 に書き換え、1バイトのタグを256通り試すと、51〜248回目で通りました。openssl_decrypt を呼ぶ前に、タグがちょうど16バイト、nonce がちょうど12バイトかを自分で確認するか、サイズが固定の libsodium を使います。
ファイルの暗号化に openssl enc -aes-256-cbc を使っても大丈夫ですか?
既定のままでは不十分です。OpenSSL 4.1.0 でも -pbkdf2 を付けないと古い鍵導出が使われ、警告が出るだけで終了コードは0です。-pbkdf2 の既定反復回数は1万回です。CBC には完全性の保証がなく、暗号化ファイルの pay=100 を pay=900 に書き換えても復号は0で終わりました。enc は GCM などの AEAD を受け付けません。