ruby-sasl 0.0.3

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,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)
@@ -0,0 +1,4 @@
1
+ SASL_PATH = File.dirname(__FILE__) + "/sasl"
2
+ Dir.foreach(SASL_PATH) do |f|
3
+ require "#{SASL_PATH}/#{f}" if f =~ /^[^\.].+\.rb$/
4
+ 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
@@ -0,0 +1,144 @@
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
+ ##
10
+ # Authorization identitiy ('username@domain' in XMPP)
11
+ def authzid
12
+ nil
13
+ end
14
+
15
+ ##
16
+ # Realm ('domain' in XMPP)
17
+ def realm
18
+ raise AbstractMethod
19
+ end
20
+
21
+
22
+ ##
23
+ # digest-uri: serv-type/serv-name | serv-type/host/serv-name
24
+ # ('xmpp/domain' in XMPP)
25
+ def digest_uri
26
+ raise AbstractMethod
27
+ end
28
+
29
+ def username
30
+ raise AbstractMethod
31
+ end
32
+
33
+ def has_password?
34
+ false
35
+ end
36
+
37
+ def allow_plaintext?
38
+ false
39
+ end
40
+
41
+ def password
42
+ ''
43
+ end
44
+
45
+ def want_anonymous?
46
+ false
47
+ end
48
+ end
49
+
50
+ ##
51
+ # Will be raised by SASL.new_mechanism if mechanism passed to the
52
+ # constructor is not known.
53
+ class UnknownMechanism < RuntimeError
54
+ def initialize(mechanism)
55
+ @mechanism = mechanism
56
+ end
57
+
58
+ def to_s
59
+ "Unknown mechanism: #{@mechanism.inspect}"
60
+ end
61
+ end
62
+
63
+ def SASL.new(mechanisms, preferences)
64
+ best_mechanism = if preferences.want_anonymous? && mechanisms.include?('ANONYMOUS')
65
+ 'ANONYMOUS'
66
+ elsif preferences.has_password?
67
+ if mechanisms.include?('DIGEST-MD5')
68
+ 'DIGEST-MD5'
69
+ elsif preferences.allow_plaintext?
70
+ 'PLAIN'
71
+ else
72
+ raise UnknownMechanism.new(mechanisms)
73
+ end
74
+ else
75
+ raise UnknownMechanism.new(mechanisms)
76
+ end
77
+ new_mechanism(best_mechanism, preferences)
78
+ end
79
+
80
+ ##
81
+ # Create a SASL Mechanism for the named mechanism
82
+ #
83
+ # mechanism:: [String] mechanism name
84
+ def SASL.new_mechanism(mechanism, preferences)
85
+ mechanism_class = case mechanism
86
+ when 'DIGEST-MD5'
87
+ DigestMD5
88
+ when 'PLAIN'
89
+ Plain
90
+ when 'ANONYMOUS'
91
+ Anonymous
92
+ else
93
+ raise UnknownMechanism.new(mechanism)
94
+ end
95
+ mechanism_class.new(mechanism, preferences)
96
+ end
97
+
98
+
99
+ class AbstractMethod < Exception # :nodoc:
100
+ def to_s
101
+ "Abstract method is not implemented"
102
+ end
103
+ end
104
+
105
+ ##
106
+ # Common functions for mechanisms
107
+ #
108
+ # Mechanisms implement handling of methods start and receive. They
109
+ # return: [message_name, content] or nil where message_name is
110
+ # either 'auth' or 'response' and content is either a string which
111
+ # may transmitted encoded as Base64 or nil.
112
+ class Mechanism
113
+ attr_reader :mechanism
114
+ attr_reader :preferences
115
+
116
+ def initialize(mechanism, preferences)
117
+ @mechanism = mechanism
118
+ @preferences = preferences
119
+ @state = nil
120
+ end
121
+
122
+ def success?
123
+ @state == :success
124
+ end
125
+ def failure?
126
+ @state == :failure
127
+ end
128
+
129
+ def start
130
+ raise AbstractMethod
131
+ end
132
+
133
+
134
+ def receive(message_name, content)
135
+ case message_name
136
+ when 'success'
137
+ @state = :success
138
+ when 'failure'
139
+ @state = :failure
140
+ end
141
+ nil
142
+ end
143
+ end
144
+ 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'] = @qop
48
+ response['digest-uri'] = preferences.digest_uri
49
+ response['response'] = response_value(response['nonce'], response['nc'], response['cnonce'], response['qop'])
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 v.include?('"')
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 < 16
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, 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}:#{preferences.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
+
@@ -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,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-sasl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Stephan Maka
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-07 00:00:00 +01:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Simple Authentication and Security Layer (RFC 4422)
17
+ email: stephan@spaceboyz.net
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - spec/mechanism_spec.rb
26
+ - spec/anonymous_spec.rb
27
+ - spec/plain_spec.rb
28
+ - spec/digest_md5_spec.rb
29
+ - lib/sasl/base.rb
30
+ - lib/sasl/digest_md5.rb
31
+ - lib/sasl/anonymous.rb
32
+ - lib/sasl/plain.rb
33
+ - lib/sasl/base64.rb
34
+ - lib/sasl.rb
35
+ - README.markdown
36
+ has_rdoc: false
37
+ homepage: http://github.com/astro/ruby-sasl/
38
+ post_install_message:
39
+ rdoc_options: []
40
+
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: "0"
48
+ version:
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ requirements: []
56
+
57
+ rubyforge_project:
58
+ rubygems_version: 1.3.1
59
+ signing_key:
60
+ specification_version: 2
61
+ summary: SASL client library
62
+ test_files:
63
+ - spec/mechanism_spec.rb
64
+ - spec/anonymous_spec.rb
65
+ - spec/plain_spec.rb
66
+ - spec/digest_md5_spec.rb