rotp 4.0.0 → 6.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +19 -0
- data/.devcontainer/devcontainer.json +41 -0
- data/.github/workflows/release.yaml +36 -0
- data/.github/workflows/test.yaml +26 -0
- data/.release-please-manifest.json +3 -0
- data/CHANGELOG.md +91 -24
- data/Dockerfile-2.3 +1 -7
- data/Dockerfile-2.7 +11 -0
- data/Dockerfile-3.0 +12 -0
- data/Guardfile +1 -1
- data/README.md +64 -15
- data/bin/rotp +1 -1
- data/docker-compose.yml +37 -0
- data/lib/rotp/arguments.rb +6 -5
- data/lib/rotp/base32.rb +56 -30
- data/lib/rotp/cli.rb +4 -5
- data/lib/rotp/hotp.rb +6 -13
- data/lib/rotp/otp/uri.rb +78 -0
- data/lib/rotp/otp.rb +26 -25
- data/lib/rotp/totp.rb +13 -33
- data/lib/rotp/version.rb +1 -1
- data/lib/rotp.rb +2 -4
- data/release-please-config.json +12 -0
- data/rotp.gemspec +13 -15
- data/spec/lib/rotp/arguments_spec.rb +5 -6
- data/spec/lib/rotp/base32_spec.rb +45 -19
- data/spec/lib/rotp/cli_spec.rb +21 -6
- data/spec/lib/rotp/hotp_spec.rb +38 -17
- data/spec/lib/rotp/otp/uri_spec.rb +99 -0
- data/spec/lib/rotp/totp_spec.rb +61 -98
- data/spec/spec_helper.rb +1 -2
- metadata +25 -43
- data/.travis.yml +0 -8
- data/Dockerfile-1.9 +0 -15
- data/Dockerfile-2.1 +0 -16
- data/Rakefile +0 -9
- data/doc/ROTP/HOTP.html +0 -308
- data/doc/ROTP/OTP.html +0 -593
- data/doc/ROTP/TOTP.html +0 -493
- data/doc/Rotp.html +0 -179
- data/doc/_index.html +0 -144
- data/doc/class_list.html +0 -36
- data/doc/css/common.css +0 -1
- data/doc/css/full_list.css +0 -53
- data/doc/css/style.css +0 -310
- data/doc/file.README.html +0 -89
- data/doc/file_list.html +0 -38
- data/doc/frames.html +0 -13
- data/doc/index.html +0 -89
- data/doc/js/app.js +0 -203
- data/doc/js/full_list.js +0 -149
- data/doc/js/jquery.js +0 -154
- data/doc/method_list.html +0 -155
- data/doc/top-level-namespace.html +0 -88
data/lib/rotp/base32.rb
CHANGED
@@ -1,50 +1,76 @@
|
|
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
|
-
|
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
|
26
62
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
bytes[0] = (quints[0] << 3) + (quints[1] ? quints[1] >> 2 : 0)
|
32
|
-
return bytes if length < 3
|
33
|
-
bytes[1] = ((quints[1] & 3) << 6) + (quints[2] << 1) + (quints[3] ? quints[3] >> 4 : 0)
|
34
|
-
return bytes if length < 4
|
35
|
-
bytes[2] = ((quints[3] & 15) << 4) + (quints[4] ? quints[4] >> 1 : 0)
|
36
|
-
return bytes if length < 6
|
37
|
-
bytes[3] = ((quints[4] & 1) << 7) + (quints[5] << 2) + (quints[6] ? quints[6] >> 3 : 0)
|
38
|
-
return bytes if length < 7
|
39
|
-
bytes[4] = ((quints[6] & 7) << 5) + (quints[7] || 0)
|
40
|
-
bytes
|
63
|
+
# Prevent breaking changes
|
64
|
+
def random_base32(str_len = 32)
|
65
|
+
byte_length = str_len * 5/8
|
66
|
+
random(byte_length)
|
41
67
|
end
|
42
68
|
|
69
|
+
private
|
70
|
+
|
43
71
|
def decode_quint(q)
|
44
|
-
CHARS.index(q
|
72
|
+
CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
|
45
73
|
end
|
46
|
-
|
47
74
|
end
|
48
|
-
|
49
75
|
end
|
50
76
|
end
|
data/lib/rotp/cli.rb
CHANGED
@@ -16,10 +16,10 @@ module ROTP
|
|
16
16
|
# :nocov:
|
17
17
|
|
18
18
|
def errors
|
19
|
-
if [
|
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
|
@@ -31,9 +31,9 @@ module ROTP
|
|
31
31
|
return arguments.to_s if options.mode == :help
|
32
32
|
|
33
33
|
if options.mode == :time
|
34
|
-
ROTP::TOTP.new(options.secret).now
|
34
|
+
ROTP::TOTP.new(options.secret, options).now
|
35
35
|
elsif options.mode == :hmac
|
36
|
-
ROTP::HOTP.new(options.secret).at options.counter
|
36
|
+
ROTP::HOTP.new(options.secret, options).at options.counter
|
37
37
|
end
|
38
38
|
end
|
39
39
|
|
@@ -48,6 +48,5 @@ module ROTP
|
|
48
48
|
def red(string)
|
49
49
|
"\033[31m#{string}\033[0m"
|
50
50
|
end
|
51
|
-
|
52
51
|
end
|
53
52
|
end
|
data/lib/rotp/hotp.rb
CHANGED
@@ -12,10 +12,10 @@ module ROTP
|
|
12
12
|
# @param counter [Integer] the counter of the OTP
|
13
13
|
# @param retries [Integer] number of counters to incrementally retry
|
14
14
|
def verify(otp, counter, retries: 0)
|
15
|
-
counters = (counter..counter+retries).to_a
|
16
|
-
counters.find
|
17
|
-
super(otp,
|
18
|
-
|
15
|
+
counters = (counter..counter + retries).to_a
|
16
|
+
counters.find do |c|
|
17
|
+
super(otp, at(c))
|
18
|
+
end
|
19
19
|
end
|
20
20
|
|
21
21
|
# Returns the provisioning URI for the OTP
|
@@ -24,15 +24,8 @@ module ROTP
|
|
24
24
|
# @param [String] name of the account
|
25
25
|
# @param [Integer] initial_count starting counter value, defaults to 0
|
26
26
|
# @return [String] provisioning uri
|
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/#{URI.encode(name)}", params)
|
27
|
+
def provisioning_uri(name = nil, initial_count = 0)
|
28
|
+
OTP::URI.new(self, account_name: name || @name, counter: initial_count).to_s
|
34
29
|
end
|
35
|
-
|
36
30
|
end
|
37
|
-
|
38
31
|
end
|
data/lib/rotp/otp/uri.rb
ADDED
@@ -0,0 +1,78 @@
|
|
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: nil, 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
|
+
@otp.issuer&.strip&.tr(':', '_')
|
38
|
+
end
|
39
|
+
|
40
|
+
def label
|
41
|
+
[issuer, @account_name.rstrip]
|
42
|
+
.compact
|
43
|
+
.map { |s| s.tr(':', '_') }
|
44
|
+
.map { |s| ERB::Util.url_encode(s) }
|
45
|
+
.join(':')
|
46
|
+
end
|
47
|
+
|
48
|
+
def parameters
|
49
|
+
{
|
50
|
+
secret: @otp.secret,
|
51
|
+
issuer: issuer,
|
52
|
+
algorithm: algorithm,
|
53
|
+
digits: digits,
|
54
|
+
period: period,
|
55
|
+
counter: counter,
|
56
|
+
}
|
57
|
+
.merge(@otp.provisioning_params)
|
58
|
+
.reject { |_, v| v.nil? }
|
59
|
+
.map { |k, v| "#{k}=#{ERB::Util.url_encode(v)}" }
|
60
|
+
.join('&')
|
61
|
+
end
|
62
|
+
|
63
|
+
def period
|
64
|
+
return if @otp.is_a?(HOTP)
|
65
|
+
return if @otp.interval == DEFAULT_INTERVAL
|
66
|
+
|
67
|
+
@otp.interval
|
68
|
+
end
|
69
|
+
|
70
|
+
def type
|
71
|
+
case @otp
|
72
|
+
when TOTP then 'totp'
|
73
|
+
when HOTP then 'hotp'
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/lib/rotp/otp.rb
CHANGED
@@ -1,24 +1,35 @@
|
|
1
1
|
module ROTP
|
2
2
|
class OTP
|
3
|
-
attr_reader :secret, :digits, :digest
|
3
|
+
attr_reader :secret, :digits, :digest, :name, :issuer, :provisioning_params
|
4
4
|
DEFAULT_DIGITS = 6
|
5
5
|
|
6
6
|
# @param [String] secret in the form of base32
|
7
7
|
# @option options digits [Integer] (6)
|
8
|
-
# Number of integers in the OTP
|
8
|
+
# Number of integers in the OTP.
|
9
9
|
# Google Authenticate only supports 6 currently
|
10
10
|
# @option options digest [String] (sha1)
|
11
|
-
# Digest used in the HMAC
|
11
|
+
# Digest used in the HMAC.
|
12
12
|
# Google Authenticate only supports 'sha1' currently
|
13
|
+
# @option options name [String]
|
14
|
+
# The name of the account for the OTP.
|
15
|
+
# Used in the provisioning URL
|
16
|
+
# @option options issuer [String]
|
17
|
+
# The issuer of the OTP.
|
18
|
+
# Used in the provisioning URL
|
19
|
+
# @option options provisioning_params [Hash] ({})
|
20
|
+
# Additional non-standard params you may want appended to the
|
21
|
+
# provisioning URI. Ex. `image: 'https://example.com/icon.png'`
|
13
22
|
# @returns [OTP] OTP instantiation
|
14
23
|
def initialize(s, options = {})
|
15
24
|
@digits = options[:digits] || DEFAULT_DIGITS
|
16
|
-
@digest = options[:digest] ||
|
25
|
+
@digest = options[:digest] || 'sha1'
|
26
|
+
@name = options[:name]
|
27
|
+
@issuer = options[:issuer]
|
28
|
+
@provisioning_params = options[:provisioning_params] || {}
|
17
29
|
@secret = s
|
18
30
|
end
|
19
31
|
|
20
32
|
# @param [Integer] input the number used seed the HMAC
|
21
|
-
# @option padded [Boolean] (false) Output the otp as a 0 padded string
|
22
33
|
# Usually either the counter, or the computed integer
|
23
34
|
# based on the Unix timestamp
|
24
35
|
def generate_otp(input)
|
@@ -30,17 +41,19 @@ module ROTP
|
|
30
41
|
|
31
42
|
offset = hmac[-1].ord & 0xf
|
32
43
|
code = (hmac[offset].ord & 0x7f) << 24 |
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
(code % 10 ** digits).to_s
|
44
|
+
(hmac[offset + 1].ord & 0xff) << 16 |
|
45
|
+
(hmac[offset + 2].ord & 0xff) << 8 |
|
46
|
+
(hmac[offset + 3].ord & 0xff)
|
47
|
+
code_str = (10 ** digits + (code % 10 ** digits)).to_s
|
48
|
+
code_str[-digits..-1]
|
37
49
|
end
|
38
50
|
|
39
51
|
private
|
40
52
|
|
41
53
|
def verify(input, generated)
|
42
|
-
raise ArgumentError,
|
54
|
+
raise ArgumentError, '`otp` should be a String' unless
|
43
55
|
input.is_a?(String)
|
56
|
+
|
44
57
|
time_constant_compare(input, generated)
|
45
58
|
end
|
46
59
|
|
@@ -54,37 +67,25 @@ module ROTP
|
|
54
67
|
#
|
55
68
|
def int_to_bytestring(int, padding = 8)
|
56
69
|
unless int >= 0
|
57
|
-
raise ArgumentError,
|
70
|
+
raise ArgumentError, '#int_to_bytestring requires a positive number'
|
58
71
|
end
|
59
72
|
|
60
73
|
result = []
|
61
74
|
until int == 0
|
62
75
|
result << (int & 0xFF).chr
|
63
|
-
int >>=
|
76
|
+
int >>= 8
|
64
77
|
end
|
65
78
|
result.reverse.join.rjust(padding, 0.chr)
|
66
79
|
end
|
67
80
|
|
68
|
-
# A very simple param encoder
|
69
|
-
def encode_params(uri, params)
|
70
|
-
params_str = String.new("?")
|
71
|
-
params.each do |k,v|
|
72
|
-
if v
|
73
|
-
params_str << "#{k}=#{CGI::escape(v.to_s)}&"
|
74
|
-
end
|
75
|
-
end
|
76
|
-
params_str.chop!
|
77
|
-
uri + params_str
|
78
|
-
end
|
79
|
-
|
80
81
|
# constant-time compare the strings
|
81
82
|
def time_constant_compare(a, b)
|
82
83
|
return false if a.empty? || b.empty? || a.bytesize != b.bytesize
|
84
|
+
|
83
85
|
l = a.unpack "C#{a.bytesize}"
|
84
86
|
res = 0
|
85
87
|
b.each_byte { |byte| res |= byte ^ l.shift }
|
86
88
|
res == 0
|
87
89
|
end
|
88
|
-
|
89
90
|
end
|
90
91
|
end
|
data/lib/rotp/totp.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
module ROTP
|
2
2
|
DEFAULT_INTERVAL = 30
|
3
3
|
class TOTP < OTP
|
4
|
-
|
5
4
|
attr_reader :interval, :issuer
|
6
5
|
|
7
6
|
# @option options [Integer] interval (30) the time interval in seconds for OTP
|
@@ -21,7 +20,7 @@ module ROTP
|
|
21
20
|
|
22
21
|
# Generate the current time OTP
|
23
22
|
# @return [Integer] the OTP as an integer
|
24
|
-
def now
|
23
|
+
def now
|
25
24
|
generate_otp(timecode(Time.now))
|
26
25
|
end
|
27
26
|
|
@@ -40,39 +39,22 @@ module ROTP
|
|
40
39
|
def verify(otp, drift_ahead: 0, drift_behind: 0, after: nil, at: Time.now)
|
41
40
|
timecodes = get_timecodes(at, drift_behind, drift_ahead)
|
42
41
|
|
43
|
-
if after
|
44
|
-
timecodes = timecodes.select { |t| t > timecode(after) }
|
45
|
-
end
|
42
|
+
timecodes = timecodes.select { |t| t > timecode(after) } if after
|
46
43
|
|
47
44
|
result = nil
|
48
|
-
timecodes.each
|
49
|
-
if
|
50
|
-
|
51
|
-
|
52
|
-
}
|
53
|
-
return result
|
45
|
+
timecodes.each do |t|
|
46
|
+
result = t * interval if super(otp, generate_otp(t))
|
47
|
+
end
|
48
|
+
result
|
54
49
|
end
|
55
50
|
|
56
|
-
|
57
51
|
# Returns the provisioning URI for the OTP
|
58
52
|
# This can then be encoded in a QR Code and used
|
59
53
|
# to provision the Google Authenticator app
|
60
54
|
# @param [String] name of the account
|
61
55
|
# @return [String] provisioning URI
|
62
|
-
def provisioning_uri(name)
|
63
|
-
|
64
|
-
# https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
65
|
-
# For compatibility the issuer appears both before that account name and also in the
|
66
|
-
# query string.
|
67
|
-
issuer_string = issuer.nil? ? "" : "#{URI.encode(issuer)}:"
|
68
|
-
params = {
|
69
|
-
secret: secret,
|
70
|
-
period: interval == 30 ? nil : interval,
|
71
|
-
issuer: issuer,
|
72
|
-
digits: digits == DEFAULT_DIGITS ? nil : digits,
|
73
|
-
algorithm: digest.upcase == 'SHA1' ? nil : digest.upcase,
|
74
|
-
}
|
75
|
-
encode_params("otpauth://totp/#{issuer_string}#{URI.encode(name)}", params)
|
56
|
+
def provisioning_uri(name = nil)
|
57
|
+
OTP::URI.new(self, account_name: name || @name).to_s
|
76
58
|
end
|
77
59
|
|
78
60
|
private
|
@@ -82,20 +64,18 @@ module ROTP
|
|
82
64
|
now = timeint(at)
|
83
65
|
timecode_start = timecode(now - drift_behind)
|
84
66
|
timecode_end = timecode(now + drift_ahead)
|
85
|
-
|
67
|
+
(timecode_start..timecode_end).step(1).to_a
|
86
68
|
end
|
87
69
|
|
88
70
|
# Ensure UTC int
|
89
71
|
def timeint(time)
|
90
|
-
unless time.class == Time
|
91
|
-
|
92
|
-
|
93
|
-
return time.utc.to_i
|
72
|
+
return time.to_i unless time.class == Time
|
73
|
+
|
74
|
+
time.utc.to_i
|
94
75
|
end
|
95
76
|
|
96
77
|
def timecode(time)
|
97
|
-
|
78
|
+
timeint(time) / interval
|
98
79
|
end
|
99
|
-
|
100
80
|
end
|
101
81
|
end
|
data/lib/rotp/version.rb
CHANGED
data/lib/rotp.rb
CHANGED
@@ -0,0 +1,12 @@
|
|
1
|
+
{
|
2
|
+
"packages": {
|
3
|
+
".": {
|
4
|
+
"changelog-path": "CHANGELOG.md",
|
5
|
+
"bump-minor-pre-major": false,
|
6
|
+
"bump-patch-for-minor-pre-major": false,
|
7
|
+
"draft": false,
|
8
|
+
"prerelease": false
|
9
|
+
}
|
10
|
+
},
|
11
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
12
|
+
}
|
data/rotp.gemspec
CHANGED
@@ -1,26 +1,24 @@
|
|
1
|
-
|
2
|
-
require "./lib/rotp/version"
|
1
|
+
require './lib/rotp/version'
|
3
2
|
|
4
3
|
Gem::Specification.new do |s|
|
5
|
-
s.name =
|
4
|
+
s.name = 'rotp'
|
6
5
|
s.version = ROTP::VERSION
|
7
6
|
s.platform = Gem::Platform::RUBY
|
8
|
-
s.
|
9
|
-
s.
|
10
|
-
s.
|
11
|
-
s.
|
12
|
-
s.
|
13
|
-
s.
|
14
|
-
|
15
|
-
s.rubyforge_project = "rotp"
|
7
|
+
s.required_ruby_version = '>= 2.3'
|
8
|
+
s.license = 'MIT'
|
9
|
+
s.authors = ['Mark Percival']
|
10
|
+
s.email = ['mark@markpercival.us']
|
11
|
+
s.homepage = 'https://github.com/mdp/rotp'
|
12
|
+
s.summary = 'A Ruby library for generating and verifying one time passwords'
|
13
|
+
s.description = 'Works for both HOTP and TOTP, and includes QR Code provisioning'
|
16
14
|
|
17
15
|
s.files = `git ls-files`.split("\n")
|
18
16
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
-
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
-
s.require_paths = [
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
18
|
+
s.require_paths = ['lib']
|
21
19
|
|
22
|
-
s.add_development_dependency
|
20
|
+
s.add_development_dependency "rake", "~> 13.0"
|
23
21
|
s.add_development_dependency 'rspec', '~> 3.5'
|
24
|
-
s.add_development_dependency 'timecop', '~> 0.8'
|
25
22
|
s.add_development_dependency 'simplecov', '~> 0.12'
|
23
|
+
s.add_development_dependency 'timecop', '~> 0.8'
|
26
24
|
end
|
@@ -24,7 +24,7 @@ RSpec.describe ROTP::Arguments do
|
|
24
24
|
end
|
25
25
|
|
26
26
|
context 'unknown arguments' do
|
27
|
-
let(:argv) { %w
|
27
|
+
let(:argv) { %w[--does-not-exist -xyz] }
|
28
28
|
|
29
29
|
describe '#options' do
|
30
30
|
it 'is in help mode' do
|
@@ -48,7 +48,7 @@ RSpec.describe ROTP::Arguments do
|
|
48
48
|
end
|
49
49
|
|
50
50
|
context 'asking for help' do
|
51
|
-
let(:argv) { %w
|
51
|
+
let(:argv) { %w[--help] }
|
52
52
|
|
53
53
|
describe '#options' do
|
54
54
|
it 'is in help mode' do
|
@@ -58,7 +58,7 @@ RSpec.describe ROTP::Arguments do
|
|
58
58
|
end
|
59
59
|
|
60
60
|
context 'generating a counter based secret' do
|
61
|
-
let(:argv) { %w
|
61
|
+
let(:argv) { %w[--hmac --secret s3same] }
|
62
62
|
|
63
63
|
describe '#options' do
|
64
64
|
it 'is in hmac mode' do
|
@@ -72,7 +72,7 @@ RSpec.describe ROTP::Arguments do
|
|
72
72
|
end
|
73
73
|
|
74
74
|
context 'generating a counter based secret' do
|
75
|
-
let(:argv) { %w
|
75
|
+
let(:argv) { %w[--time --secret s3same] }
|
76
76
|
|
77
77
|
describe '#options' do
|
78
78
|
it 'is in hmac mode' do
|
@@ -86,7 +86,7 @@ RSpec.describe ROTP::Arguments do
|
|
86
86
|
end
|
87
87
|
|
88
88
|
context 'generating a time based secret' do
|
89
|
-
let(:argv) { %w
|
89
|
+
let(:argv) { %w[--secret s3same] }
|
90
90
|
|
91
91
|
describe '#options' do
|
92
92
|
it 'is in time mode' do
|
@@ -98,5 +98,4 @@ RSpec.describe ROTP::Arguments do
|
|
98
98
|
end
|
99
99
|
end
|
100
100
|
end
|
101
|
-
|
102
101
|
end
|
@@ -1,25 +1,33 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
RSpec.describe ROTP::Base32 do
|
4
|
-
|
5
|
-
describe '.random_base32' do
|
4
|
+
describe '.random' do
|
6
5
|
context 'without arguments' do
|
7
|
-
let(:base32) { ROTP::Base32.
|
6
|
+
let(:base32) { ROTP::Base32.random }
|
8
7
|
|
9
|
-
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
|
10
10
|
expect(base32.length).to eq 32
|
11
11
|
end
|
12
12
|
|
13
|
-
it 'is
|
14
|
-
expect(base32).to match
|
13
|
+
it 'is base32 charset' do
|
14
|
+
expect(base32).to match(/\A[A-Z2-7]+\z/)
|
15
15
|
end
|
16
16
|
end
|
17
17
|
|
18
18
|
context 'with arguments' do
|
19
|
-
let(:base32) { ROTP::Base32.
|
19
|
+
let(:base32) { ROTP::Base32.random 48 }
|
20
20
|
|
21
|
-
it '
|
22
|
-
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
|
23
31
|
end
|
24
32
|
end
|
25
33
|
end
|
@@ -34,22 +42,40 @@ RSpec.describe ROTP::Base32 do
|
|
34
42
|
|
35
43
|
context 'valid input data' do
|
36
44
|
it 'correctly decodes a string' do
|
37
|
-
expect(ROTP::Base32.decode('
|
38
|
-
expect(ROTP::Base32.decode('
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
expect(ROTP::Base32.decode('
|
45
|
-
|
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
|
+
|
46
58
|
end
|
47
59
|
|
48
60
|
context 'with padding' do
|
49
61
|
it 'correctly decodes a string' do
|
50
|
-
expect(ROTP::Base32.decode('
|
62
|
+
expect(ROTP::Base32.decode('234A===').unpack('H*').first).to eq 'd6f8'
|
51
63
|
end
|
52
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
|
53
74
|
end
|
54
75
|
end
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
def hex_to_bin(s)
|
80
|
+
s.scan(/../).map { |x| x.hex }.pack('c*')
|
55
81
|
end
|