octokey 0.1.pre.2-jruby

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.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
+