見出し画像

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のコードだけで済んでしまいました。普段使ってるメソッドの挙動や歴史を追うのは楽しいですね。今回は軽くてランチ勉強会などにもちょうどいい感じでした。

暗号関連の書籍だと、結城先生の秘密の国のアリスが個人的なオススメです。また読み直そうかな。


この記事が気に入ったらサポートをしてみませんか?