rotp 4.0.0 → 6.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +19 -0
  3. data/.devcontainer/devcontainer.json +41 -0
  4. data/.github/workflows/release.yaml +36 -0
  5. data/.github/workflows/test.yaml +26 -0
  6. data/.release-please-manifest.json +3 -0
  7. data/CHANGELOG.md +91 -24
  8. data/Dockerfile-2.3 +1 -7
  9. data/Dockerfile-2.7 +11 -0
  10. data/Dockerfile-3.0 +12 -0
  11. data/Guardfile +1 -1
  12. data/README.md +64 -15
  13. data/bin/rotp +1 -1
  14. data/docker-compose.yml +37 -0
  15. data/lib/rotp/arguments.rb +6 -5
  16. data/lib/rotp/base32.rb +56 -30
  17. data/lib/rotp/cli.rb +4 -5
  18. data/lib/rotp/hotp.rb +6 -13
  19. data/lib/rotp/otp/uri.rb +78 -0
  20. data/lib/rotp/otp.rb +26 -25
  21. data/lib/rotp/totp.rb +13 -33
  22. data/lib/rotp/version.rb +1 -1
  23. data/lib/rotp.rb +2 -4
  24. data/release-please-config.json +12 -0
  25. data/rotp.gemspec +13 -15
  26. data/spec/lib/rotp/arguments_spec.rb +5 -6
  27. data/spec/lib/rotp/base32_spec.rb +45 -19
  28. data/spec/lib/rotp/cli_spec.rb +21 -6
  29. data/spec/lib/rotp/hotp_spec.rb +38 -17
  30. data/spec/lib/rotp/otp/uri_spec.rb +99 -0
  31. data/spec/lib/rotp/totp_spec.rb +61 -98
  32. data/spec/spec_helper.rb +1 -2
  33. metadata +25 -43
  34. data/.travis.yml +0 -8
  35. data/Dockerfile-1.9 +0 -15
  36. data/Dockerfile-2.1 +0 -16
  37. data/Rakefile +0 -9
  38. data/doc/ROTP/HOTP.html +0 -308
  39. data/doc/ROTP/OTP.html +0 -593
  40. data/doc/ROTP/TOTP.html +0 -493
  41. data/doc/Rotp.html +0 -179
  42. data/doc/_index.html +0 -144
  43. data/doc/class_list.html +0 -36
  44. data/doc/css/common.css +0 -1
  45. data/doc/css/full_list.css +0 -53
  46. data/doc/css/style.css +0 -310
  47. data/doc/file.README.html +0 -89
  48. data/doc/file_list.html +0 -38
  49. data/doc/frames.html +0 -13
  50. data/doc/index.html +0 -89
  51. data/doc/js/app.js +0 -203
  52. data/doc/js/full_list.js +0 -149
  53. data/doc/js/jquery.js +0 -154
  54. data/doc/method_list.html +0 -155
  55. data/doc/top-level-namespace.html +0 -88
data/lib/rotp/base32.rb CHANGED
@@ -1,50 +1,76 @@
1
+ require 'securerandom'
2
+
1
3
  module ROTP
2
4
  class Base32
3
5
  class Base32Error < RuntimeError; end
4
- CHARS = "abcdefghijklmnopqrstuvwxyz234567".each_char.to_a
6
+ CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.each_char.to_a
7
+ SHIFT = 5
8
+ MASK = 31
5
9
 
6
10
  class << self
11
+
7
12
  def decode(str)
8
- str = str.tr('=','')
9
- output = []
10
- str.scan(/.{1,8}/).each do |block|
11
- char_array = decode_block(block).map{|c| c.chr}
12
- output << char_array
13
+ buffer = 0
14
+ idx = 0
15
+ bits_left = 0
16
+ str = str.tr('=', '').upcase
17
+ result = []
18
+ str.split('').each do |char|
19
+ buffer = buffer << SHIFT
20
+ buffer = buffer | (decode_quint(char) & MASK)
21
+ bits_left = bits_left + SHIFT
22
+ if bits_left >= 8
23
+ result[idx] = (buffer >> (bits_left - 8)) & 255
24
+ idx = idx + 1
25
+ bits_left = bits_left - 8
26
+ end
13
27
  end
14
- output.join
28
+ result.pack('c*')
15
29
  end
16
30
 
17
- def random_base32(length=32)
18
- b32 = String.new
19
- SecureRandom.random_bytes(length).each_byte do |b|
20
- b32 << CHARS[b % 32]
31
+ def encode(b)
32
+ data = b.unpack('c*')
33
+ out = String.new
34
+ buffer = data[0]
35
+ idx = 1
36
+ bits_left = 8
37
+ while bits_left > 0 || idx < data.length
38
+ if bits_left < SHIFT
39
+ if idx < data.length
40
+ buffer = buffer << 8
41
+ buffer = buffer | (data[idx] & 255)
42
+ bits_left = bits_left + 8
43
+ idx = idx + 1
44
+ else
45
+ pad = SHIFT - bits_left
46
+ buffer = buffer << pad
47
+ bits_left = bits_left + pad
48
+ end
49
+ end
50
+ val = MASK & (buffer >> (bits_left - SHIFT))
51
+ bits_left = bits_left - SHIFT
52
+ out.concat(CHARS[val])
21
53
  end
22
- b32
54
+ return out
23
55
  end
24
56
 
25
- private
57
+ # Defaults to 160 bit long secret (meaning a 32 character long base32 secret)
58
+ def random(byte_length = 20)
59
+ rand_bytes = SecureRandom.random_bytes(byte_length)
60
+ self.encode(rand_bytes)
61
+ end
26
62
 
27
- def decode_block(block)
28
- length = block.scan(/[^=]/).length
29
- quints = block.each_char.map {|c| decode_quint(c)}
30
- bytes = []
31
- bytes[0] = (quints[0] << 3) + (quints[1] ? quints[1] >> 2 : 0)
32
- return bytes if length < 3
33
- bytes[1] = ((quints[1] & 3) << 6) + (quints[2] << 1) + (quints[3] ? quints[3] >> 4 : 0)
34
- return bytes if length < 4
35
- bytes[2] = ((quints[3] & 15) << 4) + (quints[4] ? quints[4] >> 1 : 0)
36
- return bytes if length < 6
37
- bytes[3] = ((quints[4] & 1) << 7) + (quints[5] << 2) + (quints[6] ? quints[6] >> 3 : 0)
38
- return bytes if length < 7
39
- bytes[4] = ((quints[6] & 7) << 5) + (quints[7] || 0)
40
- bytes
63
+ # Prevent breaking changes
64
+ def random_base32(str_len = 32)
65
+ byte_length = str_len * 5/8
66
+ random(byte_length)
41
67
  end
42
68
 
69
+ private
70
+
43
71
  def decode_quint(q)
44
- CHARS.index(q.downcase) or raise(Base32Error, "Invalid Base32 Character - '#{q}'")
72
+ CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
45
73
  end
46
-
47
74
  end
48
-
49
75
  end
50
76
  end
data/lib/rotp/cli.rb CHANGED
@@ -16,10 +16,10 @@ module ROTP
16
16
  # :nocov:
17
17
 
18
18
  def errors
19
- if [:time, :hmac].include?(options.mode)
19
+ if %i[time hmac].include?(options.mode)
20
20
  if options.secret.to_s == ''
21
21
  red 'You must also specify a --secret. Try --help for help.'
22
- elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.downcase) == nil }
22
+ elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.upcase).nil? }
23
23
  red 'Secret must be in RFC4648 Base32 format - http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet'
24
24
  end
25
25
  end
@@ -31,9 +31,9 @@ module ROTP
31
31
  return arguments.to_s if options.mode == :help
32
32
 
33
33
  if options.mode == :time
34
- ROTP::TOTP.new(options.secret).now
34
+ ROTP::TOTP.new(options.secret, options).now
35
35
  elsif options.mode == :hmac
36
- ROTP::HOTP.new(options.secret).at options.counter
36
+ ROTP::HOTP.new(options.secret, options).at options.counter
37
37
  end
38
38
  end
39
39
 
@@ -48,6 +48,5 @@ module ROTP
48
48
  def red(string)
49
49
  "\033[31m#{string}\033[0m"
50
50
  end
51
-
52
51
  end
53
52
  end
data/lib/rotp/hotp.rb CHANGED
@@ -12,10 +12,10 @@ module ROTP
12
12
  # @param counter [Integer] the counter of the OTP
13
13
  # @param retries [Integer] number of counters to incrementally retry
14
14
  def verify(otp, counter, retries: 0)
15
- counters = (counter..counter+retries).to_a
16
- counters.find { |c|
17
- super(otp, self.at(c))
18
- }
15
+ counters = (counter..counter + retries).to_a
16
+ counters.find do |c|
17
+ super(otp, at(c))
18
+ end
19
19
  end
20
20
 
21
21
  # Returns the provisioning URI for the OTP
@@ -24,15 +24,8 @@ module ROTP
24
24
  # @param [String] name of the account
25
25
  # @param [Integer] initial_count starting counter value, defaults to 0
26
26
  # @return [String] provisioning uri
27
- def provisioning_uri(name, initial_count=0)
28
- params = {
29
- secret: secret,
30
- counter: initial_count,
31
- digits: digits == DEFAULT_DIGITS ? nil : digits
32
- }
33
- encode_params("otpauth://hotp/#{URI.encode(name)}", params)
27
+ def provisioning_uri(name = nil, initial_count = 0)
28
+ OTP::URI.new(self, account_name: name || @name, counter: initial_count).to_s
34
29
  end
35
-
36
30
  end
37
-
38
31
  end
@@ -0,0 +1,78 @@
1
+ module ROTP
2
+ class OTP
3
+ # https://github.com/google/google-authenticator/wiki/Key-Uri-Format
4
+ class URI
5
+ def initialize(otp, account_name: nil, counter: nil)
6
+ @otp = otp
7
+ @account_name = account_name || ''
8
+ @counter = counter
9
+ end
10
+
11
+ def to_s
12
+ "otpauth://#{type}/#{label}?#{parameters}"
13
+ end
14
+
15
+ private
16
+
17
+ def algorithm
18
+ return unless %w[sha256 sha512].include?(@otp.digest)
19
+
20
+ @otp.digest.upcase
21
+ end
22
+
23
+ def counter
24
+ return if @otp.is_a?(TOTP)
25
+ fail if @counter.nil?
26
+
27
+ @counter
28
+ end
29
+
30
+ def digits
31
+ return if @otp.digits == DEFAULT_DIGITS
32
+
33
+ @otp.digits
34
+ end
35
+
36
+ def issuer
37
+ @otp.issuer&.strip&.tr(':', '_')
38
+ end
39
+
40
+ def label
41
+ [issuer, @account_name.rstrip]
42
+ .compact
43
+ .map { |s| s.tr(':', '_') }
44
+ .map { |s| ERB::Util.url_encode(s) }
45
+ .join(':')
46
+ end
47
+
48
+ def parameters
49
+ {
50
+ secret: @otp.secret,
51
+ issuer: issuer,
52
+ algorithm: algorithm,
53
+ digits: digits,
54
+ period: period,
55
+ counter: counter,
56
+ }
57
+ .merge(@otp.provisioning_params)
58
+ .reject { |_, v| v.nil? }
59
+ .map { |k, v| "#{k}=#{ERB::Util.url_encode(v)}" }
60
+ .join('&')
61
+ end
62
+
63
+ def period
64
+ return if @otp.is_a?(HOTP)
65
+ return if @otp.interval == DEFAULT_INTERVAL
66
+
67
+ @otp.interval
68
+ end
69
+
70
+ def type
71
+ case @otp
72
+ when TOTP then 'totp'
73
+ when HOTP then 'hotp'
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/rotp/otp.rb CHANGED
@@ -1,24 +1,35 @@
1
1
  module ROTP
2
2
  class OTP
3
- attr_reader :secret, :digits, :digest
3
+ attr_reader :secret, :digits, :digest, :name, :issuer, :provisioning_params
4
4
  DEFAULT_DIGITS = 6
5
5
 
6
6
  # @param [String] secret in the form of base32
7
7
  # @option options digits [Integer] (6)
8
- # Number of integers in the OTP
8
+ # Number of integers in the OTP.
9
9
  # Google Authenticate only supports 6 currently
10
10
  # @option options digest [String] (sha1)
11
- # Digest used in the HMAC
11
+ # Digest used in the HMAC.
12
12
  # Google Authenticate only supports 'sha1' currently
13
+ # @option options name [String]
14
+ # The name of the account for the OTP.
15
+ # Used in the provisioning URL
16
+ # @option options issuer [String]
17
+ # The issuer of the OTP.
18
+ # Used in the provisioning URL
19
+ # @option options provisioning_params [Hash] ({})
20
+ # Additional non-standard params you may want appended to the
21
+ # provisioning URI. Ex. `image: 'https://example.com/icon.png'`
13
22
  # @returns [OTP] OTP instantiation
14
23
  def initialize(s, options = {})
15
24
  @digits = options[:digits] || DEFAULT_DIGITS
16
- @digest = options[:digest] || "sha1"
25
+ @digest = options[:digest] || 'sha1'
26
+ @name = options[:name]
27
+ @issuer = options[:issuer]
28
+ @provisioning_params = options[:provisioning_params] || {}
17
29
  @secret = s
18
30
  end
19
31
 
20
32
  # @param [Integer] input the number used seed the HMAC
21
- # @option padded [Boolean] (false) Output the otp as a 0 padded string
22
33
  # Usually either the counter, or the computed integer
23
34
  # based on the Unix timestamp
24
35
  def generate_otp(input)
@@ -30,17 +41,19 @@ module ROTP
30
41
 
31
42
  offset = hmac[-1].ord & 0xf
32
43
  code = (hmac[offset].ord & 0x7f) << 24 |
33
- (hmac[offset + 1].ord & 0xff) << 16 |
34
- (hmac[offset + 2].ord & 0xff) << 8 |
35
- (hmac[offset + 3].ord & 0xff)
36
- (code % 10 ** digits).to_s.rjust(digits, '0')
44
+ (hmac[offset + 1].ord & 0xff) << 16 |
45
+ (hmac[offset + 2].ord & 0xff) << 8 |
46
+ (hmac[offset + 3].ord & 0xff)
47
+ code_str = (10 ** digits + (code % 10 ** digits)).to_s
48
+ code_str[-digits..-1]
37
49
  end
38
50
 
39
51
  private
40
52
 
41
53
  def verify(input, generated)
42
- raise ArgumentError, "`otp` should be a String" unless
54
+ raise ArgumentError, '`otp` should be a String' unless
43
55
  input.is_a?(String)
56
+
44
57
  time_constant_compare(input, generated)
45
58
  end
46
59
 
@@ -54,37 +67,25 @@ module ROTP
54
67
  #
55
68
  def int_to_bytestring(int, padding = 8)
56
69
  unless int >= 0
57
- raise ArgumentError, "#int_to_bytestring requires a positive number"
70
+ raise ArgumentError, '#int_to_bytestring requires a positive number'
58
71
  end
59
72
 
60
73
  result = []
61
74
  until int == 0
62
75
  result << (int & 0xFF).chr
63
- int >>= 8
76
+ int >>= 8
64
77
  end
65
78
  result.reverse.join.rjust(padding, 0.chr)
66
79
  end
67
80
 
68
- # A very simple param encoder
69
- def encode_params(uri, params)
70
- params_str = String.new("?")
71
- params.each do |k,v|
72
- if v
73
- params_str << "#{k}=#{CGI::escape(v.to_s)}&"
74
- end
75
- end
76
- params_str.chop!
77
- uri + params_str
78
- end
79
-
80
81
  # constant-time compare the strings
81
82
  def time_constant_compare(a, b)
82
83
  return false if a.empty? || b.empty? || a.bytesize != b.bytesize
84
+
83
85
  l = a.unpack "C#{a.bytesize}"
84
86
  res = 0
85
87
  b.each_byte { |byte| res |= byte ^ l.shift }
86
88
  res == 0
87
89
  end
88
-
89
90
  end
90
91
  end
data/lib/rotp/totp.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  module ROTP
2
2
  DEFAULT_INTERVAL = 30
3
3
  class TOTP < OTP
4
-
5
4
  attr_reader :interval, :issuer
6
5
 
7
6
  # @option options [Integer] interval (30) the time interval in seconds for OTP
@@ -21,7 +20,7 @@ module ROTP
21
20
 
22
21
  # Generate the current time OTP
23
22
  # @return [Integer] the OTP as an integer
24
- def now()
23
+ def now
25
24
  generate_otp(timecode(Time.now))
26
25
  end
27
26
 
@@ -40,39 +39,22 @@ module ROTP
40
39
  def verify(otp, drift_ahead: 0, drift_behind: 0, after: nil, at: Time.now)
41
40
  timecodes = get_timecodes(at, drift_behind, drift_ahead)
42
41
 
43
- if after
44
- timecodes = timecodes.select { |t| t > timecode(after) }
45
- end
42
+ timecodes = timecodes.select { |t| t > timecode(after) } if after
46
43
 
47
44
  result = nil
48
- timecodes.each { |t|
49
- if (super(otp, self.generate_otp(t)))
50
- result = t * interval
51
- end
52
- }
53
- return result
45
+ timecodes.each do |t|
46
+ result = t * interval if super(otp, generate_otp(t))
47
+ end
48
+ result
54
49
  end
55
50
 
56
-
57
51
  # Returns the provisioning URI for the OTP
58
52
  # This can then be encoded in a QR Code and used
59
53
  # to provision the Google Authenticator app
60
54
  # @param [String] name of the account
61
55
  # @return [String] provisioning URI
62
- def provisioning_uri(name)
63
- # The format of this URI is documented at:
64
- # https://github.com/google/google-authenticator/wiki/Key-Uri-Format
65
- # For compatibility the issuer appears both before that account name and also in the
66
- # query string.
67
- issuer_string = issuer.nil? ? "" : "#{URI.encode(issuer)}:"
68
- params = {
69
- secret: secret,
70
- period: interval == 30 ? nil : interval,
71
- issuer: issuer,
72
- digits: digits == DEFAULT_DIGITS ? nil : digits,
73
- algorithm: digest.upcase == 'SHA1' ? nil : digest.upcase,
74
- }
75
- encode_params("otpauth://totp/#{issuer_string}#{URI.encode(name)}", params)
56
+ def provisioning_uri(name = nil)
57
+ OTP::URI.new(self, account_name: name || @name).to_s
76
58
  end
77
59
 
78
60
  private
@@ -82,20 +64,18 @@ module ROTP
82
64
  now = timeint(at)
83
65
  timecode_start = timecode(now - drift_behind)
84
66
  timecode_end = timecode(now + drift_ahead)
85
- return (timecode_start..timecode_end).step(1).to_a
67
+ (timecode_start..timecode_end).step(1).to_a
86
68
  end
87
69
 
88
70
  # Ensure UTC int
89
71
  def timeint(time)
90
- unless time.class == Time
91
- return time.to_i
92
- end
93
- return time.utc.to_i
72
+ return time.to_i unless time.class == Time
73
+
74
+ time.utc.to_i
94
75
  end
95
76
 
96
77
  def timecode(time)
97
- return timeint(time) / interval
78
+ timeint(time) / interval
98
79
  end
99
-
100
80
  end
101
81
  end
data/lib/rotp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module ROTP
2
- VERSION = "4.0.0"
2
+ VERSION = '6.3.0'.freeze
3
3
  end
data/lib/rotp.rb CHANGED
@@ -1,12 +1,10 @@
1
- require 'cgi'
2
- require 'uri'
3
- require 'securerandom'
4
1
  require 'openssl'
2
+ require 'erb'
5
3
  require 'rotp/base32'
6
4
  require 'rotp/otp'
5
+ require 'rotp/otp/uri'
7
6
  require 'rotp/hotp'
8
7
  require 'rotp/totp'
9
8
 
10
-
11
9
  module ROTP
12
10
  end
@@ -0,0 +1,12 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "changelog-path": "CHANGELOG.md",
5
+ "bump-minor-pre-major": false,
6
+ "bump-patch-for-minor-pre-major": false,
7
+ "draft": false,
8
+ "prerelease": false
9
+ }
10
+ },
11
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
12
+ }
data/rotp.gemspec CHANGED
@@ -1,26 +1,24 @@
1
- # -*- encoding: utf-8 -*-
2
- require "./lib/rotp/version"
1
+ require './lib/rotp/version'
3
2
 
4
3
  Gem::Specification.new do |s|
5
- s.name = "rotp"
4
+ s.name = 'rotp'
6
5
  s.version = ROTP::VERSION
7
6
  s.platform = Gem::Platform::RUBY
8
- s.license = "MIT"
9
- s.authors = ["Mark Percival"]
10
- s.email = ["mark@markpercival.us"]
11
- s.homepage = "http://github.com/mdp/rotp"
12
- s.summary = %q{A Ruby library for generating and verifying one time passwords}
13
- s.description = %q{Works for both HOTP and TOTP, and includes QR Code provisioning}
14
-
15
- s.rubyforge_project = "rotp"
7
+ s.required_ruby_version = '>= 2.3'
8
+ s.license = 'MIT'
9
+ s.authors = ['Mark Percival']
10
+ s.email = ['mark@markpercival.us']
11
+ s.homepage = 'https://github.com/mdp/rotp'
12
+ s.summary = 'A Ruby library for generating and verifying one time passwords'
13
+ s.description = 'Works for both HOTP and TOTP, and includes QR Code provisioning'
16
14
 
17
15
  s.files = `git ls-files`.split("\n")
18
16
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
- s.require_paths = ["lib"]
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
18
+ s.require_paths = ['lib']
21
19
 
22
- s.add_development_dependency 'rake', '~> 10.5'
20
+ s.add_development_dependency "rake", "~> 13.0"
23
21
  s.add_development_dependency 'rspec', '~> 3.5'
24
- s.add_development_dependency 'timecop', '~> 0.8'
25
22
  s.add_development_dependency 'simplecov', '~> 0.12'
23
+ s.add_development_dependency 'timecop', '~> 0.8'
26
24
  end
@@ -24,7 +24,7 @@ RSpec.describe ROTP::Arguments do
24
24
  end
25
25
 
26
26
  context 'unknown arguments' do
27
- let(:argv) { %w(--does-not-exist -xyz) }
27
+ let(:argv) { %w[--does-not-exist -xyz] }
28
28
 
29
29
  describe '#options' do
30
30
  it 'is in help mode' do
@@ -48,7 +48,7 @@ RSpec.describe ROTP::Arguments do
48
48
  end
49
49
 
50
50
  context 'asking for help' do
51
- let(:argv) { %w(--help) }
51
+ let(:argv) { %w[--help] }
52
52
 
53
53
  describe '#options' do
54
54
  it 'is in help mode' do
@@ -58,7 +58,7 @@ RSpec.describe ROTP::Arguments do
58
58
  end
59
59
 
60
60
  context 'generating a counter based secret' do
61
- let(:argv) { %w(--hmac --secret s3same) }
61
+ let(:argv) { %w[--hmac --secret s3same] }
62
62
 
63
63
  describe '#options' do
64
64
  it 'is in hmac mode' do
@@ -72,7 +72,7 @@ RSpec.describe ROTP::Arguments do
72
72
  end
73
73
 
74
74
  context 'generating a counter based secret' do
75
- let(:argv) { %w(--time --secret s3same) }
75
+ let(:argv) { %w[--time --secret s3same] }
76
76
 
77
77
  describe '#options' do
78
78
  it 'is in hmac mode' do
@@ -86,7 +86,7 @@ RSpec.describe ROTP::Arguments do
86
86
  end
87
87
 
88
88
  context 'generating a time based secret' do
89
- let(:argv) { %w(--secret s3same) }
89
+ let(:argv) { %w[--secret s3same] }
90
90
 
91
91
  describe '#options' do
92
92
  it 'is in time mode' do
@@ -98,5 +98,4 @@ RSpec.describe ROTP::Arguments do
98
98
  end
99
99
  end
100
100
  end
101
-
102
101
  end
@@ -1,25 +1,33 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  RSpec.describe ROTP::Base32 do
4
-
5
- describe '.random_base32' do
4
+ describe '.random' do
6
5
  context 'without arguments' do
7
- let(:base32) { ROTP::Base32.random_base32 }
6
+ let(:base32) { ROTP::Base32.random }
8
7
 
9
- it 'is 32 characters long' do
8
+ it 'is 20 bytes (160 bits) long (resulting in a 32 character base32 code)' do
9
+ expect(ROTP::Base32.decode(base32).length).to eq 20
10
10
  expect(base32.length).to eq 32
11
11
  end
12
12
 
13
- it 'is hexadecimal' do
14
- expect(base32).to match %r{\A[a-z2-7]+\z}
13
+ it 'is base32 charset' do
14
+ expect(base32).to match(/\A[A-Z2-7]+\z/)
15
15
  end
16
16
  end
17
17
 
18
18
  context 'with arguments' do
19
- let(:base32) { ROTP::Base32.random_base32 32 }
19
+ let(:base32) { ROTP::Base32.random 48 }
20
20
 
21
- it 'allows a specific length' do
22
- expect(base32.length).to eq 32
21
+ it 'returns the appropriate byte length code' do
22
+ expect(ROTP::Base32.decode(base32).length).to eq 48
23
+ end
24
+ end
25
+
26
+ context 'alias to older random_base32' do
27
+ let(:base32) { ROTP::Base32.random_base32(36) }
28
+ it 'is base32 charset' do
29
+ expect(base32.length).to eq 36
30
+ expect(ROTP::Base32.decode(base32).length).to eq 22
23
31
  end
24
32
  end
25
33
  end
@@ -34,22 +42,40 @@ RSpec.describe ROTP::Base32 do
34
42
 
35
43
  context 'valid input data' do
36
44
  it 'correctly decodes a string' do
37
- expect(ROTP::Base32.decode('F').unpack('H*').first).to eq '28'
38
- expect(ROTP::Base32.decode('23').unpack('H*').first).to eq 'd6'
39
- expect(ROTP::Base32.decode('234').unpack('H*').first).to eq 'd6f8'
40
- expect(ROTP::Base32.decode('234A').unpack('H*').first).to eq 'd6f800'
41
- expect(ROTP::Base32.decode('234B').unpack('H*').first).to eq 'd6f810'
42
- expect(ROTP::Base32.decode('234BCD').unpack('H*').first).to eq 'd6f8110c'
43
- expect(ROTP::Base32.decode('234BCDE').unpack('H*').first).to eq 'd6f8110c80'
44
- expect(ROTP::Base32.decode('234BCDEFG').unpack('H*').first).to eq 'd6f8110c8530'
45
- expect(ROTP::Base32.decode('234BCDEFG234BCDEFG').unpack('H*').first).to eq 'd6f8110c8536b7c0886429'
45
+ expect(ROTP::Base32.decode('2EB7C66WC5TSO').unpack('H*').first).to eq 'd103f17bd6176727'
46
+ expect(ROTP::Base32.decode('Y6Y5ZCAC7NABCHSJ').unpack('H*').first).to eq 'c7b1dc8802fb40111e49'
47
+ end
48
+
49
+ it 'correctly decode strings with trailing bits (not a multiple of 8)' do
50
+ # Dropbox style 26 characters (26*5==130 bits or 16.25 bytes, but chopped to 128)
51
+ # Matches the behavior of Google Authenticator, drops extra 2 empty bits
52
+ expect(ROTP::Base32.decode('YVT6Z2XF4BQJNBMTD7M6QBQCEM').unpack('H*').first).to eq 'c567eceae5e0609685931fd9e8060223'
53
+
54
+ # For completeness, test all the possibilities allowed by Google Authenticator
55
+ # Drop the incomplete empty extra 4 bits (28*5==140bits or 17.5 bytes, chopped to 136 bits)
56
+ expect(ROTP::Base32.decode('5GGZQB3WN6LD7V3L5HPDYTQUANEQ').unpack('H*').first).to eq 'e98d9807766f963fd76be9de3c4e140349'
57
+
46
58
  end
47
59
 
48
60
  context 'with padding' do
49
61
  it 'correctly decodes a string' do
50
- expect(ROTP::Base32.decode('F==').unpack('H*').first).to eq '28'
62
+ expect(ROTP::Base32.decode('234A===').unpack('H*').first).to eq 'd6f8'
51
63
  end
52
64
  end
65
+
66
+ end
67
+ end
68
+
69
+ describe '.encode' do
70
+ context 'encode input data' do
71
+ it 'correctly encodes data' do
72
+ expect(ROTP::Base32.encode(hex_to_bin('3c204da94294ff82103ee34e96f74b48'))).to eq 'HQQE3KKCST7YEEB64NHJN52LJA'
73
+ end
53
74
  end
54
75
  end
76
+
77
+ end
78
+
79
+ def hex_to_bin(s)
80
+ s.scan(/../).map { |x| x.hex }.pack('c*')
55
81
  end