Skip to content
ブログに戻る
セキュリティ

AES 復号に失敗する原因 — 鍵・IV・モード・パディング

AES の復号が失敗する原因は誤解を招く。鍵が違えばパディング例外、IV が違えば最初のブロックだけが壊れる。無料のオンラインツールで切り分ける。

16 分で読める

AES 復号に失敗する原因 — 鍵・IV・モード・パディング

ログに AES の復号失敗が出たとき、そこに書かれているメッセージは、たいてい別の問題の話をしている。互いに無関係な 4 つのバグがほぼ同じ症状を出すうえ、いちばん多い原因である「鍵の間違い」は、パディングのエラーとして姿を現す。

CBC の復号が BadPaddingException を投げている状況から始めるなら、次の順に疑うのがいちばん時間を節約できる。

  1. 両者の鍵バイトが違う。圧倒的に多い。
  2. 鍵導出が違う。同じパスフレーズでも KDF や反復回数が違えば、出てくる鍵バイトは別物になる。
  3. 転送中に暗号文が壊れた。切り詰められた、base64 が崩れた、あるいはテキストエンコードを往復した。
  4. IV が違う。実在する原因だが、これはパディングエラーを投げない。16 バイトを静かに壊すだけだ。

この順序には構造的な理由がある。CBC がパディングを検査するのは復号の最終ステップ、鍵を適用してチェーンをほどき終わったあとだ。つまりパディングは、それより上流のすべてに対するチェックサムとして働く。上流のどこが壊れていても、大声を上げるのはパディングになる。急ぐなら、手元の暗号文を AES オンライン復号ツールに貼り付けて、セクション 9 の二分探索から入るのが早い。

以下の数値はすべて java 1.8.0_162node v25.8.2openssl 3.6.2 で実測した。デフォルト値はバージョンによって動くので、バージョン番号も結果の一部だと考えたほうがいい。

1. エラーが「何を否定したか」から始める

AES の失敗メッセージは、原因についてはほとんど何も語らない。だが「原因ではありえないもの」については雄弁だ。分岐を選ぶためではなく、分岐を消すために使う。

見えているもの否定できることまだ生きている可能性
BadPaddingExceptionbad decryptwrong final block lengthGCM、IV だけの間違い、デコードの失敗鍵の間違い、KDF の違い、暗号文の切り詰め、IV を暗号文として食っている、モード不一致、パディング方式の不一致
GCM の Authentication failedUnsupported state or unable to authenticate dataパディング、部分的な出力を前提とする説すべて鍵の間違い、nonce の間違い、タグの分離や位置ずれ、タグ長の不一致、AAD の不一致
例外は出ないが、出力がゴミ認証付きモードすべてECB、CTR、たまたま通った CBC、モード不一致、IV の間違い

BadPaddingExceptionbad decryptwrong final block length

同じ事象が Java、OpenSSL、.NET でそれぞれこう出る。発火するのは CBC または ECB の復号の最後、平文の最終ブロックが正しい PKCS#7 のパターンで終わっていないときだ。

有用なのは否定の側だ。ここまで到達したということは、base64 なり hex なりのデコードは成功し、バイト数はゼロでない 16 の倍数だったということ。つまり転送はデータを刻んでいないし、GCM でもない。例外は wrong final block length で、こちらはバイト数が 16 の倍数になっていなかった場合だ。鍵ではなく切り詰めを指しているので、セクション 8 を見ること。

GCM の Authentication failed とその仲間

GCM は NIST SP 800-38D の要求どおり、平文を 1 バイトも出す前にタグを照合する。そのぶん、エラーの意味はパディングエラーより正確だ。(鍵, nonce, 暗号文, 追加認証データ, タグ)という組のどれかが、暗号化側が使ったものと一致していない、と言っている。どの要素かまでは教えてくれない。そこまで絞り込まないのはアルゴリズムの意図的な設計だ。言語をまたいだときに最も壊れやすい要素はセクション 6 で扱う。壊れるのはタグの値ではなく、タグの位置のほうだ。

エラーは出ないが、出力がゴミ

いちばん危ない結末だ。ダッシュボードには成功として記録されてしまう。CTR は例外を投げないし、ECB も投げない。CBC が投げるのは最終バイトのパターンがパディング検査に落ちたときだけで、鍵が違えばそのバイトは事実上ランダムだから、256 回に 1 回ほどは 0x01 に着地して検査を通ってしまう。鍵が違う CBC 復号の 0.4% 弱が「成功」する計算になる。ただしゴミには形があり、その形からバグを特定できる。覚えておく価値のある指紋が 2 つあり、セクション 4 と 5 で扱う。

2. AES でいちばん誤解を招くエラー

デバッグの優先順位をひっくり返す実測がこれだ。鍵 0123456789abcdef、IV は全ゼロ、AES/CBC/PKCS5Padding、平文 hello world、環境は java 1.8.0_162 と JDK 同梱の SunJCE プロバイダー。

シナリオ変更点実測結果
A鍵を 1 バイトだけ間違える(末尾の fXjavax.crypto.BadPaddingException: Given final block not properly padded を投げる。パディング自体は一度も壊れていない。エラーは完全に的外れだ
B鍵は正しく、IV を 1 バイト間違える例外なし。平文 hello worldiello world として返ってきた。壊れたのは最初のブロックの該当バイトだけ
C鍵は正しく、CBC の暗号文を AES/ECB で復号例外なしで黙って成功する。モード不一致は何も投げないことがある

シナリオ A は午後を丸ごと溶かす。ただし本当に厄介なのはシナリオ C で、壊れたデータがそのまま本番に流れていく。

なぜ鍵の間違いがパディングエラーになるのか

パディングにはどこも問題がない。暗号化側は hello world を 16 バイトに揃えるために 0x05 を 5 バイト付け足し、そのブロックを暗号化した。それは今も無傷のまま暗号文の中にある。

失敗が起きるのは出口側だ。CBC の復号はブロック暗号を逆向きに回し、その結果を直前の暗号文ブロックと XOR し、そのうえで最終ブロックの末尾を読んで何バイト削るかを決める。鍵が違えば暗号は 16 バイトのノイズを吐き、ノイズが正しい PKCS#7 のパターンで終わることはまずない。ライブラリは見たままを報告する。パディングが不正だ、と。嘘ではないが、原因の場所は指していない。

BadPaddingException は「再構成した平文が、パディング済み平文の終わり方をしていない」と読み替えるといい。再構成が間違っている理由として最もありそうなのは鍵だ。だから aes decrypt wrong key で検索しても bad padding exception で検索しても、たどり着くスレッドは同じになる。症状は 2 つに見えて 1 つしかない。設計上、この区別を呼び出し側に見せてはいけない。「パディングが不正」と「パディングは正しいが中身が違う」を区別できることこそ、パディングオラクル攻撃の餌になる(Vaudenay, EUROCRYPT 2002)。

GCM は何が違うのか

GCM は順序を逆にする。平文を 1 バイトも作る前にタグを検証するので、「部分的に正しいバイト列」が存在する窓がない。GCM が失敗したとき、出力が本物かどうかで悩むことはない。そもそも出力がないからだ。また GCM は中身がカウンターモードなのでパディングを一切持たず、暗号文長は平文長と等しい。したがって GCM だと思っていたシステムでパディングエラーが出たなら、それはそのシステムが GCM ではない証拠になる。たいていは設定が CBC にフォールバックしている。

3. 両者は同じ鍵バイトを使っているか

AES は鍵の文字列を見ていない。見ているのは 16、24、32 バイトのいずれかの並びだ。2 つのシステムが設定ファイルに一字一句同じ鍵素材を持っていても食い違うことがあるのは、「同一」がテキストの性質であってバイト列の性質ではないからだ。

鍵文字列がバイト列になる 3 通りの道

リテラル文字列 0123456789abcdef を 3 つのライブラリに渡すと、こうなる。

hex として解釈       -> 8 バイト    (AES の鍵長として不正)
base64 として解釈    -> 12 バイト   (AES の鍵長として不正)
生の UTF-8 として    -> 16 バイト   (AES-128 として有効)

16 文字から 3 通りのバイト数。この例が厄介なのは、3 通りの読み方すべてで妥当だからだ。どの文字も hex と base64 の両方のアルファベットに含まれ、16 文字はどちらのデコーダーにとっても合法な長さなので、パース時には何のエラーも出ない。

各エコシステムが秘密鍵の文字列をどう解釈するかのライブラリ横断マトリクスは、JWTの「invalid signature」エラー:原因の特定と修正方法にまとめてある。AES 向けに要約すれば、鍵素材がどのエンコードなのかを書き留めておき、両側で明示的にデコードさせること。同じバグの HMAC 版は webhook の受信側を噛む。こちらは Webhook 署名検証が失敗する原因と対処法で扱っている。

AES が受け付ける鍵長はちょうど 16、24、32 バイト

先に HMAC を触っていると、ここで足をすくわれる。HMAC は任意の鍵長を受け入れる。RFC 2104 はブロックサイズより長いものはハッシュし、短いものはゼロで埋めるので、HMAC ジェネレーターは 7 バイトの秘密鍵でも 700 バイトの秘密鍵でも文句を言わない。AES の合法な鍵長はちょうど 3 つで、それ以外はブロックを 1 つも処理しないうちに拒否する。

この厳格さはありがたい。長さのエラーは、パディングの陰に隠れず原因をそのまま名指ししてくれる数少ない AES 失敗だ。当サイトのツールでは Key must be 16, 24, or 32 bytes (AES-128/192/256). と表示される。長さを狂わせる罠は次のとおり。

  • 末尾の改行。KEY=$(cat key.txt)echo "$KEY" から混入する。printfecho -n を使うこと。シークレットマネージャーの UI からコピーしたときに付いてくる末尾のスペースも同じ結果になる。
  • 0x プレフィックス。デバッガーからコピーしたもので、34 文字になってもはや正しい hex ではない。
  • 非 ASCII 文字。contraseña は 10 文字だが UTF-8 では 11 バイトなので、アクセント付き文字が 1 つ入った「32 文字」のパスフレーズは 33 バイトになる。

SecretKeySpec とプラットフォーム既定の文字セット

Java には、デプロイ後にしか現れない変種がある。引数なしの "my secret".getBytes() はプラットフォーム既定の文字セットを使い、それは JDK 18 より前では file.encoding プロパティ、つまりマシンの OS とロケール由来だった。UTF-8 のノート PC と ANSI_X3.4-1968 のコンテナは、非 ASCII 文字について異なるバイト列を生む。JEP 400 が JDK 18 で UTF-8 を既定にしたが、直るのは JDK 18 以降で動かし直したコードだけだ。

// 誤り: バイト列がマシンに依存する
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");

// 正しい: バイト列は何にも依存しない
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");

ローカルでは動くのにサーバーではパディングエラーで落ち、しかもパスフレーズに ASCII 外の文字が入っているなら、まずここを見ること。日本の業務システムは Java を中心に組まれていることが多く、この罠を踏む確率もそのぶん上がる。例外メッセージまで日本語になることはまずないので、復号(復号化)の失敗を追うときは、ログに出た英語の原文をそのまま検索窓に入れたほうが早い。

4. IV をどこに置くか、間違えるとどう見えるか

aes iv mismatch は、真っ先に疑われて最後に診断される失敗だ。他の原因と違って例外を投げず、壊す範囲も 1 ブロックに限られるからだ。

IV が違うと、ちょうど 1 ブロックだけ壊れる

シナリオ B をもう一度見てほしい。鍵は正しく、IV が 1 バイトだけ違う場合。

hello world   ->   iello world

例外は出ず、変わったのは 1 文字。最初のブロックについて CBC の式を書き出せば明らかだ。P1 = D(C1) XOR IV。IV は最初の平文ブロックにそのまま XOR され、それ以外には一切触れない。だから IV の 1 ビットを反転させると、平文の同じ位置の同じビットが反転する。ここでは h(0x68)が i(0x69)になったので、IV の先頭バイトはちょうど 0x01 だけずれていたことになる。

指紋はこうなる。CBC で先頭 16 バイトがゴミ、それ以降がきれいなら、IV が違って鍵は正しい。全ブロックがゴミなら鍵が違う。この 1 つの観察だけで、最も多い 2 つの原因をコード 1 行も変えずに切り分けられる。AES オンライン復号はデコード後のバイト列を表示するので、そのまま読み取れる。

なぜ何も投げなかったのか。hello world は 11 バイトなので単一ブロックで、PKCS#7 のパディングはそのブロックの 11 〜 15 バイト目にある。変わった IV のバイトは 0 バイト目だったので、パディング領域は手つかずのまま検査を通った。IV の 11 バイト目以降を壊せば、代わりにパディングエラーが出る。パディングエラーが嘘をつくもう一つの経路だ。

3 つの受け渡し規約

IV をどこに置くかに標準はなく、相互運用性の悪い習慣が 3 つ並立している。

いちばん一般的なのは先頭に付ける方式で、iv || ciphertext は当サイトのツールの既定でもある。何バイト削るかを両者で合意しておく必要がある。CBC と CTR なら 16 バイト、GCM なら 12 バイト。裏返しのバグは、送り側が先頭に付けているのに受け側が剥がしていないケース。このとき「暗号文」の先頭 16 バイトは IV なのでブロックが丸ごとずれ、パディングエラーになる。

別フィールドに分ける {"iv": "...", "ciphertext": "..."} は原理的にはきれいだが、エンコードが食い違いうる箇所を 2 倍にする。IV が独自に「base64 か hex か」という問題を抱えることになるからだ。

三つめは IV を固定値にしてしまう方式。たいていは全ゼロで、誰かが決定性を必要としてハードコードしたものだ。相互運用は完璧に動く。それが危険なところでもある。CBC では固定 IV がレコード間の同一性を漏らし、GCM では 1 つの鍵のもとで nonce を再利用すると 2 つの平文の XOR が露出し、タグを認証する GHASH のサブキーまで暴かれうる。SP 800-38D は一意性について明示している。

ツールの「生の暗号文」スイッチと IV の明示指定を組み合わせれば、同じバイト列に対して 3 つの規約すべてを順に試せる。

GCM の IV は 16 バイトではなく 12 バイト

既存の CBC の経路を書き換えて GCM を導入したチームは、16 バイトの IV をそのまま持ち込んでしまい、手がかりのないまま失敗する。

SP 800-38D が標準としているのは 96 ビットの IV だ。他の長さも許容されてはいるが、それは単なる「長い IV」ではない。IV が 96 ビットでないとき、GCM は IV をそのまま使う代わりに GHASH に通して初期カウンターブロックを導出する。したがって同じ 16 バイトを nonce として使うと、先頭 12 バイトを使った場合とはまったく別のキーストリームとタグが出てきて、汎用的な認証失敗になる。暗号文が他所から来ていてレイアウトを推測している段階なら、後ろから数えるといい。タグは末尾 16 バイト、nonce はほぼ確実に先頭 12 バイトだ。

5. 黙って通るモード不一致

Cipher.getInstance("AES") は ECB

Java では、モードもパディング方式も指定せずに暗号方式だけを名指しできる。拒否もしなければ警告も出ない。JDK 同梱の SunJCE プロバイダーは、その空欄を ECB と PKCS5Padding で埋める。

これを証明するには適切な実験がいる。同一の 32 バイト(A 2 ブロック分)を鍵 0123456789abcdef で暗号化し、2 つの暗号文ブロックが一致するかを見る。java 1.8.0_162 での結果。

getInstance("AES")           ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
  block1 = 3bfd04cc0d7ed55358e2cbe19de21383
  block2 = 3bfd04cc0d7ed55358e2cbe19de21383   <- ブロックが同一 = ECB の指紋(平文の構造が漏れる)
getInstance("AES/CBC/PKCS5Padding")  ブロックが異なる = チェーンが効いている

1 バイトも違わず同一。これが ECB の指紋で、暗号化したペンギン画像がペンギンのまま見える例と同じ性質だ。この実験は平文ブロックが同一のときにしか成立しない。A を 16 バイト並べたあとに B を 16 バイト並べた場合は ECB でも 2 つの暗号文ブロックが異なるので、既定は CBC だったと誤った結論を出してしまう。

この結果が言えるのは、上のバージョンの JDK に同梱された SunJCE プロバイダーについてだけだ。既定の変換方式はプロバイダーが決める。BouncyCastle のようなサードパーティのプロバイダーは、同じ略記を別に解決しうる。だから一般化するなら「Java は ECB になる」ではなく、「修飾されていない変換文字列はプロバイダー任せになるので、書いてはいけない」となる。

モードを間違えてもエラーが出ないことがある

シナリオ C は CBC の暗号文を AES/ECB で復号し、例外なしで正しい平文を返した。式を書き出すまでは不可能に見える。最初のブロックの CBC 暗号化は C1 = E(P1 XOR IV) で、そのブロックの ECB 復号は D(C1) = P1 XOR IV になる。ここでは IV が全ゼロだったので P1 XOR 0 = P1 となり、最初のブロックは完璧に復号される。hello world は 1 ブロックの長さなので、「最初のブロック」がメッセージ全体だった。

一般則として覚えておく価値がある。IV がゼロなら、ECB と CBC は最初のブロックで一致し、それ以降のすべてのブロックで食い違う。長い CBC メッセージを ECB として復号すると、きれいな 16 バイトのあとにノイズが続く。IV を間違えたときの指紋のちょうど裏返しだ。形は正反対だが、どちらもエラーを出さないところは同じになる。ゼロ IV のハードコードは珍しくないので、これは実際に踏むほうのバグだ。

各言語で最小限の呼び出しが何をくれるか

エコシステム最小限の呼び出し実際に得られるモード
Java(SunJCE)Cipher.getInstance("AES")ECB と PKCS5Padding が、黙って選ばれる
Node cryptocreateDecipheriv('aes-256-cbc', key, iv)アルゴリズム文字列に書いたとおり。既定は存在しない
Web Cryptocrypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...)明示指定。ECB はそもそも実装されていない
Python cryptographyCipher(algorithms.AES(key), modes.CBC(iv))モードオブジェクトは必須
PyCryptodomeAES.new(key, AES.MODE_ECB)必須引数だが、ECB が補完候補のすぐそこにいる
Go crypto/aesaes.NewCipher(key) は生の cipher.Block を返すそのブロックに対する Decrypt 呼び出しはそのまま ECB。cipher.NewCBCDecryptercipher.NewGCM で包むこと
CryptoJSCryptoJS.AES.decrypt(ct, "passphrase")CBC、PKCS#7、MD5 を使う EVP_BytesToKey(セクション 7 参照)

モードが文字列やオブジェクトに書かれているエコシステムでは事故が起きにくい。「AES だけ」の呼び出しを許している Java と Go が、うっかり ECB 事案の報告元になっている。手元の暗号文がどのモードで作られたのか確信が持てないなら、同じバイト列にモードを順に当てて確かめるといい。

6. GCM は同じバイト列を違う API で扱う

言語をまたいだ aes gcm auth tag の失敗は、その大半が暗号的な問題ではない。両者は同じ 16 バイトを計算していて、そのバイト列がどこにあるかで食い違っているだけだ。

実測

鍵 = 32 バイトの 0123456789abcdef0123456789abcdef、IV = ゼロ 12 バイト、平文 hello world、環境は node v25.8.2java 1.8.0_162

Node   ciphertext = a616cd6d7d2328379d41e5                    (11 B)   <- update+final
       authTag    = c87af9f8ad7148e873fa797292c0af3f          (16 B)   <- getAuthTag() で別途取得
Java   doFinal()  = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f   (27 B)   <- 暗号文とタグが連結済み

Node ciphertext || authTagJava doFinal() と完全に一致する。27 バイトすべてがだ。エンコードの変換も、値のすり合わせも要らない。Node は 2 つの断片を別々に渡し、Java はくっつけて渡す。違いはそれだけだ。もう一点、11 バイトの平文から 11 バイトの暗号文が出ている。GCM はパディングを足さないからだ。本物の GCM の経路からパディングエラーが決して出ないのは、これが理由だ。

連結か分離か、ランタイム別

ランタイム暗号化 APIタグの行き先
Node cryptoupdate() + final()、そのあと getAuthTag()分離
Java(SunJCE、AES/GCM/NoPaddingdoFinal()末尾に連結
Go cipher.AEADSeal()末尾に連結
Python cryptographyAESGCMencrypt()末尾に連結
Python cryptographyCipher + modes.GCMfinalize()、そのあと encryptor.tag分離
Web Cryptocrypto.subtle.encrypt末尾に連結

高水準 API の中では Node だけが例外的で、だから「Node から他の何か」が最も報告の多い失敗方向になっている。受け取ったバイト列がどちらの並びなのかは、タグの位置を切り出して突き合わせるとはっきりする。Node の出力を Java、Go、Python、ブラウザの受け手向けに詰め直すコードはこうなる。

const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]);   // これで doFinal() と一致する

連結されたバイト列を Node 向けにほどくとこうなる。

const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16));  // final() より前に呼ばなければならない
const pt = Buffer.concat([
  decipher.update(packed.subarray(0, packed.length - 16)),
  decipher.final(),
]);

この順序の制約は実在する。setAuthTag()final() のあとに呼ぶと、バイト列がすべて正しくても Node は Unsupported state or unable to authenticate data を投げる。

タグ長は可変、単位は API ごとにまちまち

GCM が許すタグ長は 128、120、112、104、96 ビットで、64 と 32 は制約の厳しい用途向けに留保されている(SP 800-38D 付録 C)。ほぼ全員が 128 を使う。問題は、各 API がそれをどう受け取るかだ。

  • Java の new GCMParameterSpec(128, iv) は、第 1 引数がビット
  • Web Crypto の { name: 'AES-GCM', iv, tagLength: 128 } もビットで、既定は 128。
  • Node の createCipheriv(algo, key, iv, { authTagLength: 16 })バイト

new GCMParameterSpec(16, iv) は合法に見える Java の 1 行だが、要求しているのは 16 ビットのタグだ。拒否する JDK もあり、受け入れられた場合は完全性の保証を 65,536 分の 1 のコイン投げと交換したことになる。両者のタグ長が食い違えば詰め直したあとの全体長も変わるので、受け側は間違った境界で切り出し、鍵とはまったく無関係な認証失敗を受け取る。

7. 持っているのは鍵ではなくパスフレーズ

どちらかの側が人間の打った文字列を受け取っているなら、その文字列と AES のあいだには鍵導出関数がいる。そして KDF の不一致は目に見えない。エラーは一切出さない。まったく問題のない 32 バイトを返してくるが、それがたまたま間違った 32 バイトなのだ。失敗は一つ下のレイヤーで、やはりパディングエラーとして表に出る。

PBKDF2 は 4 つを揃える必要がある

  • ソルト。OpenSSL の Salted__ 形式では暗号文の中に 8 バイトで入っている。当サイトのツールのパスフレーズ形式では 16 バイトのプレフィックス。自作の方式ではハードコードされた定数であることも多い。
  • 反復回数。openssl enc -pbkdf2 の既定は 10,000。OWASP は現在 PBKDF2-HMAC-SHA256 に対して 600,000 を推奨していて、当サイトのパスフレーズモードもこれを使っている。フレームワークはそれぞれ独自の数字を選ぶ。
  • ハッシュ。SHA-1 か SHA-256 か SHA-512 か。古いコードや一部のモバイル SDK は今も SHA-1 を既定にしている。
  • 出力長。AES-256 なら 32 バイト、AES-128 なら 16 バイト。鍵と IV を 1 回の長い導出からまとめて作る方式もあり、これは素直な 32 バイト導出とは一致しない。

EVP_BytesToKey と、CryptoJS がいつまでも動かない理由

cryptojs aes decrypt not working は、たいてい特定の 1 つの不一致だ。CryptoJS.AES.encrypt(text, "passphrase") は PBKDF2 を使っていない。使っているのは EVP_BytesToKey、OpenSSL 1.1 より前の導出方式で、MD5 と 1 回の反復だ。

EVP_BytesToKey には PBKDF2 にはない挙動もある。パスフレーズとソルトから、鍵だけでなく IV まで一度に導出するのだ。OpenSSL の Salted__ ファイルが独立した IV フィールドを持たないのはこのためであり、PBKDF2 とランダム IV で CryptoJS の出力を再現しようとする試みが二重に間違っているのもこのためだ。

この形式は見ればわかる。ASCII 8 バイトの Salted__ に 8 バイトのソルトが続く構造で、base64 にすると必ず U2FsdGVkX1 で始まる。暗号文がそう始まっているならパスフレーズ由来であり、あとはどの導出方式かを知るだけだ。AES オンライン復号はこのプレフィックスを検出し、コードを書き換えずに 3 方式を切り替えられる。

同じパスワードから違う鍵が出てくる理由

「AES のパスワード」などというものは存在しない。どのライブラリも、文字列から鍵に至る道を自分で発明してきた。

生成元導出方式1 つのパスフレーズに対する結果
CryptoJS AES.encrypt(text, pass)EVP_BytesToKey、MD5、1 反復鍵 A
openssl enc 1.0.2 以前EVP_BytesToKey、MD5、1 反復鍵 A
openssl enc 1.1 以降で -pbkdf2 なしEVP_BytesToKey、SHA-256、1 反復鍵 B
openssl enc -pbkdf2PBKDF2-HMAC-SHA256、10,000 反復鍵 C
当サイトのパスフレーズモードPBKDF2-HMAC-SHA256、600,000 反復鍵 D
Java、Python、Go既定は一切なし。導出は自分で書く書いたとおりのもの

誰もミスをしていない段階で、1 つのパスワードから 4 つの鍵が出る。1.0.2 から 1.1 への更新で既定のダイジェストが MD5 から SHA-256 に変わった。古いスクリプトが作った暗号文が、新しいマシンでは同じコマンドで復号できなくなったのはこのせいだ。データを引き継いだもののツールチェーンを誰も覚えていないなら、上の順に導出方式を試すこと。総当たりではなく、3 通り試すだけの話だ。

8. 転送がバイト列に何をしたか

暗号文は一様ランダムなバイナリなので、バイト列をテキストとして扱うあらゆるものと最大限に相性が悪い。AES の失敗のかなりの割合は、暗号そのものに一切関係していない。

base64 の方言と、欠けたパディング

標準の base64(RFC 4648 §4)は +/ を使い、URL セーフな変種(§5)は -_ を使う。URL セーフな文字列を標準デコーダーに渡すと、例外を投げるか、寛容なデコーダーの場合は問題の文字を黙って捨てて、短くずれたバイト列を返す。Java が Base64.getUrlDecoder()Base64.getDecoder() を別々のオブジェクトとして用意しているのはこのためだ。末尾の = を落とすエンコーダーもあれば、= がないと受け付けないデコーダーもあり、JWT 周りのコード経路は既定で落としてくる。

鍵を疑う前に、暗号文をデコードして長さをモードと突き合わせること。

  • CBC と ECB なら、ゼロでない 16 の倍数。それ以外は切り詰めかデコードの問題であって、鍵の問題ではない。
  • GCM なら、暗号文長は平文長と等しく、そこにタグの 16 バイト、nonce を先頭に付けているならさらに 12 バイトが乗る。
  • CTR は任意の長さなので、この検査からは何もわからない。

Base64デコード・エンコードに貼れば、デコード後のバイト数がすぐ出る。

改行、スマートクォート、UTF-8 の往復

openssl base64-A を付けない限り出力を 64 桁で折り返す。埋め込まれた改行を読み飛ばすデコーダーもあれば拒否するデコーダーもあるので、同じファイルがあるマシンではデコードでき、別のマシンでは失敗する。チャットクライアントや文書エディタを経由してコピーすると、まっすぐな引用符が曲がった引用符に、ハイフンが en ダッシュに変わる。しかもその違いはターミナル上ではほとんど見えない。

回復不能なのは UTF-8 の往復だ。生の AES 出力を、エンコードせずに一度でも文字列として保持してしまうと(Java の new String(cipherBytes)、Python の bytes.decode('utf-8', errors='replace')、どこであれ TextDecoder)、正しい UTF-8 でないバイト列はすべて U+FFFD に潰れ、それをエンコードし直すと、かつてデータがあった場所に EF BF BD が並ぶ。ランダムなバイトのおよそ半分は非 ASCII なので、暗号文の大半が破壊され、どんな鍵でも復元できない。この損失が一方向である理由は UTF-8 vs UTF-16 vs Unicode エンコーディングガイドで扱っている。バイナリの暗号文は base64 か hex で運ぶか、バイナリのまま運ぶ。文字列として運んではいけない。

データベースのカラム

ストレージは同じ損傷をもっと静かに与える。VARCHAR(255) に対して 1 ブロック分だけ長い暗号文を書き込むと切られるし、strict モードでない MySQL はそれをエラーなしでやる。末尾こそパディングブロックと GCM タグのいる場所なので、数か月前に「成功」した行が今になって失敗する。しかも切断が 16 バイト境界に落ちていれば、前述の長さ検査でも捕まらない。残りは文字セット変換が仕上げる。latin1 のカラムが UTF-8 のバイト列を受け取れば、書き込みの途中でデータが書き換えられる。

暗号文は VARBINARYBLOBbytea に格納するか、余裕を持たせたテキストカラムに base64 で格納すること。

9. 5 分で原因にたどり着く二分探索の手順

上のどのセクションも、変数を 1 つずつ潰していくためのものだ。自分で制御できる参照実装に順に当てれば収束は速い。ブラウザ上のツールはその参照に使える。処理はすべてブラウザ内で完結して鍵も暗号文もページの外に出ないうえ、設定を変えながらバイト列をそのまま見られるからだ。

  1. ステップ 0:形を測る。 暗号文をデコードして、バイト数、先頭数バイト、U2FsdGVkX1 で始まるかどうかを記録する。バイト数をセクション 8 と突き合わせる。16 の倍数でないのに CBC のはずだと思っているなら、そこで止まること。これは転送のバグだ。

  2. ステップ 1:既知の平文を暗号化する。 AES オンライン暗号化で、本番が使っているはずのパラメータを使って短い既知の文字列を暗号化し、2 つの出力を値ではなく形で比べる。全体長、先頭バイト、ソルトヘッダーの有無だ。ここが食い違うなら、形式か KDF についての前提が間違っているということで、鍵をいじり回しても直らない。

  3. ステップ 2:導出方式を順に回す。 パスフレーズ由来のデータなら、AES オンライン復号で正確な反復回数を指定した PBKDF2、次に EVP-SHA256、次に EVP-MD5 を試す。正解はちょうど 1 つ。どれも通らないなら、バグは KDF より上にある。

  4. ステップ 3:規約をすべて取り払う。 生の鍵に切り替え、生の暗号文モードをオンにし、IV を明示的に与える。これでどのバイトが鍵で、どれが IV で、どれが暗号文かを、推測に頼らず明示したことになる。ここでは復号できるのに自分のコードでは落ちるなら、バグは暗号的なものではなく組み立て方の問題だ(剥がし忘れた IV のプレフィックス、間違った場所にあるタグ)。

  5. ステップ 4:モードを切り替える。 同じバイト列に対して CBC、CTR、GCM を順に試す。CBC が失敗したところで CTR が読めるテキストを返したなら、それはモード不一致で確定だ。

  6. ステップ 5:ゴミを読む。 最初のブロックが壊れていて残りがきれいなら IV。最初のブロックがきれいで残りが壊れているなら、ゼロ IV の CBC を ECB として復号している。全部壊れているなら鍵か導出方式だ。

10. よくある質問

AES のコードがローカルでは動くのに本番で失敗するのはなぜ?

ローカルでは通って本番で落ちるなら、ソース管理に入っていない何かを環境側が変えている。よくある容疑者を順に挙げる。環境変数やシークレットマネージャーから来た鍵に末尾の改行が付いていた。Java のプラットフォーム既定文字セットがノート PC とコンテナで違い、getBytes() が別のバイト列を返した(セクション 3)。本番の OpenSSL が 1.1 以降なのにローカルのスクリプトは 1.0.2 向けで、EVP_BytesToKey のダイジェストが MD5 から SHA-256 に変わっていた。あるいはデータベースのカラムが片方の環境でだけ暗号文を切り詰めている。まずは両側で鍵の長さと暗号文の長さをバイト単位で出力すること。たいていはこの 2 つの数字で決着がつく。

Node で暗号化したものが Java で復号できない。どこから手を付ければいい?

Node と Java のあいだなら、GCM のタグから始めるといい。最も多く、最も見えにくい原因だ。Node は暗号文とタグを別々に返すが、Java の doFinal()ciphertext || tag の形で連結されていることを期待する。セクション 6 が示すとおり、それ以外のバイトは同一だ。CBC の場合は IV の規約から始める。Node は IV を先頭に付けたか、Java 側は復号前に 16 バイト剥がしているか。3 番目が鍵そのもので、Buffer.from(k, 'hex')k.getBytes(StandardCharsets.UTF_8) は同じ文字列から違う長さを作る。

Java の PKCS5Padding は PKCS#7 と同じ?

AES で使うかぎり、Java の PKCS5Padding と PKCS#7 は事実上同じものだ。PKCS#5(RFC 8018)は 8 バイトブロック専用に定義されている。PKCS#7(RFC 5652)はこの方式を 1 〜 255 バイトのブロックサイズへ一般化したものだ。Java の PKCS5Padding を 16 バイトのブロック暗号に適用すると PKCS#7 の挙動になり、名前のほうが歴史的な名残なので、ここがバグになることはない。バグになるのは NoPadding のほうだ。こちらは平文があらかじめ 16 の倍数であることを要求し、復号時にはパディングをデータとしてそのまま返すので、\x05\x05\x05\x05\x05 のような末尾バイトが付いた、それらしく見えるテキストが出てくる。

鍵は 32 文字なのに AES が鍵長エラーを出すのはなぜ?

長さエラーは、ライブラリが受け取ったバイト数が 16、24、32 のどれでもなかったという意味だ。32 文字の文字列でこうなる原因はたいてい、末尾の改行(33 バイト)、文字列を不正な hex にしてしまう 0x プレフィックス、あるいは UTF-8 で 2 〜 3 バイトを占める非 ASCII 文字のいずれかだ。もっと危ないのは、エラーがまったく出ない変種のほう。32 文字の hex は 16 バイトに、32 文字の base64 は 24 バイトにデコードされ、どちらも合法な AES の鍵長だ。ライブラリはそれを受け入れ、間違った鍵を使い、代わりにパディングの失敗を返してくる。文字数ではなくバイト数を確認すること。

復号は「成功」したのに出力がゴミ。何が起きた?

復号が「成功」したのにゴミが出てくるなら、何も検証しないモードにいる。CTR と ECB は決して例外を投げないし、CBC が投げるのは最終バイトのパターンがパディング検査に落ちたときだけで、鍵が違ってもこれを 0.4% 弱の確率で通過する。形を読むこと。先頭 16 バイトが壊れて残りがきれいなら IV。先頭 16 バイトがきれいで残りが壊れているなら、ゼロ IV の CBC 暗号文を ECB として復号している。一様に壊れているなら鍵か導出方式だ。読めるテキストの末尾に妙なバイトが数個付いているなら、パディング済みのデータに NoPadding を使っている。長期的な解決策は GCM にすることで、そうすれば「成功」に意味が出る。

IV を失っても復号できる?

IV がなくても、CBC なら先頭 16 バイトを除いて復号できる。2 ブロック目以降は D(C_i) XOR C_{i-1} で復元でき、その入力はすべてすでに暗号文の中にあるので、IV を必要とするのは最初のブロックだけだ。平文の書き出しがわかっているなら、たとえば {"userId": で始まる JSON なら、D(C1) XOR P1 で IV そのものを復元することもできる。CTR では IV がキーストリーム全体の種になるので、失えばすべてを失う。GCM では nonce がカウンターとタグの両方に効くので、部分的な復元もない。

GCM のタグが切り詰められたり失われたりした場合、平文を復元できる?

GCM のタグが切り詰められたり失われたりした暗号文でも、復元は数学的には可能、実務的には手間がかかる。GCM は中身が CTR モードなので、鍵と nonce だけあればキーストリームを再現できる。ただし主要なライブラリはどれも代わりにやってはくれない。Java も Go も Python も Web Crypto も、有効なタグなしに平文を出すことを設計として拒否する。回避策は、同じバイト列を AES-CTR として、初期カウンターブロックを 12 バイトの nonce に 00000002 を続けた値に設定して復号することだ。そこが GCM の最初のデータブロックの開始位置になる。データは戻ってくるが、完全性の保証はすべて放棄することになるので、結果は信頼できないものとして扱うこと。16 バイトのタグが全部揃っているのに認証が失敗するなら、タグは欠けていない。このページのどこか別の項目があなたのバグだ。AES オンライン復号に持ち込んで、ステップ 0 から始めてほしい。

タグ: aes encryption debugging cryptography interoperability