astro-ruby-sasl 0.0.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,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
data/lib/sasl/base.rb ADDED
@@ -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,168 @@
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
+ @state = :success
56
+ ['success', nil]
57
+ else
58
+ @state = :failure
59
+ ['failure', nil]
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def decode_challenge(text)
68
+ challenge = {}
69
+
70
+ state = :key
71
+ key = ''
72
+ value = ''
73
+
74
+ text.scan(/./) do |ch|
75
+ if state == :key
76
+ if ch == '='
77
+ state = :value
78
+ elsif ch =~ /\S/
79
+ key += ch
80
+ end
81
+
82
+ elsif state == :value
83
+ if ch == ','
84
+ challenge[key] = value
85
+ key = ''
86
+ value = ''
87
+ state = :key
88
+ elsif ch == '"' and value == ''
89
+ state = :quote
90
+ else
91
+ value += ch
92
+ end
93
+
94
+ elsif state == :quote
95
+ if ch == '"'
96
+ state = :value
97
+ else
98
+ value += ch
99
+ end
100
+ end
101
+ end
102
+ challenge[key] = value unless key == ''
103
+
104
+ p :decode_challenge => challenge
105
+ challenge
106
+ end
107
+
108
+ def encode_response(response)
109
+ p :encode_response => response
110
+ response.collect do |k,v|
111
+ if v.include?('"')
112
+ v.sub!('\\', '\\\\')
113
+ v.sub!('"', '\\"')
114
+ "#{k}=\"#{v}\""
115
+ else
116
+ "#{k}=#{v}"
117
+ end
118
+ end.join(',')
119
+ end
120
+
121
+ def generate_nonce
122
+ nonce = ''
123
+ while nonce.length < 16
124
+ c = rand(128).chr
125
+ nonce += c if c =~ /^[a-zA-Z0-9]$/
126
+ end
127
+ nonce
128
+ end
129
+
130
+ ##
131
+ # Function from RFC2831
132
+ def h(s); Digest::MD5.digest(s); end
133
+ ##
134
+ # Function from RFC2831
135
+ def hh(s); Digest::MD5.hexdigest(s); end
136
+
137
+ ##
138
+ # Calculate the value for the response field
139
+ def response_value(nonce, nc, cnonce, qop, a2_prefix='AUTHENTICATE')
140
+ p :response_value => {:nonce=>nonce,
141
+ :cnonce=>cnonce,
142
+ :qop=>qop,
143
+ :username=>preferences.username,
144
+ :realm=>preferences.realm,
145
+ :password=>preferences.password,
146
+ :authzid=>preferences.authzid}
147
+ a1_h = h("#{preferences.username}:#{preferences.realm}:#{preferences.password}")
148
+ a1 = "#{a1_h}:#{nonce}:#{cnonce}"
149
+ if preferences.authzid
150
+ a1 += ":#{preferences.authzid}"
151
+ end
152
+ if qop && (qop.downcase == 'auth-int' || qop.downcase == 'auth-conf')
153
+ a2 = "#{a2_prefix}:#{preferences.digest_uri}:00000000000000000000000000000000"
154
+ else
155
+ a2 = "#{a2_prefix}:#{preferences.digest_uri}"
156
+ end
157
+ hh("#{hh(a1)}:#{nonce}:#{nc}:#{cnonce}:#{qop}:#{hh(a2)}")
158
+ end
159
+
160
+ def next_nc
161
+ @nonce_count += 1
162
+ s = @nonce_count.to_s
163
+ s = "0#{s}" while s.length < 8
164
+ s
165
+ end
166
+ end
167
+ end
168
+
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,97 @@
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
+ ['success', nil]
48
+ sasl.success?.should == true
49
+ end
50
+
51
+ it 'should authenticate (2)' do
52
+ preferences.serv_type = 'acap'
53
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
54
+ sasl.start.should == ['auth', nil]
55
+ sasl.cnonce = 'OA9BSuZWMSpW8m'
56
+ response = sasl.receive('challenge',
57
+ 'realm="elwood.innosoft.com",nonce="OA9BSXrbuRhWay",qop="auth",
58
+ algorithm=md5-sess,charset=utf-8')
59
+ response[0].should == 'response'
60
+ response[1].should =~ /charset="?utf-8"?/
61
+ response[1].should =~ /username="?chris"?/
62
+ response[1].should =~ /realm="?elwood.innosoft.com"?/
63
+ response[1].should =~ /nonce="?OA9BSXrbuRhWay"?/
64
+ response[1].should =~ /nc="?00000001"?/
65
+ response[1].should =~ /cnonce="?OA9BSuZWMSpW8m"?/
66
+ response[1].should =~ /digest-uri="?acap\/elwood.innosoft.com"?/
67
+ response[1].should =~ /response=6084c6db3fede7352c551284490fd0fc"?/
68
+ response[1].should =~ /"?qop=auth"?/
69
+
70
+ sasl.receive('challenge',
71
+ 'rspauth=2f0b3d7c3c2e486600ef710726aa2eae').should ==
72
+ ['success', nil]
73
+ sasl.success?.should == true
74
+ end
75
+
76
+ it 'should reauthenticate' do
77
+ preferences.serv_type = 'imap'
78
+ sasl = SASL::DigestMD5.new('DIGEST-MD5', preferences)
79
+ sasl.start.should == ['auth', nil]
80
+ sasl.cnonce = 'OA6MHXh6VqTrRk'
81
+ sasl.receive('challenge',
82
+ 'realm="elwood.innosoft.com",nonce="OA6MG9tEQGm2hh",qop="auth",
83
+ algorithm=md5-sess,charset=utf-8')
84
+ # reauth:
85
+ response = sasl.start
86
+ response[0].should == 'response'
87
+ response[1].should =~ /charset="?utf-8"?/
88
+ response[1].should =~ /username="?chris"?/
89
+ response[1].should =~ /realm="?elwood.innosoft.com"?/
90
+ response[1].should =~ /nonce="?OA6MG9tEQGm2hh"?/
91
+ response[1].should =~ /nc="?00000002"?/
92
+ response[1].should =~ /cnonce="?OA6MHXh6VqTrRk"?/
93
+ response[1].should =~ /digest-uri="?imap\/elwood.innosoft.com"?/
94
+ response[1].should =~ /response=b0b5d72a400655b8306e434566b10efb"?/ # my own result
95
+ response[1].should =~ /"?qop=auth"?/
96
+ end
97
+ 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: astro-ruby-sasl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Stephan Maka
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-06 00:00:00 -08: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.2.0
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