From 12a0a89e22fbc312e4a95a7749bc153532daa855 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sun, 5 Dec 2021 21:53:35 +0900 Subject: [ruby/securerandom] Split Random::Formatter from SecureRandom [Feature #18190] https://github.com/ruby/securerandom/commit/1e57277b9e --- lib/random/formatter.rb | 215 ++++++++++++++++++++++++++++++++++ lib/securerandom.rb | 233 +------------------------------------ test/ruby/test_random_formatter.rb | 123 ++++++++++++++++++++ test/test_securerandom.rb | 102 +--------------- 4 files changed, 346 insertions(+), 327 deletions(-) create mode 100644 lib/random/formatter.rb create mode 100644 test/ruby/test_random_formatter.rb diff --git a/lib/random/formatter.rb b/lib/random/formatter.rb new file mode 100644 index 0000000000..c48886f9e0 --- /dev/null +++ b/lib/random/formatter.rb @@ -0,0 +1,215 @@ +# -*- coding: us-ascii -*- +# frozen_string_literal: true + +# == Random number formatter. +# +# === Examples +# +# Generate random hexadecimal strings: +# +# require 'random/formatter' +# +# prng.hex(10) #=> "52750b30ffbc7de3b362" +# prng.hex(10) #=> "92b15d6c8dc4beb5f559" +# prng.hex(13) #=> "39b290146bea6ce975c37cfc23" +# +# Generate random base64 strings: +# +# prng.base64(10) #=> "EcmTPZwWRAozdA==" +# prng.base64(10) #=> "KO1nIU+p9DKxGg==" +# prng.base64(12) #=> "7kJSM/MzBJI+75j8" +# +# Generate random binary strings: +# +# prng.random_bytes(10) #=> "\016\t{\370g\310pbr\301" +# prng.random_bytes(10) #=> "\323U\030TO\234\357\020\a\337" +# +# Generate alphanumeric strings: +# +# prng.alphanumeric(10) #=> "S8baxMJnPl" +# prng.alphanumeric(10) #=> "aOxAg8BAJe" +# +# Generate UUIDs: +# +# prng.uuid #=> "2d931510-d99f-494a-8c67-87feb05e1594" +# prng.uuid #=> "bad85eb9-0713-4da7-8d36-07a8e4b00eab" + +module Random::Formatter + + # Random::Formatter#random_bytes generates a random binary string. + # + # The argument _n_ specifies the length of the result string. + # + # If _n_ is not specified or is nil, 16 is assumed. + # It may be larger in future. + # + # The result may contain any byte: "\x00" - "\xff". + # + # require 'random/formatter' + # + # prng.random_bytes #=> "\xD8\\\xE0\xF4\r\xB2\xFC*WM\xFF\x83\x18\xF45\xB6" + # prng.random_bytes #=> "m\xDC\xFC/\a\x00Uf\xB2\xB2P\xBD\xFF6S\x97" + def random_bytes(n=nil) + n = n ? n.to_int : 16 + gen_random(n) + end + + # Random::Formatter#hex generates a random hexadecimal string. + # + # The argument _n_ specifies the length, in bytes, of the random number to be generated. + # The length of the resulting hexadecimal string is twice of _n_. + # + # If _n_ is not specified or is nil, 16 is assumed. + # It may be larger in the future. + # + # The result may contain 0-9 and a-f. + # + # require 'random/formatter' + # + # prng.hex #=> "eb693ec8252cd630102fd0d0fb7c3485" + # prng.hex #=> "91dc3bfb4de5b11d029d376634589b61" + def hex(n=nil) + random_bytes(n).unpack("H*")[0] + end + + # Random::Formatter#base64 generates a random base64 string. + # + # The argument _n_ specifies the length, in bytes, of the random number + # to be generated. The length of the result string is about 4/3 of _n_. + # + # If _n_ is not specified or is nil, 16 is assumed. + # It may be larger in the future. + # + # The result may contain A-Z, a-z, 0-9, "+", "/" and "=". + # + # require 'random/formatter' + # + # prng.base64 #=> "/2BuBuLf3+WfSKyQbRcc/A==" + # prng.base64 #=> "6BbW0pxO0YENxn38HMUbcQ==" + # + # See RFC 3548 for the definition of base64. + def base64(n=nil) + [random_bytes(n)].pack("m0") + end + + # Random::Formatter#urlsafe_base64 generates a random URL-safe base64 string. + # + # The argument _n_ specifies the length, in bytes, of the random number + # to be generated. The length of the result string is about 4/3 of _n_. + # + # If _n_ is not specified or is nil, 16 is assumed. + # It may be larger in the future. + # + # The boolean argument _padding_ specifies the padding. + # If it is false or nil, padding is not generated. + # Otherwise padding is generated. + # By default, padding is not generated because "=" may be used as a URL delimiter. + # + # The result may contain A-Z, a-z, 0-9, "-" and "_". + # "=" is also used if _padding_ is true. + # + # require 'random/formatter' + # + # prng.urlsafe_base64 #=> "b4GOKm4pOYU_-BOXcrUGDg" + # prng.urlsafe_base64 #=> "UZLdOkzop70Ddx-IJR0ABg" + # + # prng.urlsafe_base64(nil, true) #=> "i0XQ-7gglIsHGV2_BNPrdQ==" + # prng.urlsafe_base64(nil, true) #=> "-M8rLhr7JEpJlqFGUMmOxg==" + # + # See RFC 3548 for the definition of URL-safe base64. + def urlsafe_base64(n=nil, padding=false) + s = [random_bytes(n)].pack("m0") + s.tr!("+/", "-_") + s.delete!("=") unless padding + s + end + + # Random::Formatter#uuid generates a random v4 UUID (Universally Unique IDentifier). + # + # require 'random/formatter' + # + # prng.uuid #=> "2d931510-d99f-494a-8c67-87feb05e1594" + # prng.uuid #=> "bad85eb9-0713-4da7-8d36-07a8e4b00eab" + # prng.uuid #=> "62936e70-1815-439b-bf89-8492855a7e6b" + # + # The version 4 UUID is purely random (except the version). + # It doesn't contain meaningful information such as MAC addresses, timestamps, etc. + # + # The result contains 122 random bits (15.25 random bytes). + # + # See RFC 4122 for details of UUID. + # + 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 + + private def gen_random(n) + self.bytes(n) + end + + # Random::Formatter#choose generates a string that randomly draws from a + # source array of characters. + # + # The argument _source_ specifies the array of characters from which + # to generate the string. + # The argument _n_ specifies the length, in characters, of the string to be + # generated. + # + # The result may contain whatever characters are in the source array. + # + # require 'random/formatter' + # + # prng.choose([*'l'..'r'], 16) #=> "lmrqpoonmmlqlron" + # prng.choose([*'0'..'9'], 5) #=> "27309" + private def choose(source, n) + size = source.size + m = 1 + limit = size + while limit * size <= 0x100000000 + limit *= size + m += 1 + end + result = ''.dup + while m <= n + rs = random_number(limit) + is = rs.digits(size) + (m-is.length).times { is << 0 } + result << source.values_at(*is).join('') + n -= m + end + if 0 < n + rs = random_number(limit) + is = rs.digits(size) + if is.length < n + (n-is.length).times { is << 0 } + else + is.pop while n < is.length + end + result.concat source.values_at(*is).join('') + end + result + end + + ALPHANUMERIC = [*'A'..'Z', *'a'..'z', *'0'..'9'] + # Random::Formatter#alphanumeric generates a random alphanumeric string. + # + # The argument _n_ specifies the length, in characters, of the alphanumeric + # string to be generated. + # + # If _n_ is not specified or is nil, 16 is assumed. + # It may be larger in the future. + # + # The result may contain A-Z, a-z and 0-9. + # + # require 'random/formatter' + # + # prng.alphanumeric #=> "2BuBuLf3WfSKyQbR" + # prng.alphanumeric(10) #=> "i6K93NdqiH" + def alphanumeric(n=nil) + n = 16 if n.nil? + choose(ALPHANUMERIC, n) + end +end diff --git a/lib/securerandom.rb b/lib/securerandom.rb index 323d4af3b7..9cbf4ea789 100644 --- a/lib/securerandom.rb +++ b/lib/securerandom.rb @@ -1,6 +1,8 @@ # -*- coding: us-ascii -*- # frozen_string_literal: true +require 'random/formatter' + # == Secure random number generator interface. # # This library is an interface to secure random number generators which are @@ -33,37 +35,8 @@ # These methods are usable as class methods of SecureRandom such as # +SecureRandom.hex+. # -# === Examples -# -# Generate random hexadecimal strings: -# -# require 'securerandom' -# -# SecureRandom.hex(10) #=> "52750b30ffbc7de3b362" -# SecureRandom.hex(10) #=> "92b15d6c8dc4beb5f559" -# SecureRandom.hex(13) #=> "39b290146bea6ce975c37cfc23" -# -# Generate random base64 strings: -# -# SecureRandom.base64(10) #=> "EcmTPZwWRAozdA==" -# SecureRandom.base64(10) #=> "KO1nIU+p9DKxGg==" -# SecureRandom.base64(12) #=> "7kJSM/MzBJI+75j8" -# -# Generate random binary strings: -# -# SecureRandom.random_bytes(10) #=> "\016\t{\370g\310pbr\301" -# SecureRandom.random_bytes(10) #=> "\323U\030TO\234\357\020\a\337" -# -# Generate alphanumeric strings: -# -# SecureRandom.alphanumeric(10) #=> "S8baxMJnPl" -# SecureRandom.alphanumeric(10) #=> "aOxAg8BAJe" -# -# Generate UUIDs: -# -# SecureRandom.uuid #=> "2d931510-d99f-494a-8c67-87feb05e1594" -# SecureRandom.uuid #=> "bad85eb9-0713-4da7-8d36-07a8e4b00eab" -# +# If a secure random number generator is not available, +# +NotImplementedError+ is raised. module SecureRandom class << self @@ -116,202 +89,4 @@ module SecureRandom end end -module Random::Formatter - - # SecureRandom.random_bytes generates a random binary string. - # - # The argument _n_ specifies the length of the result string. - # - # If _n_ is not specified or is nil, 16 is assumed. - # It may be larger in future. - # - # The result may contain any byte: "\x00" - "\xff". - # - # require 'securerandom' - # - # SecureRandom.random_bytes #=> "\xD8\\\xE0\xF4\r\xB2\xFC*WM\xFF\x83\x18\xF45\xB6" - # SecureRandom.random_bytes #=> "m\xDC\xFC/\a\x00Uf\xB2\xB2P\xBD\xFF6S\x97" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - def random_bytes(n=nil) - n = n ? n.to_int : 16 - gen_random(n) - end - - # SecureRandom.hex generates a random hexadecimal string. - # - # The argument _n_ specifies the length, in bytes, of the random number to be generated. - # The length of the resulting hexadecimal string is twice of _n_. - # - # If _n_ is not specified or is nil, 16 is assumed. - # It may be larger in the future. - # - # The result may contain 0-9 and a-f. - # - # require 'securerandom' - # - # SecureRandom.hex #=> "eb693ec8252cd630102fd0d0fb7c3485" - # SecureRandom.hex #=> "91dc3bfb4de5b11d029d376634589b61" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - def hex(n=nil) - random_bytes(n).unpack("H*")[0] - end - - # SecureRandom.base64 generates a random base64 string. - # - # The argument _n_ specifies the length, in bytes, of the random number - # to be generated. The length of the result string is about 4/3 of _n_. - # - # If _n_ is not specified or is nil, 16 is assumed. - # It may be larger in the future. - # - # The result may contain A-Z, a-z, 0-9, "+", "/" and "=". - # - # require 'securerandom' - # - # SecureRandom.base64 #=> "/2BuBuLf3+WfSKyQbRcc/A==" - # SecureRandom.base64 #=> "6BbW0pxO0YENxn38HMUbcQ==" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - # - # See RFC 3548 for the definition of base64. - def base64(n=nil) - [random_bytes(n)].pack("m0") - end - - # SecureRandom.urlsafe_base64 generates a random URL-safe base64 string. - # - # The argument _n_ specifies the length, in bytes, of the random number - # to be generated. The length of the result string is about 4/3 of _n_. - # - # If _n_ is not specified or is nil, 16 is assumed. - # It may be larger in the future. - # - # The boolean argument _padding_ specifies the padding. - # If it is false or nil, padding is not generated. - # Otherwise padding is generated. - # By default, padding is not generated because "=" may be used as a URL delimiter. - # - # The result may contain A-Z, a-z, 0-9, "-" and "_". - # "=" is also used if _padding_ is true. - # - # require 'securerandom' - # - # SecureRandom.urlsafe_base64 #=> "b4GOKm4pOYU_-BOXcrUGDg" - # SecureRandom.urlsafe_base64 #=> "UZLdOkzop70Ddx-IJR0ABg" - # - # SecureRandom.urlsafe_base64(nil, true) #=> "i0XQ-7gglIsHGV2_BNPrdQ==" - # SecureRandom.urlsafe_base64(nil, true) #=> "-M8rLhr7JEpJlqFGUMmOxg==" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - # - # See RFC 3548 for the definition of URL-safe base64. - def urlsafe_base64(n=nil, padding=false) - s = [random_bytes(n)].pack("m0") - s.tr!("+/", "-_") - s.delete!("=") unless padding - s - end - - # SecureRandom.uuid generates a random v4 UUID (Universally Unique IDentifier). - # - # require 'securerandom' - # - # SecureRandom.uuid #=> "2d931510-d99f-494a-8c67-87feb05e1594" - # SecureRandom.uuid #=> "bad85eb9-0713-4da7-8d36-07a8e4b00eab" - # SecureRandom.uuid #=> "62936e70-1815-439b-bf89-8492855a7e6b" - # - # The version 4 UUID is purely random (except the version). - # It doesn't contain meaningful information such as MAC addresses, timestamps, etc. - # - # The result contains 122 random bits (15.25 random bytes). - # - # See RFC 4122 for details of UUID. - # - 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 - - private def gen_random(n) - self.bytes(n) - end - - # SecureRandom.choose generates a string that randomly draws from a - # source array of characters. - # - # The argument _source_ specifies the array of characters from which - # to generate the string. - # The argument _n_ specifies the length, in characters, of the string to be - # generated. - # - # The result may contain whatever characters are in the source array. - # - # require 'securerandom' - # - # SecureRandom.choose([*'l'..'r'], 16) #=> "lmrqpoonmmlqlron" - # SecureRandom.choose([*'0'..'9'], 5) #=> "27309" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - private def choose(source, n) - size = source.size - m = 1 - limit = size - while limit * size <= 0x100000000 - limit *= size - m += 1 - end - result = ''.dup - while m <= n - rs = random_number(limit) - is = rs.digits(size) - (m-is.length).times { is << 0 } - result << source.values_at(*is).join('') - n -= m - end - if 0 < n - rs = random_number(limit) - is = rs.digits(size) - if is.length < n - (n-is.length).times { is << 0 } - else - is.pop while n < is.length - end - result.concat source.values_at(*is).join('') - end - result - end - - ALPHANUMERIC = [*'A'..'Z', *'a'..'z', *'0'..'9'] - # SecureRandom.alphanumeric generates a random alphanumeric string. - # - # The argument _n_ specifies the length, in characters, of the alphanumeric - # string to be generated. - # - # If _n_ is not specified or is nil, 16 is assumed. - # It may be larger in the future. - # - # The result may contain A-Z, a-z and 0-9. - # - # require 'securerandom' - # - # SecureRandom.alphanumeric #=> "2BuBuLf3WfSKyQbR" - # SecureRandom.alphanumeric(10) #=> "i6K93NdqiH" - # - # If a secure random number generator is not available, - # +NotImplementedError+ is raised. - def alphanumeric(n=nil) - n = 16 if n.nil? - choose(ALPHANUMERIC, n) - end -end - SecureRandom.extend(Random::Formatter) diff --git a/test/ruby/test_random_formatter.rb b/test/ruby/test_random_formatter.rb new file mode 100644 index 0000000000..df3320b41c --- /dev/null +++ b/test/ruby/test_random_formatter.rb @@ -0,0 +1,123 @@ +require 'test/unit' +require 'random/formatter' + +module Random::Formatter + module FormatterTest + def test_random_bytes + assert_equal(16, @it.random_bytes.size) + assert_equal(Encoding::ASCII_8BIT, @it.random_bytes.encoding) + 65.times do |idx| + assert_equal(idx, @it.random_bytes(idx).size) + end + end + + def test_hex + s = @it.hex + assert_equal(16 * 2, s.size) + assert_match(/\A\h+\z/, s) + 33.times do |idx| + s = @it.hex(idx) + assert_equal(idx * 2, s.size) + assert_match(/\A\h*\z/, s) + end + end + + def test_hex_encoding + assert_equal(Encoding::US_ASCII, @it.hex.encoding) + end + + def test_base64 + assert_equal(16, @it.base64.unpack('m*')[0].size) + 17.times do |idx| + assert_equal(idx, @it.base64(idx).unpack('m*')[0].size) + end + end + + def test_urlsafe_base64 + safe = /[\n+\/]/ + 65.times do |idx| + assert_not_match(safe, @it.urlsafe_base64(idx)) + end + # base64 can include unsafe byte + assert((0..10000).any? {|idx| safe =~ @it.base64(idx)}, "None of base64(0..10000) is url-safe") + end + + def test_random_number_float + 101.times do + v = @it.random_number + assert_in_range(0.0...1.0, v) + end + end + + def test_random_number_float_by_zero + 101.times do + v = @it.random_number(0) + assert_in_range(0.0...1.0, v) + end + end + + def test_random_number_int + 101.times do |idx| + next if idx.zero? + v = @it.random_number(idx) + assert_in_range(0...idx, v) + end + end + + def test_uuid + uuid = @it.uuid + assert_equal(36, uuid.size) + + # Check time_hi_and_version and clock_seq_hi_res bits (RFC 4122 4.4) + assert_equal('4', uuid[14]) + assert_include(%w'8 9 a b', uuid[19]) + + assert_match(/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/, uuid) + end + + def test_alphanumeric + 65.times do |n| + an = @it.alphanumeric(n) + assert_match(/\A[0-9a-zA-Z]*\z/, an) + assert_equal(n, an.length) + end + end + + def assert_in_range(range, result, mesg = nil) + assert(range.cover?(result), build_message(mesg, "Expected #{result} to be in #{range}")) + end + end + + module NotDefaultTest + def test_random_number_not_default + msg = "random_number should not be affected by srand" + seed = srand(0) + x = @it.random_number(1000) + 10.times do|i| + srand(0) + return unless @it.random_number(1000) == x + end + srand(0) + assert_not_equal(x, @it.random_number(1000), msg) + ensure + srand(seed) if seed + end + end + + class TestClassMethods < Test::Unit::TestCase + include FormatterTest + + def setup + @it = Random + end + end + + class TestInstanceMethods < Test::Unit::TestCase + include FormatterTest + include NotDefaultTest + + def setup + @it = Random.new + end + end +end diff --git a/test/test_securerandom.rb b/test/test_securerandom.rb index f9130ab81e..e4a0e17365 100644 --- a/test/test_securerandom.rb +++ b/test/test_securerandom.rb @@ -1,21 +1,17 @@ # frozen_string_literal: false require 'test/unit' require 'securerandom' +require_relative 'ruby/test_random_formatter' # This testcase does NOT aim to test cryptographically strongness and randomness. class TestSecureRandom < Test::Unit::TestCase + include Random::Formatter::FormatterTest + include Random::Formatter::NotDefaultTest + def setup @it = SecureRandom end - def test_s_random_bytes - assert_equal(16, @it.random_bytes.size) - assert_equal(Encoding::ASCII_8BIT, @it.random_bytes.encoding) - 65.times do |idx| - assert_equal(idx, @it.random_bytes(idx).size) - end - end - # This test took 2 minutes on my machine. # And 65536 times loop could not be enough for forcing PID recycle. if false @@ -69,96 +65,6 @@ if false end end - def test_s_hex - s = @it.hex - assert_equal(16 * 2, s.size) - assert_match(/\A\h+\z/, s) - 33.times do |idx| - s = @it.hex(idx) - assert_equal(idx * 2, s.size) - assert_match(/\A\h*\z/, s) - end - end - - def test_hex_encoding - assert_equal(Encoding::US_ASCII, @it.hex.encoding) - end - - def test_s_base64 - assert_equal(16, @it.base64.unpack('m*')[0].size) - 17.times do |idx| - assert_equal(idx, @it.base64(idx).unpack('m*')[0].size) - end - end - - def test_s_urlsafe_base64 - safe = /[\n+\/]/ - 65.times do |idx| - assert_not_match(safe, @it.urlsafe_base64(idx)) - end - # base64 can include unsafe byte - assert((0..10000).any? {|idx| safe =~ @it.base64(idx)}, "None of base64(0..10000) is url-safe") - end - - def test_s_random_number_float - 101.times do - v = @it.random_number - assert_in_range(0.0...1.0, v) - end - end - - def test_s_random_number_float_by_zero - 101.times do - v = @it.random_number(0) - assert_in_range(0.0...1.0, v) - end - end - - def test_s_random_number_int - 101.times do |idx| - next if idx.zero? - v = @it.random_number(idx) - assert_in_range(0...idx, v) - end - end - - def test_s_random_number_not_default - msg = "SecureRandom#random_number should not be affected by srand" - seed = srand(0) - x = @it.random_number(1000) - 10.times do|i| - srand(0) - return unless @it.random_number(1000) == x - end - srand(0) - assert_not_equal(x, @it.random_number(1000), msg) - ensure - srand(seed) if seed - end - - def test_uuid - uuid = @it.uuid - assert_equal(36, uuid.size) - - # Check time_hi_and_version and clock_seq_hi_res bits (RFC 4122 4.4) - assert_equal('4', uuid[14]) - assert_include(%w'8 9 a b', uuid[19]) - - assert_match(/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/, uuid) - end - - def test_alphanumeric - 65.times do |n| - an = @it.alphanumeric(n) - assert_match(/\A[0-9a-zA-Z]*\z/, an) - assert_equal(n, an.length) - end - end - - def assert_in_range(range, result, mesg = nil) - assert(range.cover?(result), build_message(mesg, "Expected #{result} to be in #{range}")) - end - def test_with_openssl begin require 'openssl' -- cgit v1.2.3