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