# =XMPP4R - XMPP Library for Ruby
# License:: Ruby's license (see the LICENSE file) or GNU GPL, at your option.
# Website::http://home.gna.org/xmpp4r/
require 'digest/md5'
require 'xmpp4r/base64'
module Jabber
##
# Helpers for SASL authentication (RFC2222)
#
# You might not need to use them directly, they are
# invoked by Jabber::Client#auth
module SASL
NS_SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
##
# Factory function to obtain a SASL helper for the specified mechanism
def SASL.new(stream, mechanism)
case mechanism
when 'DIGEST-MD5'
DigestMD5.new(stream)
when 'PLAIN'
Plain.new(stream)
when 'ANONYMOUS'
Anonymous.new(stream)
else
raise "Unknown SASL mechanism: #{mechanism}"
end
end
##
# SASL mechanism base class (stub)
class Base
def initialize(stream)
@stream = stream
end
private
def generate_auth(mechanism, text=nil)
auth = REXML::Element.new 'auth'
auth.add_namespace NS_SASL
auth.attributes['mechanism'] = mechanism
auth.text = text
auth
end
def generate_nonce
Digest::MD5.hexdigest(Time.new.to_f.to_s)
end
end
##
# SASL PLAIN authentication helper (RFC2595)
class Plain < Base
##
# Authenticate via sending password in clear-text
def auth(password)
auth_text = "#{@stream.jid.strip}\x00#{@stream.jid.node}\x00#{password}"
error = nil
@stream.send(generate_auth('PLAIN', Base64::encode64(auth_text).gsub(/\s/, ''))) { |reply|
if reply.name != 'success'
error = reply.first_element(nil).name
end
true
}
raise error if error
end
end
##
# SASL Anonymous authentication helper
class Anonymous < Base
##
# Authenticate by sending nothing with the ANONYMOUS token
def auth(password)
auth_text = "#{@stream.jid.node}"
error = nil
@stream.send(generate_auth('ANONYMOUS', Base64::encode64(auth_text).gsub(/\s/, ''))) { |reply|
if reply.name != 'success'
error = reply.first_element(nil).name
end
true
}
raise error if error
end
end
##
# SASL DIGEST-MD5 authentication helper (RFC2831)
class DigestMD5 < Base
##
# Sends the wished auth mechanism and wait for a challenge
#
# (proceed with DigestMD5#auth)
def initialize(stream)
super
challenge = {}
error = nil
@stream.send(generate_auth('DIGEST-MD5')) { |reply|
if reply.name == 'challenge' and reply.namespace == NS_SASL
challenge = decode_challenge(reply.text)
else
error = reply.first_element(nil).name
end
true
}
raise error if error
@nonce = challenge['nonce']
@realm = challenge['realm']
end
def decode_challenge(challenge)
text = Base64::decode64(challenge)
res = {}
state = :key
key = ''
value = ''
text.scan(/./) do |ch|
if state == :key
if ch == '='
state = :value
else
key += ch
end
elsif state == :value
if ch == ','
# due to our home-made parsing of the challenge, the key could have
# leading whitespace. strip it, or that would break jabberd2 support.
key = key.strip
res[key] = value
key = ''
value = ''
state = :key
elsif ch == '"' and value == ''
state = :quote
else
value += ch
end
elsif state == :quote
if ch == '"'
state = :value
else
value += ch
end
end
end
# due to our home-made parsing of the challenge, the key could have
# leading whitespace. strip it, or that would break jabberd2 support.
key = key.strip
res[key] = value unless key == ''
Jabber::debuglog("SASL DIGEST-MD5 challenge:\n#{text}\n#{res.inspect}")
res
end
##
# * Send a response
# * Wait for the server's challenge (which aren't checked)
# * Send a blind response to the server's challenge
def auth(password)
response = {}
response['nonce'] = @nonce
response['charset'] = 'utf-8'
response['username'] = @stream.jid.node
response['realm'] = @realm || @stream.jid.domain
response['cnonce'] = generate_nonce
response['nc'] = '00000001'
response['qop'] = 'auth'
response['digest-uri'] = "xmpp/#{@stream.jid.domain}"
response['response'] = response_value(@stream.jid.node, @stream.jid.domain, response['digest-uri'], password, @nonce, response['cnonce'], response['qop'], response['authzid'])
response.each { |key,value|
unless %w(nc qop response charset).include? key
response[key] = "\"#{value}\""
end
}
response_text = response.collect { |k,v| "#{k}=#{v}" }.join(',')
Jabber::debuglog("SASL DIGEST-MD5 response:\n#{response_text}\n#{response.inspect}")
r = REXML::Element.new('response')
r.add_namespace NS_SASL
r.text = Base64::encode64(response_text).gsub(/\s/, '')
success_already = false
error = nil
@stream.send(r) { |reply|
if reply.name == 'success'
success_already = true
elsif reply.name != 'challenge'
error = reply.first_element(nil).name
end
true
}
return if success_already
raise error if error
# TODO: check the challenge from the server
r.text = nil
@stream.send(r) { |reply|
if reply.name != 'success'
error = reply.first_element(nil).name
end
true
}
raise error if error
end
private
##
# Function from RFC2831
def h(s); Digest::MD5.digest(s); end
##
# Function from RFC2831
def hh(s); Digest::MD5.hexdigest(s); end
##
# Calculate the value for the response field
def response_value(username, realm, digest_uri, passwd, nonce, cnonce, qop, authzid)
a1_h = h("#{username}:#{realm}:#{passwd}")
a1 = "#{a1_h}:#{nonce}:#{cnonce}"
if authzid
a1 += ":#{authzid}"
end
if qop == 'auth-int' || qop == 'auth-conf'
a2 = "AUTHENTICATE:#{digest_uri}:00000000000000000000000000000000"
else
a2 = "AUTHENTICATE:#{digest_uri}"
end
hh("#{hh(a1)}:#{nonce}:00000001:#{cnonce}:#{qop}:#{hh(a2)}")
end
end
end
end