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 +4 -4
- data/.travis.yml +2 -1
- data/CHANGELOG.md +30 -0
- data/Dockerfile-2.3 +10 -0
- data/{Dockerfile-2.0 → Dockerfile-2.7} +1 -1
- data/Dockerfile-3.0-rc +12 -0
- data/README.md +28 -9
- data/docker-compose.yml +37 -0
- data/lib/rotp.rb +2 -3
- data/lib/rotp/base32.rb +56 -32
- data/lib/rotp/cli.rb +1 -1
- data/lib/rotp/hotp.rb +1 -6
- data/lib/rotp/otp.rb +0 -10
- data/lib/rotp/otp/uri.rb +79 -0
- data/lib/rotp/totp.rb +1 -13
- data/lib/rotp/version.rb +1 -1
- data/rotp.gemspec +3 -6
- data/spec/lib/rotp/base32_spec.rb +44 -17
- data/spec/lib/rotp/hotp_spec.rb +6 -21
- data/spec/lib/rotp/otp/uri_spec.rb +99 -0
- data/spec/lib/rotp/totp_spec.rb +3 -73
- metadata +13 -24
- data/Rakefile +0 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cd976bfa6985075f5e2b76607256d0afbbdf88a82c38cd094d0eaffbb5bce4f2
|
4
|
+
data.tar.gz: 70df660f1eca3dd9efc7baa1f53061ba9af1bbb49e4bb6ead507509f6e845d38
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7fb326cc887a1a5614c90c492ac43b72188f75caa90fcc50c3338d129abe2efe4f67af88d018c378379806f1bef0c1d0e40fc6c683f4427f40ad411326729022
|
7
|
+
data.tar.gz: 4f913bf0693c1cead926bfe625e226fe8277323f93a552459447a792cd27b9189860ab26d3907baafff0e217430b04fb9e8b1829b0795e4c96e83062fac409fb
|
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -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
|
data/Dockerfile-2.3
ADDED
data/Dockerfile-3.0-rc
ADDED
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
|
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("
|
61
|
-
hotp.verify("
|
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.
|
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
|
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
|
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
|
-
###
|
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
|
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
|
data/docker-compose.yml
ADDED
@@ -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"
|
data/lib/rotp.rb
CHANGED
data/lib/rotp/base32.rb
CHANGED
@@ -1,51 +1,75 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
1
3
|
module ROTP
|
2
4
|
class Base32
|
3
5
|
class Base32Error < RuntimeError; end
|
4
|
-
CHARS = '
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
28
|
+
result.pack('c*')
|
15
29
|
end
|
16
30
|
|
17
|
-
def
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
54
|
+
return out
|
23
55
|
end
|
24
56
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
44
|
-
|
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
|
72
|
+
CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
|
49
73
|
end
|
50
74
|
end
|
51
75
|
end
|
data/lib/rotp/cli.rb
CHANGED
@@ -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.
|
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
|
data/lib/rotp/hotp.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/rotp/otp.rb
CHANGED
@@ -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
|
data/lib/rotp/otp/uri.rb
ADDED
@@ -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
|
data/lib/rotp/totp.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/rotp/version.rb
CHANGED
data/rotp.gemspec
CHANGED
@@ -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 = '
|
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.
|
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 '.
|
4
|
+
describe '.random' do
|
5
5
|
context 'without arguments' do
|
6
|
-
let(:base32) { ROTP::Base32.
|
6
|
+
let(:base32) { ROTP::Base32.random }
|
7
7
|
|
8
|
-
it 'is 32
|
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[
|
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.
|
19
|
+
let(:base32) { ROTP::Base32.random 48 }
|
19
20
|
|
20
|
-
it '
|
21
|
-
expect(base32.length).to eq
|
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('
|
37
|
-
expect(ROTP::Base32.decode('
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
expect(ROTP::Base32.decode('
|
44
|
-
|
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('
|
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
|
data/spec/lib/rotp/hotp_spec.rb
CHANGED
@@ -108,29 +108,14 @@ RSpec.describe ROTP::HOTP do
|
|
108
108
|
end
|
109
109
|
|
110
110
|
describe '#provisioning_uri' do
|
111
|
-
|
112
|
-
|
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
|
-
|
129
|
-
|
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
|
data/spec/lib/rotp/totp_spec.rb
CHANGED
@@ -221,79 +221,9 @@ RSpec.describe ROTP::TOTP do
|
|
221
221
|
end
|
222
222
|
|
223
223
|
describe '#provisioning_uri' do
|
224
|
-
|
225
|
-
|
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
|
+
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:
|
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: '
|
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: '
|
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.
|
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:
|
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: '
|
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
|
-
|
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
|