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 +149 -0
- data/lib/octokey/auth_request.rb +210 -0
- data/lib/octokey/buffer.rb +344 -0
- data/lib/octokey/challenge.rb +178 -0
- data/lib/octokey/config.rb +101 -0
- data/lib/octokey/public_key.rb +157 -0
- metadata +113 -0
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
|
+
|