opentoken 0.2.0 → 0.2.1
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 +0 -1
- data/VERSION +1 -1
- data/lib/opentoken.rb +7 -174
- data/lib/opentoken/key_value_serializer.rb +115 -0
- data/lib/opentoken/password_key_generator.rb +55 -0
- data/opentoken.gemspec +4 -5
- data/test/test_opentoken.rb +12 -0
- metadata +10 -24
data/Rakefile
CHANGED
@@ -10,7 +10,6 @@ begin
|
|
10
10
|
gem.email = "ryan@socialcast.com"
|
11
11
|
gem.homepage = "http://github.com/wireframe/opentoken"
|
12
12
|
gem.authors = ["Ryan Sonnek"]
|
13
|
-
gem.add_dependency "activesupport", ">=2.3.4"
|
14
13
|
gem.add_development_dependency "shoulda", ">= 0"
|
15
14
|
gem.add_development_dependency "timecop", ">=0.3.4"
|
16
15
|
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.2.
|
1
|
+
0.2.1
|
data/lib/opentoken.rb
CHANGED
@@ -4,6 +4,8 @@ require 'digest/sha1'
|
|
4
4
|
require 'zlib'
|
5
5
|
require 'stringio'
|
6
6
|
require 'cgi'
|
7
|
+
require File.join(File.dirname(__FILE__), 'opentoken', 'key_value_serializer')
|
8
|
+
require File.join(File.dirname(__FILE__), 'opentoken', 'password_key_generator')
|
7
9
|
|
8
10
|
class OpenToken
|
9
11
|
class TokenExpiredError < StandardError; end
|
@@ -90,6 +92,7 @@ class OpenToken
|
|
90
92
|
rescue Zlib::BufError
|
91
93
|
Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(compressed_payload[2, compressed_payload.size])
|
92
94
|
end
|
95
|
+
puts 'EXPANDED PAYLOAD', unparsed_payload if DEBUG
|
93
96
|
|
94
97
|
#validate payload hmac
|
95
98
|
mac = "0x01".hex.chr
|
@@ -102,7 +105,10 @@ class OpenToken
|
|
102
105
|
raise "HMAC for payload was #{hash} and expected to be #{payload_hmac}" unless payload_hmac == hash
|
103
106
|
end
|
104
107
|
|
105
|
-
|
108
|
+
unescaped_payload = CGI::unescapeHTML(unparsed_payload)
|
109
|
+
puts 'UNESCAPED PAYLOAD', unescaped_payload if DEBUG
|
110
|
+
@payload = KeyValueSerializer.deserialize unescaped_payload
|
111
|
+
puts @payload.inspect if DEBUG
|
106
112
|
raise TokenExpiredError.new("#{Time.now.utc} is not within token duration: #{self.start_at} - #{self.end_at}") if self.expired?
|
107
113
|
end
|
108
114
|
|
@@ -151,176 +157,3 @@ class OpenToken
|
|
151
157
|
end
|
152
158
|
end
|
153
159
|
end
|
154
|
-
|
155
|
-
class PasswordKeyGenerator
|
156
|
-
SHA1_DIGEST = OpenSSL::Digest::Digest.new('sha1')
|
157
|
-
|
158
|
-
def self.generate(password, cipher_suite)
|
159
|
-
salt = 0.chr * 8
|
160
|
-
self.generate_impl(password, cipher_suite, salt, 1000)
|
161
|
-
end
|
162
|
-
|
163
|
-
def self.generate_block(password, salt, count, index)
|
164
|
-
mac = salt
|
165
|
-
mac += [index].pack("N")
|
166
|
-
|
167
|
-
result = OpenSSL::HMAC.digest(SHA1_DIGEST, password, mac)
|
168
|
-
cur = result
|
169
|
-
|
170
|
-
i_count = 1
|
171
|
-
while i_count < count
|
172
|
-
i_count +=1
|
173
|
-
|
174
|
-
cur = OpenSSL::HMAC.digest(SHA1_DIGEST, password, cur)
|
175
|
-
|
176
|
-
20.times do |i|
|
177
|
-
result[i] = result[i] ^ cur[i]
|
178
|
-
end
|
179
|
-
end
|
180
|
-
|
181
|
-
return result
|
182
|
-
end
|
183
|
-
|
184
|
-
def self.generate_impl(password, cipher, salt, iterations)
|
185
|
-
return unless cipher[:algorithm]
|
186
|
-
|
187
|
-
key_size = cipher[:key_length] / 8
|
188
|
-
numblocks = key_size / 20
|
189
|
-
numblocks += 1 if (key_size % 20) > 0
|
190
|
-
|
191
|
-
# Generate the appropriate number of blocks and write their output to
|
192
|
-
# the key bytes; note that it's important to start from 1 (vs. 0) as the
|
193
|
-
# initial block number affects the hash. It's not clear that this fact
|
194
|
-
# is stated explicitly anywhere, but without this approach, the generated
|
195
|
-
# keys will not match up with test cases defined in RFC 3962.
|
196
|
-
key_buffer_index = 0
|
197
|
-
key = ""
|
198
|
-
|
199
|
-
numblocks.times do |i|
|
200
|
-
i+=1 # Previously zero based, needs to be 1 based
|
201
|
-
block = self.generate_block(password, salt, iterations, i)
|
202
|
-
len = [20, (key_size - key_buffer_index)].min
|
203
|
-
key += block[0, len]
|
204
|
-
key_buffer_index += len
|
205
|
-
end
|
206
|
-
|
207
|
-
return key
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
class KeyValueSerializer
|
212
|
-
LINE_START = 0
|
213
|
-
EMPTY_SPACE = 1
|
214
|
-
VALUE_START = 2
|
215
|
-
LINE_END = 3
|
216
|
-
IN_KEY = 4
|
217
|
-
IN_VALUE = 5
|
218
|
-
IN_QUOTED_VALUE = 6
|
219
|
-
|
220
|
-
def self.unescape_value(value)
|
221
|
-
value.gsub("\\\"", "\"").gsub("\'", "'")
|
222
|
-
end
|
223
|
-
|
224
|
-
def self.deserialize(string)
|
225
|
-
result = {}
|
226
|
-
state = LINE_START
|
227
|
-
open_quote_char = 0.chr
|
228
|
-
currkey = ""
|
229
|
-
token = ""
|
230
|
-
nextval = ""
|
231
|
-
|
232
|
-
string.split(//).each do |c|
|
233
|
-
|
234
|
-
nextval = c
|
235
|
-
|
236
|
-
case c
|
237
|
-
when "\t"
|
238
|
-
if state == IN_KEY
|
239
|
-
# key ends
|
240
|
-
currkey = token
|
241
|
-
token = ""
|
242
|
-
state = EMPTY_SPACE
|
243
|
-
elsif state == IN_VALUE
|
244
|
-
# non-quoted value ends
|
245
|
-
result[currkey] = self.deserialize(token)
|
246
|
-
token = ""
|
247
|
-
state = LINE_END
|
248
|
-
elsif state == IN_QUOTED_VALUE
|
249
|
-
token += c
|
250
|
-
end
|
251
|
-
when " "
|
252
|
-
if state == IN_KEY
|
253
|
-
# key ends
|
254
|
-
currkey = token
|
255
|
-
token = ""
|
256
|
-
state = EMPTY_SPACE
|
257
|
-
elsif state == IN_VALUE
|
258
|
-
# non-quoted value ends
|
259
|
-
result[currkey] = self.deserialize(token)
|
260
|
-
token = ""
|
261
|
-
state = LINE_END
|
262
|
-
elsif state == IN_QUOTED_VALUE
|
263
|
-
token += c
|
264
|
-
end
|
265
|
-
when "\n"
|
266
|
-
# newline
|
267
|
-
if (state == IN_VALUE) || (state == VALUE_START)
|
268
|
-
result[currkey] = self.unescape_value(token)
|
269
|
-
token = ""
|
270
|
-
state = LINE_START
|
271
|
-
elsif state == LINE_END
|
272
|
-
token = ""
|
273
|
-
state = LINE_START
|
274
|
-
elsif state == IN_QUOTED_VALUE
|
275
|
-
token += c
|
276
|
-
end
|
277
|
-
when "="
|
278
|
-
if state == IN_KEY
|
279
|
-
currkey = token
|
280
|
-
token = ""
|
281
|
-
state = VALUE_START
|
282
|
-
elsif (state == IN_QUOTED_VALUE) || (state == IN_VALUE)
|
283
|
-
token += c
|
284
|
-
end
|
285
|
-
when "\""
|
286
|
-
if state == IN_QUOTED_VALUE
|
287
|
-
if (c == open_quote_char) && (token[token.size-1] != "\\")
|
288
|
-
result[currkey] = self.unescape_value(token)
|
289
|
-
token = ""
|
290
|
-
state = LINE_END
|
291
|
-
else
|
292
|
-
token += c
|
293
|
-
end
|
294
|
-
elsif state == VALUE_START
|
295
|
-
state = IN_QUOTED_VALUE
|
296
|
-
open_quote_char = c
|
297
|
-
end
|
298
|
-
when "'"
|
299
|
-
if state == IN_QUOTED_VALUE
|
300
|
-
if (c == open_quote_char) && (token[token.size-1] != "\\")
|
301
|
-
result[currkey] = self.unescape_value(token)
|
302
|
-
token = ""
|
303
|
-
state = LINE_END
|
304
|
-
else
|
305
|
-
token += c
|
306
|
-
end
|
307
|
-
else state == VALUE_START
|
308
|
-
state = IN_QUOTED_VALUE
|
309
|
-
open_quote_char = c
|
310
|
-
end
|
311
|
-
else
|
312
|
-
if state == LINE_START
|
313
|
-
state = IN_KEY
|
314
|
-
elsif state == VALUE_START
|
315
|
-
state = IN_VALUE
|
316
|
-
end
|
317
|
-
token += c
|
318
|
-
end
|
319
|
-
|
320
|
-
if (state == IN_QUOTED_VALUE) || (state == IN_VALUE)
|
321
|
-
result[currkey] = unescape_value(token)
|
322
|
-
end
|
323
|
-
end
|
324
|
-
result
|
325
|
-
end
|
326
|
-
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
class KeyValueSerializer
|
2
|
+
LINE_START = 0
|
3
|
+
EMPTY_SPACE = 1
|
4
|
+
VALUE_START = 2
|
5
|
+
LINE_END = 3
|
6
|
+
IN_KEY = 4
|
7
|
+
IN_VALUE = 5
|
8
|
+
IN_QUOTED_VALUE = 6
|
9
|
+
|
10
|
+
def self.unescape_value(value)
|
11
|
+
value.gsub("\\\"", "\"").gsub("\\\'", "'")
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.deserialize(string)
|
15
|
+
result = {}
|
16
|
+
state = LINE_START
|
17
|
+
open_quote_char = 0.chr
|
18
|
+
currkey = ""
|
19
|
+
token = ""
|
20
|
+
nextval = ""
|
21
|
+
|
22
|
+
string.split(//).each do |c|
|
23
|
+
nextval = c
|
24
|
+
|
25
|
+
case c
|
26
|
+
when "\t"
|
27
|
+
if state == IN_KEY
|
28
|
+
# key ends
|
29
|
+
currkey = token
|
30
|
+
token = ""
|
31
|
+
state = EMPTY_SPACE
|
32
|
+
elsif state == IN_VALUE
|
33
|
+
# non-quoted value ends
|
34
|
+
result[currkey] = self.deserialize(token)
|
35
|
+
token = ""
|
36
|
+
state = LINE_END
|
37
|
+
elsif state == IN_QUOTED_VALUE
|
38
|
+
token += c
|
39
|
+
end
|
40
|
+
when " "
|
41
|
+
if state == IN_KEY
|
42
|
+
# key ends
|
43
|
+
currkey = token
|
44
|
+
token = ""
|
45
|
+
state = EMPTY_SPACE
|
46
|
+
elsif state == IN_VALUE
|
47
|
+
# non-quoted value ends
|
48
|
+
result[currkey] = self.deserialize(token)
|
49
|
+
token = ""
|
50
|
+
state = LINE_END
|
51
|
+
elsif state == IN_QUOTED_VALUE
|
52
|
+
token += c
|
53
|
+
end
|
54
|
+
when "\n"
|
55
|
+
# newline
|
56
|
+
if (state == IN_VALUE) || (state == VALUE_START)
|
57
|
+
result[currkey] = self.unescape_value(token)
|
58
|
+
token = ""
|
59
|
+
state = LINE_START
|
60
|
+
elsif state == LINE_END
|
61
|
+
token = ""
|
62
|
+
state = LINE_START
|
63
|
+
elsif state == IN_QUOTED_VALUE
|
64
|
+
token += c
|
65
|
+
end
|
66
|
+
when "="
|
67
|
+
if state == IN_KEY
|
68
|
+
currkey = token
|
69
|
+
token = ""
|
70
|
+
state = VALUE_START
|
71
|
+
elsif (state == IN_QUOTED_VALUE) || (state == IN_VALUE)
|
72
|
+
token += c
|
73
|
+
end
|
74
|
+
when "\""
|
75
|
+
if state == IN_QUOTED_VALUE
|
76
|
+
if (c == open_quote_char) && (token[token.size-1] != "\\"[0])
|
77
|
+
result[currkey] = self.unescape_value(token)
|
78
|
+
token = ""
|
79
|
+
state = LINE_END
|
80
|
+
else
|
81
|
+
token += c
|
82
|
+
end
|
83
|
+
elsif state == VALUE_START
|
84
|
+
state = IN_QUOTED_VALUE
|
85
|
+
open_quote_char = c
|
86
|
+
end
|
87
|
+
when "'"
|
88
|
+
if state == IN_QUOTED_VALUE
|
89
|
+
if (c == open_quote_char) && (token[token.size-1] != "\\"[0])
|
90
|
+
result[currkey] = self.unescape_value(token)
|
91
|
+
token = ""
|
92
|
+
state = LINE_END
|
93
|
+
else
|
94
|
+
token += c
|
95
|
+
end
|
96
|
+
else state == VALUE_START
|
97
|
+
state = IN_QUOTED_VALUE
|
98
|
+
open_quote_char = c
|
99
|
+
end
|
100
|
+
else
|
101
|
+
if state == LINE_START
|
102
|
+
state = IN_KEY
|
103
|
+
elsif state == VALUE_START
|
104
|
+
state = IN_VALUE
|
105
|
+
end
|
106
|
+
token += c
|
107
|
+
end
|
108
|
+
|
109
|
+
if (state == IN_QUOTED_VALUE) || (state == IN_VALUE)
|
110
|
+
result[currkey] = unescape_value(token)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
result
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
class PasswordKeyGenerator
|
2
|
+
SHA1_DIGEST = OpenSSL::Digest::Digest.new('sha1')
|
3
|
+
|
4
|
+
def self.generate(password, cipher_suite)
|
5
|
+
salt = 0.chr * 8
|
6
|
+
self.generate_impl(password, cipher_suite, salt, 1000)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.generate_block(password, salt, count, index)
|
10
|
+
mac = salt
|
11
|
+
mac += [index].pack("N")
|
12
|
+
|
13
|
+
result = OpenSSL::HMAC.digest(SHA1_DIGEST, password, mac)
|
14
|
+
cur = result
|
15
|
+
|
16
|
+
i_count = 1
|
17
|
+
while i_count < count
|
18
|
+
i_count +=1
|
19
|
+
|
20
|
+
cur = OpenSSL::HMAC.digest(SHA1_DIGEST, password, cur)
|
21
|
+
|
22
|
+
20.times do |i|
|
23
|
+
result[i] = result[i] ^ cur[i]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
return result
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.generate_impl(password, cipher, salt, iterations)
|
31
|
+
return unless cipher[:algorithm]
|
32
|
+
|
33
|
+
key_size = cipher[:key_length] / 8
|
34
|
+
numblocks = key_size / 20
|
35
|
+
numblocks += 1 if (key_size % 20) > 0
|
36
|
+
|
37
|
+
# Generate the appropriate number of blocks and write their output to
|
38
|
+
# the key bytes; note that it's important to start from 1 (vs. 0) as the
|
39
|
+
# initial block number affects the hash. It's not clear that this fact
|
40
|
+
# is stated explicitly anywhere, but without this approach, the generated
|
41
|
+
# keys will not match up with test cases defined in RFC 3962.
|
42
|
+
key_buffer_index = 0
|
43
|
+
key = ""
|
44
|
+
|
45
|
+
numblocks.times do |i|
|
46
|
+
i+=1 # Previously zero based, needs to be 1 based
|
47
|
+
block = self.generate_block(password, salt, iterations, i)
|
48
|
+
len = [20, (key_size - key_buffer_index)].min
|
49
|
+
key += block[0, len]
|
50
|
+
key_buffer_index += len
|
51
|
+
end
|
52
|
+
|
53
|
+
return key
|
54
|
+
end
|
55
|
+
end
|
data/opentoken.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{opentoken}
|
8
|
-
s.version = "0.2.
|
8
|
+
s.version = "0.2.1"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Ryan Sonnek"]
|
12
|
-
s.date = %q{2011-01-
|
12
|
+
s.date = %q{2011-01-13}
|
13
13
|
s.description = %q{parse opentoken properties passed for Single Signon requests}
|
14
14
|
s.email = %q{ryan@socialcast.com}
|
15
15
|
s.extra_rdoc_files = [
|
@@ -23,6 +23,8 @@ Gem::Specification.new do |s|
|
|
23
23
|
"Rakefile",
|
24
24
|
"VERSION",
|
25
25
|
"lib/opentoken.rb",
|
26
|
+
"lib/opentoken/key_value_serializer.rb",
|
27
|
+
"lib/opentoken/password_key_generator.rb",
|
26
28
|
"opentoken.gemspec",
|
27
29
|
"test/helper.rb",
|
28
30
|
"test/test_opentoken.rb"
|
@@ -40,16 +42,13 @@ Gem::Specification.new do |s|
|
|
40
42
|
s.specification_version = 3
|
41
43
|
|
42
44
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
43
|
-
s.add_runtime_dependency(%q<activesupport>, [">= 2.3.4"])
|
44
45
|
s.add_development_dependency(%q<shoulda>, [">= 0"])
|
45
46
|
s.add_development_dependency(%q<timecop>, [">= 0.3.4"])
|
46
47
|
else
|
47
|
-
s.add_dependency(%q<activesupport>, [">= 2.3.4"])
|
48
48
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
49
49
|
s.add_dependency(%q<timecop>, [">= 0.3.4"])
|
50
50
|
end
|
51
51
|
else
|
52
|
-
s.add_dependency(%q<activesupport>, [">= 2.3.4"])
|
53
52
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
54
53
|
s.add_dependency(%q<timecop>, [">= 0.3.4"])
|
55
54
|
end
|
data/test/test_opentoken.rb
CHANGED
@@ -44,5 +44,17 @@ class TestOpentoken < Test::Unit::TestCase
|
|
44
44
|
end
|
45
45
|
end
|
46
46
|
end
|
47
|
+
|
48
|
+
context "parsing token with attribute value containing apostrophe" do
|
49
|
+
setup do
|
50
|
+
Timecop.travel(Time.iso8601('2011-01-13T11:08:01Z')) do
|
51
|
+
@opentoken = "T1RLAQLIjiqgexqi1PQcEKCetvGoSYR2jhDFSIfE5ctlSBxEnq3S1ydjAADQUNRIKJx6_14aE3MQZnDABupGJrKNfoJHFS5VOnKexjMtboeOgst31Hf-D9CZBrpB7Jv0KBwnQ7DN3HizecPT76oX3UGtq_Vi5j5bKYCeObYm9W6h7NY-VzcZY5TTqIuulc2Jit381usAWZ2Sv1c_CWwhrH4hw-x7vUQMSjErvXK1qvsrFCpfNr7XlArx0HjI6kT5XEaHgQNdC0zrLw9cZ4rewoEisR3H5oM7B6gMaP82wTSFVBXvpn5r0KT-Iuc3JuG2en1zVh3GNf110oQCKQ**"
|
52
|
+
@token = OpenToken.new @opentoken, :password => @password
|
53
|
+
end
|
54
|
+
end
|
55
|
+
should 'preserve apostrophe in attribute payload' do
|
56
|
+
assert_equal "D'angelo", @token[:last_name]
|
57
|
+
end
|
58
|
+
end
|
47
59
|
end
|
48
60
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: opentoken
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 21
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 2
|
9
|
-
-
|
10
|
-
version: 0.2.
|
9
|
+
- 1
|
10
|
+
version: 0.2.1
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Ryan Sonnek
|
@@ -15,29 +15,13 @@ autorequire:
|
|
15
15
|
bindir: bin
|
16
16
|
cert_chain: []
|
17
17
|
|
18
|
-
date: 2011-01-
|
18
|
+
date: 2011-01-13 00:00:00 -06:00
|
19
19
|
default_executable:
|
20
20
|
dependencies:
|
21
|
-
- !ruby/object:Gem::Dependency
|
22
|
-
name: activesupport
|
23
|
-
prerelease: false
|
24
|
-
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
-
none: false
|
26
|
-
requirements:
|
27
|
-
- - ">="
|
28
|
-
- !ruby/object:Gem::Version
|
29
|
-
hash: 11
|
30
|
-
segments:
|
31
|
-
- 2
|
32
|
-
- 3
|
33
|
-
- 4
|
34
|
-
version: 2.3.4
|
35
|
-
type: :runtime
|
36
|
-
version_requirements: *id001
|
37
21
|
- !ruby/object:Gem::Dependency
|
38
22
|
name: shoulda
|
39
23
|
prerelease: false
|
40
|
-
requirement: &
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
41
25
|
none: false
|
42
26
|
requirements:
|
43
27
|
- - ">="
|
@@ -47,11 +31,11 @@ dependencies:
|
|
47
31
|
- 0
|
48
32
|
version: "0"
|
49
33
|
type: :development
|
50
|
-
version_requirements: *
|
34
|
+
version_requirements: *id001
|
51
35
|
- !ruby/object:Gem::Dependency
|
52
36
|
name: timecop
|
53
37
|
prerelease: false
|
54
|
-
requirement: &
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
55
39
|
none: false
|
56
40
|
requirements:
|
57
41
|
- - ">="
|
@@ -63,7 +47,7 @@ dependencies:
|
|
63
47
|
- 4
|
64
48
|
version: 0.3.4
|
65
49
|
type: :development
|
66
|
-
version_requirements: *
|
50
|
+
version_requirements: *id002
|
67
51
|
description: parse opentoken properties passed for Single Signon requests
|
68
52
|
email: ryan@socialcast.com
|
69
53
|
executables: []
|
@@ -80,6 +64,8 @@ files:
|
|
80
64
|
- Rakefile
|
81
65
|
- VERSION
|
82
66
|
- lib/opentoken.rb
|
67
|
+
- lib/opentoken/key_value_serializer.rb
|
68
|
+
- lib/opentoken/password_key_generator.rb
|
83
69
|
- opentoken.gemspec
|
84
70
|
- test/helper.rb
|
85
71
|
- test/test_opentoken.rb
|