rotp 3.3.1 → 4.0.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 +5 -5
- data/.travis.yml +0 -1
- data/CHANGELOG.md +6 -0
- data/README.md +57 -46
- data/lib/rotp/base32.rb +1 -1
- data/lib/rotp/cli.rb +2 -5
- data/lib/rotp/hotp.rb +10 -23
- data/lib/rotp/otp.rb +4 -9
- data/lib/rotp/totp.rb +44 -46
- data/lib/rotp/version.rb +1 -1
- data/spec/lib/rotp/arguments_spec.rb +14 -0
- data/spec/lib/rotp/base32_spec.rb +2 -2
- data/spec/lib/rotp/cli_spec.rb +24 -0
- data/spec/lib/rotp/hotp_spec.rb +39 -63
- data/spec/lib/rotp/totp_spec.rb +167 -132
- data/spec/spec_helper.rb +5 -2
- metadata +3 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 681041e122df89a1947016e02fd7c11e3102ffc3eed78c1dcdcb5056af116afd
|
4
|
+
data.tar.gz: 0333b10d91241c31b7fb2b3b5b7bd0d99f0e02b471ce0d10dc5961ae98cb89d7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a7454a2072e7f6b4cabd84338b2c9b1d06363102230d2c93bd244f0a486004b6363e9cd50aece371c951ae68798a6cc109798948c11034f679030bfd7106e464
|
7
|
+
data.tar.gz: 18e79a2ca2a61d9b6a2dd902744e344fe1be9b7f4ac2c3d4674d6c83929f69b2395d794c9d9081115678fbfc81599f50a07cf8b287ce1c7f8ed05dcee4322e13
|
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,9 +1,15 @@
|
|
1
1
|
### Changelog
|
2
2
|
|
3
|
+
#### 4.0.0
|
4
|
+
|
5
|
+
- Simplify API
|
6
|
+
- Remove support for Ruby < 2.0
|
7
|
+
|
3
8
|
#### 3.3.1
|
4
9
|
|
5
10
|
- Add OpenSSL as a requirement for Ruby 2.5. Fixes #70 & #64
|
6
11
|
- Allow Base32 with padding. #71
|
12
|
+
- Prevent verify with drift being negative #69
|
7
13
|
|
8
14
|
#### 3.3.0
|
9
15
|
|
data/README.md
CHANGED
@@ -4,16 +4,24 @@
|
|
4
4
|
[](https://rubygems.org/gems/rotp)
|
5
5
|
[](https://github.com/mdp/rotp/blob/master/LICENSE)
|
6
6
|
|
7
|
-
A ruby library for generating one time passwords (HOTP & TOTP) according to [RFC 4226](http://tools.ietf.org/html/rfc4226) and [RFC 6238](http://tools.ietf.org/html/rfc6238).
|
7
|
+
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).
|
8
8
|
|
9
|
-
ROTP is compatible with
|
9
|
+
ROTP is compatible with [Google Authenticator](https://github.com/google/google-authenticator) available for [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) and [iPhone](https://itunes.apple.com/en/app/google-authenticator/id388497605) and any other TOTP based implementations.
|
10
10
|
|
11
|
-
Many websites use this for [multi-factor authentication](https://www.youtube.com/watch?v=17rykTIX_HY), such as GMail, Facebook, Amazon EC2, WordPress, and Salesforce. You can find
|
11
|
+
Many websites use this for [multi-factor authentication](https://www.youtube.com/watch?v=17rykTIX_HY), such as GMail, Facebook, Amazon EC2, WordPress, and Salesforce. You can find a more complete [list here](https://en.wikipedia.org/wiki/Google_Authenticator#Usage).
|
12
12
|
|
13
13
|
## Dependencies
|
14
14
|
|
15
15
|
* OpenSSL
|
16
|
-
* Ruby
|
16
|
+
* Ruby 2.0 or higher
|
17
|
+
|
18
|
+
## Breaking changes in >= 4.0
|
19
|
+
|
20
|
+
- Simplified API
|
21
|
+
- `verify` now takes options for `drift` and `after`
|
22
|
+
- `verify` returns a timestamp if true, nil if false
|
23
|
+
- Dropping support for Ruby < 2.0
|
24
|
+
- Docs for 3.x can be found [here](https://github.com/mdp/rotp/tree/v3.3.0)
|
17
25
|
|
18
26
|
## Installation
|
19
27
|
|
@@ -26,20 +34,17 @@ gem install rotp
|
|
26
34
|
### Time based OTP's
|
27
35
|
|
28
36
|
```ruby
|
29
|
-
totp = ROTP::TOTP.new("base32secret3232")
|
37
|
+
totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service")
|
30
38
|
totp.now # => "492039"
|
31
39
|
|
32
|
-
# OTP verified for current time
|
33
|
-
|
34
|
-
|
35
|
-
totp.verify("492039") # => false
|
36
|
-
```
|
40
|
+
# OTP verified for current time - returns timestamp of the current interval
|
41
|
+
# period.
|
42
|
+
totp.verify("492039") # => 1474590700
|
37
43
|
|
38
|
-
|
44
|
+
sleep 30
|
39
45
|
|
40
|
-
|
41
|
-
totp
|
42
|
-
totp.provisioning_uri("alice@google.com")
|
46
|
+
# OTP fails to verify - returns nil
|
47
|
+
totp.verify("492039") # => nil
|
43
48
|
```
|
44
49
|
|
45
50
|
### Counter based OTP's
|
@@ -51,69 +56,76 @@ hotp.at(1) # => "595254"
|
|
51
56
|
hotp.at(1401) # => "259769"
|
52
57
|
|
53
58
|
# OTP verified with a counter
|
54
|
-
hotp.verify("
|
55
|
-
hotp.verify("
|
56
|
-
```
|
57
|
-
|
58
|
-
### Verifying a Time based OTP with drift
|
59
|
-
|
60
|
-
Some users devices may be slightly behind or ahead of the actual time. ROTP allows users to verify
|
61
|
-
an OTP code with an specific amount of 'drift'
|
62
|
-
|
63
|
-
```ruby
|
64
|
-
totp = ROTP::TOTP.new("base32secret3232")
|
65
|
-
totp.now # => "492039"
|
66
|
-
|
67
|
-
# OTP verified for current time with 120 seconds allowed drift
|
68
|
-
totp.verify_with_drift("492039", 60, Time.now - 30) # => true
|
69
|
-
totp.verify_with_drift("492039", 60, Time.now - 90) # => false
|
59
|
+
hotp.verify("316439", 1401) # => 1401
|
60
|
+
hotp.verify("316439", 1402) # => nil
|
70
61
|
```
|
71
62
|
|
72
63
|
### Preventing reuse of Time based OTP's
|
73
64
|
|
74
|
-
|
75
|
-
|
65
|
+
By keeping track of the last time a user's OTP was verified, we can prevent token reuse during
|
66
|
+
the interval window (default 30 seconds)
|
67
|
+
|
68
|
+
The following is an example of this in action:
|
76
69
|
|
77
70
|
```ruby
|
78
71
|
User.find(someUserID)
|
79
72
|
totp = ROTP::TOTP.new(user.otp_secret)
|
80
73
|
totp.now # => "492039"
|
81
74
|
|
82
|
-
user.last_otp_at # =>
|
75
|
+
user.last_otp_at # => 1432703530
|
83
76
|
|
84
77
|
# Verify the OTP
|
85
|
-
|
78
|
+
last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> 1472145760
|
79
|
+
# ROTP returns the timestamp(int) of the current period
|
86
80
|
# Store this on the user's account
|
87
|
-
user.update(last_otp_at:
|
88
|
-
|
81
|
+
user.update(last_otp_at: last_otp_at)
|
82
|
+
# Someone attempts to reused the OTP inside the 30s window
|
83
|
+
last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> nil
|
84
|
+
# It fails to verify because we are still in the same 30s interval window
|
85
|
+
```
|
86
|
+
|
87
|
+
### Verifying a Time based OTP with drift
|
88
|
+
|
89
|
+
Some users may enter a code just after it has expired. By adding 'drift' you can allow
|
90
|
+
for a recently expired token to remain valid.
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
totp = ROTP::TOTP.new("base32secret3232")
|
94
|
+
now = Time.at(1474590600) #2016-09-23 00:30:00 UTC
|
95
|
+
totp.at(now) # => "250939"
|
96
|
+
|
97
|
+
# OTP verified for current time along with 15 seconds earlier
|
98
|
+
# ie. User enters a code just after it expired
|
99
|
+
totp.verify("250939", drift_behind: 15, at: now + 35) # => 1474590600
|
100
|
+
# User waits too long. Fails to validate previous OTP
|
101
|
+
totp.verify("250939", drift_behind: 15, at: now + 45) # => nil
|
89
102
|
```
|
90
103
|
|
91
104
|
### Generating a Base32 Secret key
|
92
105
|
|
93
106
|
```ruby
|
94
|
-
ROTP::Base32.random_base32 # returns a
|
107
|
+
ROTP::Base32.random_base32 # returns a 32 character base32 secret. Compatible with Google Authenticator
|
95
108
|
```
|
96
109
|
|
97
110
|
Note: The Base32 format conforms to [RFC 4648 Base32](http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet)
|
98
111
|
|
99
|
-
###
|
112
|
+
### Generating QR Codes for provisioning mobile apps
|
100
113
|
|
101
|
-
Provisioning URI's generated by ROTP are compatible with
|
102
|
-
|
114
|
+
Provisioning URI's generated by ROTP are compatible with most One Time Password applications, including
|
115
|
+
Google Authenticator.
|
103
116
|
|
104
117
|
```ruby
|
105
118
|
totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/issuer:alice@google.com?secret=JBSWY3DPEHPK3PXP'
|
106
119
|
hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/issuer:alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=0'
|
107
120
|
```
|
108
121
|
|
109
|
-
This can then be rendered as a QR Code which can
|
110
|
-
list of OTP credentials.
|
122
|
+
This can then be rendered as a QR Code which the user can scan using their mobile phone and the appropriate application.
|
111
123
|
|
112
124
|
#### Working example
|
113
125
|
|
114
126
|
Scan the following barcode with your phone, using Google Authenticator
|
115
127
|
|
116
|
-

|
117
129
|
|
118
130
|
Now run the following and compare the output
|
119
131
|
|
@@ -133,8 +145,7 @@ bundle exec rspec
|
|
133
145
|
|
134
146
|
## Executable Usage
|
135
147
|
|
136
|
-
|
137
|
-
(if not, you might find trouble-shooting help [at this stackoverflow question](http://stackoverflow.com/a/909980)).
|
148
|
+
The rotp rubygem includes an executable for helping with testing and debugging
|
138
149
|
|
139
150
|
```bash
|
140
151
|
# Try this to get an overview of the commands
|
@@ -151,7 +162,7 @@ Have a look at the [contributors graph](https://github.com/mdp/rotp/graphs/contr
|
|
151
162
|
|
152
163
|
## License
|
153
164
|
|
154
|
-
MIT Copyright (C)
|
165
|
+
MIT Copyright (C) 2016 by Mark Percival, see [LICENSE](https://github.com/mdp/rotp/blob/master/LICENSE) for details.
|
155
166
|
|
156
167
|
## Other implementations
|
157
168
|
|
data/lib/rotp/base32.rb
CHANGED
data/lib/rotp/cli.rb
CHANGED
@@ -9,9 +9,11 @@ module ROTP
|
|
9
9
|
@argv = argv
|
10
10
|
end
|
11
11
|
|
12
|
+
# :nocov:
|
12
13
|
def run
|
13
14
|
puts output
|
14
15
|
end
|
16
|
+
# :nocov:
|
15
17
|
|
16
18
|
def errors
|
17
19
|
if [:time, :hmac].include?(options.mode)
|
@@ -20,8 +22,6 @@ module ROTP
|
|
20
22
|
elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.downcase) == nil }
|
21
23
|
red 'Secret must be in RFC4648 Base32 format - http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet'
|
22
24
|
end
|
23
|
-
elsif options.mode == :hmac && options.counter.to_i < 0
|
24
|
-
red 'You must also specify a --counter. Try --help for help.'
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
@@ -34,9 +34,6 @@ module ROTP
|
|
34
34
|
ROTP::TOTP.new(options.secret).now
|
35
35
|
elsif options.mode == :hmac
|
36
36
|
ROTP::HOTP.new(options.secret).at options.counter
|
37
|
-
|
38
|
-
else
|
39
|
-
fail NotImplementedError
|
40
37
|
end
|
41
38
|
end
|
42
39
|
|
data/lib/rotp/hotp.rb
CHANGED
@@ -2,33 +2,20 @@ module ROTP
|
|
2
2
|
class HOTP < OTP
|
3
3
|
# Generates the OTP for the given count
|
4
4
|
# @param [Integer] count counter
|
5
|
-
# @option [Boolean] padding (false) Issue the number as a 0 padded string
|
6
5
|
# @returns [Integer] OTP
|
7
|
-
def at(count
|
8
|
-
generate_otp(count
|
6
|
+
def at(count)
|
7
|
+
generate_otp(count)
|
9
8
|
end
|
10
9
|
|
11
10
|
# Verifies the OTP passed in against the current time OTP
|
12
|
-
# @param [String/Integer]
|
13
|
-
# @param [Integer]
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
# @param [String/Integer] otp the OTP to check against
|
21
|
-
# @param [Integer] initial counter the counter of the OTP
|
22
|
-
# @param [Integer] number of retries
|
23
|
-
def verify_with_retries(otp, initial_count, retries = 1)
|
24
|
-
return false if retries <= 0
|
25
|
-
|
26
|
-
1.upto(retries) do |counter|
|
27
|
-
current_counter = initial_count + counter
|
28
|
-
return current_counter if verify(otp, current_counter)
|
29
|
-
end
|
30
|
-
|
31
|
-
false
|
11
|
+
# @param otp [String/Integer] the OTP to check against
|
12
|
+
# @param counter [Integer] the counter of the OTP
|
13
|
+
# @param retries [Integer] number of counters to incrementally retry
|
14
|
+
def verify(otp, counter, retries: 0)
|
15
|
+
counters = (counter..counter+retries).to_a
|
16
|
+
counters.find { |c|
|
17
|
+
super(otp, self.at(c))
|
18
|
+
}
|
32
19
|
end
|
33
20
|
|
34
21
|
# Returns the provisioning URI for the OTP
|
data/lib/rotp/otp.rb
CHANGED
@@ -21,7 +21,7 @@ module ROTP
|
|
21
21
|
# @option padded [Boolean] (false) Output the otp as a 0 padded string
|
22
22
|
# Usually either the counter, or the computed integer
|
23
23
|
# based on the Unix timestamp
|
24
|
-
def generate_otp(input
|
24
|
+
def generate_otp(input)
|
25
25
|
hmac = OpenSSL::HMAC.digest(
|
26
26
|
OpenSSL::Digest.new(digest),
|
27
27
|
byte_secret,
|
@@ -33,19 +33,14 @@ module ROTP
|
|
33
33
|
(hmac[offset + 1].ord & 0xff) << 16 |
|
34
34
|
(hmac[offset + 2].ord & 0xff) << 8 |
|
35
35
|
(hmac[offset + 3].ord & 0xff)
|
36
|
-
|
37
|
-
(code % 10 ** digits).to_s.rjust(digits, '0')
|
38
|
-
else
|
39
|
-
code % 10 ** digits
|
40
|
-
end
|
36
|
+
(code % 10 ** digits).to_s.rjust(digits, '0')
|
41
37
|
end
|
42
38
|
|
43
39
|
private
|
44
40
|
|
45
41
|
def verify(input, generated)
|
46
|
-
|
47
|
-
|
48
|
-
end
|
42
|
+
raise ArgumentError, "`otp` should be a String" unless
|
43
|
+
input.is_a?(String)
|
49
44
|
time_constant_compare(input, generated)
|
50
45
|
end
|
51
46
|
|
data/lib/rotp/totp.rb
CHANGED
@@ -14,64 +14,46 @@ module ROTP
|
|
14
14
|
|
15
15
|
# Accepts either a Unix timestamp integer or a Time object.
|
16
16
|
# Time objects will be adjusted to UTC automatically
|
17
|
-
# @param [Time/Integer]
|
18
|
-
|
19
|
-
|
20
|
-
unless time.class == Time
|
21
|
-
time = Time.at(time.to_i)
|
22
|
-
end
|
23
|
-
|
24
|
-
generate_otp(timecode(time), padding)
|
17
|
+
# @param time [Time/Integer] the time to generate an OTP for, integer unix timestamp or Time object
|
18
|
+
def at(time)
|
19
|
+
generate_otp(timecode(time))
|
25
20
|
end
|
26
21
|
|
27
22
|
# Generate the current time OTP
|
28
23
|
# @return [Integer] the OTP as an integer
|
29
|
-
def now(
|
30
|
-
generate_otp(timecode(Time.now)
|
31
|
-
end
|
32
|
-
|
33
|
-
# Verifies the OTP passed in against the current time OTP
|
34
|
-
# @param [String/Integer] otp the OTP to check against
|
35
|
-
def verify(otp, time = Time.now)
|
36
|
-
super(otp, self.at(time))
|
37
|
-
end
|
38
|
-
|
39
|
-
# Verifies the OTP passed in against the current time OTP
|
40
|
-
# and adjacent intervals up to +drift+.
|
41
|
-
# @param [String] otp the OTP to check against
|
42
|
-
# @param [Integer] drift the number of seconds that the client
|
43
|
-
# and server are allowed to drift apart
|
44
|
-
def verify_with_drift(otp, drift, time = Time.now)
|
45
|
-
time = time.to_i
|
46
|
-
times = (time-drift..time+drift).step(interval).to_a
|
47
|
-
times << time + drift if times.last < time + drift
|
48
|
-
times.any? { |ti| verify(otp, ti) }
|
24
|
+
def now()
|
25
|
+
generate_otp(timecode(Time.now))
|
49
26
|
end
|
50
27
|
|
51
28
|
# Verifies the OTP passed in against the current time OTP
|
52
29
|
# and adjacent intervals up to +drift+. Excludes OTPs
|
53
|
-
# from
|
30
|
+
# from `after` and earlier. Returns time value of
|
54
31
|
# matching OTP code for use in subsequent call.
|
55
|
-
# @param [String]
|
56
|
-
# @param [Integer]
|
57
|
-
#
|
58
|
-
# @param [Integer]
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
32
|
+
# @param otp [String] the one time password to verify
|
33
|
+
# @param drift_behind [Integer] how many seconds to look back
|
34
|
+
# @param drift_ahead [Integer] how many seconds to look ahead
|
35
|
+
# @param after [Integer] prevent token reuse, last login timestamp
|
36
|
+
# @param at [Time] time at which to generate and verify a particular
|
37
|
+
# otp. default Time.now
|
38
|
+
# @return [Integer, nil] the last successful timestamp
|
39
|
+
# interval
|
40
|
+
def verify(otp, drift_ahead: 0, drift_behind: 0, after: nil, at: Time.now)
|
41
|
+
timecodes = get_timecodes(at, drift_behind, drift_ahead)
|
63
42
|
|
64
|
-
|
65
|
-
|
66
|
-
prior_bin = prior_time.to_i / interval * interval
|
67
|
-
first_bin = prior_bin + interval if prior_bin >= first_bin
|
68
|
-
# fail if we've already used the last available OTP code
|
69
|
-
return if first_bin > last_bin
|
43
|
+
if after
|
44
|
+
timecodes = timecodes.select { |t| t > timecode(after) }
|
70
45
|
end
|
71
|
-
|
72
|
-
|
46
|
+
|
47
|
+
result = nil
|
48
|
+
timecodes.each { |t|
|
49
|
+
if (super(otp, self.generate_otp(t)))
|
50
|
+
result = t * interval
|
51
|
+
end
|
52
|
+
}
|
53
|
+
return result
|
73
54
|
end
|
74
55
|
|
56
|
+
|
75
57
|
# Returns the provisioning URI for the OTP
|
76
58
|
# This can then be encoded in a QR Code and used
|
77
59
|
# to provision the Google Authenticator app
|
@@ -95,8 +77,24 @@ module ROTP
|
|
95
77
|
|
96
78
|
private
|
97
79
|
|
80
|
+
# Get back an array of timecodes for a period
|
81
|
+
def get_timecodes(at, drift_behind, drift_ahead)
|
82
|
+
now = timeint(at)
|
83
|
+
timecode_start = timecode(now - drift_behind)
|
84
|
+
timecode_end = timecode(now + drift_ahead)
|
85
|
+
return (timecode_start..timecode_end).step(1).to_a
|
86
|
+
end
|
87
|
+
|
88
|
+
# Ensure UTC int
|
89
|
+
def timeint(time)
|
90
|
+
unless time.class == Time
|
91
|
+
return time.to_i
|
92
|
+
end
|
93
|
+
return time.utc.to_i
|
94
|
+
end
|
95
|
+
|
98
96
|
def timecode(time)
|
99
|
-
time
|
97
|
+
return timeint(time) / interval
|
100
98
|
end
|
101
99
|
|
102
100
|
end
|
data/lib/rotp/version.rb
CHANGED
@@ -71,6 +71,20 @@ RSpec.describe ROTP::Arguments do
|
|
71
71
|
end
|
72
72
|
end
|
73
73
|
|
74
|
+
context 'generating a counter based secret' do
|
75
|
+
let(:argv) { %w(--time --secret s3same) }
|
76
|
+
|
77
|
+
describe '#options' do
|
78
|
+
it 'is in hmac mode' do
|
79
|
+
expect(options.mode).to eq :time
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'knows the secret' do
|
83
|
+
expect(options.secret).to eq 's3same'
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
74
88
|
context 'generating a time based secret' do
|
75
89
|
let(:argv) { %w(--secret s3same) }
|
76
90
|
|
@@ -6,8 +6,8 @@ RSpec.describe ROTP::Base32 do
|
|
6
6
|
context 'without arguments' do
|
7
7
|
let(:base32) { ROTP::Base32.random_base32 }
|
8
8
|
|
9
|
-
it 'is
|
10
|
-
expect(base32.length).to eq
|
9
|
+
it 'is 32 characters long' do
|
10
|
+
expect(base32.length).to eq 32
|
11
11
|
end
|
12
12
|
|
13
13
|
it 'is hexadecimal' do
|
data/spec/lib/rotp/cli_spec.rb
CHANGED
@@ -18,6 +18,30 @@ RSpec.describe ROTP::CLI do
|
|
18
18
|
end
|
19
19
|
end
|
20
20
|
|
21
|
+
context 'generating a TOTP with no secret' do
|
22
|
+
let(:argv) { %W(--time --secret) }
|
23
|
+
|
24
|
+
it 'prints the corresponding token' do
|
25
|
+
expect(output).to match 'You must also specify a --secret'
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context 'generating a TOTP with bad base32 secret' do
|
30
|
+
let(:argv) { %W(--time --secret #{'1' * 32}) }
|
31
|
+
|
32
|
+
it 'prints the corresponding token' do
|
33
|
+
expect(output).to match 'Secret must be in RFC4648 Base32 format'
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'trying to generate an unsupport type' do
|
38
|
+
let(:argv) { %W(--notreal --secret #{'a' * 32}) }
|
39
|
+
|
40
|
+
it 'prints the corresponding token' do
|
41
|
+
expect(output).to match 'invalid option: --notreal'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
21
45
|
context 'generating a HOTP' do
|
22
46
|
let(:argv) { %W(--hmac --secret #{'a' * 32} --counter 1234) }
|
23
47
|
|
data/spec/lib/rotp/hotp_spec.rb
CHANGED
@@ -14,14 +14,6 @@ RSpec.describe ROTP::HOTP do
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
-
context 'without padding' do
|
18
|
-
let(:token) { hotp.at counter, false }
|
19
|
-
|
20
|
-
it 'generates an integer OTP' do
|
21
|
-
expect(token).to eq 161024
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
17
|
context 'RFC compatibility' do
|
26
18
|
let(:hotp) { ROTP::HOTP.new('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ') }
|
27
19
|
|
@@ -69,6 +61,45 @@ RSpec.describe ROTP::HOTP do
|
|
69
61
|
expect(hotp.verify(token, 10)).to be_falsey
|
70
62
|
end
|
71
63
|
end
|
64
|
+
describe 'with retries' do
|
65
|
+
let(:verification) { hotp.verify token, counter, retries:retries }
|
66
|
+
|
67
|
+
context 'counter outside than retries' do
|
68
|
+
let(:counter) { 1223 }
|
69
|
+
let(:retries) { 10 }
|
70
|
+
|
71
|
+
it 'is false' do
|
72
|
+
expect(verification).to be_falsey
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context 'counter exactly in retry range' do
|
77
|
+
let(:counter) { 1224 }
|
78
|
+
let(:retries) { 10 }
|
79
|
+
|
80
|
+
it 'is true' do
|
81
|
+
expect(verification).to eq 1234
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
context 'counter in retry range' do
|
86
|
+
let(:counter) { 1224 }
|
87
|
+
let(:retries) { 11 }
|
88
|
+
|
89
|
+
it 'is true' do
|
90
|
+
expect(verification).to eq 1234
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
context 'counter ahead of token' do
|
95
|
+
let(:counter) { 1235 }
|
96
|
+
let(:retries) { 3 }
|
97
|
+
|
98
|
+
it 'is false' do
|
99
|
+
expect(verification).to be_falsey
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
72
103
|
end
|
73
104
|
|
74
105
|
describe '#provisioning_uri' do
|
@@ -98,59 +129,4 @@ RSpec.describe ROTP::HOTP do
|
|
98
129
|
end
|
99
130
|
end
|
100
131
|
|
101
|
-
describe '#verify_with_retries' do
|
102
|
-
let(:verification) { hotp.verify_with_retries token, counter, retries }
|
103
|
-
|
104
|
-
context 'negative retries' do
|
105
|
-
let(:retries) { -1 }
|
106
|
-
|
107
|
-
it 'is false' do
|
108
|
-
expect(verification).to be_falsey
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
|
-
context 'zero retries' do
|
113
|
-
let(:retries) { 0 }
|
114
|
-
|
115
|
-
it 'is false' do
|
116
|
-
expect(verification).to be_falsey
|
117
|
-
end
|
118
|
-
end
|
119
|
-
|
120
|
-
context 'counter lower than retries' do
|
121
|
-
let(:counter) { 1223 }
|
122
|
-
let(:retries) { 10 }
|
123
|
-
|
124
|
-
it 'is false' do
|
125
|
-
expect(verification).to be_falsey
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
129
|
-
context 'counter exactly in retry range' do
|
130
|
-
let(:counter) { 1224 }
|
131
|
-
let(:retries) { 10 }
|
132
|
-
|
133
|
-
it 'is true' do
|
134
|
-
expect(verification).to eq 1234
|
135
|
-
end
|
136
|
-
end
|
137
|
-
|
138
|
-
context 'counter in retry range' do
|
139
|
-
let(:counter) { 1224 }
|
140
|
-
let(:retries) { 11 }
|
141
|
-
|
142
|
-
it 'is true' do
|
143
|
-
expect(verification).to eq 1234
|
144
|
-
end
|
145
|
-
end
|
146
|
-
|
147
|
-
context 'counter too high' do
|
148
|
-
let(:counter) { 1235 }
|
149
|
-
let(:retries) { 3 }
|
150
|
-
|
151
|
-
it 'is false' do
|
152
|
-
expect(verification).to be_falsey
|
153
|
-
end
|
154
|
-
end
|
155
|
-
end
|
156
132
|
end
|
data/spec/lib/rotp/totp_spec.rb
CHANGED
@@ -1,25 +1,18 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
+
TEST_TIME = Time.utc 2016,9,23,9 # 2016-09-23 09:00:00 UTC
|
4
|
+
TEST_TOKEN = "082630"
|
5
|
+
|
3
6
|
RSpec.describe ROTP::TOTP do
|
4
|
-
let(:now) {
|
5
|
-
let(:token) {
|
7
|
+
let(:now) { TEST_TIME }
|
8
|
+
let(:token) { TEST_TOKEN }
|
6
9
|
let(:totp) { ROTP::TOTP.new 'JBSWY3DPEHPK3PXP' }
|
7
10
|
|
8
11
|
describe '#at' do
|
9
|
-
|
10
|
-
let(:token) { totp.at now }
|
11
|
-
|
12
|
-
it 'is a string number' do
|
13
|
-
expect(token).to eq '068212'
|
14
|
-
end
|
15
|
-
end
|
12
|
+
let(:token) { totp.at now }
|
16
13
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
it 'is an integer' do
|
21
|
-
expect(token).to eq 68212
|
22
|
-
end
|
14
|
+
it 'is a string number' do
|
15
|
+
expect(token).to eq TEST_TOKEN
|
23
16
|
end
|
24
17
|
|
25
18
|
context 'RFC compatibility' do
|
@@ -35,26 +28,26 @@ RSpec.describe ROTP::TOTP do
|
|
35
28
|
end
|
36
29
|
|
37
30
|
describe '#verify' do
|
38
|
-
let(:verification) { totp.verify token, now }
|
31
|
+
let(:verification) { totp.verify token, at: now }
|
39
32
|
|
40
33
|
context 'numeric token' do
|
41
|
-
let(:token) {
|
34
|
+
let(:token) { 82630 }
|
42
35
|
|
43
|
-
it 'raises an error' do
|
36
|
+
it 'raises an error with an integer' do
|
44
37
|
expect { verification }.to raise_error(ArgumentError)
|
45
38
|
end
|
46
39
|
end
|
47
40
|
|
48
41
|
context 'unpadded string token' do
|
49
|
-
let(:token) { '
|
42
|
+
let(:token) { '82630' }
|
50
43
|
|
51
|
-
it '
|
44
|
+
it 'fails to verify' do
|
52
45
|
expect(verification).to be_falsey
|
53
46
|
end
|
54
47
|
end
|
55
48
|
|
56
49
|
context 'correctly padded string token' do
|
57
|
-
it '
|
50
|
+
it 'verifies' do
|
58
51
|
expect(verification).to be_truthy
|
59
52
|
end
|
60
53
|
end
|
@@ -70,17 +63,168 @@ RSpec.describe ROTP::TOTP do
|
|
70
63
|
let(:token) { '102705' }
|
71
64
|
let(:now) { Time.at 1297553958 }
|
72
65
|
|
73
|
-
it '
|
66
|
+
it 'verifies' do
|
74
67
|
expect(totp.verify('102705')).to be_truthy
|
75
68
|
end
|
76
69
|
end
|
77
70
|
|
78
71
|
context 'wrong time based OTP' do
|
79
|
-
it '
|
72
|
+
it 'fails to verify' do
|
80
73
|
expect(totp.verify('102705')).to be_falsey
|
81
74
|
end
|
82
75
|
end
|
83
76
|
end
|
77
|
+
context 'invalidating reused tokens' do
|
78
|
+
let(:verification) {
|
79
|
+
totp.verify token,
|
80
|
+
after: after,
|
81
|
+
at: now
|
82
|
+
}
|
83
|
+
let(:after) { nil }
|
84
|
+
|
85
|
+
context 'passing in the `after` timestamp' do
|
86
|
+
let(:after) {
|
87
|
+
totp.verify TEST_TOKEN, after: nil, at: now
|
88
|
+
}
|
89
|
+
|
90
|
+
it 'returns a timecode' do
|
91
|
+
expect(after).to be_kind_of(Integer)
|
92
|
+
expect(after).to be_within(30).of(now.to_i)
|
93
|
+
end
|
94
|
+
|
95
|
+
context 'reusing same token' do
|
96
|
+
it 'is false' do
|
97
|
+
expect(verification).to be_falsy
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def get_timecodes(at, b, a)
|
105
|
+
# Test the private method
|
106
|
+
totp.send('get_timecodes', at, b, a)
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "drifting timecodes" do
|
110
|
+
it 'should get timecodes behind' do
|
111
|
+
expect(get_timecodes(TEST_TIME+15, 15, 0)).to eq([49154040])
|
112
|
+
expect(get_timecodes(TEST_TIME, 15, 0)).to eq([49154039, 49154040])
|
113
|
+
expect(get_timecodes(TEST_TIME, 40, 0)).to eq([49154038, 49154039, 49154040])
|
114
|
+
expect(get_timecodes(TEST_TIME, 90, 0)).to eq([49154037, 49154038, 49154039, 49154040])
|
115
|
+
end
|
116
|
+
it 'should get timecodes ahead' do
|
117
|
+
expect(get_timecodes(TEST_TIME, 0, 15)).to eq([49154040])
|
118
|
+
expect(get_timecodes(TEST_TIME+15, 0, 15)).to eq([49154040, 49154041])
|
119
|
+
expect(get_timecodes(TEST_TIME, 0, 30)).to eq([49154040, 49154041])
|
120
|
+
expect(get_timecodes(TEST_TIME, 0, 70)).to eq([49154040, 49154041, 49154042])
|
121
|
+
expect(get_timecodes(TEST_TIME, 0, 90)).to eq([49154040, 49154041, 49154042, 49154043])
|
122
|
+
end
|
123
|
+
it 'should get timecodes behind and ahead' do
|
124
|
+
expect(get_timecodes(TEST_TIME, 30, 30)).to eq([49154039, 49154040, 49154041])
|
125
|
+
expect(get_timecodes(TEST_TIME, 60, 60)).to eq([49154038, 49154039, 49154040, 49154041, 49154042])
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
describe '#verify with drift' do
|
130
|
+
let(:verification) { totp.verify token, drift_ahead: drift_ahead, drift_behind: drift_behind, at: now }
|
131
|
+
let(:drift_ahead) { 0 }
|
132
|
+
let(:drift_behind) { 0 }
|
133
|
+
|
134
|
+
|
135
|
+
context 'with an old OTP' do
|
136
|
+
let(:token) { totp.at TEST_TIME - 30 } # Previous token at 2016-09-23 08:59:30 UTC
|
137
|
+
let(:drift_behind) { 15 }
|
138
|
+
|
139
|
+
# Tested at 2016-09-23 09:00:00 UTC, and with drift back to 2016-09-23 08:59:45 UTC
|
140
|
+
# This would therefore include 2 intervals
|
141
|
+
it 'inside of drift range' do
|
142
|
+
expect(verification).to be_truthy
|
143
|
+
end
|
144
|
+
|
145
|
+
# Tested at 2016-09-23 09:00:20 UTC, and with drift back to 2016-09-23 09:00:05 UTC
|
146
|
+
# This only includes 1 interval, therefore only the current token is valid
|
147
|
+
context 'outside of drift range' do
|
148
|
+
let(:now) { TEST_TIME + 20 }
|
149
|
+
|
150
|
+
it 'is nil' do
|
151
|
+
expect(verification).to be_nil
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
end
|
156
|
+
|
157
|
+
context 'with a future OTP' do
|
158
|
+
let(:token) { totp.at TEST_TIME + 30 } # The next valid token - 2016-09-23 09:00:30 UTC
|
159
|
+
let(:drift_ahead) { 15 }
|
160
|
+
|
161
|
+
# Tested at 2016-09-23 09:00:00 UTC, and ahead to 2016-09-23 09:00:15 UTC
|
162
|
+
# This only includes 1 interval, therefore only the current token is valid
|
163
|
+
it 'outside of drift range' do
|
164
|
+
expect(verification).to be_falsey
|
165
|
+
end
|
166
|
+
# Tested at 2016-09-23 09:00:20 UTC, and with drift ahead to 2016-09-23 09:00:35 UTC
|
167
|
+
# This would therefore include 2 intervals
|
168
|
+
context 'inside of drift range' do
|
169
|
+
let(:now) { TEST_TIME + 20 }
|
170
|
+
|
171
|
+
it 'is true' do
|
172
|
+
expect(verification).to be_truthy
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
end
|
178
|
+
|
179
|
+
describe '#verify with drift and prevent token reuse' do
|
180
|
+
let(:verification) { totp.verify token, drift_ahead: drift_ahead, drift_behind: drift_behind, after: after, at: now }
|
181
|
+
let(:drift_ahead) { 0 }
|
182
|
+
let(:drift_behind) { 0 }
|
183
|
+
let(:after) { nil }
|
184
|
+
|
185
|
+
context 'with the `after` timestamp set' do
|
186
|
+
|
187
|
+
context 'older token' do
|
188
|
+
let(:token) { totp.at TEST_TIME - 30 }
|
189
|
+
let(:drift_behind) { 15 }
|
190
|
+
|
191
|
+
it 'is true' do
|
192
|
+
expect(verification).to be_truthy
|
193
|
+
expect(verification).to eq((TEST_TIME - 30).to_i)
|
194
|
+
end
|
195
|
+
|
196
|
+
context 'after it has been used' do
|
197
|
+
let(:after) {
|
198
|
+
totp.verify token, after: nil, at: now, drift_behind: drift_behind
|
199
|
+
}
|
200
|
+
it 'is false' do
|
201
|
+
expect(verification).to be_falsey
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
|
207
|
+
context 'newer token' do
|
208
|
+
let(:token) { totp.at TEST_TIME + 30 }
|
209
|
+
let(:drift_ahead) { 15 }
|
210
|
+
let(:now) { TEST_TIME + 15 }
|
211
|
+
|
212
|
+
it 'is true' do
|
213
|
+
expect(verification).to be_truthy
|
214
|
+
expect(verification).to eq((TEST_TIME + 30).to_i)
|
215
|
+
end
|
216
|
+
|
217
|
+
context 'after it has been used' do
|
218
|
+
let(:after) {
|
219
|
+
totp.verify token, after: nil, at: now, drift_ahead: drift_ahead
|
220
|
+
}
|
221
|
+
it 'is false' do
|
222
|
+
expect(verification).to be_falsey
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
end
|
227
|
+
end
|
84
228
|
end
|
85
229
|
|
86
230
|
describe '#provisioning_uri' do
|
@@ -161,115 +305,6 @@ RSpec.describe ROTP::TOTP do
|
|
161
305
|
|
162
306
|
end
|
163
307
|
|
164
|
-
describe 'invalid_verification with nil time as argument' do
|
165
|
-
let(:verification) { totp.verify_with_drift token, drift, nil }
|
166
|
-
|
167
|
-
context 'positive drift' do
|
168
|
-
let(:token) { totp.at now - 30 }
|
169
|
-
let(:drift) { 60 }
|
170
|
-
|
171
|
-
it 'raises error' do
|
172
|
-
expect do
|
173
|
-
verification
|
174
|
-
end.to raise_error(ArgumentError)
|
175
|
-
end
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
describe '#verify_with_drift' do
|
180
|
-
let(:verification) { totp.verify_with_drift token, drift, now }
|
181
|
-
let(:drift) { 0 }
|
182
|
-
|
183
|
-
|
184
|
-
context 'slightly old number' do
|
185
|
-
let(:token) { totp.at now - 30 }
|
186
|
-
let(:drift) { 60 }
|
187
|
-
|
188
|
-
it 'is true' do
|
189
|
-
expect(verification).to be_truthy
|
190
|
-
end
|
191
|
-
end
|
192
|
-
|
193
|
-
context 'slightly new number' do
|
194
|
-
let(:token) { totp.at now + 60 }
|
195
|
-
let(:drift) { 60 }
|
196
|
-
|
197
|
-
it 'is true' do
|
198
|
-
expect(verification).to be_truthy
|
199
|
-
end
|
200
|
-
end
|
201
|
-
|
202
|
-
context 'outside of drift range' do
|
203
|
-
let(:token) { totp.at now - 60 }
|
204
|
-
let(:drift) { 30 }
|
205
|
-
|
206
|
-
it 'is false' do
|
207
|
-
expect(verification).to be_falsey
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
context 'drift is not multiple of TOTP interval' do
|
212
|
-
context 'slightly old number' do
|
213
|
-
let(:token) { totp.at now - 45 }
|
214
|
-
let(:drift) { 45 }
|
215
|
-
|
216
|
-
it 'is true' do
|
217
|
-
expect(verification).to be_truthy
|
218
|
-
end
|
219
|
-
end
|
220
|
-
|
221
|
-
context 'slightly new number' do
|
222
|
-
let(:token) { totp.at now + 40 }
|
223
|
-
let(:drift) { 40 }
|
224
|
-
|
225
|
-
it 'is true' do
|
226
|
-
expect(verification).to be_truthy
|
227
|
-
end
|
228
|
-
end
|
229
|
-
end
|
230
|
-
end
|
231
|
-
|
232
|
-
describe '#verify_with_drift_and_prior' do
|
233
|
-
let(:verification) { totp.verify_with_drift_and_prior token, drift, prior, now }
|
234
|
-
let(:drift) { 0 }
|
235
|
-
let(:prior) { nil }
|
236
|
-
|
237
|
-
context 'with a prior verify' do
|
238
|
-
let(:prior) { totp.verify_with_drift_and_prior '068212', 0, nil, now }
|
239
|
-
|
240
|
-
it 'returns a timecode' do
|
241
|
-
expect(prior).to be_kind_of(Integer)
|
242
|
-
expect(prior).to be_within(30).of(now.to_i)
|
243
|
-
end
|
244
|
-
|
245
|
-
context 'reusing same token' do
|
246
|
-
it 'is false' do
|
247
|
-
expect(verification).to be_falsy
|
248
|
-
end
|
249
|
-
end
|
250
|
-
|
251
|
-
context 'newer token' do
|
252
|
-
let(:token) { totp.at now + 40 }
|
253
|
-
let(:drift) { 40 }
|
254
|
-
|
255
|
-
it 'is true' do
|
256
|
-
expect(verification).to be_kind_of(Integer)
|
257
|
-
expect(verification).to be_within(30).of(now.to_i)
|
258
|
-
expect(verification).to be_truthy
|
259
|
-
end
|
260
|
-
end
|
261
|
-
|
262
|
-
context 'older token' do
|
263
|
-
let(:token) { totp.at now - 40 }
|
264
|
-
let(:drift) { 40 }
|
265
|
-
|
266
|
-
it 'is false' do
|
267
|
-
expect(verification).to be_falsy
|
268
|
-
end
|
269
|
-
end
|
270
|
-
end
|
271
|
-
end
|
272
|
-
|
273
308
|
describe '#now' do
|
274
309
|
before do
|
275
310
|
Timecop.freeze now
|
data/spec/spec_helper.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
|
+
require 'simplecov'
|
2
|
+
SimpleCov.start do
|
3
|
+
add_filter "/spec/"
|
4
|
+
end
|
5
|
+
|
1
6
|
require 'rotp'
|
2
7
|
require 'timecop'
|
3
|
-
require 'simplecov'
|
4
8
|
|
5
9
|
RSpec.configure do |config|
|
6
10
|
config.disable_monkey_patching!
|
@@ -13,6 +17,5 @@ RSpec.configure do |config|
|
|
13
17
|
end
|
14
18
|
end
|
15
19
|
|
16
|
-
SimpleCov.start
|
17
20
|
|
18
21
|
require_relative '../lib/rotp'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rotp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 4.0.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
|
+
date: 2018-11-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -140,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
140
140
|
version: '0'
|
141
141
|
requirements: []
|
142
142
|
rubyforge_project: rotp
|
143
|
-
rubygems_version: 2.
|
143
|
+
rubygems_version: 2.7.6
|
144
144
|
signing_key:
|
145
145
|
specification_version: 4
|
146
146
|
summary: A Ruby library for generating and verifying one time passwords
|
@@ -151,4 +151,3 @@ test_files:
|
|
151
151
|
- spec/lib/rotp/hotp_spec.rb
|
152
152
|
- spec/lib/rotp/totp_spec.rb
|
153
153
|
- spec/spec_helper.rb
|
154
|
-
has_rdoc:
|