メールで送りたいファイルに、API キーとデータベースのパスワードが並んでいます。テキストファイルなので、手に入れた人は開くだけで中身を読めます。今日はこのファイルに鍵をかけます。そして、かけた鍵を相手にどう渡すかで行き詰まります。
OpenSSL で手を動かして学ぶ暗号の基礎、第2回は共通鍵暗号です。環境の準備がまだの方は第0回、ハッシュの復習は第1回からどうぞ。
コマンドを打つ前に、今日やることを4コマにしておきます。
そのまま送ると、途中で読まれてしまう。
鍵をかけると、中の文字はぐちゃぐちゃになる。これが暗号化。
途中で覗かれても、読めない。
かける鍵と開ける鍵が同じ。だから「共通鍵」。開けることを復号という。
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 を別途伝えなければ、手元に残るのは永遠に開かないファイルだけになります。
ではどうやって伝えるか。メールで送れば、暗号文と同じ経路です。覗ける相手なら両方拾って終わり。鍵を玄関マットの下に置いて出かけるのと変わりません。
箱と同じ道で鍵を送ると、いっしょに盗まれる。
では電話で読み上げる、会って手渡す。これなら安全かもしれません。ところがここで話が一周します。安全にパスワードを渡せる経路があるのなら、最初からその経路でファイルを送ればよかったのです。
これが共通鍵暗号の限界です。ひとつの鍵で施錠も開錠もする仕組みは、その鍵を安全に届ける手段を自分の中に持っていません。暗号は安全な経路がないから使うのに、共通鍵暗号を使うには先に安全な経路が要る。人数が増えれば鍵も増えます。鍵は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、パスワード付き ZIP、スマホの中身。どれも中で働いているのは AES。
共通鍵暗号はひとつの鍵で施錠も開錠もします。ハッシュと違って元に戻せます。同じ入力でも暗号文が毎回変わるのは、ソルトという乱数を混ぜて鍵を作り直しているからです。そのソルトは暗号文の先頭に平文で置かれ、隠す必要はありません。そして共通鍵暗号は、鍵を相手に届ける方法を自前では持っていません。
次回は公開鍵暗号(RSA)です。鍵を2つに分ける、という発想がひとつ入るだけで、今日行き詰まった受け渡しが解けます。片方は世界中に配ってよく、片方だけを手元に隠す。第0回で作った乱数とも、そこで再会します。
