ドキュメントには記されていないが、OpenSSL::PKCS5 というクラスが存在し、OpenSSL の PBKDF2 実装にアクセスすることができる。ただし OpenSSL 自体の制約により、バージョン0.9.8kの時点ではハッシュ関数として HMAC-SHA1 しか使用できない。
keys = OpenSSL::PKCS5.pbkdf2_hmac_sha1("password", "saltsalt", 1000, 16)
p keys.unpack("H*").join #=> "e9febff54bfce668fde301acc85563cc"
OpenSSL の将来のバージョン(1.0.0?)ではより汎用的な関数が追加される予定であり、Ruby からも利用できるようになるようだ。
keys = OpenSSL::PKCS5.pbkdf2_hmac("password", "saltsalt", 1000, 32, "sha256")
# => OpenSSL 0.9.8k では NotImplementedError
このあたりがまだ中途半端なので、ドキュメントには記されていないのだろうか。
Ruby のリポジトリをチェックしてみると、2007年4月5日にまず trunk に対して追加され、その後1.8系にバックポートされたらしい。
暗号ライブラリ内部でどんな処理が行われているのか、Ruby と PHP について調べてみた。調査に使用したバージョンは ruby-1.9.1-p129, php-5.2.9, openssl-0.9.8k
ruby-openssl ライブラリの場合
乱数用のインターフェイスは OpenSSL::Random モジュール。例えば
salt = OpenSSL::Random.random_bytes(8)
とすると8バイトのランダムなバイト列が取得できるのだが、こんな風に初期化処理なしで、いきなり乱数の取得メソッドを使っても大丈夫なのだろうか?
- Ruby のソースを見ると random_bytes の定義は ossl_rand.c にあり、実体は OpenSSL の RAND_bytes() に対するアダプタ
- OpenSSL のソースを見ると RAND_bytes の定義は rand_lib.c にあり、実体は(非FIPSモードでは)md_rand.c の中の ssleay_rand_bytes()
- ssleay_rand_bytes() の中で初期化済みか否か(initialized)を調べて、初期化済みでなかったら RAND_poll() を呼び出す
- RAND_poll の定義はOS/環境ごとに分かれているが、可能な限りのソースからエントロピーを収集するようになっている
- UNIX: rand_unix.c 通常はまず乱数デバイスからエントロピーの読み出しを試みる。試行対象となるデバイスのパスは e_os.h で DEVRANDOM として定義されており、デフォルトでは /dev/urandom が最優先となる(この際、Linux では poll, その他では select を使って非同期で読み出される)。それでも十分なエントロピーが集まらなかった場合、エントロピー収集デーモン(EGD)のソケットファイルから読み出しを試みる(こちらも e_os.h に試行対象となるパスが定義されている)。さらに pid と uid と現在時刻もエントロピーとして追加する。
- Windows: rand_win.c の場合は CryptAPI の CryptGenRandom() 関数から取得した疑似乱数をエントロピーとして追加する。さらにメモリ使用量やプロセスIDといったデータもエントロピーとして追加する。
ということで、ほとんどのケースではいきなり random_bytes メソッドなどを使っても大丈夫そうである。
ちなみに OpenSSL::Random モジュールには十分なエントロピーで初期化されたかどうかを調べる status? メソッドもある(現在はUndocumentedだが)。こちらもソースをたどって行けば初期化コードに繋がっているので、システムに十分なエントロピーが存在する環境ならば、常に true を返すはずである。
OpenSSL::Random.status? #=> true
php-mcrypt ライブラリの場合
直接的な乱数インターフェイスは存在しないが、初期化ベクトルの生成用として mcrypt_create_iv() 関数が用意されている。 この関数の定義は ext/mcrypt/mcrypt.c にあり、中身は指定されたソース(/dev/random または /dev/urandom)からそのままデータを読み出しているだけだった。
fd = open(source == RANDOM ? "/dev/random" : "/dev/urandom", O_RDONLY);
if (fd < 0) {
efree(iv);
php_error_docref(NULL TSRMLS_CC, E_WARNING, "Cannot open source device");
RETURN_FALSE;
}
while (read_bytes < size) {
n = read(fd, iv + read_bytes, size - read_bytes);
if (n < 0) {
break;
}
read_bytes += n;
}
n = read_bytes;
close(fd);
結構ナイーブな実装だな、というのが率直な感想。てっきり mcrypt に対応するインターフェイスがあって、処理を委譲しているのかと思っていたが…(気になって libmcrypt-2.5.8 のソースもざっとチェックしてみたが、やはり乱数の処理は入っていないようだ)。
Non-blocking I/O を使うなどの工夫は無いため、/dev/random では読み出しでブロックする可能性がある。また MCRYPT_RAND は単に rand 関数を必要な回数呼び出すだけなので、暗号用途では使うべきではない(つまりWindows環境では実質使用できない、ということになる)。
OpenSSL::Cipher::Cipher#padding= でパディング処理の有無を制御できる。では実際にどのようなパディング処理が行われているのだろう?
暗号化時にはパディングを有効にして、復号時に無効にすれば確認できる。
# Ruby 1.8.7 or 1.9
require 'openssl'
def enc_dec_base(operation)
password = 'fixed password'
lambda do | cipher, data, padding |
c = OpenSSL::Cipher::Cipher.new(cipher)
c.send(operation)
c.padding = padding
c.pkcs5_keyivgen(password)
c.update(data) + c.final
end
end
encrypt = enc_dec_base(:encrypt)
decrypt = enc_dec_base(:decrypt)
cipher = 'aes-256-cbc'
msg = 'ABCDEFGHIJKLMNOP' # 16bytes = AES block size
(1..msg.size).each do |len|
plain = msg[0, len]
encrypted = encrypt[cipher, plain, 1]
decrypted = decrypt[cipher, encrypted, 0] # disable padding
puts "original message: #{plain}"
p plain.bytes.to_a
p decrypted.bytes.to_a
puts
end
結果、PKCS#5 の PBES1, PBES2 と同じく RFC 1423 で定義されたパディング処理を行っていることが分かった。このパディングアルゴリズムは一般には PKCS#5 Padding などと呼ばれているらしい。
PHP の mcrypt ライブラリと相互運用する場合は、PHP 側で適切に処理する必要がある。PHP Manual の User Contributed Notes に実装例を見つけた:duerra_NOT_THIS_ at pushitlive dot net。
一応実行結果も載せておく。見事に階段状になっていてちょっと面白い。
(続きを読む…)
GitHubにて、PKCS#5 の PBKDF2 を Ruby で実装された方がいたので fork させていただいた。
tkyk’s pbkdf2-ruby at master – GitHub
とりあえず Ruby 1.9 対応を行った。テスト(spec)は主にRFC 3962に基づいているようなので、併せて詳細を調べていく予定。
結論から書くと、pkcs5_keyivgen に相当する処理を pure Ruby で実装すれば次のようになる。
def evp_bytes_to_key(digest, key_len, iv_len, pass, salt="", count=2048)
total_len = key_len + iv_len
return_buf = ""
digest_buf = ""
until return_buf.size >= total_len
digest_buf < < pass << salt
count.times { digest_buf = digest.digest(digest_buf) }
return_buf << digest_buf
end
return return_buf[0,key_len], return_buf[key_len,iv_len]
end
まず Ruby のソースを find & grep したところ、pkcs5_keyivgen の実装は ext/openssl/ossl_cipher.c にあることが分かった。そのコードを注意深く読むと、実際の処理は openssl の EVP_BytesToKey という関数で行われていることが分かった。そこで次に openssl のソースを同様に調査したところ、crypto/evp/evp_key.c に実装があることが分かった。また OpenSSL のサイトで概要を記したドキュメントも見つけた。これら手がかりから同等の処理を組み立てたのが上記の evp_bytes_to_key である。
その調査の過程で、pkcs5_keyivgen という名前は PKCS#5 という、「パスワードに基づく暗号化」を規定した標準規格に由来するということが分かった。しかし現在の Ruby の実装(つまり EVP_BytesToKey に基づく実装)は PKCS#5 の古いバージョン(v1.5)に、限定的に適合しているだけだということも分かった。PKCS#5 の最新のバージョンは2.0であり、Ruby 1.9 のソースコードにも、前出のドキュメントにも、今後は v2.0 を使うように、と注意書きがしてある。
その点から言うと先日のエントリの実行例は、PKCS#5 v2.0 は勿論のこと、v1.5 にさえ適合していない。なぜなら v1.5 ではハッシュ関数の出力長を超える長さの鍵を導出する方法は規定されていないからだ。その部分は EVP_BytesToKey の独自拡張という扱いになる。
この妥協によって、どのような用途でどのような脅威が生じ得るのかは、今の私にはちょっと計り難い。少なくとも、ビットが反転できるとか、暗号文が毎回同じになるとか、そういう分かりやすい現象は出てこない。しかし、それでも推奨されている方法があるのなら、できるだけそちらを使いたい。
ということで今度は PKCS#5 v2.0、すなわち RPC 2898 について調べていくことにする。
参考:
- EVP_BytesToKeyをRubyで実装する – lambda {|diary| lambda { diary.succ! } }.call(hatena)
- 私と同じく Ruby で EVP_BytesToKey を実装している。こちらの方がC言語からのベタ移植に近い。
- RFC 2898 – PKCS #5: Password-Based Cryptography Specification Version 2.0
- PKCS#5 はもとは RSA 社の規格だったらしいが、現在は RFC にもなっている。和訳は、今のところは存在しないようだ。あと v1.5 の仕様も探したのだが、見つけられなかった。どうやら v2.0 の中に含まれているらしい。
最後に Ruby 実装版 EVP_BytesToKey の動作確認の方法を載せておく。オリジナルの pkcs5_keyivgen が生成した鍵と初期化ベクトルは見ることができないので、暗号文が全く同じになることで間接的に確認する。
(続きを読む…)
Ruby で共通鍵暗号を使用する場合、openssl ライブラリを使用するのが簡単らしい。
require 'openssl'
def encrypt(cipher, msg, pass)
salt = OpenSSL::Random.random_bytes(8)
enc = OpenSSL::Cipher::Cipher.new(cipher)
enc.encrypt
enc.pkcs5_keyivgen(pass, salt)
return enc.update(msg) + enc.final, salt
end
def decrypt(cipher, crypt_msg, pass, salt)
dec = OpenSSL::Cipher::Cipher.new(cipher)
dec.decrypt
dec.pkcs5_keyivgen(pass, salt)
dec.update(crypt_msg) + dec.final
end
使用例:
cipher = 'aes-256-cbc' #鍵長256ビットのAES
msg = "秘密のメッセージ"
pass = "PASSWORD"
crypt, salt = encrypt(cipher, msg, pass)
p crypt.unpack("H*").join
p salt.unpack("H*").join
msg2 = decrypt(cipher, crypt, pass, salt)
p msg2
PHP + mcrypt の場合と違って、鍵や初期化ベクトルを直接設定する必要はない(不可能というわけでは無い)。代わりに pkcs5_keyivgen メソッドを使って、パスワードとソルトから鍵と初期化ベクトルを生成する。
パスワードとソルトが揃っていないと復号できないので、ソルトは暗号文とともに転送・保存する必要がある。それ故にソルトは、毎回異なる安全な乱数でなければならない。しかし、ここが重要なところだが、秘密にしておく必要はない。初期化ベクトルと違って、本当に秘密にしておく必要は無い。なぜならソルトだけでは鍵も初期化ベクトルも作り出せないし、ソルトを改竄しても平文を意図的に改竄することはできないからだ。
とても優れた仕組みだと思う。ただしその仕組みは、結局のところ pkcs5_keyivgen というただ一つのメソッドに立脚しているので、次はこのメソッドの詳細について調べてみたい。
参考:
- ウノウラボ Unoh Labs: rubyで手軽に暗号化文字列やハッシュ値を生成
- 参考にはなった…が、ソルトについて触れていないとか、パスワードのことをソルト(solt)と呼んでいるとか、hashの戻り値が文字列になっていないとか、突っ込みどころがいろいろある…。あと、2007年の記事なので仕方ないかもだが、Ruby 1.9 では動かない。
- class OpenSSL::Cipher::Cipher
- Ruby 1.9.1 リファレンスマニュアル OpenSSL::Cipher::Cipher クラス
『プログラムErlang』12章、Cの代わりにRubyで書いてみた。極力ベタな移植を心がけたが、随所にRubyらしさを漂わせたつもり。
example1_driver.rb
require './example1'
require './example1_comm'
def main()
begin
loop do
fun, *args = read_cmd()
result = case fun
when 1
twice(args[0])
when 2
sum(args[0], args[1])
end
write_cmd([result]) if result
end
rescue
STDERR.puts("finishied")
exit
end
end
main()
example1_comm.rb
def read_cmd()
len = read_exact(2).unpack('n').first
read_exact(len).unpack('C*')
end
def write_cmd(cmds)
bin = ([cmds.size] + cmds).pack('nC*')
write_exact(bin, bin.size)
end
def read_exact(len)
str = ""
str += STDIN.sysread(len) while str.size < len
str
end
def write_exact(bin, len)
wrote = 0
wrote += STDOUT.syswrite(bin[wrote...len]) while wrote < len
wrote
end
example1.rbはそのまんまなので省略。