astro-ruby-sasl 0.0.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,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