AES-GCM実装ガイド:認証付き暗号化を正しく実装
WebアプリでAES-GCM暗号化を正しく実装。ナンス管理・鍵処理・認証付き暗号化で避けるべき落とし穴。
AES-GCM(Galois/Counter Mode)は AES-CTR 暗号化と GHASH 認証を組み合わせ、関連データ付き認証暗号化(AEAD)を生成します。正しい実装は256ビット鍵・鍵ごとに一意の(再利用しない)96ビット(12バイト)ナンス・128ビット認証タグ・オプションで認証されるが暗号化されない関連データ(AAD)を使用します。NIST SP 800-38D が正確な構造を指定しています。特にナンス再利用について何か間違えると、GCM のセキュリティは崩壊します。単一の繰り返された(鍵、ナンス)ペアで攻撃者が認証鍵を回復し、任意の暗号文を偽造できます。このガイドではブラウザ・Node・サーバーコンテキストでの AES-GCM の正しい使用方法を解説します。
GCM が実際に保証すること
2つの特性:
機密性: 鍵なしでは平文を回復できません。AES-GCM の CTR モード暗号化層がこれを提供します。
完全性と真正性: 暗号文・ナンス・関連データへの変更は復号の失敗を引き起こします。GHASH は128ビットタグを生成し、復号時に定時間で検証されます。
GCM が保証しないもの:否認防止(対称なので鍵を持つ誰もが有効な暗号文を作れます)・リプレイ保護(これは上位層の懸念事項)・順序付け(ストリームには何らかのチェーンが必要)。
重要なポイント:GCM はナンスが鍵ごとに一意である場合にのみ安全です。ほぼ一意ではなく、通常一意でもなく、実際に一意でなければなりません。再利用するとセキュリティ証明が崩壊します。
ナンス管理:最も重要なこと
96ビットナンスは2つの方法で生成できます。
ランダム: crypto.getRandomValues(new Uint8Array(12))。単一の鍵の下のランダムな96ビットナンスでは、誕生日境界の衝突は約2^48回の暗号化後に現れます。NIST は安全マージンを提案しているため、1つの鍵あたりの使用を2^32に制限してください。
カウンター: 96ビット整数をインクリメントします。2^96メッセージまで一意性を保証します。信頼できるモノトニックな状態が必要であり、分散システムでは困難です。
ファイルごとに新しい鍵を使うファイル転送では、ランダムナンスは完全に安全です。1つの鍵で2^32回の暗号化に達することはないでしょう。単一のファイル鍵の下でのチャンク暗号化には、ナンスがチャンクインデックスをエンコードするカウンターを使用してください:
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setUint32(0, messageId);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex));
壊滅的なケース:ランダムナンスを使って同じ共有鍵の下で1秒あたり何百万回も暗号化する複数のプロセス。誕生日衝突が確率的になります。プロセス間で鍵を共有しなければならない場合は、プロセス ID プレフィックスを持つ協調カウンターを使用してください。
64ビットナンスを使わない
AES-GCM は可変ナンス長をサポートしますが、96ビットナンスのみが NIST 800-38D で指定された最適化された構造を使用します。他の長さ(通常64または128ビット)は GHASH 前処理ステップを起動し、パフォーマンスを低下させ複雑さを増します。Web Crypto API は96ビット以外の IV を受け入れますが、スペックは96を推奨します。96だけ使用してください。
タグ長:短くしない
GCM のタグは最大128ビットです。一部のスペックでは96・64・または32ビットへの切り捨てを許可しています。やめてください。切り捨てたタグは偽造攻撃を容易にし、節約(メッセージあたり4〜12バイト)はファイル転送には無関係です。Web Crypto の AES-GCM は tagLength パラメータ経由でデフォルト128ビットタグを使用します。そのままにしてください。
関連データ(AAD)の活用
AAD は認証されるが暗号化されないデータです。暗号文にバインドしたいメタデータ(ファイル名・コンテンツタイプ・有効期限タイムスタンプ・アップローダー ID)に使用します。
await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
additionalData: new TextEncoder().encode(JSON.stringify({
filename: "report.pdf",
contentType: "application/pdf",
expires: 1712345678,
})),
},
key,
plaintext
);
攻撃者が AAD を変更すると復号が失敗します。これにより保存された暗号文のファイル名を検出なしに置き換えるスワップ攻撃を防ぎます。受信者は復号するために正確な AAD を知る必要があるため、暗号文と一緒に保存してください。
鍵の生成と導出
ファイルごとの鍵の場合:
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
2026年のデフォルトは256ビットです。128ビットの AES はまだ安全ですが、耐量子マージンが少なくなります(Grover のアルゴリズムで有効強度が半減します)。
パスワード導出鍵の場合:
const aesKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: crypto.getRandomValues(new Uint8Array(16)),
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
ソルトを暗号文と一緒に保存してください。秘密ではなく、パスワードごとに一意でなければなりません。
重要なコードパス
最小限の暗号化関数:
async function encrypt(key, plaintext, aad = new Uint8Array()) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
plaintext
)
);
return { nonce, ciphertext, aad };
}
適切なエラー処理付き復号:
async function decrypt(key, { nonce, ciphertext, aad }) {
try {
return await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
ciphertext
);
} catch (e) {
// 認証失敗
throw new Error("復号失敗:暗号文が改ざんされたか鍵が違います");
}
}
decrypt 呼び出しはタグの不一致・短い暗号文・または間違った鍵で OperationError をスローします。例外を完全性の失敗として扱い、区別しようとしないでください。
チャンク化された大きなファイル
数百メガバイトを超えるファイルはメモリ圧を避けるためにチャンク化してください:
async function encryptChunks(key, file, chunkSize = 1024 * 1024) {
const chunks = [];
let chunkIndex = 0;
for (let offset = 0; offset < file.size; offset += chunkSize) {
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, chunk
);
chunks.push(new Uint8Array(ct));
}
return chunks;
}
注意点:チャンク化された AES-GCM は切り捨てを検出しません。攻撃者が末尾のチャンクを削除しても、各生き残ったチャンクは正常に復号されます。防御のために各チャンクの AAD に総チャンク数を含めるか、libsodium の crypto_secretstream を使用してください(これを処理します)。
サーバーサイドの復号(Node.js)
Node の crypto モジュールでブラウザで暗号化されたデータを復号できます:
const { createDecipheriv } = require('crypto');
function decrypt(key, nonce, ciphertextWithTag) {
const tag = ciphertextWithTag.slice(-16);
const ct = ciphertextWithTag.slice(0, -16);
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
}
Web Crypto は128ビットタグを暗号文に付加します。Node の API はタグと暗号文を別々に期待します。適切に分割してください。
パフォーマンス数値
AES-NI を持つ典型的な2024〜2026年のハードウェアでは:
- ネイティブ(OpenSSL・AES-NI):コアあたり3〜5 GB/s
- Web Crypto(ハードウェアアクセラレーション付きブラウザ):1〜2 GB/s
- libsodium.js WASM AES-GCM:400〜800 MB/s
- 純粋 JS(@noble/ciphers):50〜150 MB/s
1 GB のファイルの場合、Web Crypto 暗号化は0.5〜1秒で実行されます。純粋 JS では7〜20秒かかります。この現実に基づいて実装を選択してください。大きなファイル転送の UX には Web Crypto が実際的な選択です。
よくある落とし穴まとめ
- ナンス再利用:壊滅的。最大の単一失敗モード。
crypto.getRandomValues()の代わりにMath.random()を使用する。- AAD で関連メタデータを認証するのを忘れる。
- 「使い慣れているから」と CBC モードを使用する。CBC は GCM の完全性に匹敵するために別の MAC が必要。HMAC-CBC 構造は正しいが複雑で間違えやすい。GCM はその落とし穴を避けます。
- 復号エラーをサイレントにキャッチしてゴミを返す。常に大声で失敗させてください。
- 独自の GCM を実装する。Web Crypto・libsodium・または
node:cryptoを使用してください。GHASH 実装にはエキスパートが正しく実装するのに何年もかかったサイドチャネルの落とし穴があります。
HexaTransfer は96ビットランダムナンス・128ビットタグ・AAD なしの Web Crypto の AES-256-GCM を使用します。鍵はファイルごとであり、ファイル名は AEAD で保護されたメタデータに別途保存されているからです。シンプル・正確・高速。
他を選ぶ場合
AES-GCM はファイル転送に最適ですが、特定のケースでは代替を検討してください:
- XChaCha20-Poly1305: 192ビットナンスにより、あらゆる規模でランダムナンスの安全性が自明になります。AES-NI を持つハードウェアでは若干遅く、AES-NI なしの古い ARM では速い。libsodium が提供します。
- AES-GCM-SIV: 誤用耐性があります。ナンス再利用は鍵を漏らさず、平文が等しいかどうかを明らかにするだけです。ナンスの一意性を保証できない場合に有用です。
標準的な Web スタックのほとんどのファイル転送ワークロードでは、ファイルごとに新しい鍵とランダムな96ビットナンスを使った AES-256-GCM が正しい選択であり、最もシンプルに正しく実装できます。
hexatransfer.comでお試しください — 無料、登録不要、最大10GB。
エンドツーエンド暗号化で大容量ファイルを安全に送信
エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。
ファイルを送信