octokey 0.1.pre.2-jruby

Sign up to get free protection for your applications and to get access to all the features.
data/lib/octokey.rb ADDED
@@ -0,0 +1,149 @@
1
+ require 'openssl'
2
+ require 'securerandom'
3
+ require 'ipaddr'
4
+ require 'uri'
5
+
6
+ require File.expand_path('octokey/buffer', File.dirname(__FILE__))
7
+ require File.expand_path('octokey/config', File.dirname(__FILE__))
8
+ require File.expand_path('octokey/challenge', File.dirname(__FILE__))
9
+ require File.expand_path('octokey/public_key', File.dirname(__FILE__))
10
+ require File.expand_path('octokey/auth_request', File.dirname(__FILE__))
11
+
12
+ # Octokey is a private key based login mechanism for websites inspired
13
+ # heavily by, and borrowing algorithms from, OpenSSH.
14
+ class Octokey
15
+ # Raised when you try and access details of an invalid octokey request.
16
+ # If you always check .can_log_in? or .can_sign_up? first, you should not
17
+ # see this exception.
18
+ class InvalidRequest < StandardError; end
19
+
20
+ # Raised if an Octokey::Buffer is invalid. This is usually caught by Octokey
21
+ # so you will only need to catch it if you are parsing buffers yourself.
22
+ class InvalidBuffer < InvalidRequest; end
23
+
24
+ # Create a new challenge.
25
+ #
26
+ # Once created, the challenge should be sent to the client for it to sign.
27
+ #
28
+ # The client_ip address is included in the outgoing challenge to verify that
29
+ # incoming login requests came from the same client as requested the challenge.
30
+ #
31
+ # @param [Hash] opts
32
+ # @option opts [String,IPAddr] client_ip The IP address of the client.
33
+ # @return [String] The Base64 encoded challenge.
34
+ def self.new_challenge(opts)
35
+ Octokey::Challenge.generate(opts).to_s
36
+ end
37
+
38
+ # Sign a challenge.
39
+ #
40
+ # If you're acting as an Octokey client, then you use this function to turn
41
+ # a challenge that you've been issued into an auth_request to send back to the
42
+ # server.
43
+ #
44
+ # @param [String] challenge The base64-encoded challenge issued by the server.
45
+ # @param [Hash] opts
46
+ # @option opts [String] :username Which username would you like to log in as.
47
+ # @option opts [String] :request_url Which page would you like to log in to.
48
+ # @option opts [OpenSSL::PKey::RSA] :private_key The private key with which to sign the challenge.
49
+ # @return [String] The Base64 encoded auth_request
50
+ def self.sign_challenge(challenge, opts)
51
+ Octokey::AuthRequest.generate({:challenge => challenge}.merge(opts)).to_s
52
+ end
53
+
54
+ # Handle a new Octokey request.
55
+ #
56
+ # The options passed in to this method will be passed in as the second parameter to
57
+ # all the configuration blocks.
58
+ #
59
+ # @param [String] auth_request The Base64 encoded auth request from the client.
60
+ # @param [Hash] opts
61
+ # @option opts [String] :username The username that the user wishes to log in as.
62
+ # @option opts [String,IPAddr] :client_ip The ip address of the client.
63
+ def initialize(auth_request, opts)
64
+ raise ArgumentError, "no :username given" unless opts[:username]
65
+ raise ArgumentError, "no :client_ip given" unless opts[:client_ip]
66
+ @opts = opts.dup
67
+ @auth_request = Octokey::Config.get_auth_request(auth_request, opts)
68
+ end
69
+
70
+ # Should the user be allowed to log in?
71
+ #
72
+ # If this method returns true then you can assume that the user with :username
73
+ # is actually the user who's trying to log in.
74
+ #
75
+ # @return [Boolean]
76
+ def can_log_in?
77
+ valid_auth_request? && valid_public_key?
78
+ end
79
+
80
+ # Should the user be allowed to sign up?
81
+ #
82
+ # If this method returns true then you can store the public_key against the
83
+ # user's username. Future logins by that user will use that to verify that the user
84
+ # trying to log in has access to the private key that corresponds to this public key.
85
+ #
86
+ # @return [Boolean]
87
+ def can_sign_up?
88
+ valid_auth_request?
89
+ end
90
+
91
+ # Was the failure to log in or sign up transient?
92
+ #
93
+ # This will return true if the client may be able to log in simply by requesting
94
+ # a new challenge from the server and retrying the auth_request.
95
+ #
96
+ # @return [Boolean]
97
+ def should_retry?
98
+ !valid_auth_request? && auth_request.valid_ignoring_challenge?(opts)
99
+ end
100
+
101
+ # Get the username used for this request.
102
+ #
103
+ # You must validate that the username meets your requirements for a valid username,
104
+ # Octokey allows any username (for example the empty string, the string "\r\n"). You might
105
+ # want to enforce that the username is an email address, or contains only visible characters.
106
+ #
107
+ # @return [String] username
108
+ # @raise [InvalidRequest] if neither .can_sign_up? nor .can_log_in?
109
+ def username
110
+ raise InvalidRequest, "Tried to get username from invalid octokey" unless valid_auth_request?
111
+ auth_request.username
112
+ end
113
+
114
+ # Get the public_key used for this request.
115
+ #
116
+ # You will need this when handling a sign up request for the user in order to
117
+ # extract the public key needed to log in.
118
+ #
119
+ # The format of the returned public key is exactly the same as used by SSH in the
120
+ # ~/.authorized_keys file. That format can be parsed by {Octokey::PublicKey} if you
121
+ # need more information.
122
+ #
123
+ # @return [String]
124
+ # @raise [InvalidRequest] if neither .can_sign_up? nor .can_log_in?
125
+ def public_key
126
+ raise InvalidRequest, "Tried to get username from invalid octokey" unless valid_auth_request?
127
+ auth_request.public_key.to_s
128
+ end
129
+
130
+ private
131
+ attr_accessor :opts, :auth_request
132
+
133
+ # Is the auth_request valid?
134
+ # @return [Boolean]
135
+ def valid_auth_request?
136
+ @valid ||= auth_request.valid?(opts)
137
+ end
138
+
139
+ # Is the public key used to sign the auth request one of those that belongs to the username?
140
+ # @return [Boolean]
141
+ def valid_public_key?
142
+ strings = Octokey::Config.get_public_keys(opts[:username], opts)
143
+ public_keys = strings.map{ |string| Octokey::PublicKey.from_string(string) }
144
+ unless public_keys.all?(&:valid?)
145
+ raise ArgumentError, "Invalid public key returned to Octokey for #{username}"
146
+ end
147
+ public_keys.include?(auth_request.public_key)
148
+ end
149
+ end
@@ -0,0 +1,210 @@
1
+ class Octokey
2
+ # An AuthRequest is sent by the client when it wants to log in or sign up.
3
+ #
4
+ # It includes an {Octokey::Challenge} so that we can verify its recency, and
5
+ # also the username the user wishes to log in as, the url that they wish to
6
+ # log in to, and the public key corresponding to their private key.
7
+ #
8
+ # You can create an Octokey::AuthRequest from any string, and later determine
9
+ # whether or not it was valid by calling {#valid?}
10
+ class AuthRequest
11
+ # The service name is used to check that the client knows which protocol it is speaking.
12
+ SERVICE_NAME = "octokey-auth"
13
+ # The auth method indicates that the client wants to use publickey authentication.
14
+ AUTH_METHOD = "publickey"
15
+ # The signing algorithm is copied straight from SSH.
16
+ SIGNING_ALGORITHM = "ssh-rsa"
17
+
18
+ attr_accessor :challenge_buffer, :request_url, :username, :service_name,
19
+ :auth_method, :signing_algorithm, :public_key, :signature_buffer,
20
+ :invalid_buffer
21
+
22
+ # Given a challenge and a private key, generate an auth request.
23
+ #
24
+ # @param [Hash] opts
25
+ # @option opts [String] :request_url
26
+ # @option opts [String] :username
27
+ # @option opts [String] :challenge The base64-encoded challenge
28
+ # @option opts [OpenSSL::PKey::RSA] :private_key
29
+ # @return [Octokey::AuthRequest]
30
+ def self.generate(opts)
31
+ private_key = opts[:private_key] or raise ArgumentError, "No private_key given"
32
+ challenge = opts[:challenge] or raise ArgumentError, "No challenge given"
33
+
34
+ new.instance_eval do
35
+ self.challenge_buffer = Octokey::Buffer.new(challenge)
36
+ self.request_url = opts[:request_url] or raise ArgumentError, "No request_url given"
37
+ self.username = opts[:username] or raise ArgumentError, "No username given"
38
+ self.service_name = SERVICE_NAME
39
+ self.auth_method = AUTH_METHOD
40
+ self.signing_algorithm = SIGNING_ALGORITHM
41
+ self.public_key = Octokey::PublicKey.from_key(private_key.public_key)
42
+ self.signature_buffer = signature_buffer_with(private_key)
43
+
44
+ self
45
+ end
46
+ end
47
+
48
+ # Parse an auth request sent from the client.
49
+ #
50
+ # @param[String] The base64-encoded auth request from the client.
51
+ # @return [Octokey::AuthRequest]
52
+ def self.from_string(string)
53
+ buffer = Octokey::Buffer.new(string)
54
+ new.instance_eval do
55
+ begin
56
+ self.challenge_buffer, self.request_url, self.username,
57
+ self.service_name, self.auth_method, self.signing_algorithm,
58
+ self.public_key, self.signature_buffer =
59
+ buffer.scan_all(
60
+ :buffer, :string, :string,
61
+ :string, :string, :string,
62
+ :public_key, :buffer)
63
+ rescue Octokey::InvalidBuffer => e
64
+ self.invalid_buffer = e.message
65
+ end
66
+
67
+ self
68
+ end
69
+ end
70
+
71
+ # Get any errors ignoring those caused by the challenge.
72
+ #
73
+ # @param [Hash] opts
74
+ # @return [Array<String>]
75
+ def errors_ignoring_challenge(opts)
76
+ return [invalid_buffer] if invalid_buffer
77
+ errors = []
78
+
79
+ errors += request_url_errors(opts)
80
+ errors << "Auth request username mismatch" unless username == opts[:username]
81
+ errors << "Auth request service name mismatch" unless service_name == SERVICE_NAME
82
+ errors << "Auth request auth method unsupported" unless auth_method == AUTH_METHOD
83
+ errors << "Auth request signing algorithm unsupported" unless signing_algorithm == SIGNING_ALGORITHM
84
+
85
+ if public_key.valid?
86
+ errors += signature_errors(public_key.public_key, signature_buffer.dup)
87
+ else
88
+ errors += public_key.errors
89
+ end
90
+
91
+ errors
92
+ end
93
+
94
+ # Get any errors caused by the challenge.
95
+ #
96
+ # @param [Hash] opts
97
+ # @return [Array<String>]
98
+ def challenge_errors(opts)
99
+ return [] if invalid_buffer
100
+ Octokey::Config.get_challenge(challenge_buffer.to_s, opts).errors(opts)
101
+ end
102
+
103
+ # Get all the error for this auth request.
104
+ #
105
+ # @param [Hash] opts
106
+ # @return [Array<String>]
107
+ def errors(opts)
108
+ errors_ignoring_challenge(opts) + challenge_errors(opts)
109
+ end
110
+
111
+ # If the challenge was valid, would this auth request be valid?
112
+ #
113
+ # This can be used to check whether the auth request should be retried.
114
+ #
115
+ # @param [Hash] opts
116
+ # @return [Boolean]
117
+ def valid_ignoring_challenge?(opts)
118
+ errors_ignoring_challenge(opts) == []
119
+ end
120
+
121
+ # Is this auth request valid?
122
+ #
123
+ # @param [Hash] opts
124
+ # @return [Boolean]
125
+ def valid?(opts)
126
+ errors(opts) == []
127
+ end
128
+
129
+ # Get the Base64-encoded version of this auth request.
130
+ #
131
+ # @return [String]
132
+ def to_s
133
+ unsigned_buffer.add_buffer(signature_buffer).to_s
134
+ end
135
+
136
+ # Get a string that identifies this auth request while debugging
137
+ #
138
+ # @return [String]
139
+ def inspect
140
+ "#<Octokey::AuthRequest #{to_s.inspect}>"
141
+ end
142
+
143
+ private
144
+
145
+ # What are the problems with the signature?
146
+ #
147
+ # @param [OpenSSL::PKey::RSA] key the public key
148
+ # @param [Octokey::Buffer] signature_buffer the signature buffer
149
+ # @return [Array<String>]
150
+ def signature_errors(key, signature_buffer)
151
+ algorithm_used, signature = signature_buffer.scan_all(:string, :varbytes)
152
+
153
+ errors = []
154
+ errors << "Signature type mismatch" unless algorithm_used == signing_algorithm
155
+ errors << "Signature mismatch" unless key.verify(OpenSSL::Digest::SHA1.new, signature, unsigned_buffer.raw)
156
+ errors
157
+
158
+ rescue Octokey::InvalidBuffer => e
159
+ ["Signature #{e.message}"]
160
+ end
161
+
162
+ # What are the problems with the request url?
163
+ #
164
+ # @param [Hash] opts
165
+ # @return [Array<String>]
166
+ def request_url_errors(opts)
167
+ url = URI.parse(request_url)
168
+
169
+ valid_hostname = Octokey::Config.valid_hostnames.any? do |hostname|
170
+ if hostname[/\A\*\.(.*)\z/]
171
+ url.host.end_with?($1)
172
+ else
173
+ url.host == hostname
174
+ end
175
+ end
176
+
177
+ errors = []
178
+ errors << "Request url insecure" unless url.scheme == "https"
179
+ errors << "Request url mismatch" unless valid_hostname
180
+ errors
181
+
182
+ rescue URI::InvalidURIError
183
+ ["Request url invalid"]
184
+ end
185
+
186
+ # Get the buffer containing everything other than the signature.
187
+ #
188
+ # @return [Octokey::Buffer]
189
+ def unsigned_buffer
190
+ Octokey::Buffer.new.
191
+ add_buffer(challenge_buffer).
192
+ add_string(request_url).
193
+ add_string(username).
194
+ add_string(service_name).
195
+ add_string(auth_method).
196
+ add_string(signing_algorithm).
197
+ add_public_key(public_key)
198
+ end
199
+
200
+ # Get the signature buffer using the given key.
201
+ #
202
+ # @param [OpenSSL::PKey::RSA] private_key
203
+ # @return [Octokey::Buffer]
204
+ def signature_buffer_with(private_key)
205
+ Octokey::Buffer.new.
206
+ add_string(SIGNING_ALGORITHM).
207
+ add_varbytes(private_key.sign(OpenSSL::Digest::SHA1.new, unsigned_buffer.raw))
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,344 @@
1
+ require 'base64'
2
+ class Octokey
3
+ # Buffers are used throughout Octokey to provide a bijective serialization format.
4
+ # For any valid buffer, there's exactly one valid object, and vice-versa.
5
+ #
6
+ # Mostly we used Base64-encoded buffers to avoid problems with potentially 8-bit
7
+ # unsafe channels. You should take care not to perform any operations on the Base64
8
+ # encoded form as there are many accepted formats for Base64-encoding a given string.
9
+ #
10
+ # In the current implementation, reading out of a buffer is a destructive operation,
11
+ # you should first .dup any buffer that you want to read more than once.
12
+ class Buffer
13
+ attr_accessor :buffer, :invalid_buffer
14
+
15
+ # to avoid DOS caused by duplicating enourmous buffers,
16
+ # we limit the maximum size of any string stored to 100k
17
+ MAX_STRING_SIZE = 100 * 1024
18
+
19
+ # Create a new buffer from raw bits.
20
+ #
21
+ # @param [String] raw
22
+ def self.from_raw(raw = "")
23
+ ret = new
24
+ ret.buffer = raw.dup
25
+ ret.buffer.force_encoding('BINARY') if ret.buffer.respond_to?(:force_encoding)
26
+ ret
27
+ end
28
+
29
+ # Create a new buffer from a Base64-encoded string.
30
+ # @param [String] string
31
+ def initialize(string = "")
32
+ self.buffer = Base64.decode64(string || "")
33
+ buffer.force_encoding('BINARY') if buffer.respond_to?(:force_encoding)
34
+ self.invalid_buffer = "Badly formatted Base64" unless to_s == string
35
+ end
36
+
37
+ # Get the underlying bits contained in this buffer.
38
+ # @return [String]
39
+ def raw
40
+ buffer
41
+ end
42
+
43
+ # Get the canonical Base64 representation of this buffer.
44
+ # @return [String]
45
+ def to_s
46
+ Base64.encode64(buffer).gsub("\n", "")
47
+ end
48
+
49
+ # Get a string that describes this buffer suitably for debugging.
50
+ # @return [String]
51
+ def inspect
52
+ "#<Octokey::Buffer @buffer=#{to_s.inspect}>"
53
+ end
54
+
55
+ # Is this buffer empty?
56
+ # @return [Boolean]
57
+ def empty?
58
+ buffer.empty?
59
+ end
60
+
61
+ # Add an unsigned 8-bit number to this buffer
62
+ # @param [Fixnum] x
63
+ # @return [Octokey::Buffer] self
64
+ # @raise [Octokey::InvalidBuffer] if x is not a uint8
65
+ def add_uint8(x)
66
+ raise InvalidBuffer, "Invalid uint8: #{x}" if x < 0 || x >= 2 ** 8
67
+ buffer << [x].pack("C")
68
+ self
69
+ end
70
+
71
+ # Destructively read an unsigned 8-bit number from this buffer
72
+ # @return [Fixnum]
73
+ # @raise [Octokey::InvalidBuffer]
74
+ def scan_uint8
75
+ scan(1).unpack("C").first
76
+ end
77
+
78
+ # Add a timestamp to this buffer
79
+ #
80
+ # Times are stored to millisecond precision, and are limited to
81
+ # 2 **48 to give plenty of margin for implementations using doubles
82
+ # as the backing for their date time, which nicely gives us a range
83
+ # ending just after the year 10000.
84
+ #
85
+ # @param [Time] time
86
+ # @return [Octokey::Buffer] self
87
+ # @raise [Octokey::InvalidBuffer] if the time is too far into the future
88
+ def add_time(time)
89
+ seconds, millis = [time.to_i, (time.usec / 1000.0).round]
90
+ raw = seconds * 1000 + millis
91
+ raise Octokey::InvalidBuffer, "Invalid time" if raw >= 2 ** 48
92
+ add_uint64(raw)
93
+ self
94
+ end
95
+
96
+ # Destructively read a timestamp from this buffer
97
+ #
98
+ # Times are stored to millisecond precision
99
+ #
100
+ # @return [Time]
101
+ # @raise [Octokey::InvalidBuffer]
102
+ def scan_time
103
+ raw = scan_uint64
104
+ raise Octokey::InvalidBuffer, "Invalid time" if raw >= 2 ** 48
105
+ seconds, millis = [raw / 1000, raw % 1000]
106
+ Time.at(seconds).utc + (millis / 1000.0)
107
+ end
108
+
109
+ # Add an IPv4 or IPv6 address to this buffer
110
+ #
111
+ # @param [IPAddr] ipaddr
112
+ # @return [Octokey::Buffer] self
113
+ # @raise [Octokey::InvalidBuffer] not a valid IP address
114
+ def add_ip(ipaddr)
115
+ if ipaddr.ipv4?
116
+ add_uint8(4)
117
+ buffer << ipaddr.hton
118
+ elsif ipaddr.ipv6?
119
+ add_uint8(6)
120
+ buffer << ipaddr.hton
121
+ else
122
+ raise InvalidBuffer, "Unsupported IP address: #{ipaddr.to_s}"
123
+ end
124
+ self
125
+ end
126
+
127
+ # Destructively read an IPv4 or IPv6 address from this buffer.
128
+ # @return [IPAddr]
129
+ # @raise [Octokey::InvalidBuffer]
130
+ def scan_ip
131
+ type = scan_uint8
132
+ case type
133
+ when 4
134
+ IPAddr.new_ntoh scan(4)
135
+ when 6
136
+ IPAddr.new_ntoh scan(16)
137
+ else
138
+ raise InvalidBuffer, "Unknown IP family: #{type.inspect}"
139
+ end
140
+ end
141
+
142
+ # Add a length-prefixed number of bytes to this buffer
143
+ # @param [String] bytes
144
+ # @return [Octokey::Buffer] self
145
+ # @raise [Octokey::InvalidBuffer] if there are too any bytes
146
+ def add_varbytes(bytes)
147
+ bytes.force_encoding('BINARY') if bytes.respond_to?(:force_encoding)
148
+ size = bytes.size
149
+ raise InvalidBuffer, "Too much length: #{size}" if size > MAX_STRING_SIZE
150
+ add_uint32 size
151
+ buffer << bytes
152
+ self
153
+ end
154
+
155
+ # Destructively read a length-prefixed number of bytes from this buffer
156
+ # @return [String] bytes
157
+ # @raise [Octokey::InvalidBuffer]
158
+ def scan_varbytes
159
+ size = scan_uint32
160
+ raise InvalidBuffer, "Too much length: #{size}" if size > MAX_STRING_SIZE
161
+ scan(size)
162
+ end
163
+
164
+ # Add a length-prefixed number of bytes of UTF-8 string to this buffer
165
+ # @param [String] string
166
+ # @return [Octokey::Buffer] self
167
+ # @raise [Octokey::InvalidBuffer] if the string is not utf-8
168
+ def add_string(string)
169
+ add_varbytes(validate_utf8(string))
170
+ end
171
+
172
+ # Destructively read a length-prefixed number of bytes of UTF-8 string
173
+ # @return [String] with encoding == 'utf-8' on ruby-1.9
174
+ # @raise [Octokey::InvalidBuffer]
175
+ def scan_string
176
+ validate_utf8(scan_varbytes)
177
+ end
178
+
179
+ # Add the length-prefixed contents of another buffer to this one.
180
+ # @param [Octokey::Buffer] buffer
181
+ # @return [Octokey::Buffer] self
182
+ # @raise [Octokey::InvalidBuffer]
183
+ def add_buffer(buffer)
184
+ add_varbytes buffer.raw
185
+ self
186
+ end
187
+
188
+ # Destrictively read a length-prefixed buffer out of this one.
189
+ # @return [Octokey::Buffer]
190
+ # @raise [Octokey::InvalidBuffer]
191
+ def scan_buffer
192
+ Octokey::Buffer.from_raw scan_varbytes
193
+ end
194
+
195
+ # Add an unsigned multi-precision integer to this buffer
196
+ # @param [OpenSSL::BN,Fixnum] x
197
+ # @return [Octokey::Buffer] self
198
+ # @raise [Octokey::InvalidBuffer] if x is negative or enourmous
199
+ def add_mpint(x)
200
+ raise InvalidBuffer, "Invalid mpint: #{mpint.inspect}" if x < 0
201
+ bytes = OpenSSL::BN.new(x.to_s, 10).to_s(2)
202
+ bytes = "\x00" + bytes if bytes.bytes.first >= 0x80
203
+ add_varbytes(bytes)
204
+ self
205
+ end
206
+
207
+ # Destructively read an unsigned multi-precision integer from this buffer
208
+ # @return [OpenSSL::BN]
209
+ # @raise [Octokey::InvalidBuffer]
210
+ def scan_mpint
211
+ raw = scan_varbytes
212
+
213
+ first, second = raw.bytes.first(2)
214
+
215
+ # ensure only positive numbers with no superflous leading 0s
216
+ if first >= 0x80 || first == 0x00 && second < 0x80
217
+ raise InvalidBuffer, "Badly formatted mpint"
218
+ end
219
+
220
+ OpenSSL::BN.new(raw, 2)
221
+ end
222
+
223
+ # Destructively read a public key from this buffer
224
+ #
225
+ # NOTE: the returned public key may not be valid, you must call
226
+ # .valid? on it before trying to use it.
227
+ #
228
+ # @return [Octokey::PublicKey]
229
+ # @raise [Octokey::InvalidBuffer]
230
+ def scan_public_key
231
+ Octokey::PublicKey.from_buffer(scan_buffer)
232
+ end
233
+
234
+ # Add a public key to this buffer
235
+ # @param [Octokey::PublicKey] public_key
236
+ # @return [Octokey::Buffer] self
237
+ # @raise [Octokey::InvalidBuffer]
238
+ def add_public_key(public_key)
239
+ add_buffer public_key.to_buffer
240
+ end
241
+
242
+ # Destructively read the entire buffer.
243
+ #
244
+ # It's strongly recommended that you use this method to parse buffers, as it
245
+ # remembers to verify that the buffer doesn't contain any trailing bytes; and
246
+ # will return nothing if the buffer is invalid, so your code doesn't have to
247
+ # deal with half-parsed buffers.
248
+ #
249
+ # The tokens should correspond to the scan_X methods defined here. For example:
250
+ # type, e, n = buffer.scan_all(:string, :mpint, :mpint)
251
+ # is equivalent to:
252
+ # type, e, n, _ = [buffer.scan_string, buffer.scan_mpint, buffer.scan_mpint,
253
+ # buffer.scan_end]
254
+ #
255
+ # @param [Array<Symbol>] tokens
256
+ # @return [Array<Object>]
257
+ # @raise [Octokey::InvalidBuffer]
258
+ def scan_all(*tokens)
259
+ ret = tokens.map do |token|
260
+ raise "invalid token type: #{token.inspect}" unless respond_to?("scan_#{token}")
261
+ send("scan_#{token}")
262
+ end
263
+
264
+ scan_end
265
+ ret
266
+ end
267
+
268
+ # Verify that the buffer has been completely scanned.
269
+ # @raise [Octokey::InvalidBuffer] if there is still buffer to read.
270
+ def scan_end
271
+ raise InvalidBuffer, "Buffer too long" unless empty?
272
+ end
273
+
274
+ private
275
+
276
+ # Destructively read bytes from the front of this buffer.
277
+ # @param [Fixnum] n
278
+ # @return [String]
279
+ # @raise [Octokey::InvalidBuffer]
280
+ def scan(n)
281
+ raise InvalidBuffer, invalid_buffer if invalid_buffer
282
+ ret, buf = [buffer[0...n], buffer[n..-1]]
283
+ if ret.size < n || !buf
284
+ raise InvalidBuffer, "Buffer too short"
285
+ end
286
+ self.buffer = buf
287
+ ret
288
+ end
289
+
290
+ # Add an unsigned 32-bit number to this buffer
291
+ # @param [Fixnum] x
292
+ # @return [Octokey::Buffer] self
293
+ # @raise [Octokey::InvalidBuffer] if x is not a uint32
294
+ def add_uint32(x)
295
+ raise InvalidBuffer, "Invalid uint32: #{x}" if x < 0 || x >= 2 ** 32
296
+ buffer << [x].pack("N")
297
+ self
298
+ end
299
+
300
+ # Destructively read an unsigned 32-bit number from this buffer
301
+ # @return [Fixnum]
302
+ # @raise [Octokey::InvalidBuffer]
303
+ def scan_uint32
304
+ scan(4).unpack("N").first
305
+ end
306
+
307
+ # Add an unsigned 64-bit number to this buffer
308
+ # @param [Fixnum] x
309
+ # @return [Octokey::Buffer] self
310
+ # @raise [Octokey::InvalidBuffer] if x is not a uint64
311
+ def add_uint64(x)
312
+ raise InvalidBuffer, "Invalid uint64: #{x}" if x < 0 || x >= 2 ** 64
313
+ add_uint32(x >> 32 & 0xffff_ffff)
314
+ add_uint32(x & 0xffff_ffff)
315
+ self
316
+ end
317
+
318
+ # Destructively read an unsigned 64-bit number from this buffer
319
+ # @return [Fixnum]
320
+ # @raise [Octokey::InvalidBuffer]
321
+ def scan_uint64
322
+ (scan_uint32 << 32) + scan_uint32
323
+ end
324
+
325
+ # Check whether a string is valid utf-8
326
+ # @param [String] string
327
+ # @return [String] string
328
+ # @raise [Octokey::InvalidBuffer] invalid utf-8
329
+ def validate_utf8(string)
330
+ if string.respond_to?(:force_encoding)
331
+ string.force_encoding('UTF-8')
332
+ raise InvalidBuffer, "String not UTF-8" unless string.valid_encoding?
333
+ string
334
+ else
335
+ require 'iconv'
336
+ begin
337
+ Iconv.conv('utf-8', 'utf-8', string)
338
+ rescue Iconv::Failure
339
+ raise InvalidBuffer, "String not UTF-8"
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
@@ -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
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octokey
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: 4
5
+ version: 0.1.pre.2
6
+ platform: jruby
7
+ authors:
8
+ - Conrad Irwin
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2012-07-30 00:00:00 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rspec
17
+ prerelease: false
18
+ requirement: &id001 !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ type: :development
25
+ version_requirements: *id001
26
+ - !ruby/object:Gem::Dependency
27
+ name: active_support
28
+ prerelease: false
29
+ requirement: &id002 !ruby/object:Gem::Requirement
30
+ none: false
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ type: :development
36
+ version_requirements: *id002
37
+ - !ruby/object:Gem::Dependency
38
+ name: i18n
39
+ prerelease: false
40
+ requirement: &id003 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: "0"
46
+ type: :development
47
+ version_requirements: *id003
48
+ - !ruby/object:Gem::Dependency
49
+ name: simplecov
50
+ prerelease: false
51
+ requirement: &id004 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: "0"
57
+ type: :development
58
+ version_requirements: *id004
59
+ - !ruby/object:Gem::Dependency
60
+ name: jruby-openssl
61
+ prerelease: false
62
+ requirement: &id005 !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: "0"
68
+ type: :runtime
69
+ version_requirements: *id005
70
+ description: Allows you to use secure authentication mechanisms in place of passwords
71
+ email: conrad.irwin@gmail.com
72
+ executables: []
73
+
74
+ extensions: []
75
+
76
+ extra_rdoc_files: []
77
+
78
+ files:
79
+ - lib/octokey.rb
80
+ - lib/octokey/auth_request.rb
81
+ - lib/octokey/buffer.rb
82
+ - lib/octokey/challenge.rb
83
+ - lib/octokey/config.rb
84
+ - lib/octokey/public_key.rb
85
+ homepage: https://github.com/octokey/octokey-gem
86
+ licenses: []
87
+
88
+ post_install_message:
89
+ rdoc_options: []
90
+
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: "0"
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ">"
103
+ - !ruby/object:Gem::Version
104
+ version: 1.3.1
105
+ requirements: []
106
+
107
+ rubyforge_project:
108
+ rubygems_version: 1.8.24
109
+ signing_key:
110
+ specification_version: 3
111
+ summary: Public key authentication for the web!
112
+ test_files: []
113
+