rotp 4.0.2 → 6.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d869e33ff8ede2ef4233ed0398b730a1fc14e2b764295b090492bed133c4fc
4
- data.tar.gz: 5ee5f47d3cee494762fcaaabb4dff1dfc247bab7faa0207fc6a3b7ff7b672f05
3
+ metadata.gz: 36079aaa87791cbc44f0772c429ebc947f88d34b0389411c13e37fc65a3bd1a1
4
+ data.tar.gz: a71126bd8dc56ca8e5db1bf5bf4fa5296c2319ac06816427400e253d0d835292
5
5
  SHA512:
6
- metadata.gz: 42c5bb89a97375204c3198dd9d07080f4728162307c9b99eb6f7c46152bfa8aa21d7e02699fc911c9991ef6ae17c9465f2c8e438bf223b2097cbc9b72766912b
7
- data.tar.gz: 45dc82dd282328c5e3651a5b660ac327c1d10f07b7ca21025139fbe3edf418b661e1b67648081027e04ea9bce639195ec21d44ada7d6af9ac607984e8fcda47b
6
+ metadata.gz: 3d90ee7ee49a029e74fe22faa8e14e14130f24a3c6551bc20742f35e7b1b529e807b56229bfb4a64f8621447920996d9a54d5fe98c1a255aae392a8dbc350d79
7
+ data.tar.gz: 12dd508477209b35bf75337934d9c780f661eebf32dc0a712acceedb7216cc2455af9781168248469c9b98c3eea27deaa961879d31464cffb793367a2aa8f529
@@ -1,8 +1,9 @@
1
1
  language: ruby
2
- before_install: gem install bundler
2
+ before_install: gem install bundler -v '<2'
3
3
  rvm:
4
- - 2.3.0
5
- - 2.1.0
6
- - 2.0.0
4
+ - 2.7
5
+ - 2.6
6
+ - 2.5
7
+ - 2.3
7
8
  script:
8
9
  - bundle exec rspec
@@ -1,5 +1,36 @@
1
1
  ### Changelog
2
2
 
3
+ ### 6.1.0
4
+
5
+ - Fixing URI encoding issues again, breaking out into it's own module
6
+ due to the complexity - closes #100 (@atcruice)
7
+ - Add docker-compose.yml to help with easier testing
8
+
9
+ ### 6.0.0
10
+
11
+ - Dropping support for Ruby <2.3 (Major version bump)
12
+ - Fix issue when using --enable-frozen-string-literal Ruby option #95 (jeremyevans)
13
+ - URI Encoding fix #94 (ksuh90)
14
+ - Update gems (rake, addressable)
15
+ - Update Travis tests to include Ruby 2.7
16
+
17
+ ### 5.1.0
18
+
19
+ - Create `random_base32` to perform `random` to avoid breaking changes
20
+ - Still needed to bump to 5.x due to Base32 cleanup
21
+
22
+ ### 5.0.0
23
+
24
+ - Clean up base32 implementation to match Google Autheticator
25
+ - BREAKING `Base32.random_base32` renamed to random
26
+ - The argument is now byte length vs output string length for more precise bit strengths
27
+
28
+ ### 4.1.0
29
+
30
+ - Add a digest option to the CLI #83
31
+ - Fix provisioning URI is README #82
32
+ - Improvements to docs
33
+
3
34
  ### 4.0.2
4
35
 
5
36
  - Fix gemspec requirment for Addressable
@@ -1,16 +1,10 @@
1
1
  FROM ruby:2.3
2
2
 
3
- # throw errors if Gemfile has been modified since Gemfile.lock
4
- RUN bundle config --global frozen 1
5
-
6
3
  RUN mkdir -p /usr/src/app
7
4
  WORKDIR /usr/src/app
8
5
 
9
6
  COPY Gemfile /usr/src/app/
10
- COPY Gemfile.lock /usr/src/app/
11
7
  COPY . /usr/src/app
12
8
  RUN bundle install
13
9
 
14
-
15
- CMD ["bundler", "exec", "rspec"]
16
-
10
+ CMD ["bundle", "exec", "rspec"]
@@ -0,0 +1,10 @@
1
+ FROM ruby:2.5
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"]
@@ -0,0 +1,11 @@
1
+ FROM ruby:2.6
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"]
11
+
@@ -0,0 +1,11 @@
1
+ FROM ruby:2.7
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"]
11
+
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/mdp/rotp.svg?branch=master)](https://travis-ci.org/mdp/rotp)
4
4
  [![Gem Version](https://badge.fury.io/rb/rotp.svg)](https://rubygems.org/gems/rotp)
5
+ [![Documentation](http://img.shields.io/badge/docs-rdoc.info-blue.svg)](https://www.rubydoc.info/github/mdp/rotp/master)
5
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/mdp/rotp/blob/master/LICENSE)
6
7
 
7
8
  A ruby library for generating and validating one time passwords (HOTP & TOTP) according to [RFC 4226](http://tools.ietf.org/html/rfc4226) and [RFC 6238](http://tools.ietf.org/html/rfc6238).
@@ -15,7 +16,20 @@ Many websites use this for [multi-factor authentication](https://www.youtube.com
15
16
  * OpenSSL
16
17
  * Ruby 2.0 or higher
17
18
 
18
- ## 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
19
33
 
20
34
  - Simplified API
21
35
  - `verify` now takes options for `drift` and `after`
@@ -56,8 +70,8 @@ hotp.at(1) # => "595254"
56
70
  hotp.at(1401) # => "259769"
57
71
 
58
72
  # OTP verified with a counter
59
- hotp.verify("316439", 1401) # => 1401
60
- hotp.verify("316439", 1402) # => nil
73
+ hotp.verify("259769", 1401) # => 1401
74
+ hotp.verify("259769", 1402) # => nil
61
75
  ```
62
76
 
63
77
  ### Preventing reuse of Time based OTP's
@@ -68,18 +82,21 @@ the interval window (default 30 seconds)
68
82
  The following is an example of this in action:
69
83
 
70
84
  ```ruby
71
- User.find(someUserID)
85
+ user = User.find(someUserID)
72
86
  totp = ROTP::TOTP.new(user.otp_secret)
73
87
  totp.now # => "492039"
74
88
 
89
+ # Let's take a look at the last time the user authenticated with an OTP
75
90
  user.last_otp_at # => 1432703530
76
91
 
77
92
  # Verify the OTP
78
93
  last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> 1472145760
79
94
  # ROTP returns the timestamp(int) of the current period
95
+
80
96
  # Store this on the user's account
81
97
  user.update(last_otp_at: last_otp_at)
82
- # Someone attempts to reused the OTP inside the 30s window
98
+
99
+ # Someone attempts to reuse the OTP inside the 30s window
83
100
  last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> nil
84
101
  # It fails to verify because we are still in the same 30s interval window
85
102
  ```
@@ -104,7 +121,7 @@ totp.verify("250939", drift_behind: 15, at: now + 45) # => nil
104
121
  ### Generating a Base32 Secret key
105
122
 
106
123
  ```ruby
107
- 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
108
125
  ```
109
126
 
110
127
  Note: The Base32 format conforms to [RFC 4648 Base32](http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet)
@@ -115,8 +132,11 @@ Provisioning URI's generated by ROTP are compatible with most One Time Password
115
132
  Google Authenticator.
116
133
 
117
134
  ```ruby
118
- totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/issuer:alice@google.com?secret=JBSWY3DPEHPK3PXP'
119
- hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/issuer:alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0'
135
+ totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service")
136
+ totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/My%20Service:alice%40google.com?secret=base32secret3232&issuer=My%20Service'
137
+
138
+ hotp = ROTP::HOTP.new("base32secret3232", issuer: "My Service")
139
+ hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/alice%40google.com?secret=base32secret3232&counter=0'
120
140
  ```
121
141
 
122
142
  This can then be rendered as a QR Code which the user can scan using their mobile phone and the appropriate application.
@@ -143,9 +163,25 @@ bundle install
143
163
  bundle exec rspec
144
164
  ```
145
165
 
166
+ ### Testing with Docker
167
+
168
+ In order to make it easier to test against different ruby version, ROTP comes
169
+ with a set of Dockerfiles for each version that we test against in Travis
170
+
171
+ ```bash
172
+ docker build -f Dockerfile-2.6 -t rotp_2.6 .
173
+ docker run --rm -v $(pwd):/usr/src/app rotp_2.6
174
+ ```
175
+
176
+ Alternately, you may use docker-compose to run all the tests:
177
+
178
+ ```
179
+ docker-compose up
180
+ ```
181
+
146
182
  ## Executable Usage
147
183
 
148
- 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
149
185
 
150
186
  ```bash
151
187
  # Try this to get an overview of the commands
@@ -162,7 +198,7 @@ Have a look at the [contributors graph](https://github.com/mdp/rotp/graphs/contr
162
198
 
163
199
  ## License
164
200
 
165
- MIT Copyright (C) 2016 by Mark Percival, see [LICENSE](https://github.com/mdp/rotp/blob/master/LICENSE) for details.
201
+ MIT Copyright (C) 2019 by Mark Percival, see [LICENSE](https://github.com/mdp/rotp/blob/master/LICENSE) for details.
166
202
 
167
203
  ## Other implementations
168
204
 
@@ -0,0 +1,30 @@
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"
@@ -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
 
@@ -68,6 +68,10 @@ module ROTP
68
68
  parser.on_tail('-h', '--help', 'Show this message') do
69
69
  options!.mode = :help
70
70
  end
71
+
72
+ parser.on('-d', '--digest [ALGORITHM]', 'Use algorithm for the digest (default sha1)') do |digest|
73
+ options!.digest = digest
74
+ end
71
75
  end
72
76
  end
73
77
 
@@ -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
@@ -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
 
@@ -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
@@ -5,10 +5,10 @@ module ROTP
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
13
  # @returns [OTP] OTP instantiation
14
14
  def initialize(s, options = {})
@@ -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.0.2'.freeze
2
+ VERSION = '6.1.0'.freeze
3
3
  end
@@ -4,6 +4,7 @@ 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']
@@ -11,16 +12,12 @@ Gem::Specification.new do |s|
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
@@ -18,6 +18,14 @@ RSpec.describe ROTP::CLI do
18
18
  end
19
19
  end
20
20
 
21
+ context 'generating a TOTP with sha256 digest' do
22
+ let(:argv) { %w[--secret JBSWY3DPEHPK3PXP --digest sha256] }
23
+
24
+ it 'prints the corresponding token' do
25
+ expect(output).to eq '544902'
26
+ end
27
+ end
28
+
21
29
  context 'generating a TOTP with no secret' do
22
30
  let(:argv) { %w[--time --secret] }
23
31
 
@@ -49,4 +57,12 @@ RSpec.describe ROTP::CLI do
49
57
  expect(output).to eq '161024'
50
58
  end
51
59
  end
60
+
61
+ context 'generating a HOTP' do
62
+ let(:argv) { %W[--hmac --secret #{'a' * 32} --counter 1234 --digest sha256] }
63
+
64
+ it 'prints the corresponding token' do
65
+ expect(output).to eq '325941'
66
+ end
67
+ end
52
68
  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.0.2
4
+ version: 6.1.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: 2018-11-01 00:00:00.000000000 Z
11
+ date: 2020-08-03 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,14 @@ files:
92
78
  - ".gitignore"
93
79
  - ".travis.yml"
94
80
  - CHANGELOG.md
95
- - Dockerfile-1.9
96
- - Dockerfile-2.1
97
81
  - Dockerfile-2.3
82
+ - Dockerfile-2.5
83
+ - Dockerfile-2.6
84
+ - Dockerfile-2.7
98
85
  - Gemfile
99
86
  - Guardfile
100
87
  - LICENSE
101
88
  - README.md
102
- - Rakefile
103
89
  - bin/rotp
104
90
  - doc/ROTP/HOTP.html
105
91
  - doc/ROTP/OTP.html
@@ -119,12 +105,14 @@ files:
119
105
  - doc/js/jquery.js
120
106
  - doc/method_list.html
121
107
  - doc/top-level-namespace.html
108
+ - docker-compose.yml
122
109
  - lib/rotp.rb
123
110
  - lib/rotp/arguments.rb
124
111
  - lib/rotp/base32.rb
125
112
  - lib/rotp/cli.rb
126
113
  - lib/rotp/hotp.rb
127
114
  - lib/rotp/otp.rb
115
+ - lib/rotp/otp/uri.rb
128
116
  - lib/rotp/totp.rb
129
117
  - lib/rotp/version.rb
130
118
  - rotp.gemspec
@@ -132,6 +120,7 @@ files:
132
120
  - spec/lib/rotp/base32_spec.rb
133
121
  - spec/lib/rotp/cli_spec.rb
134
122
  - spec/lib/rotp/hotp_spec.rb
123
+ - spec/lib/rotp/otp/uri_spec.rb
135
124
  - spec/lib/rotp/totp_spec.rb
136
125
  - spec/spec_helper.rb
137
126
  homepage: http://github.com/mdp/rotp
@@ -144,24 +133,17 @@ require_paths:
144
133
  - lib
145
134
  required_ruby_version: !ruby/object:Gem::Requirement
146
135
  requirements:
147
- - - ">="
136
+ - - "~>"
148
137
  - !ruby/object:Gem::Version
149
- version: '0'
138
+ version: '2.3'
150
139
  required_rubygems_version: !ruby/object:Gem::Requirement
151
140
  requirements:
152
141
  - - ">="
153
142
  - !ruby/object:Gem::Version
154
143
  version: '0'
155
144
  requirements: []
156
- rubyforge_project: rotp
157
- rubygems_version: 2.7.6
145
+ rubygems_version: 3.1.2
158
146
  signing_key:
159
147
  specification_version: 4
160
148
  summary: A Ruby library for generating and verifying one time passwords
161
- test_files:
162
- - spec/lib/rotp/arguments_spec.rb
163
- - spec/lib/rotp/base32_spec.rb
164
- - spec/lib/rotp/cli_spec.rb
165
- - spec/lib/rotp/hotp_spec.rb
166
- - spec/lib/rotp/totp_spec.rb
167
- - spec/spec_helper.rb
149
+ test_files: []
@@ -1,15 +0,0 @@
1
- FROM ruby:1.9
2
-
3
- # throw errors if Gemfile has been modified since Gemfile.lock
4
- RUN bundle config --global frozen 1
5
-
6
- RUN mkdir -p /usr/src/app
7
- WORKDIR /usr/src/app
8
-
9
- COPY Gemfile /usr/src/app/
10
- COPY Gemfile.lock /usr/src/app/
11
- COPY . /usr/src/app
12
- RUN bundle install
13
-
14
- CMD ["bundler", "exec", "rspec"]
15
-
@@ -1,16 +0,0 @@
1
- FROM ruby:2.1
2
-
3
- # throw errors if Gemfile has been modified since Gemfile.lock
4
- RUN bundle config --global frozen 1
5
-
6
- RUN mkdir -p /usr/src/app
7
- WORKDIR /usr/src/app
8
-
9
- COPY Gemfile /usr/src/app/
10
- COPY Gemfile.lock /usr/src/app/
11
- COPY . /usr/src/app
12
- RUN bundle install
13
-
14
-
15
- CMD ["bundler", "exec", "rspec"]
16
-
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