pyu-ruby-sasl 0.0.3.2

Sign up to get free protection for your applications and to get access to all the features.
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