rotp 4.1.0 → 6.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72a52a1f0f26257e83977969144abf6324fee40c65ef4f6b3b910d30c3bb1e36
4
- data.tar.gz: 212a5ca91186490c07221f7f17e4d39cc05778b89ebf22cf53712de2cfb8944a
3
+ metadata.gz: cd976bfa6985075f5e2b76607256d0afbbdf88a82c38cd094d0eaffbb5bce4f2
4
+ data.tar.gz: 70df660f1eca3dd9efc7baa1f53061ba9af1bbb49e4bb6ead507509f6e845d38
5
5
  SHA512:
6
- metadata.gz: 41b36b57154571a35d8d3f59961c3f1754c2bfb3bf06e2a29e2360b24ba884e7d632f86d3d5491341034a30654ba971d96bfcfecae85687a701230cf8e4523ba
7
- data.tar.gz: d70217238c2d859b674f9cc702e9ffcb4fcb5cdf3a75d9b40306d0dee8f28198363d6001510e2cbc2b507d24cb753127d602b6dec211da77287d7ac04fabef98
6
+ metadata.gz: 7fb326cc887a1a5614c90c492ac43b72188f75caa90fcc50c3338d129abe2efe4f67af88d018c378379806f1bef0c1d0e40fc6c683f4427f40ad411326729022
7
+ data.tar.gz: 4f913bf0693c1cead926bfe625e226fe8277323f93a552459447a792cd27b9189860ab26d3907baafff0e217430b04fb9e8b1829b0795e4c96e83062fac409fb
@@ -1,8 +1,9 @@
1
1
  language: ruby
2
2
  before_install: gem install bundler -v '<2'
3
3
  rvm:
4
+ - 2.7
4
5
  - 2.6
5
6
  - 2.5
6
- - 2.0
7
+ - 2.3
7
8
  script:
8
9
  - bundle exec rspec
@@ -1,5 +1,35 @@
1
1
  ### Changelog
2
2
 
3
+ ### 6.2.0
4
+
5
+ - Update to expand compatibility with Ruby 3. This was only a change to the
6
+ gemspec, no code changes were necessary.
7
+
8
+ ### 6.1.0
9
+
10
+ - Fixing URI encoding issues again, breaking out into it's own module
11
+ due to the complexity - closes #100 (@atcruice)
12
+ - Add docker-compose.yml to help with easier testing
13
+
14
+ ### 6.0.0
15
+
16
+ - Dropping support for Ruby <2.3 (Major version bump)
17
+ - Fix issue when using --enable-frozen-string-literal Ruby option #95 (jeremyevans)
18
+ - URI Encoding fix #94 (ksuh90)
19
+ - Update gems (rake, addressable)
20
+ - Update Travis tests to include Ruby 2.7
21
+
22
+ ### 5.1.0
23
+
24
+ - Create `random_base32` to perform `random` to avoid breaking changes
25
+ - Still needed to bump to 5.x due to Base32 cleanup
26
+
27
+ ### 5.0.0
28
+
29
+ - Clean up base32 implementation to match Google Autheticator
30
+ - BREAKING `Base32.random_base32` renamed to random
31
+ - The argument is now byte length vs output string length for more precise bit strengths
32
+
3
33
  ### 4.1.0
4
34
 
5
35
  - Add a digest option to the CLI #83
@@ -0,0 +1,10 @@
1
+ FROM ruby:2.3
2
+
3
+ RUN mkdir -p /usr/src/app
4
+ WORKDIR /usr/src/app
5
+
6
+ COPY Gemfile /usr/src/app/
7
+ COPY . /usr/src/app
8
+ RUN bundle install
9
+
10
+ CMD ["bundle", "exec", "rspec"]
@@ -1,4 +1,4 @@
1
- FROM ruby:2.0
1
+ FROM ruby:2.7
2
2
 
3
3
  RUN mkdir -p /usr/src/app
4
4
  WORKDIR /usr/src/app
@@ -0,0 +1,12 @@
1
+ FROM ruby:3.0-rc
2
+
3
+ RUN mkdir -p /usr/src/app
4
+ WORKDIR /usr/src/app
5
+
6
+ COPY Gemfile /usr/src/app/
7
+ COPY . /usr/src/app
8
+ RUN gem install bundler
9
+ RUN bundle install
10
+
11
+ CMD ["bundle", "exec", "rspec"]
12
+
data/README.md CHANGED
@@ -16,7 +16,20 @@ Many websites use this for [multi-factor authentication](https://www.youtube.com
16
16
  * OpenSSL
17
17
  * Ruby 2.0 or higher
18
18
 
19
- ## Breaking changes in >= 4.0
19
+ ## Breaking changes
20
+
21
+ ### Breaking changes in >= 6.0
22
+
23
+ - Dropping support for Ruby <2.3
24
+
25
+ ### Breaking changes in >= 5.0
26
+
27
+ - `ROTP::Base32.random_base32` is now `ROTP::Base32.random` and the argument
28
+ has changed from secret string length to byte length to allow for more
29
+ precision. There is an alias to allow for `random_base32` for the time being.
30
+ - Cleaned up the Base32 implementation to match Google Authenticator's version.
31
+
32
+ ### Breaking changes in >= 4.0
20
33
 
21
34
  - Simplified API
22
35
  - `verify` now takes options for `drift` and `after`
@@ -57,8 +70,8 @@ hotp.at(1) # => "595254"
57
70
  hotp.at(1401) # => "259769"
58
71
 
59
72
  # OTP verified with a counter
60
- hotp.verify("316439", 1401) # => 1401
61
- hotp.verify("316439", 1402) # => nil
73
+ hotp.verify("259769", 1401) # => 1401
74
+ hotp.verify("259769", 1402) # => nil
62
75
  ```
63
76
 
64
77
  ### Preventing reuse of Time based OTP's
@@ -69,7 +82,7 @@ the interval window (default 30 seconds)
69
82
  The following is an example of this in action:
70
83
 
71
84
  ```ruby
72
- User.find(someUserID)
85
+ user = User.find(someUserID)
73
86
  totp = ROTP::TOTP.new(user.otp_secret)
74
87
  totp.now # => "492039"
75
88
 
@@ -108,7 +121,7 @@ totp.verify("250939", drift_behind: 15, at: now + 45) # => nil
108
121
  ### Generating a Base32 Secret key
109
122
 
110
123
  ```ruby
111
- ROTP::Base32.random_base32 # returns a 32 character base32 secret. Compatible with Google Authenticator
124
+ ROTP::Base32.random # returns a 160 bit (32 character) base32 secret. Compatible with Google Authenticator
112
125
  ```
113
126
 
114
127
  Note: The Base32 format conforms to [RFC 4648 Base32](http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet)
@@ -120,10 +133,10 @@ Google Authenticator.
120
133
 
121
134
  ```ruby
122
135
  totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service")
123
- totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/My%20Service:alice@google.com?secret=base32secret3232&issuer=My+Service'
136
+ totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/My%20Service:alice%40google.com?secret=base32secret3232&issuer=My%20Service'
124
137
 
125
138
  hotp = ROTP::HOTP.new("base32secret3232", issuer: "My Service")
126
- hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/alice@google.com?secret=base32secret3232&counter=0'
139
+ hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/alice%40google.com?secret=base32secret3232&counter=0'
127
140
  ```
128
141
 
129
142
  This can then be rendered as a QR Code which the user can scan using their mobile phone and the appropriate application.
@@ -150,7 +163,7 @@ bundle install
150
163
  bundle exec rspec
151
164
  ```
152
165
 
153
- ### Testign with Docker
166
+ ### Testing with Docker
154
167
 
155
168
  In order to make it easier to test against different ruby version, ROTP comes
156
169
  with a set of Dockerfiles for each version that we test against in Travis
@@ -160,9 +173,15 @@ docker build -f Dockerfile-2.6 -t rotp_2.6 .
160
173
  docker run --rm -v $(pwd):/usr/src/app rotp_2.6
161
174
  ```
162
175
 
176
+ Alternately, you may use docker-compose to run all the tests:
177
+
178
+ ```
179
+ docker-compose up
180
+ ```
181
+
163
182
  ## Executable Usage
164
183
 
165
- The rotp rubygem includes an executable for helping with testing and debugging
184
+ The rotp rubygem includes CLI version to help with testing and debugging
166
185
 
167
186
  ```bash
168
187
  # Try this to get an overview of the commands
@@ -0,0 +1,37 @@
1
+ version: "3.8"
2
+ services:
3
+ ruby_2_3:
4
+ build:
5
+ context: .
6
+ dockerfile: Dockerfile-2.3
7
+ volumes:
8
+ - "./lib:/usr/src/app/lib"
9
+ - "./spec:/usr/src/app/spec"
10
+ ruby_2_5:
11
+ build:
12
+ context: .
13
+ dockerfile: Dockerfile-2.5
14
+ volumes:
15
+ - "./lib:/usr/src/app/lib"
16
+ - "./spec:/usr/src/app/spec"
17
+ ruby_2_6:
18
+ build:
19
+ context: .
20
+ dockerfile: Dockerfile-2.6
21
+ volumes:
22
+ - "./lib:/usr/src/app/lib"
23
+ - "./spec:/usr/src/app/spec"
24
+ ruby_2_7:
25
+ build:
26
+ context: .
27
+ dockerfile: Dockerfile-2.7
28
+ volumes:
29
+ - "./lib:/usr/src/app/lib"
30
+ - "./spec:/usr/src/app/spec"
31
+ ruby_3_0_rc:
32
+ build:
33
+ context: .
34
+ dockerfile: Dockerfile-3.0-rc
35
+ volumes:
36
+ - "./lib:/usr/src/app/lib"
37
+ - "./spec:/usr/src/app/spec"
@@ -1,9 +1,8 @@
1
- require 'cgi'
2
- require 'addressable'
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
 
@@ -1,51 +1,75 @@
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(&: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 = ''
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
26
-
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
-
34
- bytes[1] = ((quints[1] & 3) << 6) + (quints[2] << 1) + (quints[3] ? quints[3] >> 4 : 0)
35
- return bytes if length < 4
36
-
37
- bytes[2] = ((quints[3] & 15) << 4) + (quints[4] ? quints[4] >> 1 : 0)
38
- return bytes if length < 6
39
-
40
- bytes[3] = ((quints[4] & 1) << 7) + (quints[5] << 2) + (quints[6] ? quints[6] >> 3 : 0)
41
- return bytes if length < 7
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
42
62
 
43
- bytes[4] = ((quints[6] & 7) << 5) + (quints[7] || 0)
44
- bytes
63
+ # Prevent breaking changes
64
+ def random_base32(str_len = 32)
65
+ byte_length = str_len * 5/8
66
+ random(byte_length)
45
67
  end
46
68
 
69
+ private
70
+
47
71
  def decode_quint(q)
48
- CHARS.index(q.downcase) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
72
+ CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
49
73
  end
50
74
  end
51
75
  end
@@ -19,7 +19,7 @@ module ROTP
19
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
@@ -25,12 +25,7 @@ module ROTP
25
25
  # @param [Integer] initial_count starting counter value, defaults to 0
26
26
  # @return [String] provisioning uri
27
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/#{Addressable::URI.escape(name)}", params)
28
+ OTP::URI.new(self, account_name: name, counter: initial_count).to_s
34
29
  end
35
30
  end
36
31
  end
@@ -66,16 +66,6 @@ module ROTP
66
66
  result.reverse.join.rjust(padding, 0.chr)
67
67
  end
68
68
 
69
- # A very simple param encoder
70
- def encode_params(uri, params)
71
- params_str = String.new('?')
72
- params.each do |k, v|
73
- params_str << "#{k}=#{CGI.escape(v.to_s)}&" if v
74
- end
75
- params_str.chop!
76
- uri + params_str
77
- end
78
-
79
69
  # constant-time compare the strings
80
70
  def time_constant_compare(a, b)
81
71
  return false if a.empty? || b.empty? || a.bytesize != b.bytesize
@@ -0,0 +1,79 @@
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:, 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
+ return if @otp.is_a?(HOTP)
38
+
39
+ @otp.issuer&.strip&.tr(':', '_')
40
+ end
41
+
42
+ def label
43
+ [issuer, @account_name.rstrip]
44
+ .compact
45
+ .map { |s| s.tr(':', '_') }
46
+ .map { |s| ERB::Util.url_encode(s) }
47
+ .join(':')
48
+ end
49
+
50
+ def parameters
51
+ {
52
+ secret: @otp.secret,
53
+ issuer: issuer,
54
+ algorithm: algorithm,
55
+ digits: digits,
56
+ period: period,
57
+ counter: counter,
58
+ }
59
+ .reject { |_, v| v.nil? }
60
+ .map { |k, v| "#{k}=#{ERB::Util.url_encode(v)}" }
61
+ .join('&')
62
+ end
63
+
64
+ def period
65
+ return if @otp.is_a?(HOTP)
66
+ return if @otp.interval == DEFAULT_INTERVAL
67
+
68
+ @otp.interval
69
+ end
70
+
71
+ def type
72
+ case @otp
73
+ when TOTP then 'totp'
74
+ when HOTP then 'hotp'
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -54,19 +54,7 @@ module ROTP
54
54
  # @param [String] name of the account
55
55
  # @return [String] provisioning URI
56
56
  def provisioning_uri(name)
57
- # The format of this URI is documented at:
58
- # https://github.com/google/google-authenticator/wiki/Key-Uri-Format
59
- # For compatibility the issuer appears both before that account name and also in the
60
- # query string.
61
- issuer_string = issuer.nil? ? '' : "#{Addressable::URI.escape(issuer)}:"
62
- params = {
63
- secret: secret,
64
- period: interval == 30 ? nil : interval,
65
- issuer: issuer,
66
- digits: digits == DEFAULT_DIGITS ? nil : digits,
67
- algorithm: digest.casecmp('SHA1').zero? ? nil : digest.upcase
68
- }
69
- encode_params("otpauth://totp/#{issuer_string}#{Addressable::URI.escape(name)}", params)
57
+ OTP::URI.new(self, account_name: name).to_s
70
58
  end
71
59
 
72
60
  private
@@ -1,3 +1,3 @@
1
1
  module ROTP
2
- VERSION = '4.1.0'.freeze
2
+ VERSION = '6.2.0'.freeze
3
3
  end
@@ -4,23 +4,20 @@ Gem::Specification.new do |s|
4
4
  s.name = 'rotp'
5
5
  s.version = ROTP::VERSION
6
6
  s.platform = Gem::Platform::RUBY
7
+ s.required_ruby_version = '>= 2.3'
7
8
  s.license = 'MIT'
8
9
  s.authors = ['Mark Percival']
9
10
  s.email = ['mark@markpercival.us']
10
- s.homepage = 'http://github.com/mdp/rotp'
11
+ s.homepage = 'https://github.com/mdp/rotp'
11
12
  s.summary = 'A Ruby library for generating and verifying one time passwords'
12
13
  s.description = 'Works for both HOTP and TOTP, and includes QR Code provisioning'
13
14
 
14
- s.rubyforge_project = 'rotp'
15
-
16
15
  s.files = `git ls-files`.split("\n")
17
16
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
17
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
19
18
  s.require_paths = ['lib']
20
19
 
21
- s.add_runtime_dependency 'addressable', '~> 2.5'
22
-
23
- s.add_development_dependency 'rake', '~> 10.5'
20
+ s.add_development_dependency "rake", "~> 13.0"
24
21
  s.add_development_dependency 'rspec', '~> 3.5'
25
22
  s.add_development_dependency 'simplecov', '~> 0.12'
26
23
  s.add_development_dependency 'timecop', '~> 0.8'
@@ -1,24 +1,33 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  RSpec.describe ROTP::Base32 do
4
- describe '.random_base32' do
4
+ describe '.random' do
5
5
  context 'without arguments' do
6
- let(:base32) { ROTP::Base32.random_base32 }
6
+ let(:base32) { ROTP::Base32.random }
7
7
 
8
- 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
9
10
  expect(base32.length).to eq 32
10
11
  end
11
12
 
12
13
  it 'is base32 charset' do
13
- expect(base32).to match(/\A[a-z2-7]+\z/)
14
+ expect(base32).to match(/\A[A-Z2-7]+\z/)
14
15
  end
15
16
  end
16
17
 
17
18
  context 'with arguments' do
18
- let(:base32) { ROTP::Base32.random_base32 32 }
19
+ let(:base32) { ROTP::Base32.random 48 }
19
20
 
20
- it 'allows a specific length' do
21
- 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
22
31
  end
23
32
  end
24
33
  end
@@ -33,22 +42,40 @@ RSpec.describe ROTP::Base32 do
33
42
 
34
43
  context 'valid input data' do
35
44
  it 'correctly decodes a string' do
36
- expect(ROTP::Base32.decode('F').unpack('H*').first).to eq '28'
37
- expect(ROTP::Base32.decode('23').unpack('H*').first).to eq 'd6'
38
- expect(ROTP::Base32.decode('234').unpack('H*').first).to eq 'd6f8'
39
- expect(ROTP::Base32.decode('234A').unpack('H*').first).to eq 'd6f800'
40
- expect(ROTP::Base32.decode('234B').unpack('H*').first).to eq 'd6f810'
41
- expect(ROTP::Base32.decode('234BCD').unpack('H*').first).to eq 'd6f8110c'
42
- expect(ROTP::Base32.decode('234BCDE').unpack('H*').first).to eq 'd6f8110c80'
43
- expect(ROTP::Base32.decode('234BCDEFG').unpack('H*').first).to eq 'd6f8110c8530'
44
- 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
+
45
58
  end
46
59
 
47
60
  context 'with padding' do
48
61
  it 'correctly decodes a string' do
49
- expect(ROTP::Base32.decode('F==').unpack('H*').first).to eq '28'
62
+ expect(ROTP::Base32.decode('234A===').unpack('H*').first).to eq 'd6f8'
50
63
  end
51
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
52
74
  end
53
75
  end
76
+
77
+ end
78
+
79
+ def hex_to_bin(s)
80
+ s.scan(/../).map { |x| x.hex }.pack('c*')
54
81
  end
@@ -108,29 +108,14 @@ RSpec.describe ROTP::HOTP do
108
108
  end
109
109
 
110
110
  describe '#provisioning_uri' do
111
- let(:uri) { hotp.provisioning_uri('mark@percival') }
112
- let(:params) { CGI.parse URI.parse(uri).query }
113
-
114
- it 'has the correct format' do
115
- expect(uri).to match %r{\Aotpauth:\/\/hotp.+}
116
- end
117
-
118
- it 'includes the secret as parameter' do
119
- expect(params['secret'].first).to eq 'a' * 32
120
- end
121
-
122
- context 'with default digits' do
123
- it 'does not include digits parameter with default digits' do
124
- expect(params['digits'].first).to be_nil
125
- end
111
+ it 'accepts the account name' do
112
+ expect(hotp.provisioning_uri('mark@percival'))
113
+ .to eq 'otpauth://hotp/mark%40percival?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&counter=0'
126
114
  end
127
115
 
128
- context 'with non-default digits' do
129
- let(:hotp) { ROTP::HOTP.new('a' * 32, digits: 8) }
130
-
131
- it 'includes digits parameter' do
132
- expect(params['digits'].first).to eq '8'
133
- end
116
+ it 'also accepts a custom counter value' do
117
+ expect(hotp.provisioning_uri('mark@percival', 17))
118
+ .to eq 'otpauth://hotp/mark%40percival?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&counter=17'
134
119
  end
135
120
  end
136
121
  end
@@ -0,0 +1,99 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe ROTP::OTP::URI do
4
+ it 'meets basic functionality' do
5
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP')
6
+ uri = described_class.new(otp, account_name: 'alice@google.com')
7
+ expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP'
8
+ end
9
+
10
+ it 'includes issuer' do
11
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Example')
12
+ uri = described_class.new(otp, account_name: 'alice@google.com')
13
+ expect(uri.to_s).to eq 'otpauth://totp/Example:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'
14
+ end
15
+
16
+ it 'encodes the account name' do
17
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Provider1')
18
+ uri = described_class.new(otp, account_name: 'Alice Smith')
19
+ expect(uri.to_s).to eq 'otpauth://totp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&issuer=Provider1'
20
+ end
21
+
22
+ it 'encodes the issuer' do
23
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Big Corporation')
24
+ uri = described_class.new(otp, account_name: ' alice@bigco.com')
25
+ expect(uri.to_s).to eq 'otpauth://totp/Big%20Corporation:%20alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation'
26
+ end
27
+
28
+ it 'includes non-default SHA256 algorithm' do
29
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digest: 'sha256')
30
+ uri = described_class.new(otp, account_name: 'alice@google.com')
31
+ expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256'
32
+ end
33
+
34
+ it 'includes non-default SHA512 algorithm' do
35
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digest: 'sha512')
36
+ uri = described_class.new(otp, account_name: 'alice@google.com')
37
+ expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512'
38
+ end
39
+
40
+ it 'includes non-default 8 digits' do
41
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digits: 8)
42
+ uri = described_class.new(otp, account_name: 'alice@google.com')
43
+ expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&digits=8'
44
+ end
45
+
46
+ it 'includes non-default period for TOTP' do
47
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', interval: 35)
48
+ uri = described_class.new(otp, account_name: 'alice@google.com')
49
+ expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&period=35'
50
+ end
51
+
52
+ it 'includes non-default counter for HOTP' do
53
+ otp = ROTP::HOTP.new('JBSWY3DPEHPK3PXP')
54
+ uri = described_class.new(otp, account_name: 'alice@google.com', counter: 17)
55
+ expect(uri.to_s).to eq 'otpauth://hotp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&counter=17'
56
+ end
57
+
58
+ it 'can include all parameters' do
59
+ otp = ROTP::TOTP.new(
60
+ 'HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ',
61
+ digest: 'sha512',
62
+ digits: 8,
63
+ interval: 60,
64
+ issuer: 'ACME Co',
65
+ )
66
+ uri = described_class.new(otp, account_name: 'john.doe@email.com')
67
+ expect(uri.to_s).to eq'otpauth://totp/ACME%20Co:john.doe%40email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'
68
+ end
69
+
70
+ it 'strips leading and trailing whitespace from the issuer' do
71
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: ' Big Corporation ')
72
+ uri = described_class.new(otp, account_name: ' alice@bigco.com')
73
+ expect(uri.to_s).to eq 'otpauth://totp/Big%20Corporation:%20alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation'
74
+ end
75
+
76
+ it 'strips trailing whitespace from the account name' do
77
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP')
78
+ uri = described_class.new(otp, account_name: ' alice@google.com ')
79
+ expect(uri.to_s).to eq 'otpauth://totp/%20%20alice%40google.com?secret=JBSWY3DPEHPK3PXP'
80
+ end
81
+
82
+ it 'replaces colons in the issuer with underscores' do
83
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Big:Corporation')
84
+ uri = described_class.new(otp, account_name: 'alice@bigco.com')
85
+ expect(uri.to_s).to eq 'otpauth://totp/Big_Corporation:alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big_Corporation'
86
+ end
87
+
88
+ it 'replaces colons in the account name with underscores' do
89
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP')
90
+ uri = described_class.new(otp, account_name: 'Alice:Smith')
91
+ expect(uri.to_s).to eq 'otpauth://totp/Alice_Smith?secret=JBSWY3DPEHPK3PXP'
92
+ end
93
+
94
+ it 'handles email account names with sub-addressing' do
95
+ otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP')
96
+ uri = described_class.new(otp, account_name: 'alice+1234@google.com')
97
+ expect(uri.to_s).to eq 'otpauth://totp/alice%2B1234%40google.com?secret=JBSWY3DPEHPK3PXP'
98
+ end
99
+ end
@@ -221,79 +221,9 @@ RSpec.describe ROTP::TOTP do
221
221
  end
222
222
 
223
223
  describe '#provisioning_uri' do
224
- let(:uri) { totp.provisioning_uri('mark@percival') }
225
- let(:params) { CGI.parse URI.parse(uri).query }
226
-
227
- context 'without issuer' do
228
- it 'has the correct format' do
229
- expect(uri).to match %r{\Aotpauth:\/\/totp.+}
230
- end
231
-
232
- it 'includes the secret as parameter' do
233
- expect(params['secret'].first).to eq 'JBSWY3DPEHPK3PXP'
234
- end
235
- end
236
-
237
- context 'with default digits' do
238
- it 'does does not include digits parameter' do
239
- expect(params['digits'].first).to be_nil
240
- end
241
- end
242
-
243
- context 'with non-default digits' do
244
- let(:totp) { ROTP::TOTP.new 'JBSWY3DPEHPK3PXP', digits: 8 }
245
-
246
- it 'does does not include digits parameter' do
247
- expect(params['digits'].first).to eq '8'
248
- end
249
- end
250
-
251
- context 'with issuer' do
252
- let(:totp) { ROTP::TOTP.new 'JBSWY3DPEHPK3PXP', issuer: 'FooCo' }
253
-
254
- it 'has the correct format' do
255
- expect(uri).to match %r{\Aotpauth:\/\/totp/FooCo:.+}
256
- end
257
-
258
- it 'includes the secret as parameter' do
259
- expect(params['secret'].first).to eq 'JBSWY3DPEHPK3PXP'
260
- end
261
-
262
- it 'includes the issuer as parameter' do
263
- expect(params['issuer'].first).to eq 'FooCo'
264
- end
265
- end
266
-
267
- context 'with custom interval' do
268
- let(:totp) { ROTP::TOTP.new 'JBSWY3DPEHPK3PXP', interval: 60 }
269
-
270
- it 'has the correct format' do
271
- expect(uri).to match %r{\Aotpauth:\/\/totp.+}
272
- end
273
-
274
- it 'includes the secret as parameter' do
275
- expect(params['secret'].first).to eq 'JBSWY3DPEHPK3PXP'
276
- end
277
-
278
- it 'includes the interval as period parameter' do
279
- expect(params['period'].first).to eq '60'
280
- end
281
- end
282
-
283
- context 'with custom digest' do
284
- let(:totp) { ROTP::TOTP.new 'JBSWY3DPEHPK3PXP', digest: 'sha256' }
285
-
286
- it 'has the correct format' do
287
- expect(uri).to match %r{\Aotpauth:\/\/totp.+}
288
- end
289
-
290
- it 'includes the secret as parameter' do
291
- expect(params['secret'].first).to eq 'JBSWY3DPEHPK3PXP'
292
- end
293
-
294
- it 'includes the digest as algorithm parameter' do
295
- expect(params['algorithm'].first).to eq 'SHA256'
296
- end
224
+ it 'accepts the account name' do
225
+ expect(totp.provisioning_uri('mark@percival'))
226
+ .to eq 'otpauth://totp/mark%40percival?secret=JBSWY3DPEHPK3PXP'
297
227
  end
298
228
  end
299
229
 
metadata CHANGED
@@ -1,43 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rotp
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Percival
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-02-28 00:00:00.000000000 Z
11
+ date: 2020-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: addressable
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '2.5'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '2.5'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: rake
29
15
  requirement: !ruby/object:Gem::Requirement
30
16
  requirements:
31
17
  - - "~>"
32
18
  - !ruby/object:Gem::Version
33
- version: '10.5'
19
+ version: '13.0'
34
20
  type: :development
35
21
  prerelease: false
36
22
  version_requirements: !ruby/object:Gem::Requirement
37
23
  requirements:
38
24
  - - "~>"
39
25
  - !ruby/object:Gem::Version
40
- version: '10.5'
26
+ version: '13.0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: rspec
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -92,14 +78,15 @@ files:
92
78
  - ".gitignore"
93
79
  - ".travis.yml"
94
80
  - CHANGELOG.md
95
- - Dockerfile-2.0
81
+ - Dockerfile-2.3
96
82
  - Dockerfile-2.5
97
83
  - Dockerfile-2.6
84
+ - Dockerfile-2.7
85
+ - Dockerfile-3.0-rc
98
86
  - Gemfile
99
87
  - Guardfile
100
88
  - LICENSE
101
89
  - README.md
102
- - Rakefile
103
90
  - bin/rotp
104
91
  - doc/ROTP/HOTP.html
105
92
  - doc/ROTP/OTP.html
@@ -119,12 +106,14 @@ files:
119
106
  - doc/js/jquery.js
120
107
  - doc/method_list.html
121
108
  - doc/top-level-namespace.html
109
+ - docker-compose.yml
122
110
  - lib/rotp.rb
123
111
  - lib/rotp/arguments.rb
124
112
  - lib/rotp/base32.rb
125
113
  - lib/rotp/cli.rb
126
114
  - lib/rotp/hotp.rb
127
115
  - lib/rotp/otp.rb
116
+ - lib/rotp/otp/uri.rb
128
117
  - lib/rotp/totp.rb
129
118
  - lib/rotp/version.rb
130
119
  - rotp.gemspec
@@ -132,9 +121,10 @@ files:
132
121
  - spec/lib/rotp/base32_spec.rb
133
122
  - spec/lib/rotp/cli_spec.rb
134
123
  - spec/lib/rotp/hotp_spec.rb
124
+ - spec/lib/rotp/otp/uri_spec.rb
135
125
  - spec/lib/rotp/totp_spec.rb
136
126
  - spec/spec_helper.rb
137
- homepage: http://github.com/mdp/rotp
127
+ homepage: https://github.com/mdp/rotp
138
128
  licenses:
139
129
  - MIT
140
130
  metadata: {}
@@ -146,15 +136,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
146
136
  requirements:
147
137
  - - ">="
148
138
  - !ruby/object:Gem::Version
149
- version: '0'
139
+ version: '2.3'
150
140
  required_rubygems_version: !ruby/object:Gem::Requirement
151
141
  requirements:
152
142
  - - ">="
153
143
  - !ruby/object:Gem::Version
154
144
  version: '0'
155
145
  requirements: []
156
- rubyforge_project: rotp
157
- rubygems_version: 2.7.6
146
+ rubygems_version: 3.1.2
158
147
  signing_key:
159
148
  specification_version: 4
160
149
  summary: A Ruby library for generating and verifying one time passwords
data/Rakefile DELETED
@@ -1,9 +0,0 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
3
- require 'rspec/core/rake_task'
4
-
5
- RSpec::Core::RakeTask.new(:rspec) do |spec|
6
- spec.pattern = 'spec/**/*_spec.rb'
7
- end
8
-
9
- task default: :rspec