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.
- data/Rakefile +1 -1
- data/VERSION +1 -1
- data/global_session.gemspec +26 -25
- data/lib/global_session/directory.rb +10 -8
- data/lib/global_session/encoding.rb +25 -4
- data/lib/global_session/rack.rb +28 -5
- data/lib/global_session/rails.rb +2 -0
- data/lib/global_session/session/abstract.rb +68 -1
- data/lib/global_session/session/v1.rb +22 -79
- data/lib/global_session/session/v2.rb +22 -76
- data/lib/global_session/session/v3.rb +373 -0
- data/lib/global_session/session.rb +20 -7
- data/lib/global_session.rb +2 -4
- metadata +80 -71
@@ -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
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
35
|
-
|
36
|
-
|
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
|
data/lib/global_session.rb
CHANGED
@@ -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
|
84
|
-
|
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
|