global_session 2.0.3 → 3.0.0

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