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

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