RubyのSecureRandomの乱数生成の実装を読んでみた
bosyuでエンジニア/PdMをしているころちゃんです。趣味は認証認可技術です。この記事は bosyu Advent Calendar 2019 1日目のエントリになります。
bosyuはRubyで作られているのですが、Rubyで乱数生成をする際の選択肢として Random と SecureRandom がありますよね。 暗号化周りではSecureRandomの使用が推奨されているのはご存知かと思います。今回はその実装が気になったので追ってみた話です。軽めの内容です。
ソースはRuby 2.6.5で確認してます。
Random
まず先にRandomを流しておきます。リファレンスによると
MT19937に基づく疑似乱数生成器を提供するクラスです。
とあります。MT=メルセンヌ・ツイスタです。疑似乱数生成器が使用されています。暗号用途には使わないほうがよいですね。
最近、以下の記事が話題になっていましたね。
・10秒で衝突するUUIDの作り方
・メルセンヌツイスタはそんなに衝突しない
SecureRandom
さて本命のSecureRandomです。リファレンスはこちら。
安全な乱数発生器のためのインターフェースを提供するモジュールです。 HTTP のセッションキーなどに適しています。
とのことです。/dev/urandom または OpenSSL がサポートされています。
/dev/urandom?
Linuxには /dev/randomと /dev/urandom という疑似デバイスファイルがあり、randomは真乱数、urandomは真乱数 + 疑似乱数を組み合わせて乱数を出力します。なおrandomが真乱数かどうかは環境にもよりますし、真乱数だから質が良いって話でもありません。
> hexdump -C -n 8 /dev/urandom
00000000 f5 fc f1 dc 96 93 27 74
> hexdump -C -n 8 /dev/urandom
00000000 9b 46 ac c7 74 3f b3 79
これらの乱数生成の種(シード)はエントロピープールと呼ばれるものから取り出されます。エントロピープールは、システムのイベント発生等の環境ノイズを乱数種としてストアしているものです。randomとurandomはこのエントロピープールに溜まった乱数種を消費して乱数を生成します。
エントロピープールには限りがあるので、大量の乱数を生成すると、エントロピー不足の問題に陥ります。randomはエントロピーが枯渇したときに何も出力しなくなりエントロピーが確保できるまで停止しますが、urandomはエントロピーを再利用するので停止はせず、安定して生成してくれます。
そして Ruby ではurandom を使って乱数を生成しています。
若干前置きが長くなりましたが、ソースを見ていきます。Rubyです。
def hex(n=nil)
random_bytes(n).unpack("H*")[0]
end
def base64(n=nil)
[random_bytes(n)].pack("m0")
end
def uuid
ary = random_bytes(16).unpack("NnnnnN")
ary[2] = (ary[2] & 0x0fff) | 0x4000
ary[3] = (ary[3] & 0x3fff) | 0x8000
"%08x-%04x-%04x-%04x-%04x%08x" % ary
end
よく使うメソッドは全てrandom_bytes(n)に依存しています。
def random_bytes(n=nil)
n = n ? n.to_int : 16
gen_random(n)
end
random_bytesの中で更にgen_randomをコール。
def gen_random(n)
ret = Random.urandom(1)
if ret.nil?
begin
require 'openssl'
rescue NoMethodError
raise NotImplementedError, "No random device"
else
@rng_chooser.synchronize do
class << self
remove_method :gen_random
alias gen_random gen_random_openssl
public :gen_random
end
end
return gen_random(n)
end
else
@rng_chooser.synchronize do
class << self
remove_method :gen_random
alias gen_random gen_random_urandom
public :gen_random
end
end
return gen_random(n)
end
end
肝心のgen_randomの中ですが、urandomの利用可否を判定して、urandom or openssl の利用を決めています。実はRandomのurandomを呼んでいますね。
両方利用可能な場合でもurandomが優先されるようです。urandom使えない環境なんてあるんか?と思いましたが、Windowsでした。
gen_randomは実行時に自身をエイリアスで上書きしているので、gen_random自体は初回しか呼ばれず、それぞれの乱数生成器(gen_random_openssl or gen_random_urandom)に対応したメソッドが呼ばれるようになります。張替え時に排他制御が入っていてなるほどと思いました。
def gen_random_openssl(n)
@pid = 0 unless defined?(@pid)
pid = $$
unless @pid == pid
now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
OpenSSL::Random.random_add([now, @pid, pid].join(""), 0.0)
seed = Random.urandom(16)
if (seed)
OpenSSL::Random.random_add(seed, 16)
end
@pid = pid
end
return OpenSSL::Random.random_bytes(n)
end
def gen_random_urandom(n)
ret = Random.urandom(n)
unless ret
raise NotImplementedError, "No random device"
end
unless ret.length == n
raise NotImplementedError, "Unexpected partial read from random device: only #{ret.length} for #{n} bytes"
end
ret
end
少し気になったのが、gen_random_openssl(n)の以下の部分です。
seed = Random.urandom(16)
if (seed)
OpenSSL::Random.random_add(seed, 16)
end
urandomの返り値がnilの場合にしかgen_random_opensslがコールされないので、この分岐の中身は呼ばれないような気がしています。urandomが急に乱数を吐き出すようになれば話は別なんですが、そんなことあるんかなーと。
もともとurandomよりもOpenSSLが優先的に採用されていたようなので、その名残でしょうかね。
関連チケットでこのあたりを見ていました。
・https://bugs.ruby-lang.org/issues/9569
・https://bugs.ruby-lang.org/issues/13885
まとめ
Cを読む覚悟でいたらRubyのコードだけで済んでしまいました。普段使ってるメソッドの挙動や歴史を追うのは楽しいですね。今回は軽くてランチ勉強会などにもちょうどいい感じでした。
暗号関連の書籍だと、結城先生の秘密の国のアリスが個人的なオススメです。また読み直そうかな。