メールで送りたいファイルに、API キーとデータベースのパスワードが並んでいます。テキストファイルなので、手に入れた人は開くだけで中身を読めます。今日はこのファイルに鍵をかけます。そして、かけた鍵を相手にどう渡すかで行き詰まります。

OpenSSL で手を動かして学ぶ暗号の基礎、第2回は共通鍵暗号です。環境の準備がまだの方は第0回、ハッシュの復習は第1回からどうぞ。

コマンドを打つ前に、今日やることを4コマにしておきます。

秘密の手紙を送りたい。
アリス ボブ こんにちは のぞき見 アリス こんにちは のぞき見 ボブ

そのまま送ると、途中で読まれてしまう。

箱に入れて、鍵をかける。
こんにちは 手紙 鍵をかける = 暗号化 7fK#q2Zp… 鍵のかかった箱 こんにちは 手紙 鍵をかける = 暗号化 7fK#q2Zp… 鍵のかかった箱

鍵をかけると、中の文字はぐちゃぐちゃになる。これが暗号化。

箱を、送る。
アリス ボブ 7fK#q2Zp… ? のぞき見 アリス 7fK#q2Zp… ? のぞき見 ボブ

途中で覗かれても、読めない。

同じ鍵で、開ける。
7fK#q2Zp… 鍵のかかった箱 さっきと同じ鍵 鍵を開ける = 復号 こんにちは 手紙 7fK#q2Zp… 鍵のかかった箱 さっきと同じ鍵 鍵を開ける = 復号 こんにちは 手紙

かける鍵と開ける鍵が同じ。だから「共通鍵」。開けることを復号という。

openssl enc -aes-256-cbc でファイルを暗号化する

送り先を間違えたくないものを用意します。

printf 'API_KEY=sk_live_9f2c8b1a4e6d\nDB_PASSWORD=hunter2-but-longer\n' > secret.txt
cat secret.txt
# API_KEY=sk_live_9f2c8b1a4e6d
# DB_PASSWORD=hunter2-but-longer

暗号化します。

openssl enc -aes-256-cbc -pbkdf2 -in secret.txt -out secret.enc
# enter AES-256-CBC encryption password:
# Verifying - enter AES-256-CBC encryption password:

パスワードを2回聞かれます。打った文字は画面に出ません。ここでは kagi-2026-torigoe を使いました。

enc は encrypt の略です。-aes-256-cbc はアルゴリズムの指定で、256 は鍵の長さのビット数、cbc はブロックを鎖のようにつないで処理する方式を指します。-pbkdf2 はパスワードから鍵を作る手順の指定です(省くと警告が出ます。後述)。

中身は読めなくなり、先頭に Salted__ だけが残る

secret.enc はテキストではないので、cat すると端末が文字化けで埋まります。16進数で見ます。

xxd secret.enc | head -3
# 00000000: 5361 6c74 6564 5f5f 94ae 6bad fccc 63f0  Salted__..k...c.
# 00000010: f760 9fbb 1da1 fdc0 cfd7 1ac6 3587 d41c  .`..........5...
# 00000020: 9ed8 5c0d ce94 fb13 f2ee 7b5f 62fd 479b  ..\.......{_b.G.

右側が、中身を文字として読もうとした結果です。API_KEY の面影はどこにもありません。ただし先頭だけ、Salted__ の8文字が読めます。なぜ読める文字が残っているのかは、後半で種明かしになります。

ハッシュとの最大の違いは、戻せること

復号します。-d を足すだけです。

openssl enc -d -aes-256-cbc -pbkdf2 -in secret.enc
# enter AES-256-CBC decryption password:
# API_KEY=sk_live_9f2c8b1a4e6d
# DB_PASSWORD=hunter2-but-longer

戻りました。ここが第1回との決定的な違いです。ハッシュには元に戻す道がなく、そもそも復号という操作が存在しませんでした。暗号化は入力を捨てず、形を変えて全部持っています。ハッシュが要約なら、暗号化は往復できる変換です。

1ビットも違わないことは、第1回の道具で確かめられます。

openssl enc -d -aes-256-cbc -pbkdf2 -in secret.enc -out decrypted.txt
openssl dgst -sha256 secret.txt decrypted.txt
# SHA2-256(secret.txt)= 8327cbf12796d22d57f6f7532b077e01ad0f3db3e98cb8cc9ef88ef95997def8
# SHA2-256(decrypted.txt)= 8327cbf12796d22d57f6f7532b077e01ad0f3db3e98cb8cc9ef88ef95997def8

パスワードを1文字間違えると bad decrypt

ここからは手数が増えるので、パスワードを直接書く -pass pass: を使います。手軽ですが実務では使いません(理由は注意点で)。末尾の1文字だけ変えて開けてみます。

openssl enc -d -aes-256-cbc -pbkdf2 -in secret.enc -out oops.txt -pass pass:kagi-2026-torigoo
# bad decrypt
# 805D22FC01000000:error:1C800064:Provider routines:ossl_cipher_unpadblock:bad decrypt:providers/implementations/ciphers/ciphercommon_block.c:107:

拒まれました。先頭の英数字はエラーの識別番号なので、手元では違う値が並びます。なお失敗しても oops.txt は48バイトのゴミとして残ります。openssl は復号しながら書き出し、最後の帳尻が合わなくて初めて音を上げるからです。

同じ鍵で暗号化しても、暗号文は毎回変わる

この回の見どころです。まったく同じファイルを、まったく同じパスワードで2回暗号化します。

openssl enc -aes-256-cbc -pbkdf2 -in secret.txt -out enc1.bin -pass pass:kagi-2026-torigoe
openssl enc -aes-256-cbc -pbkdf2 -in secret.txt -out enc2.bin -pass pass:kagi-2026-torigoe

xxd enc1.bin | head -2
# 00000000: 5361 6c74 6564 5f5f 2610 d318 c495 d451  Salted__&......Q
# 00000010: 8eb4 67be 49e1 eb2e ad3a a724 f333 75f3  ..g.I....:.$.3u.

xxd enc2.bin | head -2
# 00000000: 5361 6c74 6564 5f5f 5f77 8910 4d09 b4f8  Salted___w..M...
# 00000010: 7409 03a2 4a8a 080d 1422 f0c8 93c0 ef28  t...J....".....(

別物です。差がどこから始まるかを機械に聞きます。

cmp enc1.bin enc2.bin
# enc1.bin enc2.bin differ: char 9, line 1

9バイト目。Salted__ の8文字が終わった、まさにその次からです。

第1回と並べると対比がはっきりします。ハッシュは同じ入力なら誰がいつ実行しても同じ値でした。暗号化は逆で、同じ入力を同じパスワードで処理しても毎回違う結果になる。それでいて、どちらも同じパスワードで開きます。

openssl dgst -sha256 enc1.bin enc2.bin
# SHA2-256(enc1.bin)= 3f8e531c43229d647dbb427e1735ebc5bdb6dcea7e9d98feaff28da207edd82c
# SHA2-256(enc2.bin)= 18b4ae4754e8eae21c87c020d696021e0065a0a682c4c0dc80c2819c8d020cf3

openssl enc -d -aes-256-cbc -pbkdf2 -in enc2.bin -pass pass:kagi-2026-torigoe
# API_KEY=sk_live_9f2c8b1a4e6d
# DB_PASSWORD=hunter2-but-longer

何かが暗号文に紛れ込んでいます。

種明かしはソルト、鍵は毎回作り直される

-p を付けると、openssl が内部で使った値を教えてくれます。2回続けて実行します。

openssl enc -aes-256-cbc -pbkdf2 -p -in secret.txt -out p1.bin -pass pass:kagi-2026-torigoe
# salt=33F9371D6EFD6129
# key=61587870154CAF49F4F45FF40A22B4378F25ADF8C991CB2AB22960D69C341C6D
# iv =B411A0367D4BA52AED9D00B22111027A

openssl enc -aes-256-cbc -pbkdf2 -p -in secret.txt -out p2.bin -pass pass:kagi-2026-torigoe
# salt=487395797ABBB119
# key=717E5A6E950D519D4529173D8E61B1A04FB10E3B8769884D238646158C51D353
# iv =363605D54243D0E92FE0EE369D543FBE

同じパスワードを渡したのに key が別物です。AES が実際に使う鍵は、打った文字列そのものではありません。パスワードともう一つの材料を混ぜて作られます。その材料が salt(ソルト)で、暗号化のたびに新しく引かれる乱数です。第0回で打った openssl rand が、ここで効いてきます。

そのソルトの正体は、先頭に居座っていた Salted__ の続きでした。

xxd p1.bin | head -1
# 00000000: 5361 6c74 6564 5f5f 33f9 371d 6efd 6129  Salted__3.7.n.a)

33f9 371d 6efd 6129 が、-p の表示した salt=33F9371D6EFD6129 と一致しています。ソルトは秘密ではありません。暗号文の先頭に、暗号化されていないそのまま読める形(平文)で書いてあります。復号する側はこの8バイトとパスワードを混ぜ直し、同じ鍵を組み立て直す。鍵が毎回違うのにパスワードひとつで開けられるのは、このためです。

この手順が -pbkdf2 の正体で、PBKDF2 という方式です。ハッシュを既定で10000回繰り返し、総当たりする側の1回あたりの費用を10000倍にします。

第1回の宿題:素のハッシュがパスワード保存に向かない理由

第1回の最後で、パスワードの保存に素の SHA-256 を使わない理由は「第2回で登場するソルトの話につながる」と書きました。その話です。

ソルトのないハッシュは、同じパスワードから必ず同じ値を出します。password123 のハッシュ値は、世界中のどのデータベースでも同じ64文字です。攻撃者はよくあるパスワードを片っ端からハッシュ化した一覧表を作っておける。流出した値と突き合わせれば、逆算せずに検索だけでパスワードが割れます。

利用者ごとに違うソルトを混ぜると、この前提が崩れます。同じ password123 でも、ソルトが違えば保存される値は全員バラバラです。一覧表は使えなくなり、1人ぶんずつ作り直すはめになる。bcrypt や Argon2 は、このソルトと反復回数の考え方を組み込んだ方式です。

鍵配送問題:暗号文は送れても、鍵を渡せない

secret.enc をアリスに送ります。途中で盗まれても構いません。中身は読めないし、開くには鍵が要る。ここまでは順調です。

問題は、アリスも鍵を持っていないことです。kagi-2026-torigoe を別途伝えなければ、手元に残るのは永遠に開かないファイルだけになります。

ではどうやって伝えるか。メールで送れば、暗号文と同じ経路です。覗ける相手なら両方拾って終わり。鍵を玄関マットの下に置いて出かけるのと変わりません。

困った。鍵は、どう渡す?
アリス ボブ 7fK#q2Zp… 鍵も、同じ道で 開けられる! のぞき見 アリス 7fK#q2Zp… 鍵も、同じ道で のぞき見 開けられる! ボブ

箱と同じ道で鍵を送ると、いっしょに盗まれる。

では電話で読み上げる、会って手渡す。これなら安全かもしれません。ところがここで話が一周します。安全にパスワードを渡せる経路があるのなら、最初からその経路でファイルを送ればよかったのです。

これが共通鍵暗号の限界です。ひとつの鍵で施錠も開錠もする仕組みは、その鍵を安全に届ける手段を自分の中に持っていません。暗号は安全な経路がないから使うのに、共通鍵暗号を使うには先に安全な経路が要る。人数が増えれば鍵も増えます。鍵は2人の組ごとに1本要るので、10人が互いにやりとりするなら45本、100人なら4950本。

AES そのものは強い。それでも鍵の受け渡しという一点で詰みます。教科書がこの行き詰まりに付けた名前が、鍵配送問題です。

注意点

-pbkdf2 を付け忘れると警告が出る

外すと openssl が止めてきます。

openssl enc -aes-256-cbc -in secret.txt -out old.bin -pass pass:kagi-2026-torigoe
# *** WARNING : deprecated key derivation used.
# Using -iter or -pbkdf2 would be better.

省略時に使われるのは古い鍵導出方式で、OpenSSL 自身が現代的な方式を使うよう案内しています。警告だけで処理は成功するので、見落とすと気づきません。反復回数は -iter 600000 のように増やせます。

-pass pass: はプロセス一覧から丸見え

コマンドラインに書いたパスワードは、実行中に他人から見えます。大きなファイルを暗号化しながら覗いてみました。

ps -eo args | grep "openssl enc"
# openssl enc -aes-256-cbc -pbkdf2 -in big.bin -out big.enc -pass pass:kagi-2026-torigoe

そのまま出ています。同じ内容はシェルの履歴にも残る。man ページも、この形式は「セキュリティが重要でない場面でのみ使うこと」と明記しています。実務では手で入力するか、権限を絞ったファイルから読ませます。

printf 'kagi-2026-torigoe\n' > pw.txt
chmod 600 pw.txt
openssl enc -aes-256-cbc -pbkdf2 -in secret.txt -out f.enc -pass file:pw.txt

bad decrypt は改ざんの検知ではない

さきほどの bad decrypt は頼りになりそうですが、パスワードの照合ではありません。復号結果の末尾の詰め物が、形式として辻褄が合うかを見ているだけです。man ページによれば、でたらめなデータがこの検査を通る確率は256分の1より高い。実際に間違ったパスワードを機械的に試したところ、3000個のうち13個が素通りしました。

openssl enc -d -aes-256-cbc -pbkdf2 -in secret.enc -out wrong.txt -pass pass:wrong43
# (エラーなし。終了コードも 0。中身は63バイトのゴミ)

この wrong43 は手元の暗号文に固有なので、そのまま試しても再現しません。要するに openssl enc には、正しく復号できたことを保証する仕組みがない。暗号文を書き換えられても検知できず、変な平文が出るだけです。

自作の暗号化を、本番の秘密管理に使わない

ここまでのコマンドは、仕組みを理解する道具として最適です。一方で、本番の API キーや認証情報をこれで固めて運用するのはやめてください。改ざん検知がなく、鍵の受け渡しは未解決で、更新や失効を扱う仕組みもありません。この用途にはクラウド各社のシークレットマネージャや、age、SOPS といった専用のツールがあります。

壊れるのは AES ではなく、たいてい使い方

AES は2001年に標準化されてから今日まで、実用的な攻撃法が見つかっていません。「暗号が破られた」と流れるとき、破られているのは AES 本体ではなくその周りです。鍵が短い、使い回されている、乱数が予測できる、ソースコードに直書きされている。第0回の「錠前がいくら立派でも、番号が誕生日なら開いてしまう」の通りです。

身のまわりにも、ある。
Wi-Fi https:// HTTPS の通信 ZIP パスワード付き ZIP スマホの中身 Wi-Fi https:// HTTPS の通信 ZIP パスワード付き ZIP スマホの中身

Wi-Fi、HTTPS、パスワード付き ZIP、スマホの中身。どれも中で働いているのは AES。

共通鍵暗号はひとつの鍵で施錠も開錠もします。ハッシュと違って元に戻せます。同じ入力でも暗号文が毎回変わるのは、ソルトという乱数を混ぜて鍵を作り直しているからです。そのソルトは暗号文の先頭に平文で置かれ、隠す必要はありません。そして共通鍵暗号は、鍵を相手に届ける方法を自前では持っていません。

次回は公開鍵暗号(RSA)です。鍵を2つに分ける、という発想がひとつ入るだけで、今日行き詰まった受け渡しが解けます。片方は世界中に配ってよく、片方だけを手元に隠す。第0回で作った乱数とも、そこで再会します。