octokey 0.1.pre.2 → 0.1.pre.3
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.
- data/lib/octokey/auth_request.rb +210 -0
- data/lib/octokey/buffer.rb +239 -74
- data/lib/octokey/challenge.rb +178 -0
- data/lib/octokey/config.rb +101 -0
- data/lib/octokey/public_key.rb +157 -0
- data/lib/octokey.rb +109 -350
- metadata +79 -23
@@ -0,0 +1,178 @@
|
|
1
|
+
class Octokey
|
2
|
+
# In order to verify that the client is in posession of the private key that
|
3
|
+
# corresponds to the public key that it claims to own, we need to give it a
|
4
|
+
# string to sign.
|
5
|
+
#
|
6
|
+
# In order for the scheme to be as secure as possible, the challenges should
|
7
|
+
# be unique, unguessable and unforgeable. This prevents an attacker who can
|
8
|
+
# somehow generate valid signatures from being able to pre-compute any.
|
9
|
+
#
|
10
|
+
# Additionally, challenges should expire after a short time. This both makes
|
11
|
+
# it harder for the attacker that can generate valid signatures (they have to
|
12
|
+
# do it quickly), and also protects users in case logs of signed auth requests
|
13
|
+
# are "leaked" as the attackers will not be able to re-use any of them.
|
14
|
+
#
|
15
|
+
# The client_ip is included in the challenge to make it harder for attackers
|
16
|
+
# who can read logs in real-time to use challenges; they'd have to be able to
|
17
|
+
# forge their IP address too. This doesn't provide any protection against a
|
18
|
+
# full man-in-the-middle attack, because such an attacker could likely forge
|
19
|
+
# the client ip address anyway.
|
20
|
+
#
|
21
|
+
# If you're willing to trade architectural simplicity for extra security, you
|
22
|
+
# should consider using a database to store issued challenges and marking them
|
23
|
+
# as "invalid" as soon as they are first attempted. This helps further with
|
24
|
+
# the attacks mentioned above.
|
25
|
+
#
|
26
|
+
class Challenge
|
27
|
+
# Which version of challenges is supported.
|
28
|
+
CHALLENGE_VERSION = 3
|
29
|
+
# How many bytes of random data should be included.
|
30
|
+
RANDOM_SIZE = 32
|
31
|
+
# Hash algorithm to use in the HMAC
|
32
|
+
HMAC_ALGORITHM = "sha1"
|
33
|
+
# The maximum age of a valid challenge
|
34
|
+
MAX_AGE = 5 * 60
|
35
|
+
# The minimum age of a valid challenge
|
36
|
+
MIN_AGE = -30
|
37
|
+
|
38
|
+
private
|
39
|
+
attr_accessor :version, :time, :client_ip, :random, :digest, :invalid_buffer
|
40
|
+
|
41
|
+
public
|
42
|
+
|
43
|
+
# Parse a challenge.
|
44
|
+
#
|
45
|
+
# The resulting challenge may not be valid! You should call {#valid?} on it before
|
46
|
+
# making assumptions.
|
47
|
+
#
|
48
|
+
# @param [String] string A return value of {Octokey::Challenge.to_s}
|
49
|
+
# @return [Octokey::Challenge]
|
50
|
+
# @raise [Octokey::InvalidBuffer]
|
51
|
+
#
|
52
|
+
def self.from_string(string)
|
53
|
+
buffer = Octokey::Buffer.new(string)
|
54
|
+
new.instance_eval do
|
55
|
+
begin
|
56
|
+
self.version = buffer.scan_uint8
|
57
|
+
if version == CHALLENGE_VERSION
|
58
|
+
self.time, self.client_ip, self.random, self.digest =
|
59
|
+
buffer.scan_all(:time, :ip, :varbytes, :varbytes)
|
60
|
+
end
|
61
|
+
rescue InvalidBuffer => e
|
62
|
+
self.invalid_buffer = e.message
|
63
|
+
end
|
64
|
+
|
65
|
+
self
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Generate a new challenge.
|
70
|
+
#
|
71
|
+
# @param [Hash] opts
|
72
|
+
# @option opts [IPAddr, String] :client_ip The IP address of the client
|
73
|
+
# @option opts [Time] :current_time (Time.now) The current time
|
74
|
+
# @return [Octokey::Challenge]
|
75
|
+
#
|
76
|
+
def self.generate(opts = {})
|
77
|
+
new.instance_eval do
|
78
|
+
expected_ip = IPAddr(opts[:client_ip])
|
79
|
+
current_time = opts[:current_time] || Time.now
|
80
|
+
|
81
|
+
self.version = CHALLENGE_VERSION
|
82
|
+
self.time = current_time
|
83
|
+
self.client_ip = expected_ip
|
84
|
+
self.random = SecureRandom.random_bytes(RANDOM_SIZE)
|
85
|
+
self.digest = expected_digest
|
86
|
+
self
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Is this challenge valid?
|
91
|
+
#
|
92
|
+
# @param [Hash] opts
|
93
|
+
# @option opts [IPAddr, String] :client_ip The IP address of the client
|
94
|
+
# @option opts [Time] :current_time (Time.now) The current time
|
95
|
+
# @return [Boolean]
|
96
|
+
#
|
97
|
+
def valid?(opts)
|
98
|
+
errors(opts) == []
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
# What errors were encountered parsing this challenge?
|
103
|
+
#
|
104
|
+
# @param [Hash] opts
|
105
|
+
# @option opts [IPAddr, String] :client_ip The IP address of the client
|
106
|
+
# @option opts [Time] :current_time (Time.now) The current time
|
107
|
+
# @return [Array<String>]
|
108
|
+
#
|
109
|
+
def errors(opts)
|
110
|
+
expected_ip = IPAddr(opts[:client_ip])
|
111
|
+
current_time = opts[:current_time] || Time.now
|
112
|
+
|
113
|
+
return [invalid_buffer] unless invalid_buffer.nil?
|
114
|
+
return ["Challenge version mismatch"] unless version == CHALLENGE_VERSION
|
115
|
+
|
116
|
+
errors = []
|
117
|
+
errors << "Challenge too old" unless current_time < time + MAX_AGE
|
118
|
+
errors << "Challenge too new" unless current_time > time + MIN_AGE
|
119
|
+
errors << "Challenge IP mismatch" unless client_ip == expected_ip
|
120
|
+
errors << "Challenge random mismatch" unless random.size == RANDOM_SIZE
|
121
|
+
errors << "Challenge HMAC mismatch" unless digest == expected_digest
|
122
|
+
|
123
|
+
errors
|
124
|
+
end
|
125
|
+
|
126
|
+
# Return a the challenge serialized into a buffer.
|
127
|
+
#
|
128
|
+
# @return [String]
|
129
|
+
def to_buffer
|
130
|
+
unsigned_buffer.
|
131
|
+
add_varbytes(digest)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Return a Base64-encoded copy of this challenge serialized into a buffer.
|
135
|
+
#
|
136
|
+
# @return [String]
|
137
|
+
def to_s
|
138
|
+
to_buffer.to_s
|
139
|
+
end
|
140
|
+
|
141
|
+
# Return a string suitable for identifying this challenge while debugging.
|
142
|
+
#
|
143
|
+
# @return [String]
|
144
|
+
def inspect
|
145
|
+
"#<Octokey::Challenge @version=#{version.inspect} @time=#{time.inspect}" +
|
146
|
+
"@client_ip=#{client_ip.inspect}>"
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# The digest calculated from the remainder of the challenge
|
152
|
+
#
|
153
|
+
# @return [String]
|
154
|
+
def expected_digest
|
155
|
+
OpenSSL::HMAC.digest(HMAC_ALGORITHM, Octokey::Config.hmac_secret, unsigned_buffer.raw)
|
156
|
+
end
|
157
|
+
|
158
|
+
# A buffer containing everything except the signature
|
159
|
+
#
|
160
|
+
# @return [Octokey::Buffer]
|
161
|
+
def unsigned_buffer
|
162
|
+
Octokey::Buffer.new.
|
163
|
+
add_uint8(version).
|
164
|
+
add_time(time).
|
165
|
+
add_ip(client_ip).
|
166
|
+
add_varbytes(random)
|
167
|
+
end
|
168
|
+
|
169
|
+
# Convert a provided parameter into an IPAddr.
|
170
|
+
#
|
171
|
+
# @param [IPAddr, String] x
|
172
|
+
# @return [IPAddr]
|
173
|
+
# @raise [ArgumentError]
|
174
|
+
def IPAddr(x)
|
175
|
+
x && IPAddr.new(x.to_s) or raise ArgumentError, "no client IP given"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
class Octokey
|
2
|
+
# All configuration for Octokey is stored here.
|
3
|
+
class Config
|
4
|
+
# Configure the hmac_secret for Octokey.
|
5
|
+
#
|
6
|
+
# This should be a long random string, such as might be generated with:
|
7
|
+
# $ head -c48 /dev/random | base64
|
8
|
+
#
|
9
|
+
def self.hmac_secret=(secret)
|
10
|
+
@hmac_secret = secret.to_str
|
11
|
+
end
|
12
|
+
|
13
|
+
# Which hostnames does your website use?
|
14
|
+
#
|
15
|
+
# This should be an array, for example ["example.com", "*.example.org"]
|
16
|
+
# *.example.org will match bar.example.org, foo.bar.example.org, etc.
|
17
|
+
# example.com will only match example.com
|
18
|
+
def self.valid_hostnames=(hostnames)
|
19
|
+
@valid_hostnames = hostnames.to_ary
|
20
|
+
end
|
21
|
+
|
22
|
+
# Given a username which public keys should they be allowed to log in with
|
23
|
+
#
|
24
|
+
# Your block should only return strings that you obtained through Octokey#public_key
|
25
|
+
# as part of the sign up flow. They have the same format as ssh keys found in the
|
26
|
+
# ~/.authorized_keys file.
|
27
|
+
#
|
28
|
+
# @example
|
29
|
+
# Octokey::Config.public_keys do |username, opts|
|
30
|
+
# User.find_by_username(username).public_keys
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
def self.public_keys(&block)
|
34
|
+
@public_keys_block = block
|
35
|
+
end
|
36
|
+
|
37
|
+
# Given a string, get an Octokey::Challenge.
|
38
|
+
#
|
39
|
+
# NOTE: this is an advanced feature, you only need to implement this method
|
40
|
+
# if you subclass Octokey::Challenge.
|
41
|
+
def self.challenge(&block)
|
42
|
+
@challenge_block = block
|
43
|
+
end
|
44
|
+
|
45
|
+
# Given a string, get an Octokey::AuthRequest.
|
46
|
+
#
|
47
|
+
# NOTE: this is an advanced feature, you only need to implement this method
|
48
|
+
# if you subclass Octokey::AuthRequest.
|
49
|
+
def self.auth_request(&block)
|
50
|
+
@auth_request_block = block
|
51
|
+
end
|
52
|
+
|
53
|
+
# Get the HMAC secret previously configured
|
54
|
+
# @return [String]
|
55
|
+
def self.hmac_secret
|
56
|
+
@hmac_secret or raise "You must configure Octokey::Config.hmac_secret = FOO"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get the valid hostnames previously configured
|
60
|
+
# @return [Array<String>]
|
61
|
+
def self.valid_hostnames
|
62
|
+
@valid_hostnames or raise "You must configure Octokey::Config.valid_hostnames = ['example.com']"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Get a new Octokey::Challenge instance
|
66
|
+
# @param [String] string The Base64-encoded challenge
|
67
|
+
# @param [Hash] opts Passed to Octokey.new
|
68
|
+
# @return [Octokey::Challenge]
|
69
|
+
def self.get_challenge(string, opts)
|
70
|
+
@challenge_block.call(string, opts)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Get a new Octokey::AuthRequest instance
|
74
|
+
# @param [String] string The Base64-encoded auth_request
|
75
|
+
# @param [Hash] opts Passed to Octokey.new
|
76
|
+
# @return [Octokey::AuthRequest]
|
77
|
+
def self.get_auth_request(string, opts)
|
78
|
+
@auth_request_block.call(string, opts)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Get the public keys associated with the given username.
|
82
|
+
# @param [String] username The claimed username
|
83
|
+
# @param [Hash] opts Passed to Octokey.new
|
84
|
+
# @return [Array<String>]
|
85
|
+
def self.get_public_keys(username, opts)
|
86
|
+
@public_keys_block.call(username, opts)
|
87
|
+
end
|
88
|
+
|
89
|
+
challenge do |string, opts|
|
90
|
+
Octokey::Challenge.from_string(string)
|
91
|
+
end
|
92
|
+
|
93
|
+
auth_request do |string, opts|
|
94
|
+
Octokey::AuthRequest.from_string(string)
|
95
|
+
end
|
96
|
+
|
97
|
+
public_keys do |username, opts|
|
98
|
+
raise NotImplementedError, "You must configure Octokey::Config.public_keys{ |username, opts| [] }"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
class Octokey
|
2
|
+
# With Octokey each user has multiple public keys associated with their account.
|
3
|
+
#
|
4
|
+
# This class deals with parsing, formatting, and verifying public keys that are
|
5
|
+
# input.
|
6
|
+
class PublicKey
|
7
|
+
|
8
|
+
# The only type of PublicKey that Octokey supports is RSA.
|
9
|
+
TYPE = "ssh-rsa"
|
10
|
+
# In order to guard against client errors, we disallow short, weak, keys.
|
11
|
+
SSH_RSA_MINIMUM_MODULUS_SIZE = 768
|
12
|
+
|
13
|
+
private
|
14
|
+
attr_accessor :expected_type, :type, :e, :n, :invalid_buffer
|
15
|
+
|
16
|
+
public
|
17
|
+
|
18
|
+
# Wrap an existing public key.
|
19
|
+
#
|
20
|
+
# @param [OpenSSL::PKey::RSA] key
|
21
|
+
# @return [Octokey::PublicKey]
|
22
|
+
# @raise [ArgumentError] if the key was not an rsa key
|
23
|
+
def self.from_key(key)
|
24
|
+
raise ArgumentError, "Invalid key type" unless OpenSSL::PKey::RSA === key
|
25
|
+
new.instance_eval do
|
26
|
+
self.e = key.e
|
27
|
+
self.n = key.n
|
28
|
+
self.type = TYPE
|
29
|
+
self
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Extract a public key from a buffer.
|
34
|
+
#
|
35
|
+
# If parsing fails then the returned Octokey::PublicKey's .valid? method
|
36
|
+
# will return false.
|
37
|
+
#
|
38
|
+
# @param [Octokey::Buffer] buffer
|
39
|
+
# @param [String] expected_type (nil)
|
40
|
+
# @return [Octokey::PublicKey]
|
41
|
+
def self.from_buffer(buffer, expected_type = nil)
|
42
|
+
new.instance_eval do
|
43
|
+
begin
|
44
|
+
self.expected_type = expected_type
|
45
|
+
self.type = buffer.scan_string
|
46
|
+
if type == TYPE
|
47
|
+
self.e, self.n = buffer.scan_all(:mpint, :mpint)
|
48
|
+
end
|
49
|
+
rescue Octokey::InvalidBuffer => e
|
50
|
+
self.invalid_buffer = e.message
|
51
|
+
end
|
52
|
+
|
53
|
+
self
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Parse the string representation of a public key.
|
58
|
+
#
|
59
|
+
# The string representation used matches exactly the format which ssh uses
|
60
|
+
# to store public keys in the ~/.ssh/authorized_keys file:
|
61
|
+
# "ssh-rsa <base64-encoded-buffer>"
|
62
|
+
#
|
63
|
+
# If parsing fails then the returned Octokey::PublicKey's .valid? method
|
64
|
+
# will return false.
|
65
|
+
#
|
66
|
+
# @param [String] string the string to parse
|
67
|
+
# @return [Octokey::PublicKey]
|
68
|
+
def self.from_string(string)
|
69
|
+
if string =~ /\A([^\s]+)\s+([^\s]+)/
|
70
|
+
from_buffer(Octokey::Buffer.new($2), $1)
|
71
|
+
else
|
72
|
+
new.instance_eval do
|
73
|
+
self.invalid_buffer = "Badly formatted public key"
|
74
|
+
self
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Is this a correct valid public key?
|
80
|
+
#
|
81
|
+
# If this method returns false, the .errors method can be used to get a
|
82
|
+
# more detailed error message.
|
83
|
+
#
|
84
|
+
# @return [Boolean]
|
85
|
+
def valid?
|
86
|
+
errors == []
|
87
|
+
end
|
88
|
+
|
89
|
+
# What was wrong with this public key?
|
90
|
+
#
|
91
|
+
# @return [Array<String>] the problems
|
92
|
+
def errors
|
93
|
+
if invalid_buffer
|
94
|
+
[invalid_buffer]
|
95
|
+
elsif expected_type && type != expected_type
|
96
|
+
["Public key type mismatch"]
|
97
|
+
elsif type != TYPE
|
98
|
+
["Public key type unsupported"]
|
99
|
+
elsif n.num_bits < SSH_RSA_MINIMUM_MODULUS_SIZE
|
100
|
+
["Public key too small"]
|
101
|
+
else
|
102
|
+
[]
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# The OpenSSL::PKey::RSA version of this public key.
|
107
|
+
#
|
108
|
+
# @return [OpenSSL::PKey::RSA] the public key
|
109
|
+
# @raise [RuntimeError] if the Octokey::PublicKey is not valid
|
110
|
+
def public_key
|
111
|
+
raise RuntimeError, "Tried to read invalid public_key" unless valid?
|
112
|
+
key = OpenSSL::PKey::RSA.new
|
113
|
+
key.e = e
|
114
|
+
key.n = n
|
115
|
+
key
|
116
|
+
end
|
117
|
+
|
118
|
+
# Store the public key into a buffer.
|
119
|
+
#
|
120
|
+
# @return [Octokey::Buffer]
|
121
|
+
def to_buffer
|
122
|
+
Octokey::Buffer.new.
|
123
|
+
add_string(type).
|
124
|
+
add_mpint(e).
|
125
|
+
add_mpint(n)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get the string representation of this key.
|
129
|
+
#
|
130
|
+
# @return [String]
|
131
|
+
def to_s
|
132
|
+
"#{type.to_s} #{to_buffer.to_s}"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Get a string representation of this key suitable for use while debugging.
|
136
|
+
#
|
137
|
+
# @return [String]
|
138
|
+
def inspect
|
139
|
+
"#<Octokey::PublicKey #{to_s.inspect}>"
|
140
|
+
end
|
141
|
+
|
142
|
+
# Return a hash code suitable for storing public keys in a ruby Hash.
|
143
|
+
#
|
144
|
+
# @return [Fixnum]
|
145
|
+
def hash
|
146
|
+
to_s.hash ^ self.class.hash
|
147
|
+
end
|
148
|
+
|
149
|
+
# Compare this public key to another.
|
150
|
+
#
|
151
|
+
# @return [Boolean]
|
152
|
+
def ==(other)
|
153
|
+
self.hash == other.hash && self.to_s == other.to_s
|
154
|
+
end
|
155
|
+
alias_method :eql?, :==
|
156
|
+
end
|
157
|
+
end
|