rotp 4.0.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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