octokey 0.1.pre.1

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.
Files changed (3) hide show
  1. data/lib/octokey/buffer.rb +179 -0
  2. data/lib/octokey.rb +366 -0
  3. metadata +46 -0
@@ -0,0 +1,179 @@
1
+ require 'base64'
2
+ class Octokey
3
+ class Buffer
4
+ attr_accessor :buffer, :pos
5
+
6
+ # to avoid DOS caused by duplicating enourmous buffers,
7
+ # we limit the maximum size of any string stored to 100k
8
+ MAX_STRING_SIZE = 100 * 1024
9
+
10
+ def self.from_raw(raw = "")
11
+ ret = new
12
+ ret.buffer = raw.dup
13
+ ret.buffer.force_encoding('BINARY') if ret.buffer.respond_to?(:force_encoding)
14
+ ret
15
+ end
16
+
17
+ def initialize(string = "")
18
+ self.buffer = Base64.decode64(string || "")
19
+ self.pos = 0
20
+ buffer.force_encoding('BINARY') if @buffer.respond_to?(:force_encoding)
21
+ end
22
+
23
+ def raw
24
+ buffer
25
+ end
26
+
27
+ def empty?
28
+ buffer.empty?
29
+ end
30
+
31
+ def to_s
32
+ Base64.encode64(buffer).gsub("\n", "")
33
+ end
34
+
35
+ def <<(bytes)
36
+ buffer << bytes
37
+ end
38
+
39
+ def scan(n)
40
+ ret, buf = [buffer[0...n], buffer[n..-1]]
41
+ if ret.size < n || !buf
42
+ raise InvalidBuffer, "Tried to read beyond end of buffer"
43
+ end
44
+ self.buffer = buf
45
+ ret
46
+ end
47
+
48
+ def add_uint8(x)
49
+ raise InvalidBuffer, "Invalid uint8: #{x}" if x < 0 || x >= 2 ** 8
50
+ buffer << [x].pack("C")
51
+ end
52
+
53
+ def scan_uint8
54
+ scan(1).unpack("C").first
55
+ end
56
+
57
+ def add_uint32(x)
58
+ raise InvalidBuffer, "Invalid uint32: #{x}" if x < 0 || x >= 2 ** 32
59
+ buffer << [x].pack("N")
60
+ end
61
+
62
+ def scan_uint32
63
+ scan(4).unpack("N").first
64
+ end
65
+
66
+ def add_uint64(x)
67
+ raise InvalidBuffer, "Invalid uint64: #{x}" if x < 0 || x >= 2 ** 64
68
+ add_uint32(x >> 32 & 0xffff_ffff)
69
+ add_uint32(x & 0xffff_ffff)
70
+ end
71
+
72
+ def scan_uint64
73
+ (scan_uint32 << 32) + scan_uint32
74
+ end
75
+
76
+ def add_uint128(x)
77
+ raise InvalidBuffer, "Invalid uint128: #{x}" if x < 0 || x >= 2 ** 128
78
+ add_uint64(x >> 64 & 0xffff_ffff_ffff_ffff)
79
+ add_uint64(x & 0xffff_ffff_ffff_ffff)
80
+ end
81
+
82
+ def scan_uint128
83
+ (scan_uint64 << 64) + scan_uint64
84
+ end
85
+
86
+ def add_time(time)
87
+ add_uint64((time.to_f * 1000).to_i)
88
+ end
89
+
90
+ def scan_time
91
+ Time.at(scan_uint64.to_f / 1000)
92
+ end
93
+
94
+ def add_ip(ipaddr)
95
+ if ipaddr.ipv4?
96
+ add_uint8(4)
97
+ add_uint32(ipaddr.to_i)
98
+ elsif ipaddr.ipv6?
99
+ add_uint8(6)
100
+ add_uint128(ipaddr.to_i)
101
+ else
102
+ raise InvalidBuffer, "Unsupported IP address: #{ipaddr.to_s}"
103
+ end
104
+ end
105
+
106
+ def scan_ip
107
+ type = scan_uint8
108
+ case type
109
+ when 4
110
+ IPAddr.new_ntoh scan(4)
111
+ when 6
112
+ IPAddr.new_ntoh scan(16)
113
+ else
114
+ raise InvalidBuffer, "Unsupported IP address family: #{type}"
115
+ end
116
+ end
117
+
118
+ def add_varbytes(bytes)
119
+ size = bytes.size
120
+ raise InvalidBuffer, "String too long: #{size}" if size > MAX_STRING_SIZE
121
+ add_uint32 size
122
+ self << bytes
123
+ end
124
+
125
+ def scan_varbytes
126
+ size = scan_uint32
127
+ raise InvalidBuffer, "String too long: #{size}" if size > MAX_STRING_SIZE
128
+ scan(size)
129
+ end
130
+
131
+ def add_string(string)
132
+ if string.respond_to?(:encode)
133
+ add_varbytes string.encode('BINARY')
134
+ else
135
+ add_varbytes string
136
+ end
137
+ end
138
+
139
+ def scan_string
140
+ string = scan_varbytes
141
+ if string.respond_to?(:encode)
142
+ string.encode('UTF-8')
143
+ else
144
+ string
145
+ end
146
+ rescue EncodingError => e
147
+ raise InvalidBuffer, e
148
+ end
149
+
150
+ def add_buffer(buffer)
151
+ add_varbytes buffer.raw
152
+ end
153
+
154
+ def scan_buffer
155
+ Octokey::Buffer.from_raw scan_varbytes
156
+ end
157
+
158
+ def add_mpint(x)
159
+ raise InvalidBuffer, "Got negative mpint" if x < 0
160
+ bytes = OpenSSL::BN.new(x.to_s, 10).to_s(2)
161
+ bytes = "\x00" + bytes if bytes.bytes.first >= 0x80
162
+ add_varbytes(bytes)
163
+ end
164
+
165
+ def scan_mpint
166
+ bytes = scan_varbytes
167
+
168
+ if bytes.bytes.first >= 0x80
169
+ raise InvalidBuffer, "Got negative mpint"
170
+ end
171
+
172
+ OpenSSL::BN.new(bytes, 2)
173
+ end
174
+
175
+ def inspect
176
+ "#<Octokey::Buffer @buffer=#{to_s.inspect}>"
177
+ end
178
+ end
179
+ end
data/lib/octokey.rb ADDED
@@ -0,0 +1,366 @@
1
+ require File.expand_path('octokey/buffer', File.dirname(__FILE__))
2
+ require 'ipaddr'
3
+ require 'securerandom'
4
+ require 'uri'
5
+
6
+ class Octokey
7
+
8
+ CHALLENGE_VERSION = 2
9
+ SERVICE_NAME = "octokey-auth"
10
+ AUTH_METHOD = "publickey"
11
+ SIGNING_ALGORITHM = "ssh-rsa"
12
+ DIGEST_ALGORITHM = "SHA1"
13
+ SSH_RSA_MINIMUM_MODULUS_SIZE = 768
14
+
15
+ class InvalidRequest < StandardError; end
16
+ class InvalidBuffer < InvalidRequest; end
17
+
18
+ # Set the hmac_secret for Octokey.
19
+ #
20
+ # This is used to sign challenges to prove that they were issued by us.
21
+ #
22
+ # You can generate a suitable token to use as an hmac_secret from the
23
+ # command line:
24
+ #
25
+ # $ head -c 48 /dev/urandom | base64
26
+ #
27
+ # @param [String] hmac_secret
28
+ def self.hmac_secret=(hmac_secret)
29
+ @hmac_secret = hmac_secret
30
+ @hmac_secret_fingerprint = nil
31
+ end
32
+
33
+ # Get a challenge for signing in.
34
+ #
35
+ # The client will include this challenge in their octokey auth request when
36
+ # they log in. It hopefully provides some security against replay attacks by
37
+ # ensuring that if a signed auth-request is stolen, it is only valid in a
38
+ # limited set of circumstances.
39
+ #
40
+ # Please be careful when obtaining a client IP address that you aren't getting
41
+ # the IP address of an upstream proxy, and that you aren't trusting X-Forwarded-For
42
+ # headers that you shouldn't be.
43
+ #
44
+ # @option opts [String] :client_ip The IP address of the current client.
45
+ # @option opts [Time] :time (Time.now)
46
+ #
47
+ # @return [String]
48
+ def self.new_challenge(opts = {})
49
+ client_ip = opts[:client_ip] or raise ArgumentError, "No :client_ip given to new_challenge_for"
50
+ time = opts[:time] || Time.now
51
+
52
+ client_ip = IPAddr.new(client_ip.to_s)
53
+ buffer = Octokey::Buffer.new
54
+
55
+ buffer.add_uint8 Octokey::CHALLENGE_VERSION
56
+ buffer.add_uint8 Octokey.hmac_secret_fingerprint
57
+ buffer.add_time time
58
+ buffer.add_ip client_ip
59
+ buffer.add_varbytes SecureRandom.random_bytes(32)
60
+ buffer.add_varbytes OpenSSL::HMAC.digest("sha1", Octokey.hmac_secret, buffer.raw)
61
+
62
+ buffer.to_s
63
+ end
64
+
65
+ # Attempt to login with the given auth_request.
66
+ #
67
+ # @param [String] auth_request The string sent by the Octokey client.
68
+ # @option opts [String] :client_ip The IP address of the client (see {.new_challenge)}
69
+ # @option opts [Array<String>] :valid_hostnames The list of hostnames which clients may
70
+ # log in from.
71
+ # @option opts [Time] :time (Time.now)
72
+ #
73
+ # @yield [String] username The block should (when given a username) return a list of
74
+ # public keys that are associated with that users account.
75
+ #
76
+ # NOTE: Do not assume that the username passed to the block
77
+ # is logged in. The block is necessarily called before we know
78
+ # this.
79
+ #
80
+ # @return [String] username The user who successfully authenticated.
81
+ # @raise [InvalidRequest] If the login failed for some reason.
82
+ def self.login(auth_request, opts = {}, &block)
83
+ client_ip = opts[:client_ip] or raise ArgumentError, "No :client_ip given to login"
84
+ hostnames = opts[:valid_hostnames] or raise ArgumentError, "No :valid_hostnames given to login"
85
+ time = opts[:time] || Time.now
86
+ raise ArgumentError, "No public key lookup block given to login" unless block_given?
87
+
88
+ buffer = Octokey::Buffer.new(auth_request)
89
+
90
+ challenge = buffer.scan_string rescue ""
91
+ request_url = buffer.scan_string
92
+ username = buffer.scan_string
93
+ service_name = buffer.scan_string
94
+ auth_method = buffer.scan_string
95
+ signing_alg = buffer.scan_string
96
+ public_key_b = buffer.scan_buffer
97
+ signature_b = buffer.scan_buffer
98
+
99
+ valid_public_keys = block.call(username)
100
+ valid_public_keys.map!{ |public_key| format_public_key(unformat_public_key(public_key)) }
101
+
102
+ public_key, errors = decode_public_key(public_key_b, "ssh-rsa")
103
+ signature, sig_errors = decode_signature(signature_b, signing_alg)
104
+
105
+ errors += sig_errors
106
+ errors += validate_challenge(challenge, client_ip, time)
107
+
108
+ hostname = URI.parse(request_url).host
109
+
110
+ to_verify = Octokey::Buffer.new
111
+ to_verify.add_string challenge
112
+ to_verify.add_string request_url
113
+ to_verify.add_string username
114
+ to_verify.add_string service_name
115
+ to_verify.add_string auth_method
116
+ to_verify.add_string signing_alg
117
+ to_verify.add_buffer public_key_b
118
+
119
+ expected_digest = OpenSSL::Digest::SHA1.digest(to_verify.raw)
120
+ raw_asn1 = public_key.public_decrypt(signature)
121
+
122
+ errors += validate_digest(raw_asn1, expected_digest)
123
+
124
+ unless buffer.empty?
125
+ errors << "Request contained trailing bytes"
126
+ end
127
+
128
+ unless hostnames.include?(hostname)
129
+ errors << "Request was for unknown hostname: #{hostname.inspect}"
130
+ end
131
+
132
+ unless service_name == SERVICE_NAME
133
+ errors << "Incorrect service name: Got #{service_name.inspect}, expected: #{SERVICE_NAME.inspect}"
134
+ end
135
+
136
+ unless auth_method == AUTH_METHOD
137
+ errors << "Incorrect auth type: Got #{auth_method.inspect}, expected: #{AUTH_TYPE.inspect}"
138
+ end
139
+
140
+ unless signing_alg == SIGNING_ALGORITHM
141
+ errors << "Incorrect signing algorithm: Got #{signing_alg.inspect}, expected: #{SIGNING_ALGORITHM.inspect}"
142
+ end
143
+
144
+ unless valid_public_keys.include?(format_public_key(public_key))
145
+ errors << "Got unknown public key for #{username.inspect}: #{format_public_key(public_key).inspect}"
146
+ end
147
+
148
+ unless errors.empty?
149
+ raise InvalidRequest.new("Octokey request failed: #{errors.join(". ")}.")
150
+ end
151
+
152
+ username
153
+ end
154
+
155
+ private
156
+
157
+ def self.hmac_secret
158
+ @hmac_secret or raise "No Octokey.hmac_secret set."
159
+ end
160
+
161
+ def self.hmac_secret_fingerprint
162
+ @hmac_secret_fingerprint ||= OpenSSL::Digest::SHA1.digest(hmac_secret).bytes.first
163
+ end
164
+
165
+ def self.validate_challenge(challenge, expected_ip, expected_time = Time.now)
166
+ buffer = Octokey::Buffer.new challenge
167
+ errors = []
168
+
169
+ version = buffer.scan_uint8
170
+ if version != Octokey::CHALLENGE_VERSION
171
+ errors << "Challenge Version number is incorrect: Got #{version} expected #{Octokey::CHALLENGE_VERSION}"
172
+ end
173
+
174
+ fingerprint = buffer.scan_uint8
175
+ if fingerprint != hmac_secret_fingerprint
176
+ errors << "Challenge HMAC was signed with a different secret: Got #{fingerprint.inspect}, expected: #{hmac_secret_fingerprint.inspect}"
177
+ end
178
+
179
+ time = buffer.scan_time
180
+ puts time.inspect
181
+ if expected_time - time > 5 * 60
182
+ errors << "Challenge Timestamp is too dated: Got #{time.to_i}, expected > #{expected_time.to_i - 5 * 60}"
183
+ end
184
+
185
+ if time - expected_time > 5
186
+ errors << "Challenge Timestamp is too new: Got #{time.to_i}, expected < #{expected_time.to_i + 5}"
187
+ end
188
+
189
+ client_ip = buffer.scan_ip
190
+ if client_ip != expected_ip
191
+ errors << "Client IP address mismatch: Got #{client_ip}, expected: #{expected_ip}"
192
+ end
193
+
194
+ random = buffer.scan_varbytes
195
+ hmac = buffer.scan_varbytes
196
+
197
+ if !buffer.empty?
198
+ errors << "Challenge contained trailing bytes"
199
+ end
200
+
201
+ rehash = Octokey::Buffer.new
202
+ rehash.add_uint8 version
203
+ rehash.add_uint8 fingerprint
204
+ rehash.add_time time
205
+ rehash.add_ip client_ip
206
+ rehash.add_varbytes random
207
+ expected_hmac = OpenSSL::HMAC.digest("sha1", Octokey.hmac_secret, rehash.raw)
208
+
209
+ if hmac != expected_hmac
210
+ errors << "Challenge HMAC was incorrect: Got #{hmac.inspect}, expected: #{expected_hmac.inspect}"
211
+ end
212
+
213
+ errors
214
+ rescue InvalidBuffer => e
215
+ errors + [e.message]
216
+ end
217
+
218
+ def self.sign_challenge(challenge, username, request_url, private_key)
219
+ to_sign = Octokey::Buffer.new
220
+ to_sign.add_string challenge
221
+ to_sign.add_string request_url
222
+ to_sign.add_string username
223
+ to_sign.add_string SERVICE_NAME
224
+ to_sign.add_string AUTH_METHOD
225
+ to_sign.add_string SIGNING_ALGORITHM
226
+ to_sign.add_buffer encode_public_key(private_key)
227
+
228
+ digest = OpenSSL::Digest::SHA1.digest(to_sign.raw)
229
+ sigblob = private_key.private_encrypt(digest_into_asn1(digest))
230
+
231
+ sig_buf = Octokey::Buffer.new
232
+ sig_buf.add_string SIGNING_ALGORITHM
233
+ sig_buf.add_string sigblob
234
+
235
+ to_sign.add_buffer(sig_buf)
236
+
237
+ to_sign.to_s
238
+ end
239
+
240
+ # TODO: replace this by a call to OpenSSL
241
+ def self.digest_into_asn1(digest)
242
+ raise "not a valid digest" unless digest.size == 20
243
+ "0!0\t\x06\x05+\x0E\x03\x02\x1A\x05\x00\x04\x14#{digest}"
244
+ end
245
+
246
+ # TODO: replace this by a call to OpenSSL
247
+ #
248
+ # The ASN1 structure should look like this:
249
+ #
250
+ # [
251
+ # ["SHA-1", nil],
252
+ # "digestdigestdigest.."
253
+ # ]
254
+ def self.validate_digest(raw_asn1, expected_digest)
255
+ asn1 = OpenSSL::ASN1.decode(raw_asn1)
256
+ errors = []
257
+
258
+ unless asn1.tag == 16 && asn1.value.size == 2
259
+ return ["Invalid asn1 was signed"]
260
+ end
261
+
262
+ algorithm_asn1 = asn1.value[0]
263
+ digest_asn1 = asn1.value[1]
264
+
265
+ unless digest_asn1.tag == 4
266
+ return ["Invalid digest_asn1 was signed"]
267
+ end
268
+
269
+ unless algorithm_asn1.tag == 16 && algorithm_asn1.value.size <= 2
270
+ return ["Invalid algorithm_asn1 was signed"]
271
+ end
272
+
273
+ unless algorithm_asn1.value[0].value == DIGEST_ALGORITHM
274
+ errors << "Incorrect digest algorithm: Got #{algorithm_asn1.value[0].value}, expected: #{DIGEST_ALGORITHM.inspect}"
275
+ end
276
+
277
+ unless algorithm_asn1.value.size == 1 || algorithm_asn1.value[1].is_a?(OpenSSL::ASN1::Null)
278
+ errors << "Invalid parameters passed to SHA1 digest, expected NULL."
279
+ end
280
+
281
+ unless digest_asn1.value == expected_digest
282
+ errors << "Incorrect message digest"
283
+ end
284
+
285
+ return errors
286
+ end
287
+
288
+ def self.decode_signature(buffer, expected_alg)
289
+ buffer = buffer.dup
290
+
291
+ signing_alg = buffer.scan_string
292
+ signature = buffer.scan_varbytes
293
+
294
+ errors = []
295
+
296
+ unless buffer.empty?
297
+ errors << "Signature contained trailing bytes"
298
+ end
299
+
300
+ unless signing_alg == expected_alg
301
+ errors << "Signature algorithm mismatch: Got #{signing_alg.inspect}, expected: #{expected_alg.inspect}"
302
+ end
303
+
304
+ [signature, errors]
305
+ rescue InvalidBuffer => e
306
+ [nil, e.message]
307
+ end
308
+
309
+ def self.format_public_key(public_key)
310
+ "ssh-rsa #{encode_public_key(public_key).to_s}"
311
+ end
312
+
313
+ def self.unformat_public_key(public_key)
314
+ if public_key =~ /\A(ssh-rsa)\s+(.*)\z/
315
+ key, errors = decode_public_key(Octokey::Buffer.new($2), $1)
316
+ raise "Invalid public key: #{errors.join(". ")}." unless errors.empty?
317
+
318
+ key
319
+ else
320
+ raise "Invalid public key: Got #{public_key.inspect}, expected \"ssh-rsa AAAAf...\""
321
+ end
322
+ end
323
+
324
+ def self.encode_public_key(public_key)
325
+ raise "not an RSA key: #{public_key}" unless OpenSSL::PKey::RSA === public_key
326
+ buffer = Octokey::Buffer.new
327
+ buffer.add_string "ssh-rsa"
328
+ buffer.add_mpint public_key.e
329
+ buffer.add_mpint public_key.n
330
+ buffer
331
+ end
332
+
333
+ def self.decode_public_key(buffer, expected_type)
334
+ buffer = buffer.dup
335
+
336
+ key_type = buffer.scan_string
337
+ e = buffer.scan_mpint
338
+ n = buffer.scan_mpint
339
+
340
+ errors = []
341
+
342
+ unless buffer.empty?
343
+ errors << "Public key contained trailing bytes"
344
+ end
345
+
346
+ unless key_type == expected_type
347
+ errors << "Got unknown public key type: Got #{key_type.inspect}, expected: #{expected_type.inspect}"
348
+ end
349
+
350
+ unless n.num_bits > SSH_RSA_MINIMUM_MODULUS_SIZE
351
+ errors << "RSA modulus too small: #{n.num_bits.inspect} < #{SSH_RSA_MINIMUM_MODULUS_SIZE.inspect}"
352
+ end
353
+
354
+ # TODO: verify size of modulus and exponent
355
+
356
+ if errors == []
357
+ key = OpenSSL::PKey::RSA.new
358
+ key.e = e
359
+ key.n = n
360
+ end
361
+
362
+ [key, errors]
363
+ rescue InvalidBuffer => e
364
+ [nil, e.message]
365
+ end
366
+ end
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octokey
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.pre.1
5
+ prerelease: 4
6
+ platform: ruby
7
+ authors:
8
+ - Conrad Irwin
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-06-25 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: Allows you to use secure authentication mechanisms in plcae of passwords
15
+ email: conrad.irwin@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/octokey.rb
21
+ - lib/octokey/buffer.rb
22
+ homepage: https://github.com/octokey/octokey-gem
23
+ licenses: []
24
+ post_install_message:
25
+ rdoc_options: []
26
+ require_paths:
27
+ - lib
28
+ required_ruby_version: !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ! '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ required_rubygems_version: !ruby/object:Gem::Requirement
35
+ none: false
36
+ requirements:
37
+ - - ! '>'
38
+ - !ruby/object:Gem::Version
39
+ version: 1.3.1
40
+ requirements: []
41
+ rubyforge_project:
42
+ rubygems_version: 1.8.24
43
+ signing_key:
44
+ specification_version: 3
45
+ summary: Public key authentication for the web!
46
+ test_files: []