opentoken 0.2.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,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Ryan Sonnek
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.
@@ -0,0 +1,17 @@
1
+ = opentoken
2
+
3
+ Parse encrypted opentoken properties
4
+
5
+ see http://www.pingidentity.com/opentoken
6
+
7
+ == Note on Patches/Pull Requests
8
+
9
+ * Fork the project.
10
+ * Make your feature addition or bug fix.
11
+ * Add tests for it. This is important so I don't break it in a future version unintentionally.
12
+ * Commit, do not mess with rakefile, version, or history. (bump version in a commit by itself I can ignore when I pull)
13
+ * Send me a pull request. Bonus points for topic branches.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2010 Ryan Sonnek. See LICENSE for details.
@@ -0,0 +1,55 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "opentoken"
8
+ gem.summary = %Q{ruby implementation of the opentoken specification}
9
+ gem.description = %Q{parse opentoken properties passed for Single Signon requests}
10
+ gem.email = "ryan@socialcast.com"
11
+ gem.homepage = "http://github.com/wireframe/opentoken"
12
+ gem.authors = ["Ryan Sonnek"]
13
+ gem.add_dependency "activesupport", ">=2.3.4"
14
+ gem.add_development_dependency "shoulda", ">= 0"
15
+ gem.add_development_dependency "timecop", ">=0.3.4"
16
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
17
+ end
18
+ Jeweler::RubygemsDotOrgTasks.new
19
+ rescue LoadError
20
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
21
+ end
22
+
23
+ require 'rake/testtask'
24
+ Rake::TestTask.new(:test) do |test|
25
+ test.libs << 'lib' << 'test'
26
+ test.pattern = 'test/**/test_*.rb'
27
+ test.verbose = true
28
+ end
29
+
30
+ begin
31
+ require 'rcov/rcovtask'
32
+ Rcov::RcovTask.new do |test|
33
+ test.libs << 'test'
34
+ test.pattern = 'test/**/test_*.rb'
35
+ test.verbose = true
36
+ end
37
+ rescue LoadError
38
+ task :rcov do
39
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
40
+ end
41
+ end
42
+
43
+ task :test => :check_dependencies
44
+
45
+ task :default => :test
46
+
47
+ require 'rake/rdoctask'
48
+ Rake::RDocTask.new do |rdoc|
49
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
50
+
51
+ rdoc.rdoc_dir = 'rdoc'
52
+ rdoc.title = "opentoken #{version}"
53
+ rdoc.rdoc_files.include('README*')
54
+ rdoc.rdoc_files.include('lib/**/*.rb')
55
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,326 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+ require 'digest/sha1'
4
+ require 'zlib'
5
+ require 'stringio'
6
+ require 'cgi'
7
+
8
+ class OpenToken
9
+ class TokenExpiredError < StandardError; end
10
+
11
+ DEBUG = false
12
+ CIPHER_NULL = 0
13
+ CIPHER_AES_256_CBC = 1
14
+ CIPHER_AES_128_CBC = 2
15
+ CIPHER_3DES_168_CBC = 3
16
+
17
+ CIPHERS = {
18
+ CIPHER_NULL => {
19
+ :iv_length => 0
20
+ },
21
+ CIPHER_AES_256_CBC => {
22
+ :algorithm => 'aes-256-cbc',
23
+ :iv_length => 32,
24
+ :key_length => 256
25
+ },
26
+ CIPHER_AES_128_CBC => {
27
+ :algorithm => 'aes-128-cbc',
28
+ :iv_length => 16,
29
+ :key_length => 128
30
+ },
31
+ CIPHER_3DES_168_CBC => {
32
+ :algorithm => 'des-cbc',
33
+ :iv_length => 8,
34
+ :key_length => 168
35
+ }
36
+ }
37
+
38
+ def initialize(token, options = {})
39
+ #ruby 1.9 has Base64.urlsafe_decode64 which can be used instead of gsubbing '_' and '-'
40
+ string = (token || '').gsub('*', '=').gsub('_', '/').gsub('-', '+')
41
+ data = Base64.decode64(string)
42
+ inspect_binary_string 'DATA', data
43
+
44
+ #header: should be OTK
45
+ header = data[0..2]
46
+ raise "Invalid token header: #{header}" unless header == 'OTK'
47
+
48
+ #version: should == 1
49
+ version = data[3]
50
+ raise "Unsupported token version: #{version}" unless version == 1
51
+
52
+ #cipher suite identifier
53
+ cipher_suite = data[4]
54
+ cipher = CIPHERS[cipher_suite]
55
+ raise "Unknown cipher suite: #{cipher_suite}" if cipher.nil?
56
+
57
+ #SHA-1 HMAC
58
+ payload_hmac = data[5..24]
59
+ inspect_binary_string "PAYLOAD HMAC [5..24]", payload_hmac
60
+
61
+ #Initialization Vector (iv)
62
+ iv_length = data[25]
63
+ iv_end = [26, 26 + iv_length - 1].max
64
+ iv = data[26..iv_end]
65
+ inspect_binary_string "IV [26..#{iv_end}]", iv
66
+ raise "Cipher expects iv length of #{cipher[:iv_length]} and was: #{iv_length}" unless iv_length == cipher[:iv_length]
67
+
68
+ #key (not currently used)
69
+ key_length = data[iv_end + 1]
70
+ key_end = iv_end + 1
71
+ raise "Token key embedding is not currently supported" unless key_length == 0
72
+
73
+ #payload
74
+ payload_length = data[(key_end + 1)..(key_end + 2)].unpack('n').first
75
+ payload_offset = key_end + 3
76
+ encrypted_payload = data[payload_offset..(data.length - 1)]
77
+ raise "Payload length is #{encrypted_payload.length} and was expected to be #{payload_length}" unless encrypted_payload.length == payload_length
78
+ inspect_binary_string "ENCRYPTED PAYLOAD [#{payload_offset}..#{data.length - 1}]", encrypted_payload
79
+
80
+ key = PasswordKeyGenerator.generate(options[:password], cipher)
81
+ inspect_binary_string 'KEY', key
82
+
83
+ compressed_payload = decrypt_payload(encrypted_payload, cipher, key, iv)
84
+ inspect_binary_string 'COMPRESSED PAYLOAD', compressed_payload
85
+
86
+ #decompress the payload
87
+ #see http://stackoverflow.com/questions/1361892/how-to-decompress-gzip-data-in-ruby
88
+ unparsed_payload = begin
89
+ Zlib::Inflate.inflate(compressed_payload)
90
+ rescue Zlib::BufError
91
+ Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(compressed_payload[2, compressed_payload.size])
92
+ end
93
+
94
+ #validate payload hmac
95
+ mac = "0x01".hex.chr
96
+ mac += cipher_suite.chr
97
+ mac += iv
98
+ mac += key if key_length > 0 #key embedding is not currently supported
99
+ mac += unparsed_payload
100
+ hash = OpenSSL::HMAC.digest(PasswordKeyGenerator::SHA1_DIGEST, key, mac)
101
+ if (hash <=> payload_hmac) != 0
102
+ raise "HMAC for payload was #{hash} and expected to be #{payload_hmac}" unless payload_hmac == hash
103
+ end
104
+
105
+ @payload = KeyValueSerializer.deserialize CGI::unescapeHTML(unparsed_payload)
106
+ raise TokenExpiredError.new("#{Time.now.utc} is not within token duration: #{self.start_at} - #{self.end_at}") if self.expired?
107
+ end
108
+
109
+ def [](key)
110
+ @payload[key.to_s]
111
+ end
112
+ #verify that the current time is between the not-before and not-on-or-after values
113
+ def expired?
114
+ now = Time.now.utc
115
+ now < start_at || now >= end_at
116
+ end
117
+ def start_at
118
+ payload_date('not-before')
119
+ end
120
+ def end_at
121
+ payload_date('not-on-or-after')
122
+ end
123
+ #"renew-until"=>"2010-03-05T07:19:15Z"
124
+ def valid_until
125
+ payload_date('renew-until')
126
+ end
127
+ def payload_date(key)
128
+ Time.iso8601(self[key]).utc
129
+ end
130
+
131
+ private
132
+ def decrypt_payload(encrypted_payload, cipher, key, iv)
133
+ return encrypted_payload unless cipher[:algorithm]
134
+ #see http://snippets.dzone.com/posts/show/4975
135
+ #see http://jdwyah.blogspot.com/2009/12/decrypting-ruby-aes-encryption.html
136
+ #see http://snippets.dzone.com/posts/show/576
137
+ crypt = OpenSSL::Cipher::Cipher.new(cipher[:algorithm])
138
+ crypt.decrypt
139
+ crypt.key = key
140
+ crypt.iv = iv
141
+ crypt.update(encrypted_payload) + crypt.final
142
+ end
143
+
144
+ def inspect_binary_string(header, string)
145
+ return unless DEBUG
146
+ puts "#{header}:"
147
+ index = 0
148
+ string.each_byte do |b|
149
+ puts "#{index}: #{b} => #{b.chr}"
150
+ index += 1
151
+ end
152
+ end
153
+ 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,57 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{opentoken}
8
+ s.version = "0.2.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Ryan Sonnek"]
12
+ s.date = %q{2011-01-12}
13
+ s.description = %q{parse opentoken properties passed for Single Signon requests}
14
+ s.email = %q{ryan@socialcast.com}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE",
17
+ "README.rdoc"
18
+ ]
19
+ s.files = [
20
+ ".document",
21
+ "LICENSE",
22
+ "README.rdoc",
23
+ "Rakefile",
24
+ "VERSION",
25
+ "lib/opentoken.rb",
26
+ "opentoken.gemspec",
27
+ "test/helper.rb",
28
+ "test/test_opentoken.rb"
29
+ ]
30
+ s.homepage = %q{http://github.com/wireframe/opentoken}
31
+ s.require_paths = ["lib"]
32
+ s.rubygems_version = %q{1.4.2}
33
+ s.summary = %q{ruby implementation of the opentoken specification}
34
+ s.test_files = [
35
+ "test/helper.rb",
36
+ "test/test_opentoken.rb"
37
+ ]
38
+
39
+ if s.respond_to? :specification_version then
40
+ s.specification_version = 3
41
+
42
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
43
+ s.add_runtime_dependency(%q<activesupport>, [">= 2.3.4"])
44
+ s.add_development_dependency(%q<shoulda>, [">= 0"])
45
+ s.add_development_dependency(%q<timecop>, [">= 0.3.4"])
46
+ else
47
+ s.add_dependency(%q<activesupport>, [">= 2.3.4"])
48
+ s.add_dependency(%q<shoulda>, [">= 0"])
49
+ s.add_dependency(%q<timecop>, [">= 0.3.4"])
50
+ end
51
+ else
52
+ s.add_dependency(%q<activesupport>, [">= 2.3.4"])
53
+ s.add_dependency(%q<shoulda>, [">= 0"])
54
+ s.add_dependency(%q<timecop>, [">= 0.3.4"])
55
+ end
56
+ end
57
+
@@ -0,0 +1,12 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'timecop'
5
+ require 'activesupport'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+ require 'opentoken'
10
+
11
+ class Test::Unit::TestCase
12
+ end
@@ -0,0 +1,48 @@
1
+ require 'helper'
2
+
3
+ class TestOpentoken < Test::Unit::TestCase
4
+ #"renew-until"=>"2010-03-05T07:19:15Z"
5
+ #"not-before"=>"2010-03-04T19:19:15Z"
6
+ #"not-on-or-after"=>"2010-03-04T19:24:15Z"
7
+ context "aes-128-cbc token with subject attribute" do
8
+ setup do
9
+ @opentoken = "T1RLAQJ0Ca97sl6MLJAZDa_hdFzMlicMQBDjqUzrXl0EOXKmpj5oo7L5AACgaWoW8fZizrsLbtxb_F00aTdFmhw8flGy4iGqPWPtqYpdIzQZzg5WvrvYH8Rnq7ckJpYk2YPZw6yNyA4ohG-BgFdTHc0U7CwZTFmodg1MuO0cTh7T98s2RXiTcaZa21MNO0yuXKm2Q10cbrWhnB5yHJUhSHx6JLxlgMTZ0oE0DoUOB6JmoLMYHcyL9hKRiPTh62ky_QmXRaifDNOdl4sH2w**"
10
+ @password = 'Test123'
11
+ end
12
+ context "parsing token between expiration dates" do
13
+ setup do
14
+ Timecop.travel(Time.iso8601('2010-03-04T19:20:10Z')) do
15
+ assert_nothing_raised do
16
+ @token = OpenToken.new @opentoken, :password => @password
17
+ end
18
+ end
19
+ end
20
+ should "decrypt subject from token payload" do
21
+ assert_equal 'john@example.com', @token[:subject]
22
+ end
23
+ should "parse 'renew-until' date" do
24
+ assert_equal Time.iso8601('2010-03-05T07:19:15Z'), @token.valid_until
25
+ end
26
+ end
27
+
28
+ context "parsing token when current time is before expiration date" do
29
+ should "raise TokenExpiredError" do
30
+ Timecop.travel(Time.iso8601('2010-03-04T19:19:10Z')) do
31
+ assert_raises OpenToken::TokenExpiredError do
32
+ @token = OpenToken.new @opentoken, :password => @password
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ context "parsing token when current time is equal to expiration date" do
39
+ should "raise TokenExpiredError" do
40
+ Timecop.travel(Time.iso8601('2010-03-04T19:24:15Z')) do
41
+ assert_raises OpenToken::TokenExpiredError do
42
+ @token = OpenToken.new @opentoken, :password => @password
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: opentoken
3
+ version: !ruby/object:Gem::Version
4
+ hash: 23
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 2
9
+ - 0
10
+ version: 0.2.0
11
+ platform: ruby
12
+ authors:
13
+ - Ryan Sonnek
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-01-12 00:00:00 -06:00
19
+ default_executable:
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
+ - !ruby/object:Gem::Dependency
38
+ name: shoulda
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ hash: 3
46
+ segments:
47
+ - 0
48
+ version: "0"
49
+ type: :development
50
+ version_requirements: *id002
51
+ - !ruby/object:Gem::Dependency
52
+ name: timecop
53
+ prerelease: false
54
+ requirement: &id003 !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 27
60
+ segments:
61
+ - 0
62
+ - 3
63
+ - 4
64
+ version: 0.3.4
65
+ type: :development
66
+ version_requirements: *id003
67
+ description: parse opentoken properties passed for Single Signon requests
68
+ email: ryan@socialcast.com
69
+ executables: []
70
+
71
+ extensions: []
72
+
73
+ extra_rdoc_files:
74
+ - LICENSE
75
+ - README.rdoc
76
+ files:
77
+ - .document
78
+ - LICENSE
79
+ - README.rdoc
80
+ - Rakefile
81
+ - VERSION
82
+ - lib/opentoken.rb
83
+ - opentoken.gemspec
84
+ - test/helper.rb
85
+ - test/test_opentoken.rb
86
+ has_rdoc: true
87
+ homepage: http://github.com/wireframe/opentoken
88
+ licenses: []
89
+
90
+ post_install_message:
91
+ rdoc_options: []
92
+
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ hash: 3
101
+ segments:
102
+ - 0
103
+ version: "0"
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ hash: 3
110
+ segments:
111
+ - 0
112
+ version: "0"
113
+ requirements: []
114
+
115
+ rubyforge_project:
116
+ rubygems_version: 1.4.2
117
+ signing_key:
118
+ specification_version: 3
119
+ summary: ruby implementation of the opentoken specification
120
+ test_files:
121
+ - test/helper.rb
122
+ - test/test_opentoken.rb