前回のこれの続きです。日が開くと面倒くさくなるのでサクサクと。
前回課題になっていた部分です。
- 頻繁に起動されるスクリプトなので、暗号強度が少し下がっても、それなりの速度が欲しい。
- 一定時間は同じ入力に対して同じ暗号文を生成したい。つまり、saltとIV (Initialization Vector)を使いまわせるようにしたい。
openssl v3 のコマンドは、以下を想定しています。
$ openssl enc -pbkdf2 -salt -aes256 -base64 -k hogefugapiyopiyo -e -in plain.txt -out cipher.txt
$ openssl enc -pbkdf2 -salt -aes256 -base64 -k hogefugapiyopiyo -d -in cipher.txt -out decrypted.txt
暗号化のモードは、AES256 CBCモードです。同じsaltとIVが指定されると、同じ入力に対して同じ出力が得られます。つまり、処理時間のかかる PBKDF2 などの鍵導出は最初の一回だけにして、しばらくsaltとIVを使い回すことで、高速化できそうです。
[2025/7/1追記]
重要:IVの再利用は暗号化にとって致命的な問題を生むことがあります。モードによっては即時アウトです (AES-GCMなど)。CBCモードでも安全性が著しく低下するとされているので、用途を十分に検討すべきです。
saltとIVを明示的に与える暗号化
結論から言うと、正解例はこれです。PBKDF2の計算を先に行い、求めたsaltとIVを使ってCrypt::CBCで暗号化します。
use MIME::Base64;
use Crypt::CBC;
use Crypt::PRNG;
use Crypt::PBKDF2;
my $salt = Crypt::PRNG::random_bytes(8);
my $pbkdf2 = Crypt::PBKDF2->new(
hash_class => 'HMACSHA2',
iterations => 10000,
output_len => 32+16,
salt_len => 8,
);
my $ekr = $pbkdf2->generate('hogefugapiyopiyo', $salt);
my @ek = split(/:/, $ekr);
my $key_iv = decode_base64($ek[3]);
my $key = substr($key_iv, 0, 32);
my $iv = substr($key_iv, 32, 16);
my $crypt = Crypt::CBC->new(
-pass => $key,
-keysize => 32,
-cipher => 'Crypt::Rijndael',
-iv => $iv,
-pbkdf => 'none',
-header => 'none',
);
my $cipher = "Salted\x5f\x5f".$salt.$crypt->encrypt( 'I love you, too!' );
前回と比べるとだいぶ長くなりました。
この出力 $cipher をBase64エンコードして cipher.txt に書き込めば、前述の openssl enc -d で復号できるはずです。
ハマったところの説明
ウェブ検索で見つかる記事や、GitHubのCopilot君では、なかなか解決のヒントに辿り着けませんでした。ハマりどころは以下のとおりです。
まず、Crypt::PBKDF2 の出力をそのまま Crypt::CBC に渡すコードをよく見かけるのですが、形式が違います。 Crypt::PBKDF2 は RFC 2307 形式で鍵を出力します。printしてみるとこんな感じです。
{X-PBKDF2}HMACSHA2+256:AAAnEA:aTLiFshBDHw=:33CUEBmlL9J42BVhbfH2i7X0NGtAwIQbkj0NJExNA0/nxnPYpNAHEbtfOzAt0FL1
コロンで区切られた最後のフィールドが、Base64エンコードされた鍵です。
次に、IVをどうしたらよいのかというところで、なかなか正解にたどり着けなくて苦労しました。ウェブには任意の乱数をIVとして使うコードばかりありますが、これではダメです。自前で生成した乱数saltと、前回のコードで Crypt::CBC の中で計算されたIVのペアを使うと、今回のコードの後半部分は正常に動きます。ところが、IVを別に与えると復号できないのです。
正解は「暗号化の鍵とIVをPBKDF2の中で一緒に求める」というものです。
わかるかーい!・・・・・(ノ`Д´)ノ彡┻━┻
AES256 (CBC) では、鍵長が32バイトです。これにIV分の16バイトを足した値を PBKDF2 で生成して、前半を鍵、後半をIVとして使います。
みっつめは、Crypt::CBC のencryptの出力にsaltが付いていないことです。仕方がないので自分でヘッダーを付けます。
PBKDF2::Tiny で高速化する
そうです。同じスクリプトの中で PBKDF2 の計算をしているので、前回のコードから高速化できていません。Ryzen 7 PRO 4750GのPCで動かしてみると、user時間が70~80 msもかかっています。realは100 msぐらいです。おっそ……
調べてみると、実は Crypt::PBKDF2 のモジュールをロードする部分でも時間を食われていることがわかりました。
手っ取り早く高速化するのに、PBKDF2::Tiny を使う方法があります。"Minimalist PBKDF2 (RFC 2898) with HMAC-SHA1 or HMAC-SHA2" だそうです。これを使うと、ロード時間が短く、コードもグッとシンプルになります。
require PBKDF2::Tiny;
$key_iv = PBKDF2::Tiny::derive('SHA-256', 'hogefugapiyopiyo', $salt, 10000, 48);
user時間が40~50 ms、realが60 ms程度になりました。
saltとIVをKVSに保存して高速化してみる
スクリプトが呼ばれるたびにsalt/IVが新しくなってしまうので、当初の目的が達成できていません。毎回 PBKDF2 しなくても済むように、salt/IVを再利用することを考えます。といっても、ファイルに書き出すのでは遅いので、オンメモリの KVS (Key Value Store) などを使うのがよさそうです。Redis / Valkey でできそうですね。
というわけで、試してみたのですが、なんと Redis のモジュールをロードするのに60 msぐらいかかることが判明_('、3」∠)_ 本末転倒な結果に。
そこに神様がいました。Redis::Fast というモジュールを使うと、ロード時間が大幅に短縮されます。キャッシュしたsalt/IVを再利用することで、user時間が20 ms程度まで短縮できました。
(応用先が) 芽出たし、芽出たし。