octokey 0.1.pre.2 → 0.1.pre.3

Sign up to get free protection for your applications and to get access to all the features.
data/lib/octokey.rb CHANGED
@@ -1,390 +1,149 @@
1
- require File.expand_path('octokey/buffer', File.dirname(__FILE__))
2
- require 'ipaddr'
1
+ require 'openssl'
3
2
  require 'securerandom'
3
+ require 'ipaddr'
4
4
  require 'uri'
5
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
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__))
14
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.
15
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.
16
22
  class InvalidBuffer < InvalidRequest; end
17
23
 
18
- # Set the hmac_secret for Octokey.
24
+ # Create a new challenge.
19
25
  #
20
- # This is used to sign challenges to prove that they were issued by us.
26
+ # Once created, the challenge should be sent to the client for it to sign.
21
27
  #
22
- # You can generate a suitable token to use as an hmac_secret from the
23
- # command line:
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.
24
30
  #
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
+ # @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
31
36
  end
32
37
 
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.
38
+ # Sign a challenge.
39
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.
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
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
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
63
52
  end
64
53
 
65
- # Attempt to login with the given auth_request.
54
+ # Handle a new Octokey request.
66
55
  #
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)
56
+ # The options passed in to this method will be passed in as the second parameter to
57
+ # all the configuration blocks.
72
58
  #
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
- raise ArgumentError, "No public key lookup block given to login" unless block_given?
84
-
85
- username, public_key = signup(auth_request, opts)
86
- valid_public_keys = block.call(username)
87
- valid_public_keys.map!{ |public_key| format_public_key(unformat_public_key(public_key)) }
88
-
89
- unless valid_public_keys.include? public_key
90
- raise InvalidRequest, "Got unknown public key for #{username.inspect}: #{format_public_key(public_key).inspect}"
91
- end
92
-
93
- username
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)
94
68
  end
95
69
 
96
- # Validate a signup request.
97
- #
98
- # @param [String] auth_request The string sent by the Octokey client.
99
- # @option opts [String] :client_ip The IP address of the client (see {.new_challenge)}
100
- # @option opts [Array<String>] :valid_hostnames The list of hostnames which clients may
101
- # log in from.
102
- # @option opts [Time] :time (Time.now)
103
- #
104
- # @yield [String] username The block should (when given a username) return a list of
105
- # public keys that are associated with that users account.
70
+ # Should the user be allowed to log in?
106
71
  #
107
- # NOTE: Do not assume that the username passed to the block
108
- # is logged in. The block is necessarily called before we know
109
- # this.
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.
110
74
  #
111
- # @return [String] username The username they tried to sign up with.
112
- # @return [String] public_key Their public key
113
- # @raise [InvalidRequest] If the login failed for some reason.
114
- def self.signup(auth_request, opts = {})
115
- client_ip = opts[:client_ip] or raise ArgumentError, "No :client_ip given to login"
116
- hostnames = opts[:valid_hostnames] or raise ArgumentError, "No :valid_hostnames given to login"
117
- time = opts[:time] || Time.now
118
-
119
- buffer = Octokey::Buffer.new(auth_request)
120
-
121
- challenge = buffer.scan_string rescue ""
122
- request_url = buffer.scan_string
123
- username = buffer.scan_string
124
- service_name = buffer.scan_string
125
- auth_method = buffer.scan_string
126
- signing_alg = buffer.scan_string
127
- public_key_b = buffer.scan_buffer
128
- signature_b = buffer.scan_buffer
129
-
130
- public_key, errors = decode_public_key(public_key_b, "ssh-rsa")
131
- signature, sig_errors = decode_signature(signature_b, signing_alg)
132
-
133
- errors += sig_errors
134
- errors += validate_challenge(challenge, client_ip, time)
135
-
136
- hostname = URI.parse(request_url).host
137
-
138
- to_verify = Octokey::Buffer.new
139
- to_verify.add_string challenge
140
- to_verify.add_string request_url
141
- to_verify.add_string username
142
- to_verify.add_string service_name
143
- to_verify.add_string auth_method
144
- to_verify.add_string signing_alg
145
- to_verify.add_buffer public_key_b
146
-
147
- expected_digest = OpenSSL::Digest::SHA1.digest(to_verify.raw)
148
- raw_asn1 = public_key.public_decrypt(signature)
149
-
150
- errors += validate_digest(raw_asn1, expected_digest)
151
-
152
- unless buffer.empty?
153
- errors << "Request contained trailing bytes"
154
- end
155
-
156
- unless hostnames.include?(hostname)
157
- errors << "Request was for unknown hostname: #{hostname.inspect}"
158
- end
159
-
160
- unless service_name == SERVICE_NAME
161
- errors << "Incorrect service name: Got #{service_name.inspect}, expected: #{SERVICE_NAME.inspect}"
162
- end
163
-
164
- unless auth_method == AUTH_METHOD
165
- errors << "Incorrect auth type: Got #{auth_method.inspect}, expected: #{AUTH_TYPE.inspect}"
166
- end
167
-
168
- unless signing_alg == SIGNING_ALGORITHM
169
- errors << "Incorrect signing algorithm: Got #{signing_alg.inspect}, expected: #{SIGNING_ALGORITHM.inspect}"
170
- end
171
-
172
- unless errors.empty?
173
- raise InvalidRequest.new("Octokey request failed: #{errors.join(". ")}.")
174
- end
175
-
176
- [username, format_public_key(public_key)]
177
- end
178
-
179
- private
180
-
181
- def self.hmac_secret
182
- @hmac_secret or raise "No Octokey.hmac_secret set."
183
- end
184
-
185
- def self.hmac_secret_fingerprint
186
- @hmac_secret_fingerprint ||= OpenSSL::Digest::SHA1.digest(hmac_secret).bytes.first
75
+ # @return [Boolean]
76
+ def can_log_in?
77
+ valid_auth_request? && valid_public_key?
187
78
  end
188
79
 
189
- def self.validate_challenge(challenge, expected_ip, expected_time = Time.now)
190
- buffer = Octokey::Buffer.new challenge
191
- errors = []
192
-
193
- version = buffer.scan_uint8
194
- if version != Octokey::CHALLENGE_VERSION
195
- errors << "Challenge Version number is incorrect: Got #{version} expected #{Octokey::CHALLENGE_VERSION}"
196
- end
197
-
198
- fingerprint = buffer.scan_uint8
199
- if fingerprint != hmac_secret_fingerprint
200
- errors << "Challenge HMAC was signed with a different secret: Got #{fingerprint.inspect}, expected: #{hmac_secret_fingerprint.inspect}"
201
- end
202
-
203
- time = buffer.scan_time
204
- puts time.inspect
205
- if expected_time - time > 5 * 60
206
- errors << "Challenge Timestamp is too dated: Got #{time.to_i}, expected > #{expected_time.to_i - 5 * 60}"
207
- end
208
-
209
- if time - expected_time > 5
210
- errors << "Challenge Timestamp is too new: Got #{time.to_i}, expected < #{expected_time.to_i + 5}"
211
- end
212
-
213
- client_ip = buffer.scan_ip
214
- if client_ip != expected_ip
215
- errors << "Client IP address mismatch: Got #{client_ip}, expected: #{expected_ip}"
216
- end
217
-
218
- random = buffer.scan_varbytes
219
- hmac = buffer.scan_varbytes
220
-
221
- if !buffer.empty?
222
- errors << "Challenge contained trailing bytes"
223
- end
224
-
225
- rehash = Octokey::Buffer.new
226
- rehash.add_uint8 version
227
- rehash.add_uint8 fingerprint
228
- rehash.add_time time
229
- rehash.add_ip client_ip
230
- rehash.add_varbytes random
231
- expected_hmac = OpenSSL::HMAC.digest("sha1", Octokey.hmac_secret, rehash.raw)
232
-
233
- if hmac != expected_hmac
234
- errors << "Challenge HMAC was incorrect: Got #{hmac.inspect}, expected: #{expected_hmac.inspect}"
235
- end
236
-
237
- errors
238
- rescue InvalidBuffer => e
239
- errors + [e.message]
240
- end
241
-
242
- def self.sign_challenge(challenge, username, request_url, private_key)
243
- to_sign = Octokey::Buffer.new
244
- to_sign.add_string challenge
245
- to_sign.add_string request_url
246
- to_sign.add_string username
247
- to_sign.add_string SERVICE_NAME
248
- to_sign.add_string AUTH_METHOD
249
- to_sign.add_string SIGNING_ALGORITHM
250
- to_sign.add_buffer encode_public_key(private_key)
251
-
252
- digest = OpenSSL::Digest::SHA1.digest(to_sign.raw)
253
- sigblob = private_key.private_encrypt(digest_into_asn1(digest))
254
-
255
- sig_buf = Octokey::Buffer.new
256
- sig_buf.add_string SIGNING_ALGORITHM
257
- sig_buf.add_string sigblob
258
-
259
- to_sign.add_buffer(sig_buf)
260
-
261
- to_sign.to_s
262
- end
263
-
264
- # TODO: replace this by a call to OpenSSL
265
- def self.digest_into_asn1(digest)
266
- raise "not a valid digest" unless digest.size == 20
267
- "0!0\t\x06\x05+\x0E\x03\x02\x1A\x05\x00\x04\x14#{digest}"
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?
268
89
  end
269
90
 
270
- # TODO: replace this by a call to OpenSSL
91
+ # Was the failure to log in or sign up transient?
271
92
  #
272
- # The ASN1 structure should look like this:
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.
273
95
  #
274
- # [
275
- # ["SHA-1", nil],
276
- # "digestdigestdigest.."
277
- # ]
278
- def self.validate_digest(raw_asn1, expected_digest)
279
- asn1 = OpenSSL::ASN1.decode(raw_asn1)
280
- errors = []
281
-
282
- unless asn1.tag == 16 && asn1.value.size == 2
283
- return ["Invalid asn1 was signed"]
284
- end
285
-
286
- algorithm_asn1 = asn1.value[0]
287
- digest_asn1 = asn1.value[1]
288
-
289
- unless digest_asn1.tag == 4
290
- return ["Invalid digest_asn1 was signed"]
291
- end
292
-
293
- unless algorithm_asn1.tag == 16 && algorithm_asn1.value.size <= 2
294
- return ["Invalid algorithm_asn1 was signed"]
295
- end
296
-
297
- unless algorithm_asn1.value[0].value == DIGEST_ALGORITHM
298
- errors << "Incorrect digest algorithm: Got #{algorithm_asn1.value[0].value}, expected: #{DIGEST_ALGORITHM.inspect}"
299
- end
300
-
301
- unless algorithm_asn1.value.size == 1 || algorithm_asn1.value[1].is_a?(OpenSSL::ASN1::Null)
302
- errors << "Invalid parameters passed to SHA1 digest, expected NULL."
303
- end
304
-
305
- unless digest_asn1.value == expected_digest
306
- errors << "Incorrect message digest"
307
- end
308
-
309
- return errors
96
+ # @return [Boolean]
97
+ def should_retry?
98
+ !valid_auth_request? && auth_request.valid_ignoring_challenge?(opts)
310
99
  end
311
100
 
312
- def self.decode_signature(buffer, expected_alg)
313
- buffer = buffer.dup
314
-
315
- signing_alg = buffer.scan_string
316
- signature = buffer.scan_varbytes
317
-
318
- errors = []
319
-
320
- unless buffer.empty?
321
- errors << "Signature contained trailing bytes"
322
- end
323
-
324
- unless signing_alg == expected_alg
325
- errors << "Signature algorithm mismatch: Got #{signing_alg.inspect}, expected: #{expected_alg.inspect}"
326
- end
327
-
328
- [signature, errors]
329
- rescue InvalidBuffer => e
330
- [nil, e.message]
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
331
112
  end
332
113
 
333
- def self.format_public_key(public_key)
334
- "ssh-rsa #{encode_public_key(public_key).to_s}"
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
335
128
  end
336
129
 
337
- def self.unformat_public_key(public_key)
338
- if public_key =~ /\A(ssh-rsa)\s+(.*)\z/
339
- key, errors = decode_public_key(Octokey::Buffer.new($2), $1)
340
- raise "Invalid public key: #{errors.join(". ")}." unless errors.empty?
341
-
342
- key
343
- else
344
- raise "Invalid public key: Got #{public_key.inspect}, expected \"ssh-rsa AAAAf...\""
345
- end
346
- end
130
+ private
131
+ attr_accessor :opts, :auth_request
347
132
 
348
- def self.encode_public_key(public_key)
349
- raise "not an RSA key: #{public_key}" unless OpenSSL::PKey::RSA === public_key
350
- buffer = Octokey::Buffer.new
351
- buffer.add_string "ssh-rsa"
352
- buffer.add_mpint public_key.e
353
- buffer.add_mpint public_key.n
354
- buffer
133
+ # Is the auth_request valid?
134
+ # @return [Boolean]
135
+ def valid_auth_request?
136
+ @valid ||= auth_request.valid?(opts)
355
137
  end
356
138
 
357
- def self.decode_public_key(buffer, expected_type)
358
- buffer = buffer.dup
359
-
360
- key_type = buffer.scan_string
361
- e = buffer.scan_mpint
362
- n = buffer.scan_mpint
363
-
364
- errors = []
365
-
366
- unless buffer.empty?
367
- errors << "Public key contained trailing bytes"
368
- end
369
-
370
- unless key_type == expected_type
371
- errors << "Got unknown public key type: Got #{key_type.inspect}, expected: #{expected_type.inspect}"
372
- end
373
-
374
- unless n.num_bits > SSH_RSA_MINIMUM_MODULUS_SIZE
375
- errors << "RSA modulus too small: #{n.num_bits.inspect} < #{SSH_RSA_MINIMUM_MODULUS_SIZE.inspect}"
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}"
376
146
  end
377
-
378
- # TODO: verify size of modulus and exponent
379
-
380
- if errors == []
381
- key = OpenSSL::PKey::RSA.new
382
- key.e = e
383
- key.n = n
384
- end
385
-
386
- [key, errors]
387
- rescue InvalidBuffer => e
388
- [nil, e.message]
147
+ public_keys.include?(auth_request.public_key)
389
148
  end
390
149
  end
metadata CHANGED
@@ -1,46 +1,102 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: octokey
3
- version: !ruby/object:Gem::Version
4
- version: 0.1.pre.2
3
+ version: !ruby/object:Gem::Version
5
4
  prerelease: 4
5
+ version: 0.1.pre.3
6
6
  platform: ruby
7
- authors:
8
- - Conrad Irwin
7
+ authors:
8
+ - Conrad Irwin
9
9
  autorequire:
10
10
  bindir: bin
11
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
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
+ description: Allows you to use secure authentication mechanisms in place of passwords
15
60
  email: conrad.irwin@gmail.com
16
61
  executables: []
62
+
17
63
  extensions: []
64
+
18
65
  extra_rdoc_files: []
19
- files:
20
- - lib/octokey.rb
21
- - lib/octokey/buffer.rb
66
+
67
+ files:
68
+ - lib/octokey.rb
69
+ - lib/octokey/auth_request.rb
70
+ - lib/octokey/buffer.rb
71
+ - lib/octokey/challenge.rb
72
+ - lib/octokey/config.rb
73
+ - lib/octokey/public_key.rb
22
74
  homepage: https://github.com/octokey/octokey-gem
23
75
  licenses: []
76
+
24
77
  post_install_message:
25
78
  rdoc_options: []
26
- require_paths:
27
- - lib
28
- required_ruby_version: !ruby/object:Gem::Requirement
79
+
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
29
83
  none: false
30
- requirements:
31
- - - ! '>='
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: "0"
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
35
89
  none: false
36
- requirements:
37
- - - ! '>'
38
- - !ruby/object:Gem::Version
39
- version: 1.3.1
90
+ requirements:
91
+ - - ">"
92
+ - !ruby/object:Gem::Version
93
+ version: 1.3.1
40
94
  requirements: []
95
+
41
96
  rubyforge_project:
42
97
  rubygems_version: 1.8.24
43
98
  signing_key:
44
99
  specification_version: 3
45
100
  summary: Public key authentication for the web!
46
101
  test_files: []
102
+