potp 1.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 +7 -0
- data/LICENSE +52 -0
- data/README.md +142 -0
- data/bin/potp +84 -0
- data/lib/potp/base32.rb +140 -0
- data/lib/potp/foreign/supplement.rb +21 -0
- data/lib/potp/hotp.rb +29 -0
- data/lib/potp/otp.rb +93 -0
- data/lib/potp/random.rb +26 -0
- data/lib/potp/totp.rb +61 -0
- data/lib/potp/version.rb +11 -0
- data/lib/potp.rb +11 -0
- data/potp.gemspec +35 -0
- metadata +62 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 572944d75dff4240e25ec32bb91202acd04ce07b2dea52e585fb765bf579076d
|
4
|
+
data.tar.gz: 633ba7f0dac49a1c66d71680a030571b32185806adf6e9e9111bc5561d937865
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4e6b4d5efe9da1b692486e28a56fcf20f4c858a99bb7d870889c4cee21540fff2061589088ba01917333dcf83ec9d3293e4e9741d79d468fac039d5913b0bef2
|
7
|
+
data.tar.gz: 5ed1543a5214cb4be08e1b0b54d7ebff17833ed7442757bc7a5605356d52860967d136bf0ed2a0eb3629024797d65df313dd70ab198dd1b3397b412740744c5f
|
data/LICENSE
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# BSD-2-clause license, extended by language use conditions
|
2
|
+
|
3
|
+
Copyright (C) 2024, Bertram Scharpf <software@bertram-scharpf.de>.
|
4
|
+
All rights reserved.
|
5
|
+
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
7
|
+
modification, are permitted provided that the following conditions are
|
8
|
+
met:
|
9
|
+
|
10
|
+
* Redistributions of source code must retain the above copyright
|
11
|
+
notice, this list of conditions and the following disclaimer.
|
12
|
+
|
13
|
+
* Redistributions in binary form must reproduce the above copyright
|
14
|
+
notice, this list of conditions and the following disclaimer in
|
15
|
+
the documentation and/or other materials provided with the
|
16
|
+
distribution.
|
17
|
+
|
18
|
+
* Redistributions must not contain any clauses about anticipated
|
19
|
+
harassment or discrimination, nor must they be held in a so-called
|
20
|
+
"inclusive language". As far as German language is used, the
|
21
|
+
conditions mentioned below additionally apply.
|
22
|
+
|
23
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
24
|
+
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
25
|
+
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
26
|
+
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
27
|
+
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
28
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
29
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
30
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
31
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
32
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
33
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
34
|
+
|
35
|
+
|
36
|
+
## Use of the German Language
|
37
|
+
|
38
|
+
Beim Gebrauch deutscher Sprache sind Weiterentwicklungen und
|
39
|
+
-verbreitungen nur gestattet unter Einhaltung sowie abermaligen
|
40
|
+
Einforderns folgender zusätzlicher Bedingungen:
|
41
|
+
|
42
|
+
* Keine Verwendung von sogenannter „geschlechtergerechter Sprache“,
|
43
|
+
also Anfügen von weiblichen Endungen mit Binnen-I, Sternchen,
|
44
|
+
Doppelpunkt, Unterstrich oder ähnlichem, oder Konstruktionen, die
|
45
|
+
den Sachverhalt falsch wiedergeben („Radfahrende“, „Studierende“).
|
46
|
+
|
47
|
+
* Keine Verwendung der „reformierten Rechtschreibung“ von 1996,
|
48
|
+
insbesondere Doppel-S am Silbenende, „plazieren“ mit T, sowie
|
49
|
+
Großschreibung von Wendungen wie „des weiteren“.
|
50
|
+
|
51
|
+
|
52
|
+
<!-- vim:set ft=markdown : -->
|
data/README.md
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
# The Plain One Time Password Library
|
2
|
+
|
3
|
+
A ruby library for generating and validating one time passwords (HOTP & TOTP)
|
4
|
+
according to
|
5
|
+
[RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226)
|
6
|
+
and
|
7
|
+
[RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238).
|
8
|
+
|
9
|
+
POTP aims to be compatible with
|
10
|
+
[Google Authenticator](https://github.com/google/google-authenticator).
|
11
|
+
|
12
|
+
The Base32 format conforms to
|
13
|
+
[RFC 4648 Base32](http://en.wikipedia.org/wiki/Base32#Base_32_Encoding_per_§6)
|
14
|
+
|
15
|
+
|
16
|
+
## Installation
|
17
|
+
|
18
|
+
```bash
|
19
|
+
sudo gem install potp
|
20
|
+
```
|
21
|
+
|
22
|
+
If you like to run the executable (instead of writing a one-liner for
|
23
|
+
yourself), you have to install the `appl` gem.
|
24
|
+
|
25
|
+
```bash
|
26
|
+
sudo gem install appl
|
27
|
+
```
|
28
|
+
|
29
|
+
|
30
|
+
## Library Usage
|
31
|
+
|
32
|
+
### Time based (TOTP)
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
require "potp"
|
36
|
+
|
37
|
+
totp = POTP::TOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
38
|
+
totp.now #=> "152201"
|
39
|
+
|
40
|
+
totp.verify "152201" #=> 1735417500 # ok, value is the timestamp
|
41
|
+
sleep 30
|
42
|
+
totp.verify "152201" #=> nil # not ok
|
43
|
+
```
|
44
|
+
|
45
|
+
### Counter based (HOTP)
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
hotp = POTP::HOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
49
|
+
hotp.at 0 #=> "178748"
|
50
|
+
hotp.at 1 #=> "584373"
|
51
|
+
hotp.at 73 #=> "309764"
|
52
|
+
|
53
|
+
# OTP verifying with a counter
|
54
|
+
hotp.verify "309764", 73 #=> 73
|
55
|
+
hotp.verify "309764", 74 #=> nil
|
56
|
+
hotp.verify "309764", 70, retries: 2 #=> nil
|
57
|
+
hotp.verify "309764", 70, retries: 3 #=> 73
|
58
|
+
```
|
59
|
+
|
60
|
+
|
61
|
+
### Avoiding reuse of TOTP
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
require "potp"
|
65
|
+
totp = POTP::TOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
66
|
+
code = totp.now #=> "054626"
|
67
|
+
last_verify = totp.verify code #=> 1735527390
|
68
|
+
totp.verify code, after: last_verify #=> nil
|
69
|
+
sleep 30
|
70
|
+
code = totp.now #=> "481150"
|
71
|
+
totp.verify code, after: last_verify #=> 1735527420
|
72
|
+
```
|
73
|
+
|
74
|
+
|
75
|
+
### Verifying a TOTP with drift
|
76
|
+
|
77
|
+
In case a user entered a code just after it has expired, you can allow
|
78
|
+
the token to remain valid.
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
totp = POTP::TOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
82
|
+
now = Time.now - 30
|
83
|
+
code = totp.at now #=> "455335"
|
84
|
+
totp.verify code #=> nil
|
85
|
+
totp.verify code, drift_behind: 27 #=> 1735530510
|
86
|
+
```
|
87
|
+
|
88
|
+
|
89
|
+
### Generating a Base32 secret key
|
90
|
+
|
91
|
+
Returns a 160 bit (32 character) Base32 secret.
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
require "potp/random"
|
95
|
+
POTP::Base32.random #=> "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
96
|
+
```
|
97
|
+
|
98
|
+
|
99
|
+
### Generating QR codes for provisioning mobile apps
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
require "potp"
|
103
|
+
|
104
|
+
totp = POTP::TOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
105
|
+
uri = totp.provisioning_uri name: "jdoe@example.net", issuer: "ACME Service"
|
106
|
+
#=> "otpauth://totp/ACME%20Service:jdoe%40example.net?secret=GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A&issuer=ACME%20Service"
|
107
|
+
|
108
|
+
hotp = POTP::HOTP.new "GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A"
|
109
|
+
uri = hotp.provisioning_uri name: "jdoe@example.net", issuer: "ACME Service", counter: 0
|
110
|
+
#=> "otpauth://hotp/ACME%20Service:jdoe%40example.net?secret=GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A&issuer=ACME%20Service&counter=0"
|
111
|
+
|
112
|
+
# Then, do something like this:
|
113
|
+
system *%w(qrencode -t xpm -s 1 -o), "qr.xpm", uri
|
114
|
+
```
|
115
|
+
|
116
|
+
|
117
|
+
## Executable Usage
|
118
|
+
|
119
|
+
Generates a time-based one-time password:
|
120
|
+
|
121
|
+
```bash
|
122
|
+
potp --secret GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A
|
123
|
+
```
|
124
|
+
|
125
|
+
Generates a counter-based one-time password:
|
126
|
+
|
127
|
+
```bash
|
128
|
+
potp --hmac --secret GYS5L3N3E4AAYNMN562LW76TMWHQBJ4A --counter 42
|
129
|
+
```
|
130
|
+
|
131
|
+
What you expect:
|
132
|
+
|
133
|
+
```bash
|
134
|
+
potp --help
|
135
|
+
```
|
136
|
+
|
137
|
+
|
138
|
+
## Copyright
|
139
|
+
|
140
|
+
* (C) 2024 Bertram Scharpf <software@bertram-scharpf.de>
|
141
|
+
* License: [BSD-2-Clause+](./LICENSE)
|
142
|
+
|
data/bin/potp
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
#
|
4
|
+
# potp -- The Plain One-Time Password Tool
|
5
|
+
#
|
6
|
+
|
7
|
+
begin
|
8
|
+
require "appl"
|
9
|
+
rescue LoadError
|
10
|
+
puts "This tool requires the `appl` gem."
|
11
|
+
exit 2
|
12
|
+
end
|
13
|
+
require "potp/foreign/supplement"
|
14
|
+
require "potp"
|
15
|
+
|
16
|
+
|
17
|
+
class POTP::Appl < Application
|
18
|
+
|
19
|
+
NAME = "potp"
|
20
|
+
VERSION = POTP::VERSION
|
21
|
+
SUMMARY = "Plain One Time Password Tool"
|
22
|
+
COPYRIGHT = "(C) 2024 Bertram Scharpf <software@bertram-scharpf.de>"
|
23
|
+
LICENSE = "BSD-2-Clause+"
|
24
|
+
AUTHOR = "Bertram Scharpf <software@bertram-scharpf.de>"
|
25
|
+
|
26
|
+
DESCRIPTION = <<~EOT
|
27
|
+
Generate and validate one time passwords (HOTP & TOTP)
|
28
|
+
according to [RFC 4226] and [RFC 6238].
|
29
|
+
|
30
|
+
Examples:
|
31
|
+
|
32
|
+
potp --secret p4ssword # Generates a time-based one-time password
|
33
|
+
potp --hmac --secret p4ssword --counter 42 # Generates a counter-based one-time password
|
34
|
+
|
35
|
+
EOT
|
36
|
+
|
37
|
+
attr_writer :secret, :counter, :digest
|
38
|
+
attr_bang :debug
|
39
|
+
def time! ; @mode = :time ; end
|
40
|
+
def hmac! ; @mode = :hmac ; end
|
41
|
+
|
42
|
+
define_option "t", :time!, true, "use time-based OTP according to RFC 6238"
|
43
|
+
alias_option "t", "time"
|
44
|
+
|
45
|
+
define_option "m", :hmac!, "use counter-based OTP according to RFC 4226"
|
46
|
+
alias_option "m", "hmac"
|
47
|
+
|
48
|
+
define_option "s", :secret=, "STR", "the shared secret"
|
49
|
+
alias_option "s", "secret"
|
50
|
+
|
51
|
+
define_option "d", :digest=, "ALG", "sha1", "algorithm for the digest"
|
52
|
+
alias_option "d", "digest"
|
53
|
+
|
54
|
+
define_option "c", :counter=, "NUM", 0,
|
55
|
+
"the counter for counter-based hmac OTP"
|
56
|
+
alias_option "c", "counter"
|
57
|
+
|
58
|
+
define_option "g", :debug!, "full Ruby error messages and backtrace"
|
59
|
+
alias_option "g", "debug"
|
60
|
+
define_option "h", :help, "show options"
|
61
|
+
alias_option "h", "help"
|
62
|
+
define_option "V", :version, "show version"
|
63
|
+
alias_option "V", "version"
|
64
|
+
|
65
|
+
def run
|
66
|
+
puts generate_output
|
67
|
+
rescue POTP::Base32::Invalid
|
68
|
+
raise "Secret must be in RFC4648 Base32 format - http://en.wikipedia.org/wiki/Base32#Base_32_Encoding_per_§6"
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def generate_output
|
74
|
+
@secret.notempty? or raise "You must specify a --secret. See --help."
|
75
|
+
case @mode
|
76
|
+
when :time then (POTP::TOTP.new @secret, digest: @digest).now
|
77
|
+
when :hmac then (POTP::HOTP.new @secret, digest: @digest).at @counter
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
POTP::Appl.run
|
84
|
+
|
data/lib/potp/base32.rb
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
#
|
2
|
+
# potp/base32.rb -- Base32 Encoding
|
3
|
+
#
|
4
|
+
|
5
|
+
require "potp/foreign/supplement"
|
6
|
+
|
7
|
+
|
8
|
+
module POTP
|
9
|
+
|
10
|
+
class Base32
|
11
|
+
|
12
|
+
ORD_A, ORD_Z, ORD_a, ORD_z, ORD_2, ORD_7 = %w(A Z a z 2 7).map { |c| c.ord }
|
13
|
+
LAP = 26
|
14
|
+
DIFF_2 = ORD_2 - LAP
|
15
|
+
|
16
|
+
class << self
|
17
|
+
|
18
|
+
def encode bytes, width: nil, equals: true
|
19
|
+
res = [""]
|
20
|
+
scan_bufs bytes do |buf,r|
|
21
|
+
chunk = []
|
22
|
+
8.times {
|
23
|
+
chunk.unshift buf & 0x1f
|
24
|
+
buf >>= 5
|
25
|
+
}
|
26
|
+
loop do
|
27
|
+
r -= 5
|
28
|
+
break unless r > 0
|
29
|
+
chunk.pop
|
30
|
+
end
|
31
|
+
chunk = chunk.map { |c| (c + (c < LAP ? ORD_A : DIFF_2)).chr }
|
32
|
+
if equals then
|
33
|
+
chunk.push "=" until chunk.length >= 8
|
34
|
+
end
|
35
|
+
res.last << chunk.join
|
36
|
+
if width and res.last.length > width then
|
37
|
+
res.push res.last.slice! width, 8
|
38
|
+
end
|
39
|
+
end
|
40
|
+
new res.join "\n"
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def scan_bufs bytes
|
46
|
+
scan_blocks bytes do |s|
|
47
|
+
buf, r = 0, 40
|
48
|
+
5.times {
|
49
|
+
buf <<= 8
|
50
|
+
if (b = s.shift) then
|
51
|
+
buf |= b
|
52
|
+
r -= 8
|
53
|
+
end
|
54
|
+
}
|
55
|
+
yield buf, r
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def scan_blocks bytes
|
60
|
+
i = 0
|
61
|
+
loop do
|
62
|
+
s = bytes.byteslice i, 5
|
63
|
+
break unless s.notempty?
|
64
|
+
yield s.unpack "C*"
|
65
|
+
i += 5
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
class Invalid < ArgumentError ; end
|
73
|
+
|
74
|
+
attr_reader :data
|
75
|
+
|
76
|
+
def initialize data
|
77
|
+
@data = data
|
78
|
+
end
|
79
|
+
|
80
|
+
def decode encoding: nil
|
81
|
+
res = ""
|
82
|
+
each_block { |buf,n|
|
83
|
+
chunk = []
|
84
|
+
5.times {
|
85
|
+
chunk.unshift buf & 0xff
|
86
|
+
buf >>= 8
|
87
|
+
}
|
88
|
+
until n >= 40 do
|
89
|
+
chunk.pop
|
90
|
+
n += 8
|
91
|
+
end
|
92
|
+
res << (chunk.pack "C*")
|
93
|
+
}
|
94
|
+
encoding = Encoding.default_external if encoding == :default
|
95
|
+
res.force_encoding encoding if encoding
|
96
|
+
res
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def each_block
|
102
|
+
buf, n = 0, 0
|
103
|
+
each_char do |c|
|
104
|
+
c -= case c
|
105
|
+
when ORD_A..ORD_Z then ORD_A
|
106
|
+
when ORD_a..ORD_z then ORD_a
|
107
|
+
when ORD_2..ORD_7 then DIFF_2
|
108
|
+
else raise Invalid, "Character '#{c}'."
|
109
|
+
end
|
110
|
+
buf <<= 5
|
111
|
+
buf |= c
|
112
|
+
n += 5
|
113
|
+
if n == 40 then
|
114
|
+
yield buf, n
|
115
|
+
buf, n = 0, 0
|
116
|
+
end
|
117
|
+
end
|
118
|
+
if n.nonzero? then
|
119
|
+
buf <<= 40 - n
|
120
|
+
yield buf, n
|
121
|
+
end
|
122
|
+
nil
|
123
|
+
end
|
124
|
+
|
125
|
+
def each_char
|
126
|
+
done = false
|
127
|
+
@data.each_char { |c|
|
128
|
+
case c
|
129
|
+
when "=" then done = true ; next
|
130
|
+
when "\n" then next
|
131
|
+
else raise Invalid, "Material after '=': '#{c}'" if done
|
132
|
+
end
|
133
|
+
yield c.ord
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
|
139
|
+
end
|
140
|
+
|
@@ -0,0 +1,21 @@
|
|
1
|
+
#
|
2
|
+
# potp/foreign/supplement.rb -- Addition usefull Ruby functions
|
3
|
+
#
|
4
|
+
|
5
|
+
# The purpose of this is simply to reduce dependencies.
|
6
|
+
|
7
|
+
begin
|
8
|
+
require "supplement____"
|
9
|
+
rescue LoadError
|
10
|
+
class NilClass ; def notempty? ; end ; end
|
11
|
+
class String ; def notempty? ; self unless empty? ; end ; end
|
12
|
+
class Array ; def notempty? ; self unless empty? ; end ; end
|
13
|
+
class <<Struct ; alias [] new ; end
|
14
|
+
class String
|
15
|
+
def starts_with? oth ; o = oth.to_str ; o.length if start_with? o ; end
|
16
|
+
def ends_with? oth ; o = oth.to_str ; length - o.length if end_with? o ; end
|
17
|
+
alias starts_with starts_with?
|
18
|
+
alias ends_with ends_with?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
data/lib/potp/hotp.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#
|
2
|
+
# potp/hotp.rb -- HOTP class
|
3
|
+
#
|
4
|
+
|
5
|
+
require "potp/otp"
|
6
|
+
|
7
|
+
|
8
|
+
module POTP
|
9
|
+
|
10
|
+
class HOTP < OTP
|
11
|
+
|
12
|
+
SCHEME = "hotp"
|
13
|
+
|
14
|
+
def verify input, counter, retries: 0
|
15
|
+
while retries >= 0 do
|
16
|
+
return counter if super input, counter
|
17
|
+
counter += 1
|
18
|
+
retries -= 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def provisioning_uri counter: 0, **kwargs
|
23
|
+
super
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
data/lib/potp/otp.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
#
|
2
|
+
# potp/otp.rb -- OTP class
|
3
|
+
#
|
4
|
+
|
5
|
+
require "potp/foreign/supplement"
|
6
|
+
require "openssl"
|
7
|
+
require "potp/base32"
|
8
|
+
|
9
|
+
|
10
|
+
module POTP
|
11
|
+
|
12
|
+
class OTP
|
13
|
+
|
14
|
+
attr_reader :secret, :digits, :digest
|
15
|
+
|
16
|
+
DEFAULT_DIGITS = 6
|
17
|
+
|
18
|
+
def initialize secret, digits: nil, digest: nil, **kwargs
|
19
|
+
@secret = secret
|
20
|
+
@digits = digits || DEFAULT_DIGITS # Google Authenticate only supports 6 currently
|
21
|
+
@digest = digest || "sha1" # Google Authenticate only supports SHA1 currently
|
22
|
+
end
|
23
|
+
|
24
|
+
def at data
|
25
|
+
hmac = build_digest data
|
26
|
+
code = hmac[ (hmac.last & 0x0f), 4].inject do |c,e| c <<= 8 ; c |= e end
|
27
|
+
code &= 0x7fffffff
|
28
|
+
s = ""
|
29
|
+
@digits.times { code, d = code.divmod 10 ; s << d.to_s }
|
30
|
+
s.reverse!
|
31
|
+
s
|
32
|
+
end
|
33
|
+
|
34
|
+
def verify input, data
|
35
|
+
String === input or raise ArgumentError, "`otp` has to be a String"
|
36
|
+
time_constant_compare input, (at data)
|
37
|
+
end
|
38
|
+
|
39
|
+
# https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
40
|
+
# Example additional parameter: image: "https://example.com/icon.png"
|
41
|
+
def provisioning_uri name:, issuer: nil, **kwargs
|
42
|
+
label = [ issuer, name||""].map { |x| x&.tr ":", "_" }
|
43
|
+
parameters = {
|
44
|
+
**kwargs,
|
45
|
+
digits: (@digits unless @digits == DEFAULT_DIGITS),
|
46
|
+
algorithm: (@digest.upcase unless @digest.downcase == "sha1"),
|
47
|
+
issuer: issuer,
|
48
|
+
secret: @secret,
|
49
|
+
}
|
50
|
+
build_uri label, parameters
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def int_to_bytestring i, padding = 8
|
56
|
+
i >= 0 or raise ArgumentError, "#int_to_bytestring requires a positive number"
|
57
|
+
result = []
|
58
|
+
while i != 0 or padding > 0 do
|
59
|
+
c = (i & 0xff).chr
|
60
|
+
result.unshift c
|
61
|
+
i >>= 8
|
62
|
+
padding -= 1
|
63
|
+
end
|
64
|
+
result.join
|
65
|
+
end
|
66
|
+
|
67
|
+
def time_constant_compare a, b
|
68
|
+
a.notempty? and b.notempty? and a == b
|
69
|
+
end
|
70
|
+
|
71
|
+
def build_digest input
|
72
|
+
d = OpenSSL::Digest.new @digest
|
73
|
+
s = (Base32.new @secret).decode
|
74
|
+
i = int_to_bytestring input.to_i
|
75
|
+
(OpenSSL::HMAC.digest d, s, i).bytes
|
76
|
+
end
|
77
|
+
|
78
|
+
def build_uri label, parameters
|
79
|
+
label.compact!
|
80
|
+
label = label.map! { |s| url_encode s }.join ":"
|
81
|
+
parameters.reject! { |_,v| v.nil? }
|
82
|
+
parameters = parameters.keys.reverse.map { |k| "#{k}=#{url_encode parameters[ k]}" }.join "&"
|
83
|
+
"otpauth://#{self.class::SCHEME}/#{label}?#{parameters}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def url_encode str
|
87
|
+
str.to_s.gsub %r/([^a-zA-Z0-9_.-])/ do |c| "%%%02X" % c.ord end
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
|
data/lib/potp/random.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
#
|
2
|
+
# potp/random.rb -- Generate random secrets
|
3
|
+
#
|
4
|
+
|
5
|
+
require "potp/base32"
|
6
|
+
require "securerandom"
|
7
|
+
|
8
|
+
|
9
|
+
module POTP
|
10
|
+
|
11
|
+
class Base32
|
12
|
+
|
13
|
+
class <<self
|
14
|
+
|
15
|
+
# A byte length of 20 means 160 bits and results in 32 character long base32 value.
|
16
|
+
def random byte_length = 20
|
17
|
+
rand_bytes = SecureRandom.random_bytes byte_length
|
18
|
+
(encode rand_bytes).data
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
data/lib/potp/totp.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
#
|
2
|
+
# potp/totp.rb -- TOTP class
|
3
|
+
#
|
4
|
+
|
5
|
+
require "potp/otp"
|
6
|
+
|
7
|
+
|
8
|
+
module POTP
|
9
|
+
|
10
|
+
class TOTP < OTP
|
11
|
+
|
12
|
+
SCHEME = "totp"
|
13
|
+
|
14
|
+
DEFAULT_INTERVAL = 30
|
15
|
+
|
16
|
+
attr_reader :interval
|
17
|
+
|
18
|
+
# @option options [Integer] interval (30) the time interval in seconds for OTP
|
19
|
+
# This defaults to 30 which is standard.
|
20
|
+
def initialize secret, interval: nil, **kwargs
|
21
|
+
@interval = interval || DEFAULT_INTERVAL
|
22
|
+
super secret, **kwargs
|
23
|
+
end
|
24
|
+
|
25
|
+
def at time ; super (timeint time) / @interval ; end
|
26
|
+
def now ; at Time.now ; end
|
27
|
+
|
28
|
+
def verify input, drift_ahead: nil, drift_behind: nil, after: nil, at: nil
|
29
|
+
fin = now = timeint at||Time.now
|
30
|
+
now -= drift_behind if drift_behind
|
31
|
+
fin += drift_ahead if drift_ahead
|
32
|
+
if after then
|
33
|
+
after += @interval
|
34
|
+
now = after if now < after
|
35
|
+
end
|
36
|
+
now -= now % @interval
|
37
|
+
while now < fin do
|
38
|
+
return now if super input, now
|
39
|
+
now += @interval
|
40
|
+
end
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def provisioning_uri **kwargs
|
45
|
+
super **kwargs, period: (@interval unless @interval == TOTP::DEFAULT_INTERVAL)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def timeint time
|
51
|
+
case time
|
52
|
+
when Integer then time
|
53
|
+
when Time then time.utc.to_i
|
54
|
+
else time.to_i
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
data/lib/potp/version.rb
ADDED
data/lib/potp.rb
ADDED
data/potp.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
#
|
2
|
+
# potp.gemspec -- Gem Specification
|
3
|
+
#
|
4
|
+
|
5
|
+
require "./lib/potp/version"
|
6
|
+
|
7
|
+
|
8
|
+
Gem::Specification.new do |s|
|
9
|
+
s.name = "potp"
|
10
|
+
s.version = POTP::VERSION
|
11
|
+
s.platform = Gem::Platform::RUBY
|
12
|
+
s.required_ruby_version = ">= 3.1"
|
13
|
+
s.summary = "Plain One Time Password Tool"
|
14
|
+
s.description = <<~EOT
|
15
|
+
A Ruby library for generating and verifying one time passwords,
|
16
|
+
both HOTP and TOTP, and includes QR Code provisioning.
|
17
|
+
EOT
|
18
|
+
s.license = "LicenseRef-LICENSE"
|
19
|
+
s.authors = ["Bertram Scharpf"]
|
20
|
+
s.email = ["<software@bertram-scharpf.de>"]
|
21
|
+
s.homepage = "https://github.com/BertramScharpf/ruby-potp"
|
22
|
+
|
23
|
+
s.requirements = "Just Ruby and some more if you like"
|
24
|
+
unless :full_dependecies then
|
25
|
+
s.add_dependency "supplement", "~>2", ">=2.10"
|
26
|
+
s.add_dependency "appl", "~>1"
|
27
|
+
end
|
28
|
+
|
29
|
+
s.require_paths = %w(lib)
|
30
|
+
s.extensions = %w()
|
31
|
+
s.files = Dir[ "lib/**/*.rb", "bin/*", ]
|
32
|
+
s.executables = %w(potp)
|
33
|
+
s.extra_rdoc_files = %w(LICENSE README.md potp.gemspec)
|
34
|
+
end
|
35
|
+
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: potp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Bertram Scharpf
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-12-31 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: |
|
14
|
+
A Ruby library for generating and verifying one time passwords,
|
15
|
+
both HOTP and TOTP, and includes QR Code provisioning.
|
16
|
+
email:
|
17
|
+
- "<software@bertram-scharpf.de>"
|
18
|
+
executables:
|
19
|
+
- potp
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files:
|
22
|
+
- LICENSE
|
23
|
+
- README.md
|
24
|
+
- potp.gemspec
|
25
|
+
files:
|
26
|
+
- LICENSE
|
27
|
+
- README.md
|
28
|
+
- bin/potp
|
29
|
+
- lib/potp.rb
|
30
|
+
- lib/potp/base32.rb
|
31
|
+
- lib/potp/foreign/supplement.rb
|
32
|
+
- lib/potp/hotp.rb
|
33
|
+
- lib/potp/otp.rb
|
34
|
+
- lib/potp/random.rb
|
35
|
+
- lib/potp/totp.rb
|
36
|
+
- lib/potp/version.rb
|
37
|
+
- potp.gemspec
|
38
|
+
homepage: https://github.com/BertramScharpf/ruby-potp
|
39
|
+
licenses:
|
40
|
+
- LicenseRef-LICENSE
|
41
|
+
metadata: {}
|
42
|
+
post_install_message:
|
43
|
+
rdoc_options: []
|
44
|
+
require_paths:
|
45
|
+
- lib
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '3.1'
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
requirements:
|
57
|
+
- Just Ruby and some more if you like
|
58
|
+
rubygems_version: 3.5.23
|
59
|
+
signing_key:
|
60
|
+
specification_version: 4
|
61
|
+
summary: Plain One Time Password Tool
|
62
|
+
test_files: []
|