pyu-ruby-sasl 0.0.3.2

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/README.markdown ADDED
@@ -0,0 +1,22 @@
1
+ Simple Authentication and Security Layer (RFC 4422) for Ruby
2
+ ============================================================
3
+
4
+ Goal
5
+ ----
6
+
7
+ Have a reusable library for client implementations that need to do
8
+ authentication over SASL, mainly targeted at Jabber/XMPP libraries.
9
+
10
+ All class carry just state, are thread-agnostic and must also work in
11
+ asynchronous environments.
12
+
13
+ Usage
14
+ -----
15
+
16
+ Derive from **SASL::Preferences** and overwrite the methods. Then,
17
+ create a mechanism instance:
18
+ # mechanisms => ['DIGEST-MD5', 'PLAIN']
19
+ sasl = SASL.new(mechanisms, my_preferences)
20
+ content_to_send = sasl.start
21
+ # [...]
22
+ content_to_send = sasl.challenge(received_content)
data/lib/sasl.rb ADDED
@@ -0,0 +1,5 @@
1
+ SASL_PATH = File.dirname(__FILE__) + "/sasl"
2
+ require 'sasl/base'
3
+ Dir.foreach(SASL_PATH) do |f|
4
+ require "#{SASL_PATH}/#{f}" if f =~ /^[^\.].+\.rb$/ && f != 'base.rb'
5
+ end
@@ -0,0 +1,14 @@
1
+ module SASL
2
+ ##
3
+ # SASL ANONYMOUS where you only send a username that may not get
4
+ # evaluated by the server.
5
+ #
6
+ # RFC 4505:
7
+ # http://tools.ietf.org/html/rfc4505
8
+ class Anonymous < Mechanism
9
+ def start
10
+ @state = nil
11
+ ['auth', preferences.username.to_s]
12
+ end
13
+ end
14
+ end
data/lib/sasl/base.rb ADDED
@@ -0,0 +1,122 @@
1
+ ##
2
+ # RFC 4422:
3
+ # http://tools.ietf.org/html/rfc4422
4
+ module SASL
5
+ ##
6
+ # You must derive from class Preferences and overwrite methods you
7
+ # want to implement.
8
+ class Preferences
9
+ attr_reader :config
10
+ # key in config hash
11
+ # authzid: Authorization identitiy ('username@domain' in XMPP)
12
+ # realm: Realm ('domain' in XMPP)
13
+ # digest-uri: : serv-type/serv-name | serv-type/host/serv-name ('xmpp/domain' in XMPP)
14
+ # username
15
+ # has_password?
16
+ # allow_plaintext?
17
+ # password
18
+ # want_anonymous?
19
+
20
+ def initialize (config)
21
+ @config = {:has_password? => false, :allow_plaintext? => false, :want_anonymous? => false}.merge(config.dup)
22
+ end
23
+ def method_missing(sym, *args, &block)
24
+ @config.send "[]", sym, &block
25
+ end
26
+ end
27
+
28
+ ##
29
+ # Will be raised by SASL.new_mechanism if mechanism passed to the
30
+ # constructor is not known.
31
+ class UnknownMechanism < RuntimeError
32
+ def initialize(mechanism)
33
+ @mechanism = mechanism
34
+ end
35
+
36
+ def to_s
37
+ "Unknown mechanism: #{@mechanism.inspect}"
38
+ end
39
+ end
40
+
41
+ def SASL.new(mechanisms, preferences)
42
+ best_mechanism = if preferences.want_anonymous? && mechanisms.include?('ANONYMOUS')
43
+ 'ANONYMOUS'
44
+ elsif preferences.has_password?
45
+ if mechanisms.include?('DIGEST-MD5')
46
+ 'DIGEST-MD5'
47
+ elsif preferences.allow_plaintext?
48
+ 'PLAIN'
49
+ else
50
+ raise UnknownMechanism.new(mechanisms)
51
+ end
52
+ else
53
+ raise UnknownMechanism.new(mechanisms)
54
+ end
55
+ new_mechanism(best_mechanism, preferences)
56
+ end
57
+
58
+ ##
59
+ # Create a SASL Mechanism for the named mechanism
60
+ #
61
+ # mechanism:: [String] mechanism name
62
+ def SASL.new_mechanism(mechanism, preferences)
63
+ mechanism_class = case mechanism
64
+ when 'DIGEST-MD5'
65
+ DigestMD5
66
+ when 'PLAIN'
67
+ Plain
68
+ when 'ANONYMOUS'
69
+ Anonymous
70
+ else
71
+ raise UnknownMechanism.new(mechanism)
72
+ end
73
+ mechanism_class.new(mechanism, preferences)
74
+ end
75
+
76
+
77
+ class AbstractMethod < Exception # :nodoc:
78
+ def to_s
79
+ "Abstract method is not implemented"
80
+ end
81
+ end
82
+
83
+ ##
84
+ # Common functions for mechanisms
85
+ #
86
+ # Mechanisms implement handling of methods start and receive. They
87
+ # return: [message_name, content] or nil where message_name is
88
+ # either 'auth' or 'response' and content is either a string which
89
+ # may transmitted encoded as Base64 or nil.
90
+ class Mechanism
91
+ attr_reader :mechanism
92
+ attr_reader :preferences
93
+
94
+ def initialize(mechanism, preferences)
95
+ @mechanism = mechanism
96
+ @preferences = preferences
97
+ @state = nil
98
+ end
99
+
100
+ def success?
101
+ @state == :success
102
+ end
103
+ def failure?
104
+ @state == :failure
105
+ end
106
+
107
+ def start
108
+ raise AbstractMethod
109
+ end
110
+
111
+
112
+ def receive(message_name, content)
113
+ case message_name
114
+ when 'success'
115
+ @state = :success
116
+ when 'failure'
117
+ @state = :failure
118
+ end
119
+ nil
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,32 @@
1
+ # =XMPP4R - XMPP Library for Ruby
2
+ # License:: Ruby's license (see the LICENSE file) or GNU GPL, at your option.
3
+ # Website::http://home.gna.org/xmpp4r/
4
+
5
+ begin
6
+ require 'base64'
7
+ rescue LoadError
8
+ ##
9
+ # Ruby 1.9 has dropped the Base64 module,
10
+ # this is a replacement
11
+ #
12
+ # We could replace all call by Array#pack('m')
13
+ # and String#unpack('m'), but this module
14
+ # improves readability.
15
+ module Base64
16
+ ##
17
+ # Encode a String
18
+ # data:: [String] Binary
19
+ # result:: [String] Binary in Base64
20
+ def self.encode64(data)
21
+ [data].pack('m')
22
+ end
23
+
24
+ ##
25
+ # Decode a Base64-encoded String
26
+ # data64:: [String] Binary in Base64
27
+ # result:: [String] Binary
28
+ def self.decode64(data64)
29
+ data64.unpack('m').first
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,171 @@
1
+ require 'digest/md5'
2
+
3
+ module SASL
4
+ ##
5
+ # RFC 2831:
6
+ # http://tools.ietf.org/html/rfc2831
7
+ class DigestMD5 < Mechanism
8
+ attr_writer :cnonce
9
+
10
+ def initialize(*a)
11
+ super
12
+ @nonce_count = 0
13
+ end
14
+
15
+ def start
16
+ @state = nil
17
+ unless defined? @nonce
18
+ ['auth', nil]
19
+ else
20
+ # reauthentication
21
+ receive('challenge', '')
22
+ end
23
+ end
24
+
25
+ def receive(message_name, content)
26
+ if message_name == 'challenge'
27
+ c = decode_challenge(content)
28
+
29
+ unless c['rspauth']
30
+ response = {}
31
+ if defined?(@nonce) && response['nonce'].nil?
32
+ # Could be reauth
33
+ else
34
+ # No reauth:
35
+ @nonce_count = 0
36
+ end
37
+ @nonce ||= c['nonce']
38
+ response['nonce'] = @nonce
39
+ response['charset'] = 'utf-8'
40
+ response['username'] = preferences.username
41
+ response['realm'] = c['realm'] || preferences.realm
42
+ @cnonce = generate_nonce unless defined? @cnonce
43
+ response['cnonce'] = @cnonce
44
+ @nc = next_nc
45
+ response['nc'] = @nc
46
+ @qop = c['qop'] || 'auth'
47
+ response['qop'] = 'auth' #@qop
48
+ response['digest-uri'] = preferences.digest_uri
49
+ response['response'] = response_value(response['nonce'], response['nc'], response['cnonce'], response['qop'], response['realm'])
50
+ ['response', encode_response(response)]
51
+ else
52
+ rspauth_expected = response_value(@nonce, @nc, @cnonce, @qop, '')
53
+ p :rspauth_received=>c['rspauth'], :rspauth_expected=>rspauth_expected
54
+ if c['rspauth'] == rspauth_expected
55
+ ['response', nil]
56
+ else
57
+ # Bogus server?
58
+ @state = :failure
59
+ ['failure', nil]
60
+ end
61
+ end
62
+ else
63
+ # No challenge? Might be success or failure
64
+ super
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def decode_challenge(text)
71
+ challenge = {}
72
+
73
+ state = :key
74
+ key = ''
75
+ value = ''
76
+
77
+ text.scan(/./) do |ch|
78
+ if state == :key
79
+ if ch == '='
80
+ state = :value
81
+ elsif ch =~ /\S/
82
+ key += ch
83
+ end
84
+
85
+ elsif state == :value
86
+ if ch == ','
87
+ challenge[key] = value
88
+ key = ''
89
+ value = ''
90
+ state = :key
91
+ elsif ch == '"' and value == ''
92
+ state = :quote
93
+ else
94
+ value += ch
95
+ end
96
+
97
+ elsif state == :quote
98
+ if ch == '"'
99
+ state = :value
100
+ else
101
+ value += ch
102
+ end
103
+ end
104
+ end
105
+ challenge[key] = value unless key == ''
106
+
107
+ p :decode_challenge => challenge
108
+ challenge
109
+ end
110
+
111
+ def encode_response(response)
112
+ p :encode_response => response
113
+ response.collect do |k,v|
114
+ if ['username', 'cnonce', 'digest-uri', 'authzid','realm','qop'].include? k
115
+ v.sub!('\\', '\\\\')
116
+ v.sub!('"', '\\"')
117
+ "#{k}=\"#{v}\""
118
+ else
119
+ "#{k}=#{v}"
120
+ end
121
+ end.join(',')
122
+ end
123
+
124
+ def generate_nonce
125
+ nonce = ''
126
+ while nonce.length < 32
127
+ c = rand(128).chr
128
+ nonce += c if c =~ /^[a-zA-Z0-9]$/
129
+ end
130
+ nonce
131
+ end
132
+
133
+ ##
134
+ # Function from RFC2831
135
+ def h(s); Digest::MD5.digest(s); end
136
+ ##
137
+ # Function from RFC2831
138
+ def hh(s); Digest::MD5.hexdigest(s); end
139
+
140
+ ##
141
+ # Calculate the value for the response field
142
+ def response_value(nonce, nc, cnonce, qop, realm, a2_prefix='AUTHENTICATE')
143
+ p :response_value => {:nonce=>nonce,
144
+ :cnonce=>cnonce,
145
+ :qop=>qop,
146
+ :username=>preferences.username,
147
+ :realm=>preferences.realm,
148
+ :password=>preferences.password,
149
+ :authzid=>preferences.authzid}
150
+ a1_h = h("#{preferences.username}:#{realm}:#{preferences.password}")
151
+ a1 = "#{a1_h}:#{nonce}:#{cnonce}"
152
+ if preferences.authzid
153
+ a1 += ":#{preferences.authzid}"
154
+ end
155
+ if qop && (qop.downcase == 'auth-int' || qop.downcase == 'auth-conf')
156
+ a2 = "#{a2_prefix}:#{preferences.digest_uri}:00000000000000000000000000000000"
157
+ else
158
+ a2 = "#{a2_prefix}:#{preferences.digest_uri}"
159
+ end
160
+ hh("#{hh(a1)}:#{nonce}:#{nc}:#{cnonce}:#{qop}:#{hh(a2)}")
161
+ end
162
+
163
+ def next_nc
164
+ @nonce_count += 1
165
+ s = @nonce_count.to_s
166
+ s = "0#{s}" while s.length < 8
167
+ s
168
+ end
169
+ end
170
+ end
171
+
data/lib/sasl/plain.rb ADDED
@@ -0,0 +1,14 @@
1
+ module SASL
2
+ ##
3
+ # RFC 4616:
4
+ # http://tools.ietf.org/html/rfc4616
5
+ class Plain < Mechanism
6
+ def start
7
+ @state = nil
8
+ message = [preferences.authzid.to_s,
9
+ preferences.username,
10
+ preferences.password].join("\000")
11
+ ['auth', message]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ require 'sasl'
2
+ require 'spec'
3
+
4
+ describe SASL::Anonymous do
5
+ class MyAnonymousPreferences < SASL::Preferences
6
+ def username
7
+ 'bob'
8
+ end
9
+ end
10
+ preferences = MyAnonymousPreferences.new
11
+
12
+ it 'should authenticate anonymously' do
13
+ sasl = SASL::Anonymous.new('ANONYMOUS', preferences)
14
+ sasl.start.should == ['auth', 'bob']
15
+ sasl.success?.should == false
16
+ sasl.receive('success', nil).should == nil
17
+ sasl.success?.should == true
18
+ end
19
+ end
@@ -0,0 +1,107 @@
1
+ require 'sasl'
2
+ require 'spec'
3
+
4
+ describe SASL::DigestMD5 do
5
+ # Preferences from http://tools.ietf.org/html/rfc2831#section-4
6
+ class MyDigestMD5Preferences < SASL::Preferences
7
+ attr_writer :serv_type
8
+ def realm
9
+ 'elwood.innosoft.com'
10
+ end
11
+ def digest_uri
12
+ "#{@serv_type}/elwood.innosoft.com"
13
+ end
14
+ def username
15
+ 'chris'
16
+ end
17
+ def has_password?
18
+ true
19
+ end
20
+ def password
21
+ 'secret'
22
+ end
23
+ end
24
+ preferences = MyDigestMD5Preferences.new
25
+
26
+ it 'should authenticate (1)' do
27
+ preferences.serv_type = 'imap'
28
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
29
+ sasl.start.should == ['auth', nil]
30
+ sasl.cnonce = 'OA6MHXh6VqTrRk'
31
+ response = sasl.receive('challenge',
32
+ 'realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth",
33
+ algorithm=md5-sess,charset=utf-8')
34
+ response[0].should == 'response'
35
+ response[1].should =~ /charset="?utf-8"?/
36
+ response[1].should =~ /username="?chris"?/
37
+ response[1].should =~ /realm="?elwood.innosoft.com"?/
38
+ response[1].should =~ /nonce="?OA6MG9tEQGm2hh"?/
39
+ response[1].should =~ /nc="?00000001"?/
40
+ response[1].should =~ /cnonce="?OA6MHXh6VqTrRk"?/
41
+ response[1].should =~ /digest-uri="?imap\/elwood.innosoft.com"?/
42
+ response[1].should =~ /response=d388dad90d4bbd760a152321f2143af7"?/
43
+ response[1].should =~ /"?qop=auth"?/
44
+
45
+ sasl.receive('challenge',
46
+ 'rspauth=ea40f60335c427b5527b84dbabcdfffd').should ==
47
+ ['response', nil]
48
+ sasl.receive('success', nil).should == nil
49
+ sasl.success?.should == true
50
+ end
51
+
52
+ it 'should authenticate (2)' do
53
+ preferences.serv_type = 'acap'
54
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
55
+ sasl.start.should == ['auth', nil]
56
+ sasl.cnonce = 'OA9BSuZWMSpW8m'
57
+ response = sasl.receive('challenge',
58
+ 'realm="elwood.innosoft.com",nonce="OA9BSXrbuRhWay",qop="auth",
59
+ algorithm=md5-sess,charset=utf-8')
60
+ response[0].should == 'response'
61
+ response[1].should =~ /charset="?utf-8"?/
62
+ response[1].should =~ /username="?chris"?/
63
+ response[1].should =~ /realm="?elwood.innosoft.com"?/
64
+ response[1].should =~ /nonce="?OA9BSXrbuRhWay"?/
65
+ response[1].should =~ /nc="?00000001"?/
66
+ response[1].should =~ /cnonce="?OA9BSuZWMSpW8m"?/
67
+ response[1].should =~ /digest-uri="?acap\/elwood.innosoft.com"?/
68
+ response[1].should =~ /response=6084c6db3fede7352c551284490fd0fc"?/
69
+ response[1].should =~ /"?qop=auth"?/
70
+
71
+ sasl.receive('challenge',
72
+ 'rspauth=2f0b3d7c3c2e486600ef710726aa2eae').should ==
73
+ ['response', nil]
74
+ sasl.receive('success', nil).should == nil
75
+ sasl.success?.should == true
76
+ end
77
+
78
+ it 'should reauthenticate' do
79
+ preferences.serv_type = 'imap'
80
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
81
+ sasl.start.should == ['auth', nil]
82
+ sasl.cnonce = 'OA6MHXh6VqTrRk'
83
+ sasl.receive('challenge',
84
+ 'realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth",
85
+ algorithm=md5-sess,charset=utf-8')
86
+ # reauth:
87
+ response = sasl.start
88
+ response[0].should == 'response'
89
+ response[1].should =~ /charset="?utf-8"?/
90
+ response[1].should =~ /username="?chris"?/
91
+ response[1].should =~ /realm="?elwood.innosoft.com"?/
92
+ response[1].should =~ /nonce="?OA6MG9tEQGm2hh"?/
93
+ response[1].should =~ /nc="?00000002"?/
94
+ response[1].should =~ /cnonce="?OA6MHXh6VqTrRk"?/
95
+ response[1].should =~ /digest-uri="?imap\/elwood.innosoft.com"?/
96
+ response[1].should =~ /response=b0b5d72a400655b8306e434566b10efb"?/ # my own result
97
+ response[1].should =~ /"?qop=auth"?/
98
+ end
99
+
100
+ it 'should fail' do
101
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
102
+ sasl.start.should == ['auth', nil]
103
+ sasl.receive('failure', 'EPIC FAIL')
104
+ sasl.failure?.should == true
105
+ sasl.success?.should == false
106
+ end
107
+ end
@@ -0,0 +1,56 @@
1
+ require 'sasl'
2
+ require 'spec'
3
+
4
+ describe SASL do
5
+ it 'should know DIGEST-MD5' do
6
+ sasl = SASL.new_mechanism('DIGEST-MD5', SASL::Preferences.new)
7
+ sasl.should be_an_instance_of SASL::DigestMD5
8
+ end
9
+ it 'should know PLAIN' do
10
+ sasl = SASL.new_mechanism('PLAIN', SASL::Preferences.new)
11
+ sasl.should be_an_instance_of SASL::Plain
12
+ end
13
+ it 'should know ANONYMOUS' do
14
+ sasl = SASL.new_mechanism('ANONYMOUS', SASL::Preferences.new)
15
+ sasl.should be_an_instance_of SASL::Anonymous
16
+ end
17
+ it 'should choose ANONYMOUS' do
18
+ preferences = SASL::Preferences.new
19
+ class << preferences
20
+ def want_anonymous?
21
+ true
22
+ end
23
+ end
24
+ SASL.new(%w(PLAIN DIGEST-MD5 ANONYMOUS), preferences).should be_an_instance_of SASL::Anonymous
25
+ end
26
+ it 'should choose DIGEST-MD5' do
27
+ preferences = SASL::Preferences.new
28
+ class << preferences
29
+ def has_password?
30
+ true
31
+ end
32
+ end
33
+ SASL.new(%w(PLAIN DIGEST-MD5 ANONYMOUS), preferences).should be_an_instance_of SASL::DigestMD5
34
+ end
35
+ it 'should choose PLAIN' do
36
+ preferences = SASL::Preferences.new
37
+ class << preferences
38
+ def has_password?
39
+ true
40
+ end
41
+ def allow_plaintext?
42
+ true
43
+ end
44
+ end
45
+ SASL.new(%w(PLAIN ANONYMOUS), preferences).should be_an_instance_of SASL::Plain
46
+ end
47
+ it 'should disallow PLAIN by default' do
48
+ preferences = SASL::Preferences.new
49
+ class << preferences
50
+ def has_password?
51
+ true
52
+ end
53
+ end
54
+ lambda { SASL.new(%w(PLAIN ANONYMOUS), preferences) }.should raise_error(SASL::UnknownMechanism)
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ require 'sasl'
2
+ require 'spec'
3
+
4
+ describe SASL::Plain do
5
+ class MyPlainPreferences < SASL::Preferences
6
+ def authzid
7
+ 'bob@example.com'
8
+ end
9
+ def username
10
+ 'bob'
11
+ end
12
+ def has_password?
13
+ true
14
+ end
15
+ def password
16
+ 's3cr3t'
17
+ end
18
+ end
19
+ preferences = MyPlainPreferences.new
20
+
21
+ it 'should authenticate' do
22
+ sasl = SASL::Plain.new('PLAIN', preferences)
23
+ sasl.start.should == ['auth', "bob@example.com\000bob\000s3cr3t"]
24
+ sasl.success?.should == false
25
+ sasl.receive('success', nil).should == nil
26
+ sasl.failure?.should == false
27
+ sasl.success?.should == true
28
+ end
29
+
30
+ it 'should recognize failure' do
31
+ sasl = SASL::Plain.new('PLAIN', preferences)
32
+ sasl.start.should == ['auth', "bob@example.com\000bob\000s3cr3t"]
33
+ sasl.success?.should == false
34
+ sasl.failure?.should == false
35
+ sasl.receive('failure', 'keep-idiots-out').should == nil
36
+ sasl.failure?.should == true
37
+ sasl.success?.should == false
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pyu-ruby-sasl
3
+ version: !ruby/object:Gem::Version
4
+ hash: 71
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 3
10
+ - 2
11
+ version: 0.0.3.2
12
+ platform: ruby
13
+ authors:
14
+ - Stephan Maka
15
+ - Ping Yu
16
+ autorequire:
17
+ bindir: bin
18
+ cert_chain: []
19
+
20
+ date: 2010-10-18 00:00:00 -05:00
21
+ default_executable:
22
+ dependencies: []
23
+
24
+ description: Simple Authentication and Security Layer (RFC 4422)
25
+ email: pyu@intridea.com
26
+ executables: []
27
+
28
+ extensions: []
29
+
30
+ extra_rdoc_files: []
31
+
32
+ files:
33
+ - spec/mechanism_spec.rb
34
+ - spec/anonymous_spec.rb
35
+ - spec/plain_spec.rb
36
+ - spec/digest_md5_spec.rb
37
+ - lib/sasl/base.rb
38
+ - lib/sasl/digest_md5.rb
39
+ - lib/sasl/anonymous.rb
40
+ - lib/sasl/plain.rb
41
+ - lib/sasl/base64.rb
42
+ - lib/sasl.rb
43
+ - README.markdown
44
+ has_rdoc: true
45
+ homepage: http://github.com/pyu10055/ruby-sasl/
46
+ licenses: []
47
+
48
+ post_install_message:
49
+ rdoc_options: []
50
+
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ none: false
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ hash: 3
59
+ segments:
60
+ - 0
61
+ version: "0"
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ hash: 3
68
+ segments:
69
+ - 0
70
+ version: "0"
71
+ requirements: []
72
+
73
+ rubyforge_project:
74
+ rubygems_version: 1.3.7
75
+ signing_key:
76
+ specification_version: 3
77
+ summary: SASL client library
78
+ test_files:
79
+ - spec/mechanism_spec.rb
80
+ - spec/anonymous_spec.rb
81
+ - spec/plain_spec.rb
82
+ - spec/digest_md5_spec.rb