global_session 2.0.3 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,373 @@
1
+ # Copyright (c) 2012 RightScale Inc
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining
4
+ # a copy of this software and associated documentation files (the
5
+ # "Software"), to deal in the Software without restriction, including
6
+ # without limitation the rights to use, copy, modify, merge, publish,
7
+ # distribute, sublicense, and/or sell copies of the Software, and to
8
+ # permit persons to whom the Software is furnished to do so, subject to
9
+ # the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be
12
+ # included in all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ # Standard library dependencies
23
+ require 'set'
24
+
25
+ module GlobalSession::Session
26
+ # Global session V3 uses JSON serialization, no compression, and a detached signature that is
27
+ # excluded from the JSON structure for efficiency reasons.
28
+ #
29
+ # The binary structure of a V3 session looks like this:
30
+ # <utf8_json><0x00><binary_signature>
31
+ #
32
+ # Its JSON structure is an Array with the following format:
33
+ # [<version_integer>,
34
+ # <uuid_string>,
35
+ # <signing_authority_string>,
36
+ # <creation_timestamp_integer>,
37
+ # <expiration_timestamp_integer>,
38
+ # {<signed_data_hash>},
39
+ # {<unsigned_data_hash>}]
40
+ #
41
+ # The design goal of V3 is to ensure broad compatibility across various programming languages
42
+ # and cryptographic libraries, and to create a serialization format that can be reused for
43
+ # future versions. To this end, it sacrifices space efficiency by switching back to JSON
44
+ # encoding (instead of msgpack), and uses the undocumented OpenSSL::PKey#sign and #verify
45
+ # operations which rely on the PKCS7-compliant OpenSSL EVP API.
46
+ class V3 < Abstract
47
+ STRING_ENCODING = !!(RUBY_VERSION !~ /1.8/)
48
+
49
+ # Utility method to decode a cookie; good for console debugging. This performs no
50
+ # validation or security check of any sort.
51
+ #
52
+ # === Parameters
53
+ # cookie(String):: well-formed global session cookie
54
+ def self.decode_cookie(cookie)
55
+ bin = GlobalSession::Encoding::Base64Cookie.load(cookie)
56
+ json, sig = split_body(bin)
57
+ return GlobalSession::Encoding::JSON.load(json), sig
58
+ end
59
+
60
+ # Split an ASCII-8bit input string into two constituent parts: a UTF-8 JSON document
61
+ # and an ASCII-8bit binary string. A null (0x00) separator character is presumed to
62
+ # separate the two parts of the input string.
63
+ #
64
+ # This is an implementation helper for GlobalSession serialization and not useful for
65
+ # the public at large. It's left public as an aid for those who want to hack sessions.
66
+ #
67
+ # @param [String] input a binary string (encoding will be forced to ASCII_8BIT!)
68
+ # @return [Array] returns a 2-element Array of String: json document, plus binary signature
69
+ # @raise [ArgumentError] if the null separator is missing
70
+ def self.split_body(input)
71
+ input.force_encoding(Encoding::ASCII_8BIT) if STRING_ENCODING
72
+ null_at = input.index("\x00")
73
+
74
+ if null_at
75
+ json = input[0...null_at]
76
+ sig = input[null_at+1..-1]
77
+ if STRING_ENCODING
78
+ json.force_encoding(Encoding::UTF_8)
79
+ sig.force_encoding(Encoding::ASCII_8BIT)
80
+ end
81
+
82
+ return json, sig
83
+ else
84
+ raise ArgumentError, "Malformed input string does not contain 0x00 byte"
85
+ end
86
+ end
87
+
88
+ # Join a UTF-8 JSON document and an ASCII-8bit binary string.
89
+ #
90
+ # This is an implementation helper for GlobalSession serialization and not useful for
91
+ # the public at large. It's left public as an aid for those who want to hack sessions.
92
+ #
93
+ # @param [String] json a UTF-8 JSON document (encoding will be forced to UTF_8!)
94
+ # @param [String] signature a binary signautre (encoding will be forced to ASCII_8BIT!)
95
+ # @return [String] a binary concatenation of the two inputs, separated by 0x00
96
+ def self.join_body(json, signature)
97
+ result = ""
98
+ if STRING_ENCODING
99
+ result.force_encoding(Encoding::ASCII_8BIT)
100
+ json.force_encoding(Encoding::ASCII_8BIT)
101
+ signature.force_encoding(Encoding::ASCII_8BIT)
102
+ end
103
+
104
+ result << json
105
+ result << "\x00"
106
+ result << signature
107
+ result
108
+ end
109
+
110
+
111
+ # Serialize the session to a form suitable for use with HTTP cookies. If any
112
+ # secure attributes have changed since the session was instantiated, compute
113
+ # a fresh RSA signature.
114
+ #
115
+ # === Return
116
+ # cookie(String):: The B64cookie-encoded JSON-serialized global session
117
+ def to_s
118
+ if @cookie && !@dirty_insecure && !@dirty_secure
119
+ #use cached cookie if nothing has changed
120
+ return @cookie
121
+ end
122
+
123
+ hash = {'v' => 3,
124
+ 'id' => @id, 'a' => @authority,
125
+ 'tc' => @created_at.to_i, 'te' => @expired_at.to_i,
126
+ 'ds' => @signed}
127
+
128
+ if @signature && !@dirty_secure
129
+ #use cached signature unless we've changed secure state
130
+ authority = @authority
131
+ else
132
+ authority_check
133
+ authority = @directory.local_authority_name
134
+ hash['a'] = authority
135
+ signed_hash = RightSupport::Crypto::SignedHash.new(
136
+ hash,
137
+ :envelope=>true,
138
+ :encoding=>GlobalSession::Encoding::JSON,
139
+ :private_key=>@directory.private_key)
140
+ @signature = signed_hash.sign(@expired_at)
141
+ end
142
+
143
+ hash['dx'] = @insecure
144
+ hash['a'] = authority
145
+
146
+ array = attribute_hash_to_array(hash)
147
+ json = GlobalSession::Encoding::JSON.dump(array)
148
+ bin = self.class.join_body(json, @signature)
149
+ return GlobalSession::Encoding::Base64Cookie.dump(bin)
150
+ end
151
+
152
+ # Return the keys that are currently present in the global session.
153
+ #
154
+ # === Return
155
+ # keys(Array):: List of keys contained in the global session
156
+ def keys
157
+ @signed.keys + @insecure.keys
158
+ end
159
+
160
+ # Return the values that are currently present in the global session.
161
+ #
162
+ # === Return
163
+ # values(Array):: List of values contained in the global session
164
+ def values
165
+ @signed.values + @insecure.values
166
+ end
167
+
168
+ # Iterate over each key/value pair
169
+ #
170
+ # === Block
171
+ # An iterator which will be called with each key/value pair
172
+ #
173
+ # === Return
174
+ # Returns the value of the last expression evaluated by the block
175
+ def each_pair(&block) # :yields: |key, value|
176
+ @signed.each_pair(&block)
177
+ @insecure.each_pair(&block)
178
+ end
179
+
180
+ # Lookup a value by its key.
181
+ #
182
+ # === Parameters
183
+ # key(String):: the key
184
+ #
185
+ # === Return
186
+ # value(Object):: The value associated with +key+, or nil if +key+ is not present
187
+ def [](key)
188
+ key = key.to_s #take care of symbol-style keys
189
+ @signed[key] || @insecure[key]
190
+ end
191
+
192
+ # Set a value in the global session hash. If the supplied key is denoted as
193
+ # secure by the global session schema, causes a new signature to be computed
194
+ # when the session is next serialized.
195
+ #
196
+ # === Parameters
197
+ # key(String):: The key to set
198
+ # value(Object):: The value to set
199
+ #
200
+ # === Return
201
+ # value(Object):: Always returns the value that was set
202
+ #
203
+ # ===Raise
204
+ # InvalidSession:: if the session has been invalidated (and therefore can't be written to)
205
+ # ArgumentError:: if the configuration doesn't define the specified key as part of the global session
206
+ # NoAuthority:: if the specified key is secure and the local node is not an authority
207
+ # UnserializableType:: if the specified value can't be serialized as JSON
208
+ def []=(key, value)
209
+ key = key.to_s #take care of symbol-style keys
210
+ raise GlobalSession::InvalidSession unless valid?
211
+
212
+ if @schema_signed.include?(key)
213
+ authority_check
214
+ @signed[key] = value
215
+ @dirty_secure = true
216
+ elsif @schema_insecure.include?(key)
217
+ @insecure[key] = value
218
+ @dirty_insecure = true
219
+ else
220
+ raise ArgumentError, "Attribute '#{key}' is not specified in global session configuration"
221
+ end
222
+
223
+ return value
224
+ end
225
+
226
+ # Renews this global session, changing its expiry timestamp into the future.
227
+ # Causes a new signature will be computed when the session is next serialized.
228
+ #
229
+ # === Return
230
+ # true:: Always returns true
231
+ def renew!(expired_at=nil)
232
+ super(expired_at)
233
+ @dirty_secure = true
234
+ end
235
+
236
+ # Return the SHA1 hash of the most recently-computed RSA signature of this session.
237
+ # This isn't really intended for the end user; it exists so the Web framework integration
238
+ # code can optimize request speed by caching the most recently verified signature in the
239
+ # local session and avoid re-verifying it on every request.
240
+ #
241
+ # === Return
242
+ # digest(String):: SHA1 hex-digest of most-recently-computed signature
243
+ def signature_digest
244
+ @signature ? digest(@signature) : nil
245
+ end
246
+
247
+ private
248
+
249
+ def load_from_cookie(cookie) # :nodoc:
250
+ hash = nil
251
+
252
+ begin
253
+ array, signature = self.class.decode_cookie(cookie)
254
+ hash = attribute_array_to_hash(array)
255
+ rescue Exception => e
256
+ mc = GlobalSession::MalformedCookie.new("Caused by #{e.class.name}: #{e.message}")
257
+ mc.set_backtrace(e.backtrace)
258
+ raise mc
259
+ end
260
+
261
+ _ = hash['v']
262
+ id = hash['id']
263
+ authority = hash['a']
264
+ created_at = Time.at(hash['tc'].to_i).utc
265
+ expired_at = Time.at(hash['te'].to_i).utc
266
+ signed = hash['ds']
267
+ insecure = hash.delete('dx')
268
+
269
+ #Check trust in signing authority
270
+ if @directory.trusted_authority?(authority)
271
+ signed_hash = RightSupport::Crypto::SignedHash.new(
272
+ hash,
273
+ :envelope=>true,
274
+ :encoding=>GlobalSession::Encoding::JSON,
275
+ :public_key=>@directory.authorities[authority])
276
+
277
+ begin
278
+ signed_hash.verify!(signature, expired_at)
279
+ rescue RightSupport::Crypto::ExpiredSignature
280
+ raise GlobalSession::ExpiredSession, "Session expired at #{expired_at}"
281
+ rescue RightSupport::Crypto::InvalidSignature => e
282
+ raise SecurityError, "Global session signature verification failed: " + e.message
283
+ end
284
+
285
+ else
286
+ raise SecurityError, "Global sessions signed by #{authority.inspect} are not trusted"
287
+ end
288
+
289
+ #Check expiration
290
+ unless expired_at > Time.now.utc
291
+ raise GlobalSession::ExpiredSession, "Session expired at #{expired_at}"
292
+ end
293
+
294
+ #Check other validity (delegate to directory)
295
+ unless @directory.valid_session?(id, expired_at)
296
+ raise GlobalSession::InvalidSession, "Global session has been invalidated"
297
+ end
298
+
299
+ #If all validation stuff passed, assign our instance variables.
300
+ @id = id
301
+ @authority = authority
302
+ @created_at = created_at
303
+ @expired_at = expired_at
304
+ @signed = signed
305
+ @insecure = insecure
306
+ @signature = signature
307
+ @cookie = cookie
308
+ end
309
+
310
+ def create_from_scratch # :nodoc:
311
+ authority_check
312
+
313
+ @signed = {}
314
+ @insecure = {}
315
+ @created_at = Time.now.utc
316
+ @authority = @directory.local_authority_name
317
+ @id = RightSupport::Data::UUID.generate
318
+ renew!
319
+ end
320
+
321
+ def create_invalid # :nodoc:
322
+ @id = nil
323
+ @created_at = Time.now.utc
324
+ @expired_at = created_at
325
+ @signed = {}
326
+ @insecure = {}
327
+ @authority = nil
328
+ end
329
+
330
+ # Transform a V1-style attribute hash to an Array with fixed placement for
331
+ # each element. The V3 scheme is serialized as an array to save space.
332
+ #
333
+ # === Parameters
334
+ # hash(Hash):: the attribute hash
335
+ #
336
+ # === Return
337
+ # attributes(Array)::
338
+ #
339
+ def attribute_hash_to_array(hash)
340
+ [
341
+ hash['v'],
342
+ hash['id'],
343
+ hash['a'],
344
+ hash['tc'],
345
+ hash['te'],
346
+ hash['ds'],
347
+ hash['dx'],
348
+ ]
349
+ end
350
+
351
+ # Transform a V2-style attribute array to a Hash with the traditional attribute
352
+ # names. This is good for passing to SignedHash, or initializing a V1 session for
353
+ # downrev compatibility.
354
+ #
355
+ # === Parameters
356
+ # hash(Hash):: the attribute hash
357
+ #
358
+ # === Return
359
+ # attributes(Array):: fixed-position attributes array
360
+ #
361
+ def attribute_array_to_hash(array)
362
+ {
363
+ 'v' => array[0],
364
+ 'id' => array[1],
365
+ 'a' => array[2],
366
+ 'tc' => array[3],
367
+ 'te' => array[4],
368
+ 'ds' => array[5],
369
+ 'dx' => array[6],
370
+ }
371
+ end
372
+ end
373
+ end
@@ -6,6 +6,7 @@ end
6
6
  require 'global_session/session/abstract'
7
7
  require 'global_session/session/v1'
8
8
  require 'global_session/session/v2'
9
+ require 'global_session/session/v3'
9
10
 
10
11
  # Ladies and gentlemen: the one and only, star of the show, GLOBAL SESSION!
11
12
  #
@@ -24,15 +25,27 @@ require 'global_session/session/v2'
24
25
  # by the different versions; it is responsible for detecting the version of
25
26
  # a given cookie, then instantiating a suitable session object.
26
27
  module GlobalSession::Session
27
- def self.decode_cookie(*args)
28
- V2.decode_cookie(*args)
29
- rescue GlobalSession::MalformedCookie => e
30
- V1.decode_cookie(*args)
28
+ # Decode a global session cookie without
29
+ def self.decode_cookie(cookie)
30
+ guess_version(cookie).decode_cookie(cookie)
31
31
  end
32
32
 
33
33
  def self.new(directory, cookie=nil, valid_signature_digest=nil)
34
- V2.new(directory, cookie)
35
- rescue GlobalSession::MalformedCookie => e
36
- V1.new(directory, cookie, valid_signature_digest)
34
+ guess_version(cookie).new(directory, cookie)
35
+ end
36
+
37
+ private
38
+
39
+ def self.guess_version(cookie)
40
+ case cookie
41
+ when /^WzM/
42
+ V3
43
+ when /^l9o/
44
+ V2
45
+ when /^eNo/
46
+ V1
47
+ else
48
+ V3
49
+ end
37
50
  end
38
51
  end
@@ -80,9 +80,7 @@ require 'global_session/directory'
80
80
  require 'global_session/encoding'
81
81
  require 'global_session/session'
82
82
 
83
- #Preemptively try to activate the Rails plugin, ignoring errors
84
- begin
83
+ #Preemptively try to activate the Rails plugin
84
+ if require_succeeds?('action_pack') && require_succeeds?('action_controller')
85
85
  require 'global_session/rails'
86
- rescue Exception => e
87
- #no-op
88
86
  end