rotp 3.3.1 → 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Gem Version](https://badge.fury.io/rb/rotp.svg)](https://rubygems.org/gems/rotp)
|
5
5
|
[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](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
|
-
![QR Code for OTP](
|
128
|
+
![QR Code for OTP](https://cloud.githubusercontent.com/assets/2868/18771262/54f109dc-80f2-11e6-863f-d2be62ee587a.png)
|
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:
|