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

左から右への流れ図。パスワードとランダムな16バイトのソルトを PBKDF2 または Argon2id に入れると32バイトの鍵が出る。鍵、ランダムな12バイトの nonce、関連データとしての版文字列 v1 を AES-256-GCM の暗号化に渡す。保存する値は v1、ソルト、nonce、暗号文、16バイトのタグを Base64 にしたもの。
パスワードから暗号化して1つの値に保存する流れ。ランダムなソルトは KDF へ、ランダムな nonce は 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回ずつ測った中央値です。

鍵導出時間の棒グラフ(ミリ秒)。PBKDF2-SHA256 1万回(Python)3.0、60万回(Python)181.6、60万回(PHP)793.6、libsodium の scrypt interactive 33.9、OpenSSL CLI の scrypt N=2^17 300、Argon2id interactive 62.9、Argon2id moderate 425.7。
32バイト鍵を1つ導出する時間(5回の中央値、Apple Silicon Mac M1 Pro)。CLI の scrypt はプロセス起動時間を含む。
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 や空文字列のまま処理が続く 失敗はエラーとして扱い、元データに「戻さない」

どの方式を選ぶか

判断の図。出発点はデータを暗号化したいこと。libsodium が使えるなら、ランダムな24バイト nonce の XChaCha20-Poly1305。AES が必須なら、ランダムな12バイト nonce の AES-256-GCM を使い、タグがちょうど16バイトか確認する。出発点からの点線の先は灰色の箱で、ECB、MAC なしの CBC や CTR、openssl enc は避けるべきものとして示している。
どの方式を使うか。AEAD を選び、ECB と認証なしの CBC/CTR(openssl enc の出力はすべてこれ)は避ける。
状況 使うもの 理由
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)と cryptography 48.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 でないときだけ平文を使う。

次へ

よくある質問

鍵が秘密なら 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 を受け付けません。

  • #セキュリティ
  • #上級
  • #暗号化
  • #AES-GCM
  • #PHP
  • #Python
  • #OpenSSL

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑